@monte3l/groundwork 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/bin/m3l-groundwork.mjs +10 -0
- package/dist/assets.d.ts +20 -0
- package/dist/assets.js +79 -0
- package/dist/caps.d.ts +25 -0
- package/dist/caps.js +69 -0
- package/dist/conflicts.d.ts +12 -0
- package/dist/conflicts.js +77 -0
- package/dist/emit.d.ts +7 -0
- package/dist/emit.js +42 -0
- package/dist/git.d.ts +3 -0
- package/dist/git.js +9 -0
- package/dist/harness/conformance.d.ts +20 -0
- package/dist/harness/conformance.js +18 -0
- package/dist/harness/frontmatter.d.ts +38 -0
- package/dist/harness/frontmatter.js +204 -0
- package/dist/harness/grade.d.ts +4 -0
- package/dist/harness/grade.js +105 -0
- package/dist/harness/rules.d.ts +55 -0
- package/dist/harness/rules.js +580 -0
- package/dist/harness/types.d.ts +32 -0
- package/dist/harness/types.js +9 -0
- package/dist/inventory.d.ts +63 -0
- package/dist/inventory.js +66 -0
- package/dist/jsonc.d.ts +14 -0
- package/dist/jsonc.js +83 -0
- package/dist/main.d.ts +24 -0
- package/dist/main.js +297 -0
- package/dist/merge-json.d.ts +74 -0
- package/dist/merge-json.js +135 -0
- package/dist/mode.d.ts +19 -0
- package/dist/mode.js +53 -0
- package/dist/packs.d.ts +61 -0
- package/dist/packs.js +186 -0
- package/dist/plugin.d.ts +23 -0
- package/dist/plugin.js +79 -0
- package/dist/report.d.ts +4 -0
- package/dist/report.js +323 -0
- package/dist/survey/fs-walk.d.ts +14 -0
- package/dist/survey/fs-walk.js +60 -0
- package/dist/survey/survey-docs.d.ts +4 -0
- package/dist/survey/survey-docs.js +69 -0
- package/dist/survey/survey-harness.d.ts +4 -0
- package/dist/survey/survey-harness.js +121 -0
- package/dist/survey/survey-shape.d.ts +4 -0
- package/dist/survey/survey-shape.js +182 -0
- package/dist/survey/survey-toolchain.d.ts +4 -0
- package/dist/survey/survey-toolchain.js +217 -0
- package/dist/survey/survey.d.ts +5 -0
- package/dist/survey/survey.js +21 -0
- package/dist/survey/types.d.ts +117 -0
- package/dist/survey/types.js +8 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.js +13 -0
- package/dist/toolchain/conformance.d.ts +20 -0
- package/dist/toolchain/conformance.js +30 -0
- package/dist/toolchain/grade.d.ts +4 -0
- package/dist/toolchain/grade.js +244 -0
- package/dist/toolchain/rules.d.ts +118 -0
- package/dist/toolchain/rules.js +706 -0
- package/dist/toolchain/tsconfig-chain.d.ts +36 -0
- package/dist/toolchain/tsconfig-chain.js +116 -0
- package/dist/toolchain/types.d.ts +27 -0
- package/dist/toolchain/types.js +9 -0
- package/package.json +59 -0
- package/plugin/skills/customize/SKILL.md +305 -0
- package/plugin/src/domain-map.ts +134 -0
- package/plugin/src/index.ts +4 -0
- package/plugin/src/kind-facet-map.ts +174 -0
- package/plugin/src/pack-map.ts +65 -0
- package/templates/core/.claude/agents/Explore.md +43 -0
- package/templates/core/.claude/agents/code-implementer.md +258 -0
- package/templates/core/.claude/agents/code-reviewer.md +163 -0
- package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
- package/templates/core/.claude/agents/test-author.md +211 -0
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
- package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
- package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
- package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
- package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
- package/templates/core/.claude/rules/agent-dispatch.md +121 -0
- package/templates/core/.claude/rules/refactoring.md +52 -0
- package/templates/core/.claude/rules/src.md +114 -0
- package/templates/core/.claude/rules/tests.md +129 -0
- package/templates/core/.claude/settings.json +111 -0
- package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
- package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
- package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
- package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
- package/templates/core/.github/workflows/ci.yml +123 -0
- package/templates/core/.github/workflows/dependency-review.yml +26 -0
- package/templates/core/.github/workflows/security-audit.yml +54 -0
- package/templates/core/.node-version +1 -0
- package/templates/core/.prettierignore +5 -0
- package/templates/core/.prettierrc.json +4 -0
- package/templates/core/CLAUDE.md +127 -0
- package/templates/core/README.md +24 -0
- package/templates/core/_gitignore +19 -0
- package/templates/core/_npmrc +1 -0
- package/templates/core/bin/check-exports.mjs +92 -0
- package/templates/core/bin/check-harness.mjs +27 -0
- package/templates/core/bin/check-node-version.mjs +51 -0
- package/templates/core/bin/check-toolchain.mjs +20 -0
- package/templates/core/bin/lib/agent-roster.mjs +8 -0
- package/templates/core/bin/lib/frontmatter.mjs +210 -0
- package/templates/core/bin/lib/harness-rules.mjs +916 -0
- package/templates/core/bin/lib/protected-paths.mjs +23 -0
- package/templates/core/bin/lib/report.mjs +56 -0
- package/templates/core/bin/lib/signed-range.mjs +178 -0
- package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
- package/templates/core/bin/lib/verify-steps.mjs +131 -0
- package/templates/core/bin/lib/verify-steps.packs.json +1 -0
- package/templates/core/bin/lint-commit.mjs +50 -0
- package/templates/core/bin/strip-claude-trailers.mjs +25 -0
- package/templates/core/bin/verify.mjs +64 -0
- package/templates/core/commitlint.config.js +11 -0
- package/templates/core/docs/research/harness-refresh.md +27 -0
- package/templates/core/docs/research/typescript-refresh.md +32 -0
- package/templates/core/eslint.config.js +105 -0
- package/templates/core/knip.json +6 -0
- package/templates/core/lefthook.yml +39 -0
- package/templates/core/package.json +58 -0
- package/templates/core/pnpm-workspace.yaml +13 -0
- package/templates/core/src/index.ts +12 -0
- package/templates/core/tests/index.test.ts +8 -0
- package/templates/core/tsconfig.base.json +36 -0
- package/templates/core/tsconfig.build.json +10 -0
- package/templates/core/tsconfig.json +11 -0
- package/templates/core/vitest.config.ts +32 -0
- package/templates/packs/README.md +81 -0
- package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
- package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
- package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
- package/templates/packs/harness-extras/pack.json +65 -0
- package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
- package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
- package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
- package/templates/packs/statusline/pack.json +31 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Real packaging correctness: packs the target package (`--cwd <dir>`,
|
|
4
|
+
* default the repo root) with `pnpm pack`, then runs publint and
|
|
5
|
+
* are-the-types-wrong (attw) against the resulting tarball. Skips cleanly
|
|
6
|
+
* (exit 0, one warning) when the target has no `exports` field -- a project
|
|
7
|
+
* that isn't published doesn't need this gate, and `/customize` removes the
|
|
8
|
+
* step entirely for non-library project kinds rather than leaving a
|
|
9
|
+
* permanently-skipped one behind.
|
|
10
|
+
*/
|
|
11
|
+
import process from "node:process";
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { readFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join, isAbsolute } from "node:path";
|
|
16
|
+
import { parseJsonFlag, createReporter } from "./lib/report.mjs";
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const cwdIndex = argv.indexOf("--cwd");
|
|
20
|
+
const targetDir = cwdIndex === -1 ? "." : argv[cwdIndex + 1];
|
|
21
|
+
|
|
22
|
+
const reporter = createReporter(parseJsonFlag(argv));
|
|
23
|
+
|
|
24
|
+
const pkgPath = join(targetDir, "package.json");
|
|
25
|
+
if (!existsSync(pkgPath)) {
|
|
26
|
+
reporter.fail(`no package.json at ${targetDir}`);
|
|
27
|
+
reporter.finish();
|
|
28
|
+
process.exit(process.exitCode ?? 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
32
|
+
if (!pkg.exports) {
|
|
33
|
+
reporter.warn(
|
|
34
|
+
`${pkg.name ?? targetDir} declares no "exports" field -- skipping publint/attw (not a published package)`,
|
|
35
|
+
);
|
|
36
|
+
reporter.finish();
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const scratchDir = mkdtempSync(join(tmpdir(), "check-exports-"));
|
|
41
|
+
try {
|
|
42
|
+
const packOutput = execFileSync(
|
|
43
|
+
"pnpm",
|
|
44
|
+
["pack", "--pack-destination", scratchDir],
|
|
45
|
+
{ cwd: targetDir, encoding: "utf8" },
|
|
46
|
+
);
|
|
47
|
+
const packLastLine = packOutput.trim().split("\n").pop() ?? "";
|
|
48
|
+
// `pnpm pack --pack-destination <dir>` prints the tarball's ABSOLUTE path
|
|
49
|
+
// as its last line, not a bare filename -- joining that onto scratchDir
|
|
50
|
+
// again double-prefixes the path. Use it directly when it's already
|
|
51
|
+
// absolute; only join when a filename-only form is ever printed instead.
|
|
52
|
+
const tarballPath = isAbsolute(packLastLine)
|
|
53
|
+
? packLastLine
|
|
54
|
+
: join(scratchDir, packLastLine);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
execFileSync("pnpm", ["exec", "publint", tarballPath], {
|
|
58
|
+
stdio: "inherit",
|
|
59
|
+
});
|
|
60
|
+
reporter.ok("publint: package layout is correct");
|
|
61
|
+
} catch {
|
|
62
|
+
reporter.fail("publint reported a packaging issue (see output above)");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
execFileSync(
|
|
67
|
+
"pnpm",
|
|
68
|
+
[
|
|
69
|
+
"exec",
|
|
70
|
+
"attw",
|
|
71
|
+
"--pack",
|
|
72
|
+
tarballPath,
|
|
73
|
+
// This project is ESM-only by design (no CommonJS -- see
|
|
74
|
+
// guard-no-commonjs.mjs); attw's default node16-from-CJS check
|
|
75
|
+
// flags exactly the interop this package intentionally doesn't
|
|
76
|
+
// support. Ignoring this one rule is attw's own documented
|
|
77
|
+
// escape hatch for a deliberately ESM-only package, not a
|
|
78
|
+
// suppression of a real problem.
|
|
79
|
+
"--ignore-rules",
|
|
80
|
+
"cjs-resolves-to-esm",
|
|
81
|
+
],
|
|
82
|
+
{ stdio: "inherit" },
|
|
83
|
+
);
|
|
84
|
+
reporter.ok("attw: type resolution matches every published entry point");
|
|
85
|
+
} catch {
|
|
86
|
+
reporter.fail("attw reported a type-resolution issue (see output above)");
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
reporter.finish();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Grades this project's Claude Code harness (`.claude/` plus `CLAUDE.md`).
|
|
4
|
+
* A structural defect -- a hook registration that points at a missing file, a
|
|
5
|
+
* hook script nothing registers, a skill or agent with unreadable
|
|
6
|
+
* frontmatter, a `CLAUDE.md` path that no longer exists -- fails the gate.
|
|
7
|
+
* A rubric finding -- an oversized skill, a thin description, an unpinned
|
|
8
|
+
* model, a dead rule glob -- only warns, because a subjective rule must never
|
|
9
|
+
* block a push. Rules live in bin/lib/harness-rules.mjs. When the `claude`
|
|
10
|
+
* CLI is installed it also relays Anthropic's own `claude plugin validate`
|
|
11
|
+
* findings, as warnings only.
|
|
12
|
+
*/
|
|
13
|
+
import process from "node:process";
|
|
14
|
+
import { parseJsonFlag, createReporter, repoRoot } from "./lib/report.mjs";
|
|
15
|
+
import {
|
|
16
|
+
gradeHarness,
|
|
17
|
+
reportGrade,
|
|
18
|
+
reportOfficialValidation,
|
|
19
|
+
} from "./lib/harness-rules.mjs";
|
|
20
|
+
|
|
21
|
+
const reporter = createReporter(parseJsonFlag(process.argv.slice(2)));
|
|
22
|
+
const root = repoRoot();
|
|
23
|
+
|
|
24
|
+
reportGrade(gradeHarness(root), reporter);
|
|
25
|
+
reportOfficialValidation(root, reporter);
|
|
26
|
+
|
|
27
|
+
reporter.finish();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Makes `.node-version` the single authoritative Node version pin. Forbids
|
|
4
|
+
* a hardcoded `node-version:` literal anywhere in `.github/workflows/*.yml`
|
|
5
|
+
* -- every workflow must instead read `node-version-file: .node-version` (or
|
|
6
|
+
* an equivalent file-derived expression), so the pin can never drift between
|
|
7
|
+
* the file a developer's version manager reads and what CI actually runs.
|
|
8
|
+
*/
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { parseJsonFlag, createReporter, repoRoot } from "./lib/report.mjs";
|
|
13
|
+
|
|
14
|
+
const root = repoRoot();
|
|
15
|
+
const reporter = createReporter(parseJsonFlag(process.argv.slice(2)));
|
|
16
|
+
|
|
17
|
+
const pinPath = join(root, ".node-version");
|
|
18
|
+
if (!existsSync(pinPath)) {
|
|
19
|
+
reporter.fail(".node-version is missing at the repo root");
|
|
20
|
+
reporter.finish();
|
|
21
|
+
process.exit(process.exitCode ?? 1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const pin = readFileSync(pinPath, "utf8").trim();
|
|
25
|
+
reporter.ok(`.node-version pins ${pin}`);
|
|
26
|
+
|
|
27
|
+
const workflowsDir = join(root, ".github", "workflows");
|
|
28
|
+
const HARDCODED_VERSION = /node-version:\s*['"]?\d/;
|
|
29
|
+
const FILE_DERIVED = /node-version-file:/;
|
|
30
|
+
|
|
31
|
+
if (existsSync(workflowsDir)) {
|
|
32
|
+
for (const entry of readdirSync(workflowsDir)) {
|
|
33
|
+
if (!entry.endsWith(".yml") && !entry.endsWith(".yaml")) continue;
|
|
34
|
+
const text = readFileSync(join(workflowsDir, entry), "utf8");
|
|
35
|
+
const hasHardcoded = HARDCODED_VERSION.test(text);
|
|
36
|
+
const hasFileDerived = FILE_DERIVED.test(text);
|
|
37
|
+
if (hasHardcoded && !hasFileDerived) {
|
|
38
|
+
reporter.fail(
|
|
39
|
+
`.github/workflows/${entry} hardcodes a node-version literal instead of node-version-file: .node-version`,
|
|
40
|
+
);
|
|
41
|
+
} else {
|
|
42
|
+
reporter.ok(
|
|
43
|
+
`.github/workflows/${entry} derives its Node version from the pin file`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
reporter.ok("no .github/workflows directory to check");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
reporter.finish();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Grades this project's TypeScript toolchain. A structural defect -- a build
|
|
4
|
+
* project that emits nowhere, a tsconfig `extends` that resolves to nothing,
|
|
5
|
+
* a verify step naming a script that does not exist, a `.node-version` that
|
|
6
|
+
* contradicts `engines.node` -- fails the gate. A rubric finding -- a missing
|
|
7
|
+
* strict flag, an option TypeScript has deprecated, an ESLint config without
|
|
8
|
+
* type-aware linting, a coverage gate that is not per-file -- only warns,
|
|
9
|
+
* because a subjective rule must never block a push. Rules live in
|
|
10
|
+
* bin/lib/toolchain-rules.mjs. Offline: it reads files and never runs them.
|
|
11
|
+
*/
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
import { parseJsonFlag, createReporter, repoRoot } from "./lib/report.mjs";
|
|
14
|
+
import { gradeToolchain, reportGrade } from "./lib/toolchain-rules.mjs";
|
|
15
|
+
|
|
16
|
+
const reporter = createReporter(parseJsonFlag(process.argv.slice(2)));
|
|
17
|
+
|
|
18
|
+
reportGrade(gradeToolchain(repoRoot()), reporter);
|
|
19
|
+
|
|
20
|
+
reporter.finish();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The writer-spoke roster: the only subagent names a PreToolUse[Write|Edit]
|
|
3
|
+
* hook trusts to write into a guarded `src/`/`tests/` path. Kept as one
|
|
4
|
+
* small, static source so `guard-hub-src-writes.mjs` and this project's
|
|
5
|
+
* `code-implementer`/`test-author` agent definitions can't silently drift
|
|
6
|
+
* apart on who's authorized to write what.
|
|
7
|
+
*/
|
|
8
|
+
export const WRITER_SPOKES = new Set(["test-author", "code-implementer"]);
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reader for the YAML subset Claude Code frontmatter actually uses --
|
|
3
|
+
* `SKILL.md`, agent, and rule files. Hand-rolled so the gate has no
|
|
4
|
+
* dependencies. It handles plain, quoted, block (`>-`, `>`, `|`, `|-`), and
|
|
5
|
+
* list scalars. Not supported: nested mappings (recorded as an empty string
|
|
6
|
+
* rather than misparsed), anchors, tags. Every string result is trimmed.
|
|
7
|
+
*
|
|
8
|
+
* This file is the emitted twin of m3l-groundwork's own
|
|
9
|
+
* `packages/cli/src/harness/frontmatter.ts`; a parity test keeps them equal.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const KEY_LINE = /^([A-Za-z_][\w-]*):(?:[ \t]+(.*))?$/;
|
|
13
|
+
const BLOCK_SCALAR = /^([>|])(?:[+-]\d?|\d[+-]?)?$/;
|
|
14
|
+
|
|
15
|
+
function dedent(lines) {
|
|
16
|
+
const indents = lines
|
|
17
|
+
.filter((line) => line.trim() !== "")
|
|
18
|
+
.map((line) => /^[ \t]*/.exec(line)?.[0].length ?? 0);
|
|
19
|
+
const strip = indents.length === 0 ? 0 : Math.min(...indents);
|
|
20
|
+
return lines.map((line) => line.slice(strip));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function foldBlockScalar(lines) {
|
|
24
|
+
const paragraphs = [[]];
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
if (line.trim() === "") {
|
|
27
|
+
paragraphs.push([]);
|
|
28
|
+
} else {
|
|
29
|
+
paragraphs[paragraphs.length - 1]?.push(line.trim());
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return paragraphs
|
|
33
|
+
.filter((paragraph) => paragraph.length > 0)
|
|
34
|
+
.map((paragraph) => paragraph.join(" "))
|
|
35
|
+
.join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Unquotes one YAML scalar; an unterminated quote yields `undefined`. */
|
|
39
|
+
function unquote(text) {
|
|
40
|
+
const trimmed = text.trim();
|
|
41
|
+
const quote = trimmed[0];
|
|
42
|
+
if (quote !== '"' && quote !== "'") {
|
|
43
|
+
return trimmed.replace(/\s+#.*$/, "");
|
|
44
|
+
}
|
|
45
|
+
let out = "";
|
|
46
|
+
for (let i = 1; i < trimmed.length; i++) {
|
|
47
|
+
const ch = trimmed[i];
|
|
48
|
+
if (quote === '"' && ch === "\\") {
|
|
49
|
+
const escaped = trimmed[i + 1];
|
|
50
|
+
out += escaped === "n" ? "\n" : escaped === "t" ? "\t" : (escaped ?? "");
|
|
51
|
+
i++;
|
|
52
|
+
} else if (quote === "'" && ch === "'" && trimmed[i + 1] === "'") {
|
|
53
|
+
out += "'";
|
|
54
|
+
i++;
|
|
55
|
+
} else if (ch === quote) {
|
|
56
|
+
return out;
|
|
57
|
+
} else {
|
|
58
|
+
out += ch ?? "";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function splitFlowList(inner) {
|
|
65
|
+
const items = [];
|
|
66
|
+
let current = "";
|
|
67
|
+
let quote;
|
|
68
|
+
for (const ch of inner) {
|
|
69
|
+
if (quote !== undefined) {
|
|
70
|
+
current += ch;
|
|
71
|
+
if (ch === quote) quote = undefined;
|
|
72
|
+
} else if (ch === '"' || ch === "'") {
|
|
73
|
+
quote = ch;
|
|
74
|
+
current += ch;
|
|
75
|
+
} else if (ch === ",") {
|
|
76
|
+
items.push(current);
|
|
77
|
+
current = "";
|
|
78
|
+
} else {
|
|
79
|
+
current += ch;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
items.push(current);
|
|
83
|
+
return items.map((item) => item.trim()).filter((item) => item !== "");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Parses the `---`-delimited frontmatter at the top of `content`. Returns
|
|
88
|
+
* `{ ok: false, error }` (never throws) when there is no block or it is
|
|
89
|
+
* never closed; lines it cannot interpret land in `problems`.
|
|
90
|
+
* @param {string} content
|
|
91
|
+
*/
|
|
92
|
+
export function parseFrontmatter(content) {
|
|
93
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
94
|
+
if (lines[0]?.trimEnd() !== "---") {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: "no frontmatter block (the file must start with `---`)",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const end = lines.findIndex(
|
|
101
|
+
(line, index) => index > 0 && line.trimEnd() === "---",
|
|
102
|
+
);
|
|
103
|
+
if (end === -1) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: "frontmatter block is never closed with a `---` line",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const block = lines.slice(1, end);
|
|
111
|
+
const body = lines.slice(end + 1).join("\n");
|
|
112
|
+
const fields = new Map();
|
|
113
|
+
const problems = [];
|
|
114
|
+
|
|
115
|
+
let i = 0;
|
|
116
|
+
while (i < block.length) {
|
|
117
|
+
const line = block[i] ?? "";
|
|
118
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
|
119
|
+
i++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const match = KEY_LINE.exec(line);
|
|
123
|
+
if (match?.[1] === undefined) {
|
|
124
|
+
problems.push(`unparseable frontmatter line: ${line.trim()}`);
|
|
125
|
+
i++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const key = match[1];
|
|
129
|
+
const rest = (match[2] ?? "").trim();
|
|
130
|
+
|
|
131
|
+
const continuation = [];
|
|
132
|
+
let j = i + 1;
|
|
133
|
+
while (j < block.length) {
|
|
134
|
+
const next = block[j] ?? "";
|
|
135
|
+
if (next.trim() === "" || /^\s/.test(next) || /^-(\s|$)/.test(next)) {
|
|
136
|
+
continuation.push(next);
|
|
137
|
+
j++;
|
|
138
|
+
} else {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
i = j;
|
|
143
|
+
|
|
144
|
+
if (fields.has(key)) {
|
|
145
|
+
problems.push(`duplicate frontmatter key: ${key}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const blockScalar = BLOCK_SCALAR.exec(rest);
|
|
149
|
+
if (blockScalar !== null) {
|
|
150
|
+
const dedented = dedent(continuation);
|
|
151
|
+
const text =
|
|
152
|
+
blockScalar[1] === ">"
|
|
153
|
+
? foldBlockScalar(dedented)
|
|
154
|
+
: dedented.join("\n");
|
|
155
|
+
fields.set(key, text.trim());
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (rest === "") {
|
|
160
|
+
const items = continuation
|
|
161
|
+
.filter((entry) => /^\s*-(\s|$)/.test(entry))
|
|
162
|
+
.map((entry) => unquote(entry.replace(/^\s*-\s*/, "")) ?? "")
|
|
163
|
+
.filter((item) => item !== "");
|
|
164
|
+
fields.set(key, items.length > 0 ? items : "");
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const joined = [rest, ...continuation.map((entry) => entry.trim())]
|
|
169
|
+
.filter((part) => part !== "")
|
|
170
|
+
.join(" ");
|
|
171
|
+
|
|
172
|
+
if (rest.startsWith("[")) {
|
|
173
|
+
if (!joined.trimEnd().endsWith("]")) {
|
|
174
|
+
problems.push(`unterminated flow list for key: ${key}`);
|
|
175
|
+
fields.set(key, "");
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const inner = joined.trim().slice(1, -1);
|
|
179
|
+
fields.set(
|
|
180
|
+
key,
|
|
181
|
+
splitFlowList(inner).map((item) => unquote(item) ?? item),
|
|
182
|
+
);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const scalar = unquote(joined);
|
|
187
|
+
if (scalar === undefined) {
|
|
188
|
+
problems.push(`unterminated quoted string for key: ${key}`);
|
|
189
|
+
fields.set(key, "");
|
|
190
|
+
} else {
|
|
191
|
+
fields.set(key, scalar);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { ok: true, fields, body, problems };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A field as one string -- a list is joined with `, `. `undefined` when absent. */
|
|
199
|
+
export function fieldText(fields, key) {
|
|
200
|
+
const value = fields.get(key);
|
|
201
|
+
return Array.isArray(value) ? value.join(", ") : value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A field as a list -- a non-empty scalar becomes a one-item list. `undefined` when absent. */
|
|
205
|
+
export function fieldList(fields, key) {
|
|
206
|
+
const value = fields.get(key);
|
|
207
|
+
if (value === undefined) return undefined;
|
|
208
|
+
if (Array.isArray(value)) return value;
|
|
209
|
+
return value === "" ? [] : [value];
|
|
210
|
+
}
|