From f164cde4f679bcaa5b4514cdd1c5f853ec3438fc Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 2 May 2026 19:17:48 +0900 Subject: [PATCH 01/31] web-next: Add /[lang] article route to display translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy `web/` stack supports `/@user/year/slug/{lang}` (e.g. `/ko`) to display the matching translation of an article. The new `web-next/` stack was missing this route, so any URL with a trailing language segment 404'd, breaking parity for articles that already have one or more translations available. Add a SolidStart route at `web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx` that: - Restricts `[lang]` via `matchFilters` to a BCP-47-shaped regex so sibling literal routes (`edit`, `ogimage`) keep precedence, then runs the captured value through `normalizeLocale()` from `@hackerspub/models/i18n` for canonicalisation; an unrecognised locale renders ``. - Defines its own `LangPageQuery` that requires `$language: Locale!` and selects `articleByYearAndSlug.contents(language: $language)` inline (so the page can inspect `language` and `originalLanguage` for redirect/404 decisions) in addition to spreading the shared `Slug_head` and `Slug_body` fragments with `@arguments(language: $language)`. - Returns 404 when no article matches or when no content row matches the requested language; redirects to the canonical `/@user/year/slug` URL when the requested language IS the article's original (i.e. `content.originalLanguage == null`); and redirects to the actual stored language when locale negotiation returns a differently-tagged content (e.g. `/ko-KR` -> `/ko` when only `ko` exists). - Reuses `ArticleMetaHead` and `ArticleBody` exported from the shared `index.tsx`, so the entire article rendering stays in one place. - Uses `` around `/` so a pending preloaded query does not flash a 404 status during the initial render, and uses `createMemo` for derived values to keep the conditional branches readable. To make the shared rendering parameterisable, refactor the existing `Slug_head` and `Slug_body` Relay fragments to accept an optional `$language: Locale` argument and pass it to `contents(language:)`; the unfiltered article-translation list previously read from `contents` inside `Slug_languageSwitcher` is now selected under an `allContents: contents` alias so it doesn't conflict (Relay rejects two selections of the same field with different applied arguments). The language switcher then receives the currently-displayed language and its `originalLanguage` from its parent body component instead of inferring "current" from `contents[0]`. `SlugPageQuery` (used by `index.tsx`) gains the same nullable `$language` variable and passes it through; the index loader always sends `language: null`, so the existing route behaviour is preserved. Out of scope for this change: SEO metadata gaps (``, `og:url`, `og:locale:alternate`) which are missing on the index route too and would benefit from a separate pass; and visibility for in-progress translation rows, which is gated behind `Article.contents`' `includeBeingTranslated` argument whose current resolver semantics (`where: { beingTranslated: arg ?? false }`) only let callers fetch *either* completed *or* in-progress rows. The legacy route can show a "Translating…" placeholder for in-flight translations; matching that behaviour requires fixing the resolver and is left for a follow-up. Verified manually against the dev server with the existing `@hpdev/2025/stop-writing-if-statements-for-your-cli-flags` article: `/ko` renders the Korean translation, `/ko-KR` redirects to `/ko`, `/KO` (uppercase) renders, `/en` (the original language) redirects to the canonical URL, `/xx` (invalid locale) 404s, and the original page and language switcher continue to work after the refactor. Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- .../[handle]/[idOrYear]/[slug]/[lang].tsx | 152 ++++++++++++++++++ .../[handle]/[idOrYear]/[slug]/index.tsx | 44 +++-- 2 files changed, 180 insertions(+), 16 deletions(-) create mode 100644 web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx diff --git a/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx b/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx new file mode 100644 index 000000000..46c2da470 --- /dev/null +++ b/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/[lang].tsx @@ -0,0 +1,152 @@ +import { normalizeLocale } from "@hackerspub/models/i18n"; +import { + Navigate, + query, + type RouteDefinition, + useParams, +} from "@solidjs/router"; +import { HttpStatusCode } from "@solidjs/start"; +import { graphql } from "relay-runtime"; +import { createMemo, Match, Show, Switch } from "solid-js"; +import { + createPreloadedQuery, + loadQuery, + useRelayEnvironment, +} from "solid-relay"; +import type { LangPageQuery } from "./__generated__/LangPageQuery.graphql.ts"; +import { ArticleBody, ArticleMetaHead } from "./index.tsx"; + +export const route = { + matchFilters: { + handle: /^@/, + lang: /^[A-Za-z]{2,3}(?:[_-][A-Za-z0-9]+)*$/, + }, + preload(args) { + const handle = args.params.handle!; + const idOrYear = args.params.idOrYear!; + const slug = args.params.slug!; + const language = normalizeLocale(args.params.lang!); + if (language == null) return; + void loadLangPageQuery(handle, idOrYear, slug, language); + }, +} satisfies RouteDefinition; + +const LangPageQueryDef = graphql` + query LangPageQuery( + $handle: String! + $idOrYear: String! + $slug: String! + $language: Locale! + ) { + articleByYearAndSlug( + handle: $handle + idOrYear: $idOrYear + slug: $slug + ) { + publishedYear + slug + actor { + username + } + contents(language: $language) { + language + originalLanguage + } + ...Slug_head @arguments(language: $language) + ...Slug_body @arguments(language: $language) + } + viewer { + ...Slug_viewer + } + } +`; + +const loadLangPageQuery = query( + (handle: string, idOrYear: string, slug: string, language: string) => + loadQuery( + useRelayEnvironment()(), + LangPageQueryDef, + { handle, idOrYear, slug, language }, + ), + "loadArticleLangPageQuery", +); + +export default function ArticleLangPage() { + const params = useParams(); + + return ( + } + > + {(language) => ( + + )} + + ); +} + +interface ArticleLangPageContentProps { + handle: string; + idOrYear: string; + slug: string; + language: string; +} + +function ArticleLangPageContent(props: ArticleLangPageContentProps) { + const data = createPreloadedQuery( + LangPageQueryDef, + () => + loadLangPageQuery( + props.handle, + props.idOrYear, + props.slug, + props.language, + ), + ); + + const article = createMemo(() => data()?.articleByYearAndSlug ?? null); + const content = createMemo(() => article()?.contents[0] ?? null); + const canonicalBase = createMemo(() => { + const a = article(); + return a == null + ? null + : `/@${a.actor.username}/${a.publishedYear}/${a.slug}`; + }); + const redirectHref = createMemo(() => { + const c = content(); + const base = canonicalBase(); + if (c == null || base == null) return null; + if (c.originalLanguage == null) return base; + if (c.language !== props.language) return `${base}/${c.language}`; + return null; + }); + + return ( + + }> + + + + + + + + + + + + + + + + ); +} diff --git a/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx b/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx index 3804f44a4..e3c4525a6 100644 --- a/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx +++ b/web-next/src/routes/(root)/[handle]/[idOrYear]/[slug]/index.tsx @@ -60,14 +60,15 @@ const SlugPageQueryDef = graphql` $handle: String! $idOrYear: String! $slug: String! + $language: Locale ) { articleByYearAndSlug( handle: $handle idOrYear: $idOrYear slug: $slug ) { - ...Slug_head - ...Slug_body + ...Slug_head @arguments(language: $language) + ...Slug_body @arguments(language: $language) } viewer { ...Slug_viewer @@ -80,7 +81,7 @@ const loadPageQuery = query( loadQuery( useRelayEnvironment()(), SlugPageQueryDef, - { handle, idOrYear, slug }, + { handle, idOrYear, slug, language: null }, ), "loadArticlePageQuery", ); @@ -118,6 +119,8 @@ export default function ArticlePage() { ); } +export { ArticleBody, ArticleMetaHead }; + interface ArticleMetaHeadProps { $article: Slug_head$key; } @@ -126,14 +129,16 @@ function ArticleMetaHead(props: ArticleMetaHeadProps) { const { t } = useLingui(); const article = createFragment( graphql` - fragment Slug_head on Article { + fragment Slug_head on Article + @argumentDefinitions(language: { type: "Locale" }) + { actor { handle name rawName username } - contents { + contents(language: $language) { title summary language @@ -240,12 +245,15 @@ function ArticleBody(props: ArticleBodyProps) { const mentionState = useMentionHoverCards(proseRef); const article = createFragment( graphql` - fragment Slug_body on Article { - contents { + fragment Slug_body on Article + @argumentDefinitions(language: { type: "Locale" }) + { + contents(language: $language) { title content toc language + originalLanguage beingTranslated } tags @@ -278,7 +286,11 @@ function ArticleBody(props: ArticleBodyProps) { items={toc()} hidden={content()?.beingTranslated ?? false} /> - + {(html) => ( @@ -470,6 +482,8 @@ function ArticleInlineToc(props: ArticleInlineTocProps) { interface ArticleLanguageSwitcherProps { $article: Slug_languageSwitcher$key; + currentLanguage?: string; + currentOriginalLanguage?: string | null; } function ArticleLanguageSwitcher(props: ArticleLanguageSwitcherProps) { @@ -482,9 +496,8 @@ function ArticleLanguageSwitcher(props: ArticleLanguageSwitcherProps) { } publishedYear slug - contents { + allContents: contents { language - originalLanguage url } } @@ -495,12 +508,11 @@ function ArticleLanguageSwitcher(props: ArticleLanguageSwitcherProps) { return ( {(article) => { - const content = () => article().contents[0]; const postUrl = () => `/@${article().actor.username}/${article().publishedYear}/${article().slug}`; return ( - 1}> + 1}>