diff --git a/.reposkein/decisions/2026-09-08-one-certification-i18n-key-contract-for-every-dashboard.json b/.reposkein/decisions/2026-09-08-one-certification-i18n-key-contract-for-every-dashboard.json new file mode 100644 index 0000000..5377acb --- /dev/null +++ b/.reposkein/decisions/2026-09-08-one-certification-i18n-key-contract-for-every-dashboard.json @@ -0,0 +1,18 @@ +{ + "id": "adr:2026-09-08-one-certification-i18n-key-contract-for-every-dashboard", + "alternatives": "- **Ship the translated strings from `@cellarnode/i18n` as a shared namespace** — deferred: it fixes translation drift too, but both dashboards would have to load an extra namespace and the i18n pipeline tokens are currently broken; the key contract here is the prerequisite either way. - **Keep the two key paths and only align the English** — rejected: it is exactly the state that let the locales drift. - **Interpolate the raw id into the key unchanged** — rejected in review: `eu.organic` nests through `keySeparator` and `ns:organic` is read as a namespace.", + "anchors": [], + "body_hash": "v2:185100e5ae0f6675ee8c3adf625e912fb990a252b3068eb63d5f310f8442a650", + "consequences": "Adding a certification is one edit here plus one English string per consumer; renaming a key path in either dashboard without changing this module is a contract break and should fail that repo's i18n wiring test. The guarantee is key-path parity and one English source string, not translation parity: the seven machine-translated strings still live in each consumer's locale JSON until a shared namespace ships in `@cellarnode/i18n`, so wording can still differ between surfaces in non-English locales.", + "context": "The producer dashboard rendered the Organic / Fairtrade / Sustainable chips through `offers:form.certification.` and the importer dashboard through `opportunities:certifications.`. The English source strings were identical, but each repo's polyglot-i18n run translated its own copy, so the seven non-English locales could drift between surfaces (found in the CEL-1699 review). Certifications are not a backend canonical `reference_data` row (no `certifications` dataId in `src/canonical/reference-data.json`), so the id list has no parity test to anchor to; the contract has to live in this package.", + "decided_at": "2026-09-08", + "decided_by": "human", + "decision": "`src/certifications.ts` is the single owner of the certification id vocabulary and of the i18n key shape both dashboards bind to. The canonical ids are the frozen tuple `CERTIFICATION_TYPES` (`organic`, `fairtrade`, `sustainable`). `certificationLabelKey(id)` returns `{ key: \"certification.\", fallback }` where a canonical id is matched after trim and lower-case and always yields the canonical segment plus the shared English fallback, and an unknown id yields `certification.` with `.` and `:` replaced by `-` (i18next key and namespace separators) and the trimmed raw id echoed as the fallback so a new certification renders readably before its copy exists. The key carries no namespace: each consumer resolves it in its own default namespace (`offers` for producer, `opportunities` for importer) and owns the `certification.*` block in its English locale file. The guard is `isCertification(value: unknown)` and the boundary normaliser is `normalizeAndCheckCertification`, matching the sibling vocabularies (`isCurrency`, `isPackaging`, `isClosure`, `normalizeAndCheckPackaging`).", + "paths": [], + "status": "proposed", + "supersedes": [], + "title": "One certification i18n key contract for every dashboard", + "trigger": { + "kind": "manual" + } +} diff --git a/__tests__/certifications.test.ts b/__tests__/certifications.test.ts new file mode 100644 index 0000000..36f8bd3 --- /dev/null +++ b/__tests__/certifications.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + CERTIFICATION_TYPES, + certificationLabelKey, + isCertification, + normalizeAndCheckCertification, +} from "../src/index.js"; + +describe("certificationLabelKey (CEL-1702)", () => { + it("returns the shared key path and English fallback for the canonical types", () => { + expect(certificationLabelKey("organic")).toEqual({ + key: "certification.organic", + fallback: "Organic", + }); + expect(certificationLabelKey("fairtrade")).toEqual({ + key: "certification.fairtrade", + fallback: "Fairtrade", + }); + expect(certificationLabelKey("sustainable")).toEqual({ + key: "certification.sustainable", + fallback: "Sustainable", + }); + }); + + it("normalises case and whitespace before matching a canonical id", () => { + expect(certificationLabelKey(" Organic ")).toEqual({ + key: "certification.organic", + fallback: "Organic", + }); + expect(certificationLabelKey("FAIRTRADE")).toEqual({ + key: "certification.fairtrade", + fallback: "Fairtrade", + }); + }); + + it("keeps a stable key and the raw id as fallback for an unknown certification", () => { + expect(certificationLabelKey("biodynamic")).toEqual({ + key: "certification.biodynamic", + fallback: "biodynamic", + }); + expect(certificationLabelKey("")).toEqual({ key: "certification.", fallback: "" }); + }); + + it("keeps i18next separators out of the key for unknown ids", () => { + expect(certificationLabelKey("eu.organic").key).toBe("certification.eu-organic"); + expect(certificationLabelKey("ns:organic").key).toBe("certification.ns-organic"); + expect(certificationLabelKey("eu.organic").fallback).toBe("eu.organic"); + }); + + it.each(["constructor", "toString", "__proto__", "valueOf", "hasOwnProperty"])( + "does not resolve the prototype name %s as a certification", + (name) => { + expect(isCertification(name)).toBe(false); + expect(normalizeAndCheckCertification(name)).toBeNull(); + expect(certificationLabelKey(name).fallback).toBe(name); + }, + ); + + it("rejects non-string and empty input", () => { + expect(isCertification(undefined)).toBe(false); + expect(isCertification(42)).toBe(false); + expect(isCertification("")).toBe(false); + expect(normalizeAndCheckCertification(null)).toBeNull(); + expect(normalizeAndCheckCertification(" ")).toBeNull(); + }); + + it("exposes a frozen canonical id list", () => { + expect([...CERTIFICATION_TYPES]).toEqual(["organic", "fairtrade", "sustainable"]); + expect(CERTIFICATION_TYPES.every(isCertification)).toBe(true); + expect(Object.isFrozen(CERTIFICATION_TYPES)).toBe(true); + expect(() => { + (CERTIFICATION_TYPES as unknown as string[]).push("biodynamic"); + }).toThrow(); + }); +}); diff --git a/src/certifications.ts b/src/certifications.ts new file mode 100644 index 0000000..1f05297 --- /dev/null +++ b/src/certifications.ts @@ -0,0 +1,85 @@ +/** + * Canonical certification labels shared by every dashboard (CEL-1702). + * + * The producer and importer dashboards used to render the Organic / Fairtrade / + * Sustainable chips through two unrelated i18n key paths with identical + * English. Consumers now resolve one stable key per certification through + * this helper and add that key (with the English fallback) to their own + * locale file. + * + * Guarantee: key-path parity and one English source string. The seven + * non-English translations still live in each consumer's locale JSON and are + * produced by that repo's polyglot-i18n run, so they can still differ between + * surfaces until a shared namespace ships in `@cellarnode/i18n`. + * + * Naming follows the sibling vocabularies (`isCurrency`, `isPackaging`, + * `isClosure`): the guard is `isCertification`, the type `CertificationType`. + */ + +export const CERTIFICATION_TYPES = Object.freeze([ + "organic", + "fairtrade", + "sustainable", +] as const); + +export type CertificationType = (typeof CERTIFICATION_TYPES)[number]; + +export interface CertificationLabelKey { + /** Stable i18n key, identical across dashboards: `certification.`. */ + key: string; + /** English fallback for `t(key, fallback)`. */ + fallback: string; +} + +const CERTIFICATION_FALLBACKS: Readonly> = Object.freeze({ + organic: "Organic", + fairtrade: "Fairtrade", + sustainable: "Sustainable", +}); + +/** + * Strict predicate over an already-normalised value: true only for the three + * canonical ids. `Object.hasOwn` keeps prototype names (`constructor`, + * `toString`, `__proto__`) out of the lookup. Use + * `normalizeAndCheckCertification` at input boundaries. + */ +export function isCertification(value: unknown): value is CertificationType { + return typeof value === "string" && Object.hasOwn(CERTIFICATION_FALLBACKS, value); +} + +/** + * Trims and lower-cases `value`, then returns the canonical id or `null`. + * Mirrors `normalizeAndCheckPackaging`: use at boundaries where upstream input + * may be padded (`" Organic "`) or cased (`"FAIRTRADE"`). + */ +export function normalizeAndCheckCertification(value: unknown): CertificationType | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + return isCertification(normalized) ? normalized : null; +} + +/** + * Turns an arbitrary id into a single i18n key segment. i18next treats `.` as + * `keySeparator` and `:` as `nsSeparator`, so an id like `eu.organic` would + * otherwise nest and `ns:organic` would be read as a namespace. + */ +function keySegment(id: string): string { + return id.replace(/[.:]/g, "-"); +} + +/** + * Resolves the shared i18n key and English fallback for a certification chip. + * Canonical ids are matched case- and whitespace-insensitively and always + * yield `certification.`. Unknown ids still get a stable key + * (`certification.` with `.` and `:` replaced by `-`) and the trimmed raw + * id as fallback, so a new certification renders readably before its copy + * exists. + */ +export function certificationLabelKey(type: string): CertificationLabelKey { + const canonical = normalizeAndCheckCertification(type); + if (canonical !== null) { + return { key: `certification.${canonical}`, fallback: CERTIFICATION_FALLBACKS[canonical] }; + } + const raw = type.trim(); + return { key: `certification.${keySegment(raw)}`, fallback: raw }; +} diff --git a/src/index.ts b/src/index.ts index b0ebccd..f8c328e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -150,3 +150,11 @@ export { normalizeAndCheckBeverageCategoryId, normalizeAndCheckBeverageSubtypeId, } from "./classifications.js"; + +export type { CertificationLabelKey, CertificationType } from "./certifications.js"; +export { + CERTIFICATION_TYPES, + certificationLabelKey, + isCertification, + normalizeAndCheckCertification, +} from "./certifications.js";