Skip to content
Open
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
13 changes: 13 additions & 0 deletions .github/workflows/check-snippets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ on:
pull_request:

jobs:
references:
name: Snippet references
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: actions/setup-node@v4
with:
node-version: 22

- name: Check documentation references
run: npm run check:snippets:references

rust:
name: Rust snippets
runs-on: ubuntu-latest
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
"build": "astro build && node scripts/copy-md-sources.mjs && node scripts/generate-llms-small.mjs && node scripts/check-generated-markdown.mjs",
"preview": "astro preview",
"astro": "astro",
"check:snippets": "npm run check:snippets:js && npm run check:snippets:react-native && npm run check:snippets:rust",
"check:snippets": "npm run check:snippets:references && npm run check:snippets:js && npm run check:snippets:react-native && npm run check:snippets:rust",
"check:generated-markdown": "node scripts/check-generated-markdown.mjs",
"check:snippets:references": "node --test scripts/check-unused-snippets.test.mjs && node scripts/check-unused-snippets.mjs",
"check:snippets:js": "npm --prefix snippets/js run check",
"check:snippets:react-native": "npm --prefix snippets/react-native run check",
"check:snippets:rust": "cargo clippy --manifest-path snippets/rust/Cargo.toml --all-features -- -D warnings && cargo fmt --manifest-path snippets/rust/Cargo.toml --check"
Expand Down
191 changes: 191 additions & 0 deletions scripts/check-unused-snippets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { extname, join, relative, sep } from "node:path";
import { pathToFileURL } from "node:url";

const IGNORED_DIRECTORIES = new Set(["node_modules", "target"]);
const MARKDOWN_EXTENSIONS = new Set([".md", ".mdx"]);
const SNIPPET_START_PATTERN = /--8<--\s*\[start:([^\]]+)\]/g;
const SNIPPET_REFERENCE_PATTERN = /(?:^|\s)snippet="([^"]+)"/g;
const TEMPORARY_EXCLUSIONS = [
{
directory: "snippets/react-native",
expiresOn: "2026-09-11",
reason:
"The React Native library is not yet ready; the snippets will be reintroduced once the React Native library is on SDK version 0.10.0 as well.",
},
];

export class SnippetReferenceChecker {
constructor(
rootDirectory = process.cwd(),
{
currentDate = new Date(),
temporaryExclusions = TEMPORARY_EXCLUSIONS,
} = {},
) {
this.rootDirectory = rootDirectory;
this.snippetsDirectory = join(rootDirectory, "snippets");
this.docsDirectory = join(rootDirectory, "src/content/docs");
this.currentDate = currentDate;
this.temporaryExclusions = temporaryExclusions;
}

check() {
this.assertRequiredDirectoriesExist();
this.assertTemporaryExclusionsHaveNotExpired();

const definitions = this.findDefinitions();
const references = this.findReferences();
const unused = definitions.filter(
({ reference }) => !references.has(reference),
);

return {
definitionCount: definitions.length,
temporaryExclusions: this.temporaryExclusions,
unused,
};
}

assertTemporaryExclusionsHaveNotExpired() {
const expired = this.temporaryExclusions.filter(
({ expiresOn }) => this.currentDate >= this.expirationDate(expiresOn),
);

if (expired.length === 0) return;

const details = expired.map(
({ directory, expiresOn }) => `- ${directory} (expired ${expiresOn})`,
);
throw new Error(
[
"Temporary snippet exclusions have expired:",
...details,
"Remove or update each exclusion before running the check again.",
].join("\n"),
);
}

assertRequiredDirectoriesExist() {
for (const directory of [this.snippetsDirectory, this.docsDirectory]) {
if (!existsSync(directory)) {
throw new Error(
`Missing required directory: ${this.toReferencePath(directory)}`,
);
}
}
}

findDefinitions() {
const definitions = [];

for (const file of this.walkFiles(this.snippetsDirectory)) {
const content = readFileSync(file, "utf8");

for (const match of content.matchAll(SNIPPET_START_PATTERN)) {
definitions.push({
reference: `${this.toReferencePath(file)}:${match[1]}`,
line: this.lineNumberAt(content, match.index),
});
}
}

return definitions;
}

findReferences() {
const references = new Set();
const markdownFiles = this.walkFiles(this.docsDirectory, (file) =>
MARKDOWN_EXTENSIONS.has(extname(file)),
);

for (const file of markdownFiles) {
const content = readFileSync(file, "utf8");

for (const match of content.matchAll(SNIPPET_REFERENCE_PATTERN)) {
references.add(this.normalizeReference(match[1]));
}
}

return references;
}

walkFiles(directory, includeFile = () => true) {
const files = [];

for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue;

const fullPath = join(directory, entry.name);
if (entry.isDirectory()) {
if (this.isTemporarilyExcluded(fullPath)) continue;
files.push(...this.walkFiles(fullPath, includeFile));
} else if (entry.isFile() && includeFile(fullPath)) {
files.push(fullPath);
}
}

return files;
}

toReferencePath(file) {
return relative(this.rootDirectory, file).split(sep).join("/");
}

normalizeReference(reference) {
return reference.replaceAll("\\", "/").replace(/^\.\//, "");
}

isTemporarilyExcluded(directory) {
const referencePath = this.toReferencePath(directory);
return this.temporaryExclusions.some(
(exclusion) =>
this.normalizeReference(exclusion.directory) === referencePath,
);
}

expirationDate(date) {
const expiration = new Date(`${date}T00:00:00.000Z`);
if (Number.isNaN(expiration.getTime())) {
throw new Error(`Invalid temporary exclusion expiry date: ${date}`);
}
return expiration;
}

lineNumberAt(content, index) {
return content.slice(0, index).split("\n").length;
}
}

if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
try {
const { definitionCount, temporaryExclusions, unused } =
new SnippetReferenceChecker().check();

for (const { directory, expiresOn, reason } of temporaryExclusions) {
console.log(
`Temporarily excluding ${directory} until ${expiresOn}: ${reason}`,
);
}

if (unused.length > 0) {
console.error(
"Unused snippets found. Remove these snippet blocks from their source files:",
);
for (const { reference, line } of unused) {
console.error(`- ${reference} (line ${line})`);
}
process.exitCode = 1;
} else {
console.log(
`Verified all ${definitionCount} snippet anchors are referenced by the docs`,
);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
100 changes: 100 additions & 0 deletions scripts/check-unused-snippets.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";

import { SnippetReferenceChecker } from "./check-unused-snippets.mjs";

function checkFixture({
checkerOptions = { temporaryExclusions: [] },
docs,
snippetPath = "snippets/example/source.ts",
snippets,
}) {
const fixture = mkdtempSync(join(tmpdir(), "unused-snippets-"));

try {
const snippetFile = join(fixture, snippetPath);
mkdirSync(dirname(snippetFile), { recursive: true });
mkdirSync(join(fixture, "src/content/docs"), { recursive: true });
writeFileSync(snippetFile, snippets);
writeFileSync(join(fixture, "src/content/docs/page.md"), docs);

return new SnippetReferenceChecker(fixture, checkerOptions).check();
} finally {
rmSync(fixture, { recursive: true, force: true });
}
}

test("passes when every snippet anchor is referenced", () => {
const result = checkFixture({
snippets:
"// --8<-- [start:example]\nconst value = 1;\n// --8<-- [end:example]\n",
docs: '```ts snippet="snippets/example/source.ts:example"\n```\n',
});

assert.equal(result.definitionCount, 1);
assert.deepEqual(result.unused, []);
});

test("finds an unreferenced snippet anchor and its location", () => {
const result = checkFixture({
snippets:
"\n// --8<-- [start:unused]\nconst value = 1;\n// --8<-- [end:unused]\n",
docs: "No snippets here\n",
});

assert.equal(result.definitionCount, 1);
assert.deepEqual(result.unused, [
{
reference: "snippets/example/source.ts:unused",
line: 2,
},
]);
});

test("ignores a temporarily excluded snippet directory before its expiry", () => {
const temporaryExclusion = {
directory: "snippets/react-native",
expiresOn: "2026-09-11",
reason: "Temporarily unavailable",
};
const result = checkFixture({
checkerOptions: {
currentDate: new Date("2026-09-10T23:59:59.999Z"),
temporaryExclusions: [temporaryExclusion],
},
docs: "No snippets here\n",
snippetPath: "snippets/react-native/source.ts",
snippets:
"// --8<-- [start:unused]\nconst value = 1;\n// --8<-- [end:unused]\n",
});

assert.equal(result.definitionCount, 0);
assert.deepEqual(result.temporaryExclusions, [temporaryExclusion]);
assert.deepEqual(result.unused, []);
});

test("rejects an expired temporary exclusion", () => {
assert.throws(
() =>
checkFixture({
checkerOptions: {
currentDate: new Date("2026-09-11T00:00:00.000Z"),
temporaryExclusions: [
{
directory: "snippets/react-native",
expiresOn: "2026-09-11",
reason: "Temporarily unavailable",
},
],
},
docs: "No snippets here\n",
snippetPath: "snippets/react-native/source.ts",
snippets:
"// --8<-- [start:unused]\nconst value = 1;\n// --8<-- [end:unused]\n",
}),
/Temporary snippet exclusions have expired:\n- snippets\/react-native \(expired 2026-09-11\)/,
);
});
Loading