Skip to content
Merged
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
112 changes: 81 additions & 31 deletions scripts/content-lint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,38 @@ const today = new Date();
today.setHours(23, 59, 59, 999);

const VALID_STATUS = new Set(['published', 'archived']);
const VALID_TOPIC = new Set([
'ai-agents',
'app-building',
'integration-data',
'automation',
'modernization',
'governance',
'customer-stories',
]);
const VALID_AUDIENCE = new Set(['business', 'it', 'developer', 'general']);

// The frontmatter taxonomy, derived from the module that declares it instead of
// restated here. `src/lib/term-data.ts` holds the same rows `src/content.config.ts`
// turns into its Zod enums (through `slugsByGroup` in `src/lib/terms.ts`), so one
// edit to the taxonomy moves this gate and the build together.
//
// A literal copy here was never a second opinion, it was a second producer of one
// fact — and it drifted in the direction an author feels: add a term, use it in a
// post, and this script rejected the post that `astro check` and `astro build`
// both accept, naming the post while the file to edit was this one.
//
// The frontmatter field and the taxonomy group differ in name for the audience
// axis: `audience` holds a `role` slug. `src/content.config.ts` maps it the same
// way (`audience: z.enum(ROLE)`).
const { RAW_TERMS, termSlugPath } = await readTermData();
const slugsInGroup = (group) =>
new Set(RAW_TERMS.filter((term) => term.group === group).map((term) => term.slug));
const VALID_TOPIC = slugsInGroup('topic');
const VALID_AUDIENCE = slugsInGroup('role');
const VALID_SOLUTION = slugsInGroup('solution');
const VALID_INDUSTRY = slugsInGroup('industry');

// `topic` and `audience` carry one slug; `solutions` and `industries` carry a
// list of them (`z.array(z.enum(...)).default([])` in `src/content.config.ts`).
// Only an absent field defaults to the empty list there, so anything else that
// is not a list — `solutions:` with nothing after it parses as null — is an
// error here too, which is the point: this gate and the build should reach the
// same verdict on the same file.
const LIST_TERM_FIELDS = [
{ field: 'solutions', noun: 'solution', valid: VALID_SOLUTION },
{ field: 'industries', noun: 'industry', valid: VALID_INDUSTRY },
];
const PLACEHOLDERS = [
{ label: 'Write your article here', pattern: /write your article here/i },
{ label: 'your-domain.com', pattern: /your-domain\.com/i },
Expand Down Expand Up @@ -202,30 +224,23 @@ async function readClusterSlugs() {
}

/**
* Topic hubs — `/<locale>/blog/topics/<slugPath>/`, one page per term the site
* has content for. Two separate facts decide whether such a URL exists, so the
* registry carries both:
* The taxonomy itself, read once from the module that declares it. Two things in
* this script need it — the frontmatter term checks above and the topic-hub
* registry below — and reading it once is the point: a second reader is a second
* producer of the same fact, which is the drift this gate exists to catch.
*
* * the term list and its nesting, from `src/lib/term-data.ts`. This script
* imports the module and calls its own `termSlugPath`, so the gate and the
* route cannot disagree about where a hub lives — re-deriving the slug list
* by text-matching a TypeScript file is exactly the tolerant re-parse a gate
* must not do. That module deliberately has no value imports; see its header.
* * which terms have content in which locale. `getStaticPaths` emits a hub
* only for the terms `getAllUsedTerms(locale)` returns, and a topic hub also
* aggregates its children's articles (`articleHasTerm`, src/lib/posts.ts).
*
* Keyed on PUBLISHED posts whatever `--published` says: `astro build` exposes
* published posts only (`shouldExposePost`), so a link asks what production
* serves, not what `astro dev` additionally renders.
* The module is imported, never text-matched: re-deriving the slug list by
* scraping a TypeScript file is exactly the tolerant re-parse a gate must not do.
* `src/lib/term-data.ts` deliberately has no value imports so that plain Node can
* load it; see its header before adding one.
*/
async function readTermHubs() {
let module;
async function readTermData() {
try {
module = await import(pathToFileURL(TERMS_MODULE).href);
return await import(pathToFileURL(TERMS_MODULE).href);
} catch (error) {
console.error(
`✗ content lint could not read the term list from src/lib/term-data.ts, so ` +
`topic / audience / solutions / industries cannot be checked and ` +
`/<locale>/blog/topics/<slug>/ links cannot be resolved.\n` +
` This script imports that module directly, which needs Node type ` +
`stripping (Node 22.18+) and a module with no value imports — an ` +
Expand All @@ -234,8 +249,25 @@ async function readTermHubs() {
);
exit(1);
}
const { RAW_TERMS: terms, termSlugPath } = module;
}

/**
* Topic hubs — `/<locale>/blog/topics/<slugPath>/`, one page per term the site
* has content for. Two separate facts decide whether such a URL exists, so the
* registry carries both:
*
* * the term list and its nesting, from `src/lib/term-data.ts` (`readTermData`
* above). This script calls that module's own `termSlugPath`, so the gate and
* the route cannot disagree about where a hub lives.
* * which terms have content in which locale. `getStaticPaths` emits a hub
* only for the terms `getAllUsedTerms(locale)` returns, and a topic hub also
* aggregates its children's articles (`articleHasTerm`, src/lib/posts.ts).
*
* Keyed on PUBLISHED posts whatever `--published` says: `astro build` exposes
* published posts only (`shouldExposePost`), so a link asks what production
* serves, not what `astro dev` additionally renders.
*/
async function readTermHubs() {
const usedByLocale = new Map();
for (const file of await walk(BLOG)) {
let data;
Expand All @@ -259,14 +291,14 @@ async function readTermHubs() {
}

const childSlugs = new Map();
for (const term of terms) {
for (const term of RAW_TERMS) {
if (term.group !== 'topic' || !term.parent) continue;
childSlugs.set(term.parent, [...(childSlugs.get(term.parent) ?? []), term.slug]);
}

const byPath = new Map(); // hub path -> locales the site builds it in
const pathBySlug = new Map(); // term slug -> its one canonical hub path
for (const term of terms) {
for (const term of RAW_TERMS) {
const owned = [term.slug, ...(childSlugs.get(term.slug) ?? [])];
const locales = new Set();
for (const [locale, used] of usedByLocale) {
Expand Down Expand Up @@ -573,6 +605,24 @@ for (const file of files) {
addIssue(issues, rel, data, 'error', `Invalid audience: ${data.audience}`);
}

// A frontmatter group nobody checks is the same defect as one checked against
// a stale list, only further from being noticed: until this ran, an author
// could put any string in `solutions` or `industries` and every gate this
// script owns stayed green, leaving `astro build` to be the first to object.
for (const { field, noun, valid } of LIST_TERM_FIELDS) {
const value = data[field];
if (value === undefined) continue;
if (!Array.isArray(value)) {
addIssue(issues, rel, data, 'error', `${field} must be a list of terms, got: ${value}`);
continue;
}
for (const slug of value) {
if (!valid.has(slug)) {
addIssue(issues, rel, data, 'error', `Invalid ${noun}: ${slug}`);
}
}
}

if (typeof data.title === 'string') {
if (data.title.length > 95) {
addIssue(issues, rel, data, 'warn', `Title is long (${data.title.length} chars)`);
Expand Down
Loading