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";
}