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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions apps/site/app/docs/configuration/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]}
>
Expand Down Expand Up @@ -390,6 +391,31 @@ export default defineConfig({
<code>CMS-RELATIONSHIP-UNPUBLISHED</code>, since the live page links to
nothing live at runtime.
</div>

<h2 id="localization">Localization</h2>
<p>
Localization checks flag locale-parity gaps: a content group that is
published in some locales but missing in others. Documents are grouped
by <code>groupField</code> (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.
</p>
<CodeBlock>{`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",
},
}`}</CodeBlock>
<div className="callout">
<strong>Opt-in</strong>
The check runs only when <code>checks.localization</code> is set, and
emits <code>CMS-LOCALE-MISSING</code> for each group missing a locale.
Set <code>types</code> to limit it to localized content types.
</div>
<div className="callout">
<strong>Provider fields</strong>
SEO and image checks understand the common native shapes from the
Expand Down
8 changes: 8 additions & 0 deletions apps/site/app/docs/diagnostics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ export default function DiagnosticsPage() {
A published document&apos;s relationship is satisfied only by draft
records, so it links to nothing live at runtime.
</DiagnosticCode>
<DiagnosticCode
code="CMS-LOCALE-MISSING"
severity="warning"
group="localization"
>
A content group has no published document in one or more configured
locales. Opt-in via <code>checks.localization</code>.
</DiagnosticCode>
<DiagnosticCode
code="SEO-META-MISSING"
severity="warning"
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/exporters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ function groupForDiagnostic(diagnostic: Diagnostic): string {
return "relationships";
}

if (diagnostic.code.startsWith("CMS-LOCALE")) {
return "localization";
}

if (diagnostic.code.startsWith("SEO-")) {
return "seo";
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1688,6 +1688,7 @@ function parseCheckGroups(values: string[] | undefined): CheckGroup[] {
"fields",
"relationships",
"custom",
"localization",
]);
const groups = splitList(values);

Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,32 @@ test("validateConfig rejects unknown keys in a custom rule", () => {
).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({
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
109 changes: 109 additions & 0 deletions packages/core/src/scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}>,
) {
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([]);
});
103 changes: 102 additions & 1 deletion packages/core/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string>;
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,
Expand Down Expand Up @@ -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;
}

Expand Down
Loading