diff --git a/.github/workflows/check-snippets.yml b/.github/workflows/check-snippets.yml index 5f8dde9..ef09966 100644 --- a/.github/workflows/check-snippets.yml +++ b/.github/workflows/check-snippets.yml @@ -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 diff --git a/package.json b/package.json index 5ba3f54..cd4746d 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/check-unused-snippets.mjs b/scripts/check-unused-snippets.mjs new file mode 100644 index 0000000..cf2bfc1 --- /dev/null +++ b/scripts/check-unused-snippets.mjs @@ -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; + } +} diff --git a/scripts/check-unused-snippets.test.mjs b/scripts/check-unused-snippets.test.mjs new file mode 100644 index 0000000..090531b --- /dev/null +++ b/scripts/check-unused-snippets.test.mjs @@ -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\)/, + ); +});