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
Original file line number Diff line number Diff line change
@@ -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.<id>` and the importer dashboard through `opportunities:certifications.<id>`. 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.<id>\", 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.<id>` 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"
}
}
75 changes: 75 additions & 0 deletions __tests__/certifications.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
85 changes: 85 additions & 0 deletions src/certifications.ts
Original file line number Diff line number Diff line change
@@ -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.<id>`. */
key: string;
/** English fallback for `t(key, fallback)`. */
fallback: string;
}

const CERTIFICATION_FALLBACKS: Readonly<Record<CertificationType, string>> = 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.<canonical id>`. Unknown ids still get a stable key
* (`certification.<id>` 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 };
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading