Skip to content

Commit 37e7657

Browse files
chore: seo fixes
1 parent 20be5c7 commit 37e7657

7 files changed

Lines changed: 138 additions & 26 deletions

File tree

packages/example-nextjs16/src/app/i18n/index.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88

99
import {
1010
ALL_LOCALES,
11+
INDEXED_LOCALES,
1112
LOCALES,
1213
SOURCE,
1314
SOURCE_LOCALE,
1415
type Locale,
1516
} from '../../../../../scripts/i18n/locales.mjs';
1617

17-
export { ALL_LOCALES, LOCALES, SOURCE, SOURCE_LOCALE };
18+
export { ALL_LOCALES, INDEXED_LOCALES, LOCALES, SOURCE, SOURCE_LOCALE };
1819
export type { Locale };
1920

2021
/**
@@ -67,19 +68,28 @@ export function stripLocale(pathname: string): string {
6768
}
6869

6970
/** The public pages, locale-independent. */
70-
export const PAGES = ['', '/react-router', '/remix', '/astro', '/nextjs', '/vs/nuqs'] as const;
71+
export const PAGES = [
72+
'',
73+
'/react-router',
74+
'/remix',
75+
'/astro',
76+
'/nextjs',
77+
'/vs/nuqs',
78+
] as const;
7179

7280
/**
7381
* The reciprocal hreflang cluster for one page.
7482
*
7583
* Next's `alternates.languages` renders these as `<link rel="alternate"
76-
* hreflang>`. Every locale of a page lists every other one *and* itself, plus
77-
* `x-default` for English — a cluster whose members disagree about who is in
78-
* it is discarded wholesale rather than partially honoured.
84+
* hreflang>`. Every *indexed* locale of a page lists every other one *and*
85+
* itself, plus `x-default` for English — a cluster whose members disagree
86+
* about who is in it is discarded wholesale rather than partially honoured,
87+
* and one naming a `noindex` page is an error, so an unindexed locale is left
88+
* out on both sides (its own pages declare none — see `localeMetadata`).
7989
*/
8090
export function languageAlternates(origin: string, path: string) {
8191
const out: Record<string, string> = {};
82-
for (const locale of ALL_LOCALES) {
92+
for (const locale of INDEXED_LOCALES) {
8393
out[locale.code] = `${origin}${localePrefix(locale.code)}${path}`;
8494
}
8595
out['x-default'] = `${origin}${path}`;

packages/example-nextjs16/src/app/seoStuff.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,14 @@ export function localeMetadata({
263263
return {
264264
title: page.title,
265265
description: page.description,
266+
// An unindexed locale (`indexed: false` in the locale table) declines the
267+
// index and names no cluster; the rest of its head stays for sharing.
268+
...(locale.indexed ? {} : { robots: { index: false, follow: true } }),
266269
alternates: {
267270
canonical: url,
268-
languages: languageAlternates(vercelUrl, path),
271+
...(locale.indexed
272+
? { languages: languageAlternates(vercelUrl, path) }
273+
: {}),
269274
types: { 'text/markdown': markdownAlternates(mirror) },
270275
},
271276
openGraph: {

packages/example-nextjs16/src/app/sitemap.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@ import { MetadataRoute } from 'next';
22

33
import { CONTENT_LAST_MODIFIED } from './contentDate';
44
import { siteUrl } from './domain';
5-
import { ALL_LOCALES, languageAlternates, localePrefix, PAGES } from './i18n';
5+
import {
6+
INDEXED_LOCALES,
7+
languageAlternates,
8+
localePrefix,
9+
PAGES,
10+
} from './i18n';
611

712
/**
8-
* Every public page, in every language.
13+
* Every public page, in every indexed language — an unindexed locale is
14+
* `noindex` and belongs in neither the list nor the clusters.
915
*
1016
* Generated from the locale table rather than listed, because this file and
1117
* robots.txt and next.config.mjs's `headers()` all need the same set and a
@@ -24,14 +30,15 @@ export default function sitemap(): MetadataRoute.Sitemap {
2430
// relative within a sitemap, so giving them all a 1 said nothing.
2531
const priorityOf = (path: string) => (path === '' ? 1 : 0.8);
2632

27-
return ALL_LOCALES.flatMap((locale) =>
33+
return INDEXED_LOCALES.flatMap((locale) =>
2834
PAGES.map((path) => ({
2935
url: `${siteUrl}${localePrefix(locale.code)}${path}` || siteUrl,
3036
lastModified,
3137
changeFrequency: 'monthly' as const,
3238
// A translation is not more important than its source, and the English
3339
// pages are the ones that are actually written rather than generated.
34-
priority: locale.code === 'en' ? priorityOf(path) : priorityOf(path) * 0.8,
40+
priority:
41+
locale.code === 'en' ? priorityOf(path) : priorityOf(path) * 0.8,
3542
alternates: { languages: languageAlternates(siteUrl, path) },
3643
})),
3744
);

scripts/i18n/AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ Repo-wide guide: [`AGENTS.md`](../../AGENTS.md). Translator prompt:
44
[`TRANSLATING.md`](TRANSLATING.md). Protected terms: [`GLOSSARY.md`](GLOSSARY.md).
55

66
Eight languages besides English: `zh-CN`, `ja`, `ko`, `ru`, `es`, `pt-BR`, `fr`,
7-
`vi`. Declared once in [`locales.mjs`](locales.mjs); the README suffixes and the
7+
`vi`. Declared once in [`locales.mjs`](locales.mjs) — with an `indexed` flag
8+
per locale: only `ja`, `ru` and `vi` are told to crawlers, the rest are
9+
`noindex` (`localeMetadata` in `seoStuff.ts`), out of every hreflang cluster
10+
and out of `sitemap.ts`, while staying built and linked. The Search Console
11+
figures behind the split are in the table's comment. The README suffixes and the
812
language-switcher line both derive from that list, so adding a language is an
913
edit there plus `pnpm i18n:init`.
1014

scripts/i18n/locales.d.mts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@ export interface Locale {
1313
label: string;
1414
/** English name, for reports and prompts. */
1515
english: string;
16+
/** Told to crawlers, or `noindex` and out of every hreflang cluster and the sitemap. */
17+
indexed: boolean;
1618
}
1719

1820
export const SOURCE_LOCALE: 'en';
1921
export const SOURCE: Locale;
2022
export const LOCALES: readonly Locale[];
2123
export const ALL_LOCALES: readonly Locale[];
24+
/** The locales a crawler is told about — `ALL_LOCALES` with `indexed`. */
25+
export const INDEXED_LOCALES: readonly Locale[];
2226
export const CODES: readonly string[];
2327
export function findLocale(input: string): Locale | undefined;
2428
export function localizedName(baseName: string, code: string): string;

scripts/i18n/locales.mjs

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,28 +18,85 @@ export const SOURCE = {
1818
label: 'English',
1919
/** Exonym, for prompts and reports written in English. */
2020
english: 'English',
21+
indexed: true,
2122
};
2223

23-
/** @type {ReadonlyArray<{code: string, dir: string, label: string, english: string}>} */
24+
/**
25+
* `indexed: false` keeps a translation out of the index — `noindex` on its
26+
* pages, no hreflang or sitemap entry — while it stays built, linked from the
27+
* switcher and readable. Set from Search Console demand, Jun–Sep 2026
28+
* impressions: vi 17, ja 12, ru 11, es 4, fr 4, pt-BR 3, ko 2, zh-CN 1,
29+
* against 101 English; a locale earns `true` at 10. Eight LLM translations of
30+
* every page is the shape Google's scaled-content policy describes, and it
31+
* cost a sister site (x-pat.pages.dev) its whole index in Aug 2026.
32+
*/
33+
/** @type {ReadonlyArray<{code: string, dir: string, label: string, english: string, indexed: boolean}>} */
2434
export const LOCALES = [
25-
{ code: 'zh-CN', dir: 'zh-cn', label: '简体中文', english: 'Chinese (Simplified)' },
26-
{ code: 'ja', dir: 'ja', label: '日本語', english: 'Japanese' },
27-
{ code: 'ko', dir: 'ko', label: '한국어', english: 'Korean' },
28-
{ code: 'ru', dir: 'ru', label: 'Русский', english: 'Russian' },
29-
{ code: 'es', dir: 'es', label: 'Español', english: 'Spanish' },
30-
{ code: 'pt-BR', dir: 'pt-br', label: 'Português (BR)', english: 'Portuguese (Brazil)' },
31-
{ code: 'fr', dir: 'fr', label: 'Français', english: 'French' },
32-
{ code: 'vi', dir: 'vi', label: 'Tiếng Việt', english: 'Vietnamese' },
35+
{
36+
code: 'zh-CN',
37+
dir: 'zh-cn',
38+
label: '简体中文',
39+
english: 'Chinese (Simplified)',
40+
indexed: false,
41+
},
42+
{
43+
code: 'ja',
44+
dir: 'ja',
45+
label: '日本語',
46+
english: 'Japanese',
47+
indexed: true,
48+
},
49+
{ code: 'ko', dir: 'ko', label: '한국어', english: 'Korean', indexed: false },
50+
{
51+
code: 'ru',
52+
dir: 'ru',
53+
label: 'Русский',
54+
english: 'Russian',
55+
indexed: true,
56+
},
57+
{
58+
code: 'es',
59+
dir: 'es',
60+
label: 'Español',
61+
english: 'Spanish',
62+
indexed: false,
63+
},
64+
{
65+
code: 'pt-BR',
66+
dir: 'pt-br',
67+
label: 'Português (BR)',
68+
english: 'Portuguese (Brazil)',
69+
indexed: false,
70+
},
71+
{
72+
code: 'fr',
73+
dir: 'fr',
74+
label: 'Français',
75+
english: 'French',
76+
indexed: false,
77+
},
78+
{
79+
code: 'vi',
80+
dir: 'vi',
81+
label: 'Tiếng Việt',
82+
english: 'Vietnamese',
83+
indexed: true,
84+
},
3385
];
3486

3587
export const ALL_LOCALES = [SOURCE, ...LOCALES];
3688

89+
/** The locales a crawler is told about — see `indexed` above. */
90+
export const INDEXED_LOCALES = ALL_LOCALES.filter((l) => l.indexed);
91+
3792
export const CODES = LOCALES.map((l) => l.code);
3893

3994
/** Lookup accepting either spelling, so CLI args can be `pt-BR` or `pt-br`. */
4095
export function findLocale(input) {
4196
const needle = String(input).toLowerCase();
42-
return ALL_LOCALES.find((l) => l.code.toLowerCase() === needle || l.dir === needle);
97+
return ALL_LOCALES.find(
98+
(l) => l.code.toLowerCase() === needle || l.dir === needle,
99+
);
43100
}
44101

45102
/**
@@ -63,6 +120,8 @@ export function localizedName(baseName, code) {
63120
*/
64121
export function switcherLine(baseName, current) {
65122
return ALL_LOCALES.map((l) =>
66-
l.code === current ? l.label : `[${l.label}](./${localizedName(baseName, l.code)})`,
123+
l.code === current
124+
? l.label
125+
: `[${l.label}](./${localizedName(baseName, l.code)})`,
67126
).join(' · ');
68127
}

tests/landing/robots.spec.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,34 @@ test.describe('robots.txt (landing only)', () => {
1818
.readdirSync(
1919
// `(en)` is a route group: it shapes the layout tree, not the URLs,
2020
// so these are still served at `/test-ssr`, `/useUrlState` and so on.
21-
path.join(process.cwd(), 'packages/example-nextjs16/src/app/(en)/(tests)'),
21+
path.join(
22+
process.cwd(),
23+
'packages/example-nextjs16/src/app/(en)/(tests)',
24+
),
2225
{ withFileTypes: true },
2326
)
2427
.filter((entry) => entry.isDirectory())
2528
.map((entry) => `/${entry.name}`);
2629

2730
// The public pages, English and translated. A locale prefix must not
2831
// be caught by a fixture Disallow, and the fixtures exist only under the
29-
// unprefixed English tree.
30-
const documented = ['/', '/react-router', '/remix', '/astro', '/nextjs', '/vs/nuqs', '/ja', '/ja/react-router', '/zh-cn/remix', '/ko/astro', '/ru/nextjs', '/ja/vs/nuqs'];
32+
// unprefixed English tree. ja, ru and vi are the indexed translations
33+
// (scripts/i18n/locales.mjs); zh-cn and ko are kept out of the index.
34+
const documented = [
35+
'/',
36+
'/react-router',
37+
'/remix',
38+
'/astro',
39+
'/nextjs',
40+
'/vs/nuqs',
41+
'/ja',
42+
'/ja/react-router',
43+
'/vi/remix',
44+
'/ru/astro',
45+
'/ru/nextjs',
46+
'/ja/vs/nuqs',
47+
];
48+
const unindexed = ['/zh-cn', '/zh-cn/remix', '/ko/astro'];
3149

3250
const disallowedPrefixes = async (request: {
3351
get: (url: string) => Promise<{ text: () => Promise<string> }>;
@@ -78,5 +96,10 @@ test.describe('robots.txt (landing only)', () => {
7896
for (const route of documented.filter((r) => r !== '/')) {
7997
expect(sitemap).toContain(route);
8098
}
99+
for (const route of unindexed) {
100+
expect(sitemap, `${route} is noindex and must stay out`).not.toContain(
101+
`${route}<`,
102+
);
103+
}
81104
});
82105
});

0 commit comments

Comments
 (0)