Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/locale-cluster-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@ingram-tech/nk-i18n": minor
"@ingram-tech/nk-seo": minor
---

Fix the locale cluster shape, and give middleware one way to be written.

**Breaking, deliberately.** `prefixDefaultLocale` is removed and nothing
replaces it: the cluster's shape is no longer configurable. Whichever strategy
you pick, every locale gets its own address (the default included) and the bare
path belongs to no locale — it negotiates, and it is `x-default`.

Previously the prefix strategy made the bare path the default locale's URL, so
`localeFromUrl` returned `defaultLocale` for it. That is the URL signal, which
outranks the cookie, so a visitor who chose French snapped back to English on
the first bare internal link — and every site that starts with a cookie switcher
has bare internal links. The two shapes now excluded (bare path IS the default
locale, bare path redirects on perceived language) are the two that go wrong;
offering either as an option is how a fleet drifts.

- **`localeProxy(routing, request)`** is the whole middleware side: forwards the
pathname and locale headers, rewrites `/fr/about` → `/about` so the app keeps
one route tree, remembers an explicit choice in the cookie, never redirects.
Middleware that does more passes `requestHeaders` in and keeps editing the
response. Replaces `forwardUrlLocale` and the strip/rewrite/cookie/consolidate
code every prefix site was hand-writing.
- **`forwardRequestContext`** sets nk-seo's `x-pathname` and the locale header
together, so the two conventions can't be wired separately and one forgotten.
- **`defineLocaleRouting` is generic over the locale union.** `isLocale` is a
type guard, `resolve` / `localeFromUrl` / `createLocaleResolver` return `L`.
Sites stop writing their own guards and casts.
- **`hrefLangTags` and `cookieName` move onto routing.** A site with regional
tags no longer builds a second config object, which was exactly the drift this
package exists to prevent. `routing.htmlLang(locale)` gives the `<html lang>`
value, and `hreflangConfigFor` passes the tags through.
- **`routing.stripLocale(pathname)`** exposes the app-facing path.

nk-seo's `HreflangConfig` drops `defaultLocale` and `prefixDefaultLocale`;
`x-default` is always the bare path now, so neither is needed.

Migration: delete `prefixDefaultLocale`, replace `forwardUrlLocale` +
manual `x-pathname` with `localeProxy`, drop any local `isLocale` guard and
`as Locale` cast. Prefix sites gain `/en/…` as a real address — verify with
`assertHreflangCluster` from `@ingram-tech/nk-seo/verify`.
12 changes: 12 additions & 0 deletions .changeset/nk-dev-cold-type-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@ingram-tech/nk-dev": minor
---

`nk type-check` starts cold when the dependency tree moved: a `*.tsbuildinfo`
older than `bun.lock` / `package.json` is dropped before the run, because
`tsc --incremental` does not reliably re-check a program after a dependency's
`.d.ts` changes and a green result against the stale cache means nothing.
`--cold` drops the cache unconditionally. `nk doctor` now flags a `"prettier"`
key in `package.json` and `.prettierrc*` files alongside `.prettierignore`
(all `--fix`able), and warns when a site has no `ci` script or one that skips
`nk check` / `nk type-check`.
87 changes: 62 additions & 25 deletions docs/i18n-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,32 +38,52 @@ in 1998 and no browser implemented it.
So: negotiation is a fine convenience at a front door, and never the addressing
scheme.

## Strategies
## The cluster shape is fixed

`defineLocaleRouting({ strategy })` picks how a locale is encoded.
Whichever strategy you pick:

**`"query"` (default)** — every locale gets `?hl=<locale>`, including the
default. The bare path negotiates and belongs to no locale: it is `x-default`.
- every locale has its own address, **the default included**;
- the bare path belongs to **no** locale. It negotiates, and it is `x-default`.

```
x-default → /pricing negotiates, language varies by visitor
en → /pricing?hl=en
fr → /pricing?hl=fr
nl → /pricing?hl=nl
x-default → /pricing negotiates; language varies by visitor
en → /pricing?hl=en or /en/pricing
fr → /pricing?hl=fr or /fr/pricing
nl → /pricing?hl=nl or /nl/pricing
```

Use it when the site wants one shareable address per page and negotiation for
humans. Google supports parameter-based locale URLs and does not recommend them,
so accept a slightly thinner margin than prefixes in exchange for not having a
locale segment to maintain.
This is not configurable, deliberately. The two shapes it excludes are the ones
that go wrong:

- **bare path IS the default locale.** A French visitor who follows a bare
internal link gets English, and every site that starts with a cookie switcher
has bare internal links. This is the bug that made one fleet site fork its
middleware rather than adopt the helpers.
- **bare path redirects on perceived language.** Google tells you not to build
this, and it makes `x-default` point at a URL that is not language-neutral.

Offering either as an option is how the fleet drifts, so neither is offered.

## Strategies

`defineLocaleRouting({ strategy })` picks only the **encoding**.

The default locale gets its own `?hl=en` **because the bare path is not reliably
English**. As soon as country is a negotiation signal, the bare path renders
French to a crawl from France, so labelling it `en` would be false half the time.
**`"prefix"`** — `/fr/pricing`. Prefer this for a new site. It is better on
every SEO axis: a path cannot be folded into another document the way a query
parameter can (and the URL Parameters tool that used to override that was
retired in 2022), it survives link-sharing and CMS fields that strip query
strings, it does not combine with campaign parameters into an open-ended URL
space, it puts the target-language keyword in the URL, and analytics group by
pathname for free.

**`"prefix"`** — the default locale keeps the bare path, others get `/<locale>/…`
(`prefixDefaultLocale` prefixes every locale instead). Stronger for ranking,
since the locale is in the path, at the cost of a routing segment.
`localeProxy` rewrites `/fr/pricing` to `/pricing`, so the app keeps one route
tree and never learns what a locale is.

**`"query"` (default)** — `/pricing?hl=fr`. Google supports it and does not
recommend it. Use it when restructuring routes is not worth it, knowing it is
the weaker of the two — typically a marketing tree of React components with
inline `t()` calls, where the win from having addresses at all dwarfs the gap
between the two encodings.

## The precedence chain

Expand Down Expand Up @@ -110,29 +130,46 @@ export const routing = defineLocaleRouting({
baseUrl: "https://acme.example",
locales: ["en", "fr", "nl"],
defaultLocale: "en",
strategy: "prefix",
countryLocales: { FR: "fr", NL: "nl" }, // no BE: ambiguous
hrefLangTags: { en: "en-BE", fr: "fr-BE", nl: "nl-BE" }, // only if content differs
});
```

```ts
// proxy.ts — forward, never redirect
// proxy.ts — the whole middleware side
export function proxy(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
forwardUrlLocale(routing, request.nextUrl, requestHeaders);
return NextResponse.next({ request: { headers: requestHeaders } });
return localeProxy(routing, request);
}
```

`localeProxy` forwards the pathname and URL-locale headers, rewrites a locale
prefix away, and remembers an explicit choice in the cookie. It never redirects.
Middleware that does more of its own work passes its headers in and keeps
editing the response:

```ts
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-tenant", tenant);
const response = localeProxy(routing, request, { requestHeaders });
response.cookies.set(…);
return response;
```

```ts
// lib/i18n/locale.ts
// lib/i18n/locale.ts — narrowed to your locale union, no cast
export const resolveLocale = cache(
createLocaleResolver(routing, { account: () => getProfile().locale }),
);
```

```tsx
// app/layout.tsx
<HreflangLinks {...(await hreflangConfigFor(routing))} pathname={pathname} />
// app/layout.tsx — pathname comes from the header localeProxy set
<html lang={routing.htmlLang(locale)}>
<head>
<HreflangLinks {...(await hreflangConfigFor(routing))} />
</head>
</html>
```

The language switcher must be real `<a href>` links to `routing.urlForLocale(…)`.
Expand Down
7 changes: 5 additions & 2 deletions packages/nk-dev/bin/nk.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ Commands:
mechanical refactors — see the codemod skill.
check The CI gate: lint + format verify + knip (when configured)
+ the agent-guide import gate + the migration seal.
type-check next typegen && tsc --noEmit. Recovers automatically when
type-check [--cold] next typegen && tsc --noEmit. Recovers automatically when
generated types are damaged (e.g. a killed dev server).
Drops the incremental cache when the lockfile changed
since it was written (a stale cache passes against old
.d.ts); --cold always drops it.
clean Remove regenerable build artifacts: Next's generated
types and TypeScript incremental caches.
test [...] vitest run (extra args passed through).
Expand Down Expand Up @@ -75,7 +78,7 @@ switch (cmd) {
check();
break;
case "type-check":
typeCheck();
typeCheck(rest);
break;
case "clean":
clean();
Expand Down
34 changes: 33 additions & 1 deletion packages/nk-dev/lib/artifacts.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readdirSync, rmSync } from "node:fs";
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
import { join } from "node:path";

/**
Expand Down Expand Up @@ -45,6 +45,38 @@ export function listGeneratedArtifacts(cwd = process.cwd()) {
return [...present, ...buildInfo];
}

/**
* Files whose change means the dependency tree changed. The lockfile is the
* precise signal; `package.json` covers a hand edit not yet installed.
*/
const DEPENDENCY_MANIFESTS = [
"bun.lock",
"bun.lockb",
"package-lock.json",
"package.json",
];

/**
* TypeScript incremental caches older than the dependency manifests.
*
* `tsc --incremental` does not reliably notice a dependency's `.d.ts`
* changing, so after an upgrade a type-check can pass against the cached
* program while a cold run fails — the exact failure a dependency bump exists
* to catch. Keying invalidation on the lockfile catches that case without
* giving up the cache on every other run.
*/
export function staleBuildInfo(cwd = process.cwd()) {
const manifests = DEPENDENCY_MANIFESTS.map((name) => join(cwd, name)).filter(
(file) => existsSync(file),
);
if (manifests.length === 0) return [];
const newest = Math.max(...manifests.map((file) => statSync(file).mtimeMs));
return listGeneratedArtifacts(cwd)
.filter((entry) => entry.owner === "tsc")
.filter((entry) => statSync(join(cwd, entry.path)).mtimeMs < newest)
.map((entry) => entry.path);
}

/**
* Remove generated artifacts and return the paths actually deleted. Missing
* paths are skipped rather than reported, so this is idempotent.
Expand Down
76 changes: 64 additions & 12 deletions packages/nk-dev/lib/doctor.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { authShadowFindings } from "./auth-shadow.js";
import { SUPERSEDED_DEPS } from "./drift.js";
Expand Down Expand Up @@ -252,30 +252,82 @@ export function findings(cwd) {
}
}

// 8. .prettierignore is now dead weight (nk no longer formats SQL).
const prettierIgnore = resolve(cwd, ".prettierignore");
if (existsSync(prettierIgnore)) {
// 8. Prettier leftovers are dead weight (nk no longer runs Prettier):
// .prettierignore, any .prettierrc*, and a "prettier" key in package.json.
for (const file of prettierFiles(cwd)) {
out.push({
id: "prettierignore",
id: `prettier:${file}`,
level: "warn",
message: `${file} is unused (nk no longer runs Prettier) — remove it`,
fix: (dir) => {
rmSync(resolve(dir, file));
return `removed ${file}`;
},
});
}
if (pkg.prettier !== undefined) {
out.push({
id: "prettier:package.json",
level: "warn",
message:
".prettierignore is unused (nk no longer runs Prettier) — remove it",
'package.json has a "prettier" key (nk no longer runs Prettier) — remove it',
fix: (dir) => {
rmSync(resolve(dir, ".prettierignore"));
return "removed .prettierignore";
const p = resolve(dir, "package.json");
const j = readJson(p);
delete j.prettier;
writeJson(p, j);
return 'removed "prettier" from package.json';
},
});
}

// 9. The migration chain is sealed, and its unmodelled DDL is declared.
// 9. A `ci` script exists and runs the house gate. Its full contents are
// the site's call (migrations, i18n, email catalogs, build…), so this only
// warns and never writes — but the dep-upgrade flow and pre-push both
// assume `bun run ci` is the one command that proves a change.
const ci = scripts["ci"];
if (ci === undefined) {
out.push({
id: "script:ci",
level: "warn",
message:
'missing `ci` script — the one command that proves a change (e.g. "nk check && nk type-check && nk test")',
});
} else {
const missing = ["nk check", "nk type-check"].filter(
(cmd) => !ci.includes(cmd) && !ci.includes(`bun run ${cmd.slice(3)}`),
);
if (missing.length > 0) {
out.push({
id: "script:ci",
level: "warn",
message: `\`ci\` script does not run ${missing.map((c) => `\`${c}\``).join(" or ")} — the gate should run both`,
});
}
}

// 10. The migration chain is sealed, and its unmodelled DDL is declared.
out.push(...migrationFindings(cwd));

// 10. No page/route under app/auth/ shadows a Better Auth endpoint.
// 11. No page/route under app/auth/ shadows a Better Auth endpoint.
out.push(...authShadowFindings(cwd));

return out;
}

/** Prettier config files present in `cwd` (relative names). */
function prettierFiles(cwd) {
let names;
try {
names = readdirSync(cwd);
} catch {
return [];
}
return names.filter(
(name) => name === ".prettierignore" || name.startsWith(".prettierrc"),
);
}

/**
* Findings over a `drizzle/` chain. Silent on repos without one.
*
Expand Down Expand Up @@ -332,8 +384,8 @@ function migrationFindings(cwd) {
/**
* `nk doctor [--fix]` — report drift from the canonical nk-dev model (scripts,
* dependencies, oxlint/tsconfig extends, the CLAUDE.md guide import, stale knip
* ignores, forbidden schema-applying drizzle-kit scripts, a dead
* .prettierignore, an unsealed or unmodelled-DDL-carrying migration chain, a
* ignores, forbidden schema-applying drizzle-kit scripts, Prettier leftovers,
* a missing or thin `ci` script, an unsealed or unmodelled-DDL-carrying migration chain, a
* page under app/auth/ shadowing a Better Auth endpoint).
* With `--fix`, apply every auto-fixable finding, then remind
* to reinstall.
Expand Down
Loading