From baf9863a3e0c643470995346a72793710bad0944 Mon Sep 17 00:00:00 2001 From: mjnong Date: Tue, 8 Sep 2026 08:22:52 +0200 Subject: [PATCH 1/3] feat(certifications): shared certificationLabelKey so producer and importer resolve one key path per certification (CEL-1702) --- __tests__/certifications.test.ts | 40 +++++++++++++++++++++++++++++ src/certifications.ts | 43 ++++++++++++++++++++++++++++++++ src/index.ts | 7 ++++++ 3 files changed, 90 insertions(+) create mode 100644 __tests__/certifications.test.ts create mode 100644 src/certifications.ts diff --git a/__tests__/certifications.test.ts b/__tests__/certifications.test.ts new file mode 100644 index 0000000..5502c6e --- /dev/null +++ b/__tests__/certifications.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + CERTIFICATION_TYPES, + certificationLabelKey, + isCertificationType, +} 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("keeps a stable key and the raw id as fallback for an unknown certification", () => { + expect(certificationLabelKey("biodynamic")).toEqual({ + key: "certification.biodynamic", + fallback: "biodynamic", + }); + }); + + it("does not resolve prototype names as certifications", () => { + expect(isCertificationType("constructor")).toBe(false); + expect(certificationLabelKey("constructor").fallback).toBe("constructor"); + }); + + it("exposes the canonical id list", () => { + expect([...CERTIFICATION_TYPES]).toEqual(["organic", "fairtrade", "sustainable"]); + expect(CERTIFICATION_TYPES.every(isCertificationType)).toBe(true); + }); +}); diff --git a/src/certifications.ts b/src/certifications.ts new file mode 100644 index 0000000..c8ec866 --- /dev/null +++ b/src/certifications.ts @@ -0,0 +1,43 @@ +/** + * 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, so the machine-translated locales could drift between surfaces. + * Consumers now resolve one stable key per certification through this helper + * and add that key (with the English fallback) to their own locale file. + */ + +export const CERTIFICATION_TYPES = ["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: Record = { + organic: "Organic", + fairtrade: "Fairtrade", + sustainable: "Sustainable", +}; + +/** True for the three canonical certification ids. */ +export function isCertificationType(type: string): type is CertificationType { + return Object.hasOwn(CERTIFICATION_FALLBACKS, type); +} + +/** + * Resolves the shared i18n key and English fallback for a certification chip. + * Unknown ids still get a stable key (`certification.`) and the raw id as + * fallback, so a new certification renders readably before its copy exists. + * `Object.hasOwn` keeps prototype names (`constructor`, `toString`) out of the + * lookup. + */ +export function certificationLabelKey(type: string): CertificationLabelKey { + const fallback = isCertificationType(type) ? CERTIFICATION_FALLBACKS[type] : type; + return { key: `certification.${type}`, fallback }; +} diff --git a/src/index.ts b/src/index.ts index b0ebccd..02f3b7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -150,3 +150,10 @@ export { normalizeAndCheckBeverageCategoryId, normalizeAndCheckBeverageSubtypeId, } from "./classifications.js"; + +export type { CertificationLabelKey, CertificationType } from "./certifications.js"; +export { + CERTIFICATION_TYPES, + certificationLabelKey, + isCertificationType, +} from "./certifications.js"; From 1ca2ac8b2ac62eace54e019a225e16060d0a3b41 Mon Sep 17 00:00:00 2001 From: mjnong Date: Tue, 8 Sep 2026 08:38:47 +0200 Subject: [PATCH 2/3] fix(certifications): isCertification over unknown, normalizeAndCheckCertification, separator-safe keys, frozen id list (CEL-1702 review) --- __tests__/certifications.test.ts | 47 +++++++++++++++++--- src/certifications.ts | 74 +++++++++++++++++++++++++------- src/index.ts | 3 +- 3 files changed, 101 insertions(+), 23 deletions(-) diff --git a/__tests__/certifications.test.ts b/__tests__/certifications.test.ts index 5502c6e..36f8bd3 100644 --- a/__tests__/certifications.test.ts +++ b/__tests__/certifications.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest"; import { CERTIFICATION_TYPES, certificationLabelKey, - isCertificationType, + isCertification, + normalizeAndCheckCertification, } from "../src/index.js"; describe("certificationLabelKey (CEL-1702)", () => { @@ -21,20 +22,54 @@ describe("certificationLabelKey (CEL-1702)", () => { }); }); + 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("does not resolve prototype names as certifications", () => { - expect(isCertificationType("constructor")).toBe(false); - expect(certificationLabelKey("constructor").fallback).toBe("constructor"); + 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 the canonical id list", () => { + it("exposes a frozen canonical id list", () => { expect([...CERTIFICATION_TYPES]).toEqual(["organic", "fairtrade", "sustainable"]); - expect(CERTIFICATION_TYPES.every(isCertificationType)).toBe(true); + 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 index c8ec866..1f05297 100644 --- a/src/certifications.ts +++ b/src/certifications.ts @@ -3,41 +3,83 @@ * * The producer and importer dashboards used to render the Organic / Fairtrade / * Sustainable chips through two unrelated i18n key paths with identical - * English, so the machine-translated locales could drift between surfaces. - * Consumers now resolve one stable key per certification through this helper - * and add that key (with the English fallback) to their own locale file. + * 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 = ["organic", "fairtrade", "sustainable"] as const; +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.`. */ + /** Stable i18n key, identical across dashboards: `certification.`. */ key: string; /** English fallback for `t(key, fallback)`. */ fallback: string; } -const CERTIFICATION_FALLBACKS: Record = { +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); +} -/** True for the three canonical certification ids. */ -export function isCertificationType(type: string): type is CertificationType { - return Object.hasOwn(CERTIFICATION_FALLBACKS, type); +/** + * 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. - * Unknown ids still get a stable key (`certification.`) and the raw id as - * fallback, so a new certification renders readably before its copy exists. - * `Object.hasOwn` keeps prototype names (`constructor`, `toString`) out of the - * lookup. + * 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 fallback = isCertificationType(type) ? CERTIFICATION_FALLBACKS[type] : type; - return { key: `certification.${type}`, fallback }; + 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 02f3b7d..f8c328e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -155,5 +155,6 @@ export type { CertificationLabelKey, CertificationType } from "./certifications. export { CERTIFICATION_TYPES, certificationLabelKey, - isCertificationType, + isCertification, + normalizeAndCheckCertification, } from "./certifications.js"; From 9600a6355b064f32c37a194797309db440ac417b Mon Sep 17 00:00:00 2001 From: mjnong Date: Tue, 8 Sep 2026 08:39:56 +0200 Subject: [PATCH 3/3] docs(adr): one certification i18n key contract for every dashboard (CEL-1702) --- ...-i18n-key-contract-for-every-dashboard.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .reposkein/decisions/2026-09-08-one-certification-i18n-key-contract-for-every-dashboard.json 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" + } +}