MakeInfographics.ai keeps its infographic gallery entries, fill-in templates, blog posts and Pinterest pin metadata in TypeScript arrays: 270 records at the time of writing. The editing rules for them live in a checklist in the repository, and the header of scripts/content-lint.mjs says the point of the script is to turn that checklist into build-time constraints. It runs as prebuild, so next build stops when a rule fails. The script loads the data modules without compiling the app: it transpiles each .ts file in memory with the typescript package the project already has, runs the emitted CommonJS, then walks every record.
The code is from the repository as of 2026-11-04 (commit 2217330, last touching the script on 2026-10-29). The counts and timings below are from my machine on the same day (Node 22.12.0, macOS). The script's messages are in Chinese; I translate the ones I quote.
typescript is already in devDependencies (^5). A second runner such as ts-node or esbuild-register would work, but reusing what npm install already pulls in meant no new dependency. The is about 15 lines:
const moduleCache = new Map();
function loadTs(file) {
if (moduleCache.has(file)) return moduleCache.get(file);
const source = fs.readFileSync(file, "utf8");
const { outputText } = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
});
const mod = { exports: {} };
moduleCache.set(file, mod.exports);
const req = (spec) => (spec.startsWith("./") ? loadTs(path.join(path.dirname(file), `${spec}.ts`)) : require(spec));
new Function("module", "exports", "require", outputText)(mod, mod.exports, req);
moduleCache.set(file, mod.exports);
return mod.exports;
}
Relative imports recurse through the same function and npm packages go through Node's normal require. The cache is written twice: once before the module runs, so a file that is imported while it is still gets its partly filled exports instead of looping, and once after. transpileModule only strips types, it does not type-check, so this is a and not a compiler.
Some failures happen before any lint rule runs. The data files call assertUnique and assertRedirectsValid at the top level (for example assertUnique(BLOG_POSTS, (p) => p.slug, "blog slug") in blog-data.ts), and they throw while the module loads. The script catches that, prints a load error and exits with 1. That is deliberate: a duplicate slug should stop the build before any word-count rule is checked.
Not every rule should apply to an entry written months ago. The script has a cutoff, compared as a string against each entry's dateAdded (infographics, templates, pins) or date (blog posts):
const STRICT_FROM = process.env.CONTENT_LINT_STRICT_FROM || "2026-10-11";
const graded = (isNew, scope, msg) => (isNew ? err(scope, msg) : warn(scope, msg));
// isNew = entry.dateAdded >= STRICT_FROM (post.date for blog posts)
Hard errors fail for every entry regardless of age. Examples from the script: a broken internal link; an infographic body under 600 words; a gallery thumbnail key with no matching infographic; an unknown category; a printable PDF that is missing or does not start with %PDF-; and Pinterest limits such as title over 100 characters, description over 500, an image path that does not match the pin id, or more than five pins on one calendar day.
Graded rules go through graded. For an entry on or after STRICT_FROM a violation is an error, for an older entry the same violation is a warning and the build goes on. These include the number of keyFacts (5 to 8) and FAQ entries, blog word count (1,500 to 2,500), the length of a template's whyThisTemplate text (at least 400 characters), and near-duplicate infographic titles (Jaccard similarity of title tokens at or above 0.5). A hard-coded set, REVIEWED_DISTINCT_PAIRS, exempts ten older slug pairs that were reviewed by hand and found to target different search intents. A new entry that collides with anything is still an error.
Warnings are grouped: the console shows the first three of a kind and a count for the rest ("9 more of the same kind"), and --verbose lists everything.
After , the script validates the four main arrays and also the satellite modules: legal copy, pricing, competitor comparison pages, localized landing pages, /make/ tool pages, use cases and fill-in scenarios. Fill-in templates also get checks that the fields and the promptTemplate agree and that the printable PDFs exist. Pins have to match their board's rules and have a 2:3-ish image, and the same landing page may not receive more than a set number of pins in any seven days (2 by default, 5 for the two boards that fan out into many example pins).
If ../backend/app/catalog.py exists next to the web folder, the script also compares credit counts and USD prices with pricing.ts and the backend's free_quota with the frontend's FREE_GENERATIONS. That backend folder is on my machine, so those checks ran; in a checkout with only the frontend they are skipped without a message.
I ran node scripts/content-lint.mjs three times from the web folder. Each run took 0.31 s wall time (/usr/bin/time -p; about 0.66 s of user CPU). The summary line, translated:
content lint: infographic 149 · template 54 · blog 21 · pin 46 (strict from 2026-10-11)
That is 270 records in the four main arrays, plus 6 scenarios and 4 infographic examples from scenarios.ts. The exit code was 0 and there were 0 hard errors. I also counted how many entries fall on each side of the cutoff with a separate small script that loads the same files:
| Content type | Entries | On or after 2026-10-11 ("strict") | Older ("legacy") |
|---|---|---|---|
| Infographics | 149 | 12 | 137 |
| Templates | 54 | 17 | 37 |
| Blog posts | 21 | 2 | 19 |
| Pinterest pins | 46 | 46 | 0 |
With --verbose the run printed 12 warnings, all the same kind: a day between 2026-10-24 and 2026-11-04 with fewer than the recommended 3 to 5 pins. The script only warns for dates from STRICT_FROM up to today, so pins scheduled for the future do not trigger it early. It also printed one informational line: 11 source organisations whose websites could not be confirmed are kept as plain text.
The script only reads files. To see what it catches without touching the git tree, I copied the web folder to /tmp, symlinked node_modules, applied one patch at a time, ran the linter once and restored the file before the next patch. The patches are in a small script that selects entries by slug, so the run repeats exactly.
| Patch | Entry age | Result |
|---|---|---|
Added a link to /infographic/this-slug-does-not-exist in a blog post |
any | 1 error, exit 1: broken internal link |
Cut keyFacts ofartificial-intelligence-trends-2026 (dateAdded 2026-09-07) to 1 item |
legacy | 1 extra warning ("keyFacts 1, should be 5-8"), exit 0 |
Cut whyThisTemplate ofelegant-destination-guide (dateAdded 2026-10-22) to 29 characters |
strict | 1 error, exit 1 ("29 characters, needs at least 400") |
Put "The moon is made of cheese." at the start of the body of artificial-intelligence-trends-2026 |
legacy | nothing reported, exit 0 |
The second and third rows are the same kind of mistake with different outcomes, and the entry's date is the only thing that differs. The last row is the limit of the whole approach: the script counts words and follows links, it cannot tell whether a sentence is true.
I have not changed the linter.
CONTENT_LINT_STRICT_FROM. It is compared as a string, so a value that is not YYYY-MM-DD would silently move the cutoff, and I found no check on the variable. The pins validate their dates with a regex, but the cutoff itself is not validated.image-manifest.json. I did not try a fresh clone where images exist only on the CDN.../backend exists on my machine. I did not run the script without it.
If you keep marketing data in TypeScript, how do you gate its quality: eslint plugins, code generation, or a script like this one?
The Monthly Budget Planner template page is an example of the fill-in template records this script validates (fields, prompt template and printable PDF paths).
This article was drafted with help from an AI assistant and edited by me. The code excerpts come from the project's repository, and the measurements are from my own runs on 2026-11-04.