From 953f3ee70bfeb81b91b37ffe1ad8f8de720bae4c Mon Sep 17 00:00:00 2001 From: Afaq Rashid Date: Fri, 29 May 2026 14:05:55 +0500 Subject: [PATCH] feat(core): add localization completeness checks (#73) Adds opt-in checks.localization. Groups published documents by a translation key (groupField, default document.uid) scoped per content type, and emits CMS-LOCALE-MISSING (warning) for any group that is not published in every configured locale. Only published documents count, so a translation that exists only as a draft is reported as missing. Adapter-agnostic: locale and group key are read from document.data via configurable paths, so no per-adapter changes are needed. Adds a localization check group for --only / --skip and a localization group in the HTML report and JUnit output. --- CHANGELOG.md | 5 + apps/site/app/docs/configuration/page.tsx | 26 ++++++ apps/site/app/docs/diagnostics/page.tsx | 8 ++ packages/cli/src/exporters.ts | 4 + packages/cli/src/index.ts | 1 + packages/core/src/config.test.ts | 26 ++++++ packages/core/src/config.ts | 10 ++ packages/core/src/diagnostics.ts | 8 ++ packages/core/src/scan.test.ts | 109 ++++++++++++++++++++++ packages/core/src/scan.ts | 103 +++++++++++++++++++- packages/core/src/types.ts | 23 ++++- packages/reporter/src/index.ts | 5 + 12 files changed, 326 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62310e8..b427d70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ history stays consistent across GitHub and npm. and `routable`, JWT or API-key auth, and checks SEO and media alt text. Added `cms-lab init --cms payload`. +- Added opt-in localization completeness via `checks.localization`. Groups + published documents by a translation key (`groupField`, default + `document.uid`) and emits `CMS-LOCALE-MISSING` (warning) for any group that + is not published in every configured locale. Adds a `localization` check + group for `--only` / `--skip`. - Added `CMS-RELATIONSHIP-UNPUBLISHED` (warning): a published document whose relationship rule is satisfied only by draft records links to nothing live at runtime. Relationship checks are now status-aware. diff --git a/apps/site/app/docs/configuration/page.tsx b/apps/site/app/docs/configuration/page.tsx index aeaf784..34f2cbf 100644 --- a/apps/site/app/docs/configuration/page.tsx +++ b/apps/site/app/docs/configuration/page.tsx @@ -20,6 +20,7 @@ export default function ConfigurationPage() { { href: "#route-fields", label: "Route fields" }, { href: "#required-fields", label: "Required fields" }, { href: "#relationships", label: "Relationships" }, + { href: "#localization", label: "Localization" }, { href: "#custom-rules", label: "Custom rules" }, ]} > @@ -390,6 +391,31 @@ export default defineConfig({ CMS-RELATIONSHIP-UNPUBLISHED, since the live page links to nothing live at runtime. + +

Localization

+

+ Localization checks flag locale-parity gaps: a content group that is + published in some locales but missing in others. Documents are grouped + by groupField (defaulting to the document UID, common when + locales share a slug), and only published documents count, so a + translation that is still a draft is reported as missing. +

+ {`checks: { + localization: { + locales: ["en", "fr", "de"], + // localeField defaults to "locale"; groupField defaults to the document UID. + localeField: "locale", + groupField: "translationKey", + types: ["page", "article"], + severity: "warning", + }, +}`} +
+ Opt-in + The check runs only when checks.localization is set, and + emits CMS-LOCALE-MISSING for each group missing a locale. + Set types to limit it to localized content types. +
Provider fields SEO and image checks understand the common native shapes from the diff --git a/apps/site/app/docs/diagnostics/page.tsx b/apps/site/app/docs/diagnostics/page.tsx index 82b8cbd..0a1f48d 100644 --- a/apps/site/app/docs/diagnostics/page.tsx +++ b/apps/site/app/docs/diagnostics/page.tsx @@ -108,6 +108,14 @@ export default function DiagnosticsPage() { A published document's relationship is satisfied only by draft records, so it links to nothing live at runtime. + + A content group has no published document in one or more configured + locales. Opt-in via checks.localization. + { ).toThrow(); }); +test("validateConfig accepts localization checks and rejects empty locales", () => { + const base = { + site: { url: "http://localhost:3000" }, + framework: { type: "next", router: "app" }, + cms: { provider: "prismic", repositoryName: "demo" }, + routes: [{ type: "page", pattern: "/:uid", getPath: () => "/about" }], + }; + + const config = validateConfig({ + ...base, + checks: { + localization: { + locales: ["en", "fr"], + groupField: "translationKey", + types: ["page"], + severity: "warning", + }, + }, + }); + expect(config.checks?.localization?.locales).toEqual(["en", "fr"]); + + expect(() => + validateConfig({ ...base, checks: { localization: { locales: [] } } }), + ).toThrow(); +}); + test("validateConfig rejects unsupported SEO check sub-options", () => { expect(() => validateConfig({ diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index d57222e..4477abb 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -313,6 +313,16 @@ const checksSchema = z .optional(), relationships: z.array(relationshipRuleSchema).optional(), custom: z.array(customRuleSchema).optional(), + localization: z + .object({ + locales: z.array(z.string().min(1)).min(1), + localeField: z.string().min(1).optional(), + groupField: z.string().min(1).optional(), + types: z.array(z.string().min(1)).min(1).optional(), + severity: z.enum(["error", "warning", "info"]).optional(), + }) + .strict() + .optional(), }) .strict() .optional(); diff --git a/packages/core/src/diagnostics.ts b/packages/core/src/diagnostics.ts index 52b3af2..81285fe 100644 --- a/packages/core/src/diagnostics.ts +++ b/packages/core/src/diagnostics.ts @@ -193,6 +193,14 @@ const explanations: DiagnosticExplanation[] = [ "A relationship rule expected a document to have one or more matching related records, but cms-lab found fewer than the configured minimum.", fix: "Check the related CMS collection, the join fields in checks.relationships, and whether the related content should be published or active.", }, + { + code: "CMS-LOCALE-MISSING", + severity: "warning", + title: "Content is missing a locale translation", + meaning: + "Localization validation is enabled (checks.localization) and a content group does not have a published document in every configured locale. Only published documents count, so a translation that exists only as a draft is reported as missing.", + fix: "Publish the missing locale translation, or narrow checks.localization (locales, types, or groupField) to match how the project links translations.", + }, { code: "CMS-RELATIONSHIP-UNPUBLISHED", severity: "warning", diff --git a/packages/core/src/scan.test.ts b/packages/core/src/scan.test.ts index b37c84d..e6c4f85 100644 --- a/packages/core/src/scan.test.ts +++ b/packages/core/src/scan.test.ts @@ -2233,3 +2233,112 @@ test("structured-data validation reports routes with no JSON-LD as info", async const diag = result.diagnostics.find((d) => d.code === "SEO-JSONLD-MISSING"); expect(diag?.severity).toBe("info"); }); + +async function scanForLocalization( + localization: unknown, + docs: Array<{ + id: string; + uid?: string; + status: "published" | "draft"; + data: Record; + }>, +) { + return scanDocuments({ + config: { + ...baseConfig, + checks: { + routes: false, + seo: false, + a11y: false, + images: false, + fields: false, + localization, + }, + } as never, + project: { + framework: "next", + router: "app", + rootDir: "/site", + appDir: "/site/app", + }, + documents: docs.map((d) => ({ type: "page" as const, ...d })), + fetch: async () => new Response("ok"), + }); +} + +test("localization flags a content group missing a published locale", async () => { + const result = await scanForLocalization({ locales: ["en", "fr", "de"] }, [ + { id: "en", uid: "about", status: "published", data: { locale: "en" } }, + { id: "fr", uid: "about", status: "published", data: { locale: "fr" } }, + // de exists only as a draft -> not live -> missing. + { id: "de", uid: "about", status: "draft", data: { locale: "de" } }, + ]); + + const diag = result.diagnostics.find((d) => d.code === "CMS-LOCALE-MISSING"); + expect(diag?.severity).toBe("warning"); + expect(diag?.message).toContain("de"); + expect(diag?.message).toContain("page/about"); +}); + +test("localization is silent when every locale is published", async () => { + const result = await scanForLocalization( + { locales: ["en", "fr"], groupField: "translationKey" }, + [ + { + id: "en", + uid: "about-en", + status: "published", + data: { locale: "en", translationKey: "about" }, + }, + { + id: "fr", + uid: "a-propos", + status: "published", + data: { locale: "fr", translationKey: "about" }, + }, + ], + ); + + expect( + result.diagnostics.filter((d) => d.code === "CMS-LOCALE-MISSING"), + ).toEqual([]); +}); + +test("localization skips documents without a locale and respects --skip", async () => { + const docs = [ + { + id: "en", + uid: "about", + status: "published" as const, + data: { locale: "en" }, + }, + // No locale field -> ignored entirely. + { id: "x", uid: "settings", status: "published" as const, data: {} }, + ]; + + const flagged = await scanForLocalization({ locales: ["en", "fr"] }, docs); + expect( + flagged.diagnostics + .filter((d) => d.code === "CMS-LOCALE-MISSING") + .map((d) => d.path), + ).toEqual(["localization.page.about"]); + + const skipped = await scanDocuments({ + config: { + ...baseConfig, + checks: { routes: false, localization: { locales: ["en", "fr"] } }, + } as never, + project: { + framework: "next", + router: "app", + rootDir: "/site", + appDir: "/site/app", + }, + documents: docs.map((d) => ({ type: "page" as const, ...d })), + filters: { skip: ["localization"] }, + fetch: async () => new Response("ok"), + }); + expect( + skipped.diagnostics.filter((d) => d.code === "CMS-LOCALE-MISSING"), + ).toEqual([]); +}); diff --git a/packages/core/src/scan.ts b/packages/core/src/scan.ts index a19f5ba..6a893cd 100644 --- a/packages/core/src/scan.ts +++ b/packages/core/src/scan.ts @@ -115,6 +115,10 @@ export async function scanDocuments( ); } + if (shouldRunCheck("localization", options.config, options.filters)) { + diagnostics.push(...checkLocalization(options.config, documents)); + } + return { project: options.project, documents, @@ -1147,6 +1151,99 @@ function relationshipRules(config: CmsLabConfig): RelationshipRule[] { return config.checks?.relationships ?? []; } +/** + * Locale-parity check. Groups published documents by a translation key + * (`groupField`, defaulting to `document.uid`) and flags any group that does + * not have a live document in every configured locale. Only published + * documents count toward a locale being present, so a translation that exists + * only as a draft is reported as missing. + */ +function checkLocalization( + config: CmsLabConfig, + documents: CMSDocument[], +): Diagnostic[] { + const options = config.checks?.localization; + if (!options) { + return []; + } + + const localeField = options.localeField ?? "locale"; + const expected = [...new Set(options.locales)]; + const typeFilter = options.types ? new Set(options.types) : undefined; + + const groups = new Map< + string, + { + type: string; + groupKey: string; + locales: Set; + sample: CMSDocument; + } + >(); + + for (const document of documents) { + if (document.status !== "published") { + continue; + } + if (typeFilter && !typeFilter.has(document.type)) { + continue; + } + + const data = asRecord(document.data) ?? {}; + const localeValue = readCmsDataPath(data, localeField); + if (typeof localeValue !== "string" || localeValue.trim().length === 0) { + continue; + } + const locale = localeValue.trim(); + + const groupValue = options.groupField + ? readCmsDataPath(data, options.groupField) + : document.uid; + const groupKey = + groupValue === undefined || groupValue === null + ? undefined + : String(groupValue); + if (!groupKey) { + continue; + } + + const key = `${document.type}:${groupKey}`; + const existing = groups.get(key); + if (existing) { + existing.locales.add(locale); + } else { + groups.set(key, { + type: document.type, + groupKey, + locales: new Set([locale]), + sample: document, + }); + } + } + + const diagnostics: Diagnostic[] = []; + + for (const group of groups.values()) { + const missing = expected.filter((locale) => !group.locales.has(locale)); + if (missing.length === 0) { + continue; + } + + const present = [...group.locales].sort().join(", ") || "none"; + diagnostics.push( + createDiagnostic({ + severity: options.severity ?? "warning", + code: "CMS-LOCALE-MISSING", + message: `Content group ${group.type}/${group.groupKey} is missing locale(s): ${missing.join(", ")} (published: ${present})`, + path: `localization.${group.type}.${group.groupKey}`, + source: sourceFor(config, group.sample), + }), + ); + } + + return diagnostics; +} + function summarizeDiagnosticGroups( diagnostics: Diagnostic[], config: CmsLabConfig, @@ -1600,7 +1697,11 @@ function shouldRunCheck( return isCheckEnabled(config.checks?.images, true); } - if (group === "relationships" || group === "custom") { + if ( + group === "relationships" || + group === "custom" || + group === "localization" + ) { return true; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 29c3495..397a2c3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -161,6 +161,25 @@ export type CustomRuleFn = ( export type CustomRule = CustomDeclarativeRule | CustomRuleFn; +/** + * Locale-parity check. Groups published documents by a translation key and + * flags any group that does not have a live document in every expected locale. + */ +export type LocalizationCheckOptions = { + /** Locales every content group should have, e.g. ["en", "fr", "de"]. */ + locales: string[]; + /** Path in `document.data` to the document's locale. Defaults to "locale". */ + localeField?: string; + /** + * Path in `document.data` to the value that links translations of the same + * content. Defaults to `document.uid` (common when locales share a slug). + */ + groupField?: string; + /** Limit the check to these content types. */ + types?: string[]; + severity?: DiagnosticSeverity; +}; + export type PrismicCmsProviderConfig = { provider: "prismic"; repositoryName: string; @@ -315,6 +334,7 @@ export type CmsLabConfig = { fields?: boolean | { required?: RequiredFieldRule[] }; relationships?: RelationshipRule[]; custom?: CustomRule[]; + localization?: LocalizationCheckOptions; }; }; @@ -352,7 +372,8 @@ export type CheckGroup = | "images" | "fields" | "relationships" - | "custom"; + | "custom" + | "localization"; export type ScanFilters = { types?: string[]; diff --git a/packages/reporter/src/index.ts b/packages/reporter/src/index.ts index 2f02e8d..d63d3ec 100644 --- a/packages/reporter/src/index.ts +++ b/packages/reporter/src/index.ts @@ -644,6 +644,7 @@ function groupDiagnostics(diagnostics: Diagnostic[]): DiagnosticGroup[] { "routes", "fields", "relationships", + "localization", "seo", "a11y", "custom", @@ -677,6 +678,10 @@ function groupForDiagnostic(diagnostic: Diagnostic): string { return "relationships"; } + if (diagnostic.code.startsWith("CMS-LOCALE")) { + return "localization"; + } + if (diagnostic.code.startsWith("SEO-")) { return "seo"; }