diff --git a/package-lock.json b/package-lock.json index 407e194..2936e9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,8 @@ "license": "MIT", "dependencies": { "commander": "^15.0.0", - "fast-glob": "^3.3.3", - "gray-matter": "^4.0.3", "jiti": "^2.7.0", + "js-yaml": "^4.3.2", "mustache": "^4.2.0", "zod": "^4.4.3" }, @@ -22,6 +21,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@release-it-plugins/lerna-changelog": "^9.0.0", + "@types/js-yaml": "^4.0.9", "@types/mustache": "^4.2.6", "@types/node": "^26.0.1", "@vitest/coverage-v8": "^4.1.10", @@ -1264,6 +1264,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -1277,6 +1278,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -1286,6 +1288,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -3623,6 +3626,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4324,7 +4334,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/as-table": { @@ -4635,6 +4644,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6056,6 +6066,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -6185,18 +6196,6 @@ "dev": true, "license": "MIT" }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6208,6 +6207,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -6289,6 +6289,7 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -6345,6 +6346,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -6666,6 +6668,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6694,43 +6697,6 @@ "dev": true, "license": "ISC" }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gray-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", - "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6984,19 +6950,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7016,6 +6974,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7080,6 +7039,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -7276,10 +7236,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -7376,15 +7335,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/language-map": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/language-map/-/language-map-1.5.0.tgz", @@ -8419,6 +8369,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -8891,6 +8842,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -8904,6 +8856,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -10087,6 +10040,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -10432,6 +10386,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -10593,6 +10548,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -10650,19 +10606,6 @@ "dev": true, "license": "MIT" }, - "node_modules/section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", @@ -10860,12 +10803,6 @@ "readable-stream": "^3.0.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, "node_modules/ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -11007,15 +10944,6 @@ "node": ">=8" } }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/strip-comments-strings": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/strip-comments-strings/-/strip-comments-strings-1.2.0.tgz", @@ -11199,6 +11127,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" diff --git a/package.json b/package.json index d730a54..683e624 100644 --- a/package.json +++ b/package.json @@ -70,15 +70,15 @@ }, "dependencies": { "commander": "^15.0.0", - "fast-glob": "^3.3.3", - "gray-matter": "^4.0.3", "jiti": "^2.7.0", + "js-yaml": "^4.3.2", "mustache": "^4.2.0", "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^10.0.1", "@release-it-plugins/lerna-changelog": "^9.0.0", + "@types/js-yaml": "^4.0.9", "@types/mustache": "^4.2.6", "@types/node": "^26.0.1", "@vitest/coverage-v8": "^4.1.10", diff --git a/src/fs.ts b/src/fs.ts index be75184..1b77464 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,6 +1,5 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import fastGlob from "fast-glob"; import type { FileValue } from "./types.js"; /** Converts OS-specific path separators to forward slashes. */ @@ -8,15 +7,49 @@ export function toPosix(value: string): string { return value.split(path.sep).join("/"); } -/** Lists every file under `dir`, recursively, as absolute paths, sorted. */ +/** + * Lists every file under `dir`, recursively, as absolute paths, sorted. + * Dotfiles are included. Symlinks are followed: a link to a file is listed + * as a file and a link to a directory is walked, except a link back to a + * directory already being walked, which would loop. Broken links are + * skipped. A missing `dir` yields no files. + */ export async function walkFiles(dir: string): Promise { - const entries = await fastGlob("**/*", { - cwd: dir, - absolute: true, - onlyFiles: true, - dot: true, - }); - return entries.sort(); + const files: string[] = []; + async function walk(current: string, ancestors: Set) { + let real: string; + let entries; + try { + real = await fs.realpath(current); + entries = await fs.readdir(current, { withFileTypes: true }); + } catch (err) { + if (isNotFoundError(err)) { + return; + } + throw err; + } + if (ancestors.has(real)) { + return; + } + const below = new Set(ancestors).add(real); + for (const entry of entries) { + const full = path.join(current, entry.name); + let isFile = entry.isFile(); + let isDirectory = entry.isDirectory(); + if (entry.isSymbolicLink()) { + const stats = await fs.stat(full).catch(() => null); + isFile = stats?.isFile() ?? false; + isDirectory = stats?.isDirectory() ?? false; + } + if (isDirectory) { + await walk(full, below); + } else if (isFile) { + files.push(full); + } + } + } + await walk(path.resolve(dir), new Set()); + return files.sort(); } /** Whether `candidate` is `root` itself or sits beneath it, comparing already-resolved paths. */ diff --git a/src/targets/validation-shared.ts b/src/targets/validation-shared.ts index e885e80..1a0eefb 100644 --- a/src/targets/validation-shared.ts +++ b/src/targets/validation-shared.ts @@ -1,6 +1,6 @@ import { promises as fs, statSync } from "node:fs"; import path from "node:path"; -import matter from "gray-matter"; +import yaml from "js-yaml"; import { exists, isNotFoundError, @@ -427,20 +427,51 @@ function detectFrontmatterKind( } /** - * Parses frontmatter from a file pluginpack is validating. gray-matter - * evaluates `---js` / `---javascript` frontmatter with `eval()`, so those - * engines are replaced with one that throws: validating a package must never - * run code from it. + * Parses YAML frontmatter from a file pluginpack is validating: a `---` line + * at the top, YAML, and a closing `---` line (to end of file when it's + * missing). The delimiter rules match gray-matter, which pluginpack used + * before. Only YAML is accepted, and js-yaml 4's `load` is safe by default, + * so validating a package never runs code from it. Throws when the block + * isn't a YAML mapping. */ -export function parseMatter(content: string): matter.GrayMatterFile { - const refuse = () => { - throw new Error( - "JavaScript frontmatter is not supported; use YAML frontmatter.", - ); - }; - return matter(content, { - engines: { js: refuse, javascript: refuse }, - }); +export function parseMatter(content: string): { + data: Record; + content: string; +} { + let text = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content; + if (!text.startsWith("---") || text.charAt(3) === "-") { + return { data: {}, content: text }; + } + text = text.slice(3); + const lineEnd = text.search(/\r?\n/); + const language = (lineEnd === -1 ? text : text.slice(0, lineEnd)) + .trim() + .toLowerCase(); + if (language) { + if (language !== "yaml" && language !== "yml") { + throw new Error( + language === "js" || language === "javascript" + ? "JavaScript frontmatter is not supported; use YAML frontmatter." + : `Frontmatter language "${language}" is not supported; use YAML frontmatter.`, + ); + } + text = lineEnd === -1 ? "" : text.slice(lineEnd); + } + const closeIndex = text.indexOf("\n---"); + const block = closeIndex === -1 ? text : text.slice(0, closeIndex); + const body = + closeIndex === -1 ? "" : text.slice(closeIndex + 4).replace(/^\r?\n/, ""); + if (block.replace(/^\s*#[^\n]+/gm, "").trim() === "") { + return { data: {}, content: body }; + } + const data: unknown = yaml.load(block); + if (data === null || data === undefined) { + return { data: {}, content: body }; + } + if (typeof data !== "object" || Array.isArray(data)) { + throw new Error("Frontmatter must be a YAML mapping of fields."); + } + return { data: data as Record, content: body }; } function parseFrontmatter( diff --git a/tests/fs-and-frontmatter.test.ts b/tests/fs-and-frontmatter.test.ts new file mode 100644 index 0000000..b91834a --- /dev/null +++ b/tests/fs-and-frontmatter.test.ts @@ -0,0 +1,96 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { walkFiles } from "../src/fs.js"; +import { parseMatter } from "../src/targets/validation-shared.js"; + +let root: string | undefined; + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }); + root = undefined; + } +}); + +describe("walkFiles", () => { + it("lists files and dotfiles, follows symlinks, and skips loops and broken links", async () => { + root = await mkdtemp(path.join(tmpdir(), "pluginpack-walk-")); + const dir = path.join(root, "tree"); + await mkdir(path.join(dir, "sub", ".hidden"), { recursive: true }); + await mkdir(path.join(root, "elsewhere"), { recursive: true }); + await writeFile(path.join(dir, "a.md"), "a"); + await writeFile(path.join(dir, ".dotfile"), "d"); + await writeFile(path.join(dir, "sub", ".hidden", "b.md"), "b"); + await writeFile(path.join(root, "elsewhere", "c.md"), "c"); + await symlink(path.join(dir, "a.md"), path.join(dir, "file-link.md")); + await symlink(path.join(root, "elsewhere"), path.join(dir, "dir-link")); + await symlink(dir, path.join(dir, "sub", "loop")); + await symlink(path.join(root, "missing"), path.join(dir, "broken")); + + const files = await walkFiles(dir); + + expect(files.map((file) => path.relative(dir, file))).toEqual([ + ".dotfile", + "a.md", + path.join("dir-link", "c.md"), + "file-link.md", + path.join("sub", ".hidden", "b.md"), + ]); + expect(files.every((file) => path.isAbsolute(file))).toBe(true); + }); + + it("returns no files for a missing directory", async () => { + root = await mkdtemp(path.join(tmpdir(), "pluginpack-walk-")); + await expect(walkFiles(path.join(root, "nope"))).resolves.toEqual([]); + }); +}); + +describe("parseMatter", () => { + it("parses YAML frontmatter and returns the body", () => { + expect( + parseMatter("---\nname: demo\ndescription: A demo.\n---\n\n# Body\n"), + ).toEqual({ + data: { name: "demo", description: "A demo." }, + content: "\n# Body\n", + }); + }); + + it("accepts a BOM, CRLF line endings, and an explicit yaml language", () => { + expect( + parseMatter("\uFEFF---yaml\r\nname: demo\r\n---\r\nbody").data, + ).toEqual({ name: "demo" }); + }); + + it("returns empty data with no frontmatter, comment-only frontmatter, or a longer dash line", () => { + expect(parseMatter("# Just markdown\n").data).toEqual({}); + expect(parseMatter("---\n# only a comment\n---\nbody").data).toEqual({}); + expect(parseMatter("----\nname: x\n----\n").data).toEqual({}); + }); + + it("reads to end of file when the closing delimiter is missing", () => { + expect(parseMatter("---\nname: demo\n")).toEqual({ + data: { name: "demo" }, + content: "", + }); + }); + + it("refuses non-YAML languages without evaluating them", () => { + const flag = "__pluginpackParseMatterEval"; + expect(() => + parseMatter(`---js\n{ name: (globalThis.${flag} = "x") }\n---\n`), + ).toThrow("JavaScript frontmatter is not supported; use YAML frontmatter."); + expect((globalThis as Record)[flag]).toBeUndefined(); + expect(() => parseMatter('---json\n{"name": "x"}\n---\n')).toThrow( + 'Frontmatter language "json" is not supported; use YAML frontmatter.', + ); + }); + + it("rejects frontmatter that isn't a mapping, and invalid YAML", () => { + expect(() => parseMatter("---\njust a string\n---\n")).toThrow( + "Frontmatter must be a YAML mapping of fields.", + ); + expect(() => parseMatter("---\nname: [unclosed\n---\n")).toThrow(); + }); +});