diff --git a/package-lock.json b/package-lock.json index 4104ccf3..ad04a523 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@docusaurus/tsconfig": "3.10.2", "@docusaurus/types": "3.10.2", "cspell": "^10.0.1", + "glob": "^14.0.5", "markdown-link-check": "3.15.0", "markdownlint": "^0.41.1", "markdownlint-cli": "^0.49.1", @@ -4770,6 +4771,13 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.1.0.tgz", + "integrity": "sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -10507,6 +10515,24 @@ "integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==", "license": "ISC" }, + "node_modules/glob": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.5.tgz", + "integrity": "sha512-nbKW5Sl53DEM5PkmZ5Jwfeyss1ohuqcgOBjOarSjz1fp8W4ss4EOOvKADQRbApD6JmVBX+HkpZYfBez4wXbeQQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^11.0.5", + "minipass": "^7.1.3", + "path-scurry": "^3.0.0" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -10541,6 +10567,23 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/minimatch": { + "version": "11.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.8.tgz", + "integrity": "sha512-nFQuYSTVrGvysVBps5Ih+0BdnYFN/Cj3C816r6+p7yK+FHrVLGE4O4N9AFwykrlNlvM2HYt+Rad1Uiro6h88Ig==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/nonrecursive-glob-match": "^1.1.0", + "brace-expansion": "^5.0.12" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global-directory": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", @@ -14971,6 +15014,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -15680,6 +15733,33 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz", + "integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-to-regexp": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", diff --git a/package.json b/package.json index 0318360e..5dbc7ecc 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "precheck:links": "npm run build", "seq": "bash -c 'for cmd in \"$@\"; do npm run $cmd || exit 1; done' - ", "serve": "npm run docus:serve", - "test:unit": "node --experimental-strip-types --test 'lib/**/*.test.mts'", + "test:unit": "node --experimental-strip-types --test 'lib/**/*.test.mts' 'scripts/**/*.test.mjs'", "test": "npm run check && npm run test:unit", "typecheck": "tsc", "update:packages": "npx npm-check-updates -u" @@ -57,6 +57,7 @@ "@docusaurus/tsconfig": "3.10.2", "@docusaurus/types": "3.10.2", "cspell": "^10.0.1", + "glob": "^14.0.5", "markdown-link-check": "3.15.0", "markdownlint-cli": "^0.49.1", "markdownlint": "^0.41.1", diff --git a/scripts/add-frontmatter.test.mjs b/scripts/add-frontmatter.test.mjs new file mode 100644 index 00000000..211afc09 --- /dev/null +++ b/scripts/add-frontmatter.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import url from 'node:url'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const scriptPath = path.join(__dirname, 'add-frontmatter.mjs'); + +/** + * Run add-frontmatter.mjs against a fresh temp directory containing the + * given docs/*.md fixtures, then return the resulting file contents. + */ +async function runOnFixture(fileContents) { + const dir = await mkdtemp(path.join(tmpdir(), 'add-frontmatter-')); + try { + await mkdir(path.join(dir, 'docs'), { recursive: true }); + for (const [name, content] of Object.entries(fileContents)) { + await writeFile(path.join(dir, 'docs', name), content); + } + + execFileSync('node', [scriptPath], { cwd: dir }); + + const results = {}; + for (const name of Object.keys(fileContents)) { + results[name] = await readFile(path.join(dir, 'docs', name), 'utf-8'); + } + return results; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +describe('add-frontmatter.mjs', () => { + it('resolves the glob dependency without crashing', () => { + // Regression guard: the script previously threw + // ERR_MODULE_NOT_FOUND for 'glob' immediately on startup because the + // dependency had been dropped from package.json. + assert.doesNotThrow(() => { + execFileSync('node', [scriptPath], { cwd: tmpdir() }); + }); + }); + + it('adds a title to a file with no frontmatter', async () => { + const results = await runOnFixture({ + 'no-frontmatter.md': '# Hello World\n\nSome content.\n', + }); + assert.match( + results['no-frontmatter.md'], + /^---\ntitle: Hello World\n---\n/, + ); + assert.match(results['no-frontmatter.md'], /Some content\./); + }); + + it('inserts a title above existing frontmatter keys when none is present', async () => { + const results = await runOnFixture({ + 'no-title.md': + '---\ndescription: a page\n---\n\n# My Page\n\nBody text.\n', + }); + assert.match( + results['no-title.md'], + /^---\ntitle: My Page\ndescription: a page\n---\n/, + ); + }); + + it('quotes a title that contains a colon', async () => { + const results = await runOnFixture({ + 'colon-title.md': '# Section: Details\n\nBody text.\n', + }); + assert.match( + results['colon-title.md'], + /^---\ntitle: 'Section: Details'\n---\n/, + ); + }); + + it('leaves a file with no heading unchanged (warns instead of crashing)', async () => { + const original = 'Just a paragraph with no heading.\n'; + const results = await runOnFixture({ + 'no-heading.md': original, + }); + assert.equal(results['no-heading.md'], original); + }); +});