From ba1ee6617478f6d0bea819e8574c96f85ad0621e Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:13:18 +0200 Subject: [PATCH] fix(i18n): quote raw in the auto-sync profiles description (#12549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged for the half that is still live. The 42 locale catalogs were fixed upstream while this sat open — all 51 now carry `''` — but the TypeScript default at `featureFlagDefinitions.ts:625` still had the raw tag, so the Feature Flags card kept failing to compile wherever the default is the source. Against the current tip this PR lands exactly three files: that one-character fix, the changelog fragment, and your 135-line regression test, with zero locale files touched. The analysis is what makes it worth keeping. `next-intl` parsing `` as a rich-text tag, `FeatureFlagsGrid.tsx` rendering the description through plain `t()` with no tag element, and the result being `INVALID_MESSAGE: UNCLOSED_TAG` — the card showing the raw key instead of the description, in every language — is a failure mode that is easy to misread as a missing translation. Verifying it against `use-intl`'s `development` build, the one Turbopack dev mode actually loads, is the detail that makes the reproduction trustworthy. Wrapping in ICU single quotes matches what #12369 did for `ccOnboardingKeyPlaceholder`. 5/5 on the regression suite against the tip. --- ⚠️ base-red inherited: #12732. Thanks @pacocartones — 18 more of yours merged today. --- .../12549-i18n-escape-raw-name-tag-12505.md | 1 + .../constants/featureFlagDefinitions.ts | 2 +- ...-flag-auto-sync-profiles-tag-12505.test.ts | 135 ++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/12549-i18n-escape-raw-name-tag-12505.md create mode 100644 tests/unit/i18n-feature-flag-auto-sync-profiles-tag-12505.test.ts diff --git a/changelog.d/fixes/12549-i18n-escape-raw-name-tag-12505.md b/changelog.d/fixes/12549-i18n-escape-raw-name-tag-12505.md new file mode 100644 index 0000000000..6de3771893 --- /dev/null +++ b/changelog.d/fixes/12549-i18n-escape-raw-name-tag-12505.md @@ -0,0 +1 @@ +- **fix(i18n):** Wrap the `~/.claude/profiles//settings.json` placeholder in ICU single quotes in the `OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES` feature-flag description across all 42 locales and the TypeScript default, so next-intl no longer fails with `INVALID_MESSAGE: UNCLOSED_TAG` and the Feature Flags card shows the description instead of the raw key (#12549 — thanks @pacocartones) diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 88ace23020..8271d21c5b 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -622,7 +622,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ key: "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES", label: "Auto-Sync Claude Code Profiles", description: - "After a provider model sync, automatically (re)write ~/.claude/profiles//settings.json Claude Code profiles from the live catalog. Never changes the active/default Claude config. Off by default.", + "After a provider model sync, automatically (re)write ~/.claude/profiles/''/settings.json Claude Code profiles from the live catalog. Never changes the active/default Claude config. Off by default.", descriptionI18nKey: "featureFlagOmnirouteAutoSyncClaudeProfilesDescription", category: "cli", defaultValue: "false", diff --git a/tests/unit/i18n-feature-flag-auto-sync-profiles-tag-12505.test.ts b/tests/unit/i18n-feature-flag-auto-sync-profiles-tag-12505.test.ts new file mode 100644 index 0000000000..12beeb7119 --- /dev/null +++ b/tests/unit/i18n-feature-flag-auto-sync-profiles-tag-12505.test.ts @@ -0,0 +1,135 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { parse } from "@formatjs/icu-messageformat-parser"; +import { createTranslator } from "next-intl"; +import i18nConfig from "../../config/i18n.json" with { type: "json" }; + +const { FEATURE_FLAG_DEFINITIONS } = + await import("../../src/shared/constants/featureFlagDefinitions.ts"); + +const MESSAGES_DIR = path.resolve("src/i18n/messages"); +const FLAG_KEY = "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES"; +const MESSAGE_KEY = `definitions.${FLAG_KEY}.description`; +const RAW_PATH = "profiles//"; +const QUOTED_PATH = "profiles/''/"; +const ENTITY_PATH = "profiles/<name>/"; +const RENDERED_PATH = "~/.claude/profiles//settings.json"; + +/** + * Regression guard for #12505 (INVALID_MESSAGE: UNCLOSED_TAG on the Feature + * Flags page). The `featureFlags.definitions.OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES.description` + * message carried a literal `~/.claude/profiles//settings.json` path. + * next-intl parses `` as a rich-text tag, no tag element is ever passed + * by `FeatureFlagsGrid.tsx` (plain `t()`), so the message failed to compile and + * the card fell back to the raw key in every locale. + * + * Fix: the placeholder is wrapped in ICU single quotes (`''`) so the + * angle brackets render literally. HTML entities are not an option here: the + * value is a real file path shown to the user, and `t()` returns entities + * verbatim (`<name>` would be displayed as-is). + */ + +function flatten(obj: Record, prefix = ""): Record { + const out: Record = {}; + for (const k of Object.keys(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + const v = obj[k]; + if (v && typeof v === "object" && !Array.isArray(v)) { + Object.assign(out, flatten(v as Record, key)); + } else { + out[key] = v; + } + } + return out; +} + +describe(`i18n — ${FLAG_KEY} description UNCLOSED_TAG regression (#12505)`, () => { + const localeFiles = fs + .readdirSync(MESSAGES_DIR) + .filter((f) => f.endsWith(".json")) + .sort(); + const expectedCount = i18nConfig.locales.length; + + function readDescription(file: string): string { + const raw = fs.readFileSync(path.join(MESSAGES_DIR, file), "utf8"); + assert.notEqual(raw.charCodeAt(0), 0xfeff, `${file}: starts with BOM (U+FEFF)`); + const flat = flatten(JSON.parse(raw) as Record); + const value = flat[`featureFlags.${MESSAGE_KEY}`]; + assert.equal(typeof value, "string", `${file}: featureFlags.${MESSAGE_KEY} must be a string`); + return value as string; + } + + it(`the description exists in all ${expectedCount} locales`, () => { + assert.equal(localeFiles.length, expectedCount); + for (const file of localeFiles) { + readDescription(file); + } + }); + + it("every locale value parses as an ICU message (no unclosed tag)", () => { + const failures: string[] = []; + for (const file of localeFiles) { + try { + parse(readDescription(file), { captureLocation: false, shouldParseSkeletons: true }); + } catch (error) { + failures.push(`${file}: ${error instanceof Error ? error.message : String(error)}`); + } + } + assert.deepEqual(failures, [], `ICU parse failures: ${failures.slice(0, 5).join("; ")}`); + }); + + it("every locale wraps the profile path placeholder in ICU single quotes", () => { + const offenders: string[] = []; + for (const file of localeFiles) { + const value = readDescription(file); + if (value.includes(RAW_PATH)) offenders.push(`${file}: raw ${RAW_PATH}`); + if (value.includes(ENTITY_PATH)) offenders.push(`${file}: entity ${ENTITY_PATH}`); + if (!value.includes(QUOTED_PATH)) offenders.push(`${file}: missing ${QUOTED_PATH}`); + } + assert.deepEqual(offenders, [], offenders.slice(0, 10).join(", ")); + }); + + it("createTranslator renders the literal path in every locale without INVALID_MESSAGE", () => { + const errors: string[] = []; + const wrong: string[] = []; + for (const file of localeFiles) { + const locale = file.replace(/\.json$/, ""); + const messages = JSON.parse(fs.readFileSync(path.join(MESSAGES_DIR, file), "utf8")); + const t = createTranslator({ + locale, + messages, + namespace: "featureFlags", + onError: (err: { code?: string; originalMessage?: string; message?: string }) => { + errors.push(`${locale}: ${err.code}: ${err.originalMessage ?? err.message}`); + }, + }); + assert.ok(t.has(MESSAGE_KEY), `${locale}: t.has(${MESSAGE_KEY}) must be true`); + const rendered = t(MESSAGE_KEY); + if (!rendered.includes(RENDERED_PATH)) { + wrong.push(`${locale}: ${rendered.slice(0, 80)}`); + } + } + assert.deepEqual(errors, [], `next-intl errors: ${errors.slice(0, 5).join("; ")}`); + assert.deepEqual( + wrong, + [], + `rendered text lost the literal path: ${wrong.slice(0, 5).join("; ")}` + ); + }); + + it("the TypeScript default description parses and uses the same quoting", () => { + const flag = FEATURE_FLAG_DEFINITIONS.find((f) => f.key === FLAG_KEY); + assert.ok(flag, `${FLAG_KEY} must be defined`); + assert.doesNotThrow(() => + parse(flag.description, { captureLocation: false, shouldParseSkeletons: true }) + ); + assert.ok(flag.description.includes(QUOTED_PATH), `default must contain ${QUOTED_PATH}`); + assert.equal( + flag.description.includes(RAW_PATH), + false, + `default must not contain ${RAW_PATH}` + ); + }); +});