@jeremiewerner/motto 0.0.3
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/LICENSE +21 -0
- package/bin/motto.js +78 -0
- package/dist/public/.claude-plugin/plugin.json +5 -0
- package/dist/public/author-skill/SKILL.md +117 -0
- package/dist/public/author-skill/references/skill-schema.md +163 -0
- package/dist/public/setup-project/SKILL.md +133 -0
- package/dist/public/setup-project/references/skill-schema.md +163 -0
- package/package.json +31 -0
- package/src/build.js +214 -0
- package/src/config.js +105 -0
- package/src/frontmatter.js +134 -0
- package/src/lint.js +207 -0
- package/src/schema.js +170 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jeremiewerner/motto",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Framework for authoring, validating, and packaging Claude Code Agent Skills",
|
|
6
|
+
"author": "Jérémie Werner",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": "github:jeremiewerner/motto",
|
|
9
|
+
"homepage": "https://github.com/jeremiewerner/motto",
|
|
10
|
+
"keywords": ["claude", "agent-skills", "claude-code", "cli"],
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=20"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"motto": "./bin/motto.js"
|
|
16
|
+
},
|
|
17
|
+
"files": ["bin/", "src/", "dist/public/"],
|
|
18
|
+
"publishConfig": { "access": "public" },
|
|
19
|
+
"scripts": {
|
|
20
|
+
"prepare": "husky",
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"prepublishOnly": "node bin/motto.js build",
|
|
23
|
+
"version": "node -e \"const fs=require('fs'); let c=fs.readFileSync('motto.yaml','utf8'); c=c.replace(/^version: .+$/m,'version: \\\"'+process.env.npm_new_version+'\\\"'); fs.writeFileSync('motto.yaml',c);\" && git add motto.yaml"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"yaml": "^2.9.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"husky": "^9.1.7"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motto build orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Exports a single async function `buildProject(projectRoot)` that turns a
|
|
5
|
+
* lint-passing source tree into self-contained, distributable Claude Code
|
|
6
|
+
* plugin folders under `dist/`.
|
|
7
|
+
*
|
|
8
|
+
* Load-bearing order (D3-02, extended per RESEARCH):
|
|
9
|
+
* 1. lintProject gate — on failure, return errors without touching dist/
|
|
10
|
+
* 2. Load config + skill data from disk (Option A — re-read, D3-01 reuse)
|
|
11
|
+
* 3. Pre-pack checks — collision (D3-07) + private-contradiction (D3-12)
|
|
12
|
+
* 4. Wipe dist/ — ONLY after ALL checks pass (D3-03)
|
|
13
|
+
* 5. Pack skills — verbatim copy (verbatimSymlinks:true) + shared-ref bundling
|
|
14
|
+
* 6. Emit plugin.json per bucket (D3-13)
|
|
15
|
+
* 7. Return result
|
|
16
|
+
*
|
|
17
|
+
* Return shape:
|
|
18
|
+
* { ok, outDir, errors, skillCount, bucketCount }
|
|
19
|
+
*
|
|
20
|
+
* Exit code: callers set process.exitCode (never process.exit(1)) to avoid
|
|
21
|
+
* truncating buffered stdout (Phase 2 Pitfall 7).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFile, readdir, rm, mkdir, cp, writeFile, stat } from 'node:fs/promises';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { lintProject } from './lint.js';
|
|
28
|
+
import { loadConfig } from './config.js';
|
|
29
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Internal helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Discover skill directory names under `skillsDir` (one level deep, sorted).
|
|
37
|
+
* Returns an empty array when skillsDir is missing (ENOENT).
|
|
38
|
+
*
|
|
39
|
+
* @param {string} skillsDir
|
|
40
|
+
* @returns {Promise<string[]>}
|
|
41
|
+
*/
|
|
42
|
+
async function discoverSkillNames(skillsDir) {
|
|
43
|
+
let entries;
|
|
44
|
+
try {
|
|
45
|
+
entries = await readdir(skillsDir, { withFileTypes: true });
|
|
46
|
+
} catch (e) {
|
|
47
|
+
if (e.code === 'ENOENT') return [];
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
return entries
|
|
51
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
52
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
53
|
+
.map((e) => e.name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read the frontmatter fields needed for packaging: audience + shared_references.
|
|
58
|
+
* Lint has already validated this SKILL.md, so we trust the data shape.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} skillsDir
|
|
61
|
+
* @param {string} skillName
|
|
62
|
+
* @returns {Promise<{ audience: string, sharedRefs: string[] }>}
|
|
63
|
+
*/
|
|
64
|
+
async function loadSkillData(skillsDir, skillName) {
|
|
65
|
+
const text = await readFile(join(skillsDir, skillName, 'SKILL.md'), 'utf8');
|
|
66
|
+
const { data } = parseFrontmatter(text);
|
|
67
|
+
return {
|
|
68
|
+
audience: typeof data.audience === 'string' ? data.audience : 'public',
|
|
69
|
+
sharedRefs: Array.isArray(data.shared_references) ? data.shared_references : [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// buildProject — main export
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a Motto project rooted at `projectRoot`.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} projectRoot - absolute path to the project root
|
|
81
|
+
* @returns {Promise<{
|
|
82
|
+
* ok: boolean,
|
|
83
|
+
* outDir: string|null,
|
|
84
|
+
* errors: Array<{skill: string, message: string}>,
|
|
85
|
+
* skillCount: number,
|
|
86
|
+
* bucketCount: number
|
|
87
|
+
* }>}
|
|
88
|
+
*/
|
|
89
|
+
export async function buildProject(projectRoot) {
|
|
90
|
+
// ── STEP 1: Lint gate (D3-01, D3-02) ──────────────────────────────────────
|
|
91
|
+
// The gate runs BEFORE any mutation. A failing lint means zero writes to dist/.
|
|
92
|
+
const lintResult = await lintProject(projectRoot);
|
|
93
|
+
if (!lintResult.ok) {
|
|
94
|
+
return { ok: false, outDir: null, errors: lintResult.errors, skillCount: 0, bucketCount: 0 };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── STEP 2: Load config + skill data (Option A — re-read, RESEARCH §Reuse) ─
|
|
98
|
+
const configText = await readFile(join(projectRoot, 'motto.yaml'), 'utf8');
|
|
99
|
+
const { config } = loadConfig(configText); // lint passed → config is valid
|
|
100
|
+
|
|
101
|
+
const skillsDir = join(projectRoot, 'skills');
|
|
102
|
+
const skillNames = await discoverSkillNames(skillsDir);
|
|
103
|
+
|
|
104
|
+
const skills = [];
|
|
105
|
+
for (const name of skillNames) {
|
|
106
|
+
const skillData = await loadSkillData(skillsDir, name);
|
|
107
|
+
skills.push({ name, ...skillData });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── STEP 3: Pre-pack checks (D3-07, D3-12) — run BEFORE the wipe ──────────
|
|
111
|
+
// Task 2 adds collision and private-contradiction checks here.
|
|
112
|
+
// The buildErrors array is checked after all skills are scanned so that
|
|
113
|
+
// ALL errors are reported in a single pass (collect-don't-throw pattern).
|
|
114
|
+
const buildErrors = [];
|
|
115
|
+
|
|
116
|
+
// D3-07: Collision check — declared shared_ref vs local skill references/<ref>.md
|
|
117
|
+
for (const skill of skills) {
|
|
118
|
+
for (const ref of skill.sharedRefs) {
|
|
119
|
+
try {
|
|
120
|
+
await stat(join(skillsDir, skill.name, 'references', ref + '.md'));
|
|
121
|
+
// If stat succeeds, the file exists in the SOURCE tree → collision
|
|
122
|
+
buildErrors.push({
|
|
123
|
+
skill: skill.name,
|
|
124
|
+
message: `shared reference '${ref}' collides with a local references/${ref}.md`,
|
|
125
|
+
});
|
|
126
|
+
} catch (e) {
|
|
127
|
+
if (e.code !== 'ENOENT') throw e; // unexpected I/O error — propagate
|
|
128
|
+
// ENOENT = no collision — continue
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// D3-12: Private-contradiction check
|
|
134
|
+
for (const skill of skills) {
|
|
135
|
+
if (skill.audience === 'private' && !config.plugins?.private) {
|
|
136
|
+
buildErrors.push({
|
|
137
|
+
skill: skill.name,
|
|
138
|
+
message: 'audience private but plugins.private not set in motto.yaml',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Return early on any pre-pack errors — BEFORE the wipe (D3-02 extended)
|
|
144
|
+
if (buildErrors.length > 0) {
|
|
145
|
+
return { ok: false, outDir: null, errors: buildErrors, skillCount: 0, bucketCount: 0 };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── STEP 4: Wipe dist/ (D3-03) — only after ALL checks pass ──────────────
|
|
149
|
+
const distDir = join(projectRoot, 'dist');
|
|
150
|
+
await rm(distDir, { recursive: true, force: true }); // force:true silences ENOENT
|
|
151
|
+
|
|
152
|
+
// ── STEP 5: Pack each skill, routed by audience (D3-09) ─────────────────
|
|
153
|
+
// public is always in bucketsUsed (D3-10); private is added when a skill
|
|
154
|
+
// declares audience:private (and plugins.private is guaranteed set by Step 3).
|
|
155
|
+
const bucketsUsed = new Set(['public']);
|
|
156
|
+
|
|
157
|
+
for (const skill of skills) {
|
|
158
|
+
const audience = skill.audience; // 'public' | 'private' (validated by lint)
|
|
159
|
+
const dstSkillDir = join(distDir, audience, skill.name);
|
|
160
|
+
|
|
161
|
+
// Verbatim copy — verbatimSymlinks:true is MANDATORY (RESEARCH CRITICAL).
|
|
162
|
+
// Default and dereference:false silently rewrite relative symlinks to
|
|
163
|
+
// absolute paths pointing into the source tree on macOS (Node 24 verified).
|
|
164
|
+
await cp(join(skillsDir, skill.name), dstSkillDir, {
|
|
165
|
+
recursive: true,
|
|
166
|
+
verbatimSymlinks: true,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Bundle declared shared_references (D3-06)
|
|
170
|
+
if (skill.sharedRefs.length > 0) {
|
|
171
|
+
const outRefsDir = join(dstSkillDir, 'references');
|
|
172
|
+
// Idempotent guard: creates references/ even if the skill had no local
|
|
173
|
+
// references/ dir — cp would not have created it (RESEARCH Pitfall 3)
|
|
174
|
+
await mkdir(outRefsDir, { recursive: true });
|
|
175
|
+
for (const ref of skill.sharedRefs) {
|
|
176
|
+
await cp(
|
|
177
|
+
join(projectRoot, 'shared', 'references', ref + '.md'),
|
|
178
|
+
join(outRefsDir, ref + '.md'),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (audience === 'private') bucketsUsed.add('private');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── STEP 6: Emit plugin.json for each bucket used (D3-13) ────────────────
|
|
187
|
+
// public bucket: always emitted (D3-10); private bucket: emitted only when
|
|
188
|
+
// bucketsUsed has 'private' (D3-11 — the D3-12 check above ensures this only
|
|
189
|
+
// happens when plugins.private is set, so plugins.private is always present here).
|
|
190
|
+
const plugins = config.plugins ?? {};
|
|
191
|
+
|
|
192
|
+
for (const audience of bucketsUsed) {
|
|
193
|
+
const pluginName = audience === 'public' ? plugins.public : plugins.private;
|
|
194
|
+
const pluginDir = join(distDir, audience, '.claude-plugin');
|
|
195
|
+
await mkdir(pluginDir, { recursive: true });
|
|
196
|
+
await writeFile(
|
|
197
|
+
join(pluginDir, 'plugin.json'),
|
|
198
|
+
JSON.stringify(
|
|
199
|
+
{ name: pluginName, version: config.version, description: config.description },
|
|
200
|
+
null,
|
|
201
|
+
2,
|
|
202
|
+
) + '\n',
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── STEP 7: Return result ─────────────────────────────────────────────────
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
outDir: distDir,
|
|
210
|
+
errors: [],
|
|
211
|
+
skillCount: skills.length,
|
|
212
|
+
bucketCount: bucketsUsed.size,
|
|
213
|
+
};
|
|
214
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motto project-config validator.
|
|
3
|
+
*
|
|
4
|
+
* Pure validator — no filesystem I/O, never throws (D-01). All validation failures
|
|
5
|
+
* surface through the returned errors[].
|
|
6
|
+
*
|
|
7
|
+
* Exports:
|
|
8
|
+
* - loadConfig(text: string) -> { config: object, errors: Array<{ message: string }> }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parseDocument } from "yaml";
|
|
12
|
+
import { safeToJS } from "./frontmatter.js";
|
|
13
|
+
// src/schema.js is the sole source of NAME_KEBAB (REVIEW-11, D-16).
|
|
14
|
+
// schema.js has no imports, so there is no circular dependency.
|
|
15
|
+
import { NAME_KEBAB } from "./schema.js";
|
|
16
|
+
|
|
17
|
+
// Re-export so config.js's public surface is preserved — dogfood DOG-04 imports
|
|
18
|
+
// NAME_KEBAB from config.js to assert reference identity with schema.js.
|
|
19
|
+
export { NAME_KEBAB };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse and validate a raw motto.yaml text string.
|
|
23
|
+
*
|
|
24
|
+
* Error-aggregation model:
|
|
25
|
+
* - YAML parse errors are mapped from doc.errors[] into returned errors[] (D-02, D-18).
|
|
26
|
+
* - ALL missing required fields are collected together — no short-circuit (D-15, CONF-01).
|
|
27
|
+
* - Plugin names are validated against NAME_KEBAB only when the field is present (D-16).
|
|
28
|
+
* - plugins.private is optional — its ABSENCE is never an error (CONF-03, D-17).
|
|
29
|
+
*
|
|
30
|
+
* This function performs NO filesystem I/O. Phase 2 wires in the file read; this
|
|
31
|
+
* function owns only the parse + validation logic.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} text — raw motto.yaml content (a string, not a path)
|
|
34
|
+
* @returns {{ config: object, errors: Array<{ message: string }> }}
|
|
35
|
+
* `config` — the parsed JS object (possibly partial on parse error; {} when null/empty).
|
|
36
|
+
* `errors` — empty array when the config is fully valid; one object per error otherwise.
|
|
37
|
+
*/
|
|
38
|
+
export function loadConfig(text) {
|
|
39
|
+
const errors = [];
|
|
40
|
+
let config = {};
|
|
41
|
+
|
|
42
|
+
// ── YAML PARSE (D-02, D-18) ────────────────────────────────────────────────
|
|
43
|
+
// parseDocument accumulates errors in doc.errors[] without throwing (D-01).
|
|
44
|
+
let doc;
|
|
45
|
+
try {
|
|
46
|
+
doc = parseDocument(text);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// Defensive: parseDocument is specified not to throw, but D-01 requires we
|
|
49
|
+
// never throw regardless of input. Catch and surface any unexpected exception.
|
|
50
|
+
errors.push({ message: String(e.message || e) });
|
|
51
|
+
return { config, errors };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Map each YAML parse error into the returned errors[] (D-18).
|
|
55
|
+
for (const yamlErr of doc.errors) {
|
|
56
|
+
errors.push({ message: yamlErr.message || String(yamlErr) });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Resolve to a plain JS object. safeToJS guards against toJS() throwing on an
|
|
60
|
+
// unresolved alias (e.g. `name: *foo` — D-01, REVIEW-03). src/schema.js is
|
|
61
|
+
// the sole source of NAME_KEBAB (REVIEW-11); safeToJS is the sole helper (REVIEW-02/03).
|
|
62
|
+
const { value, threw, message } = safeToJS(doc);
|
|
63
|
+
if (threw) {
|
|
64
|
+
errors.push({ message });
|
|
65
|
+
}
|
|
66
|
+
config = value != null && typeof value === "object" ? value : {};
|
|
67
|
+
|
|
68
|
+
// ── REQUIRED FIELDS (CONF-01, D-15) ───────────────────────────────────────
|
|
69
|
+
// Collect ALL missing required fields together — no short-circuit (D-15).
|
|
70
|
+
if (!config.name) {
|
|
71
|
+
errors.push({ message: "missing name" });
|
|
72
|
+
}
|
|
73
|
+
if (!config.version) {
|
|
74
|
+
errors.push({ message: "missing version" });
|
|
75
|
+
}
|
|
76
|
+
if (!config.description) {
|
|
77
|
+
errors.push({ message: "missing description" });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── PLUGIN NAMES (CONF-02, D-16) ──────────────────────────────────────────
|
|
81
|
+
const plugins =
|
|
82
|
+
config.plugins != null && typeof config.plugins === "object"
|
|
83
|
+
? config.plugins
|
|
84
|
+
: {};
|
|
85
|
+
|
|
86
|
+
// plugins.public is required (CONF-01). Validate its format only when present.
|
|
87
|
+
if (!plugins.public) {
|
|
88
|
+
errors.push({ message: "missing plugins.public" });
|
|
89
|
+
} else if (!NAME_KEBAB.test(plugins.public)) {
|
|
90
|
+
errors.push({
|
|
91
|
+
message: `plugins.public "${plugins.public}" must be letter-start kebab-case (/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/)`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// plugins.private is OPTIONAL (CONF-03, D-17). Only validate format when present.
|
|
96
|
+
if (plugins.private != null) {
|
|
97
|
+
if (!NAME_KEBAB.test(plugins.private)) {
|
|
98
|
+
errors.push({
|
|
99
|
+
message: `plugins.private "${plugins.private}" must be letter-start kebab-case (/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/)`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { config, errors };
|
|
105
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import YAML from "yaml";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Safely convert a YAML Document node to a plain JS value without throwing.
|
|
5
|
+
*
|
|
6
|
+
* YAML's doc.toJS() throws a ReferenceError when the document contains an
|
|
7
|
+
* unresolved alias (e.g. `description: *foo` where anchor `foo` is not defined).
|
|
8
|
+
* The parsers in this project must NEVER throw (D-01, REVIEW-02, REVIEW-03);
|
|
9
|
+
* this helper centralises the guard so neither parseFrontmatter nor loadConfig
|
|
10
|
+
* needs its own try/catch.
|
|
11
|
+
*
|
|
12
|
+
* @param {import('yaml').Document} doc
|
|
13
|
+
* @returns {{ value: any, threw: boolean, message: string|null }}
|
|
14
|
+
*/
|
|
15
|
+
export function safeToJS(doc) {
|
|
16
|
+
try {
|
|
17
|
+
return { value: doc.toJS(), threw: false, message: null };
|
|
18
|
+
} catch (e) {
|
|
19
|
+
return { value: null, threw: true, message: String(e?.message ?? e) };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Split a SKILL.md into frontmatter `data` + Markdown `body`, normalizing the
|
|
25
|
+
* input first, and report every structural malformation through a uniform
|
|
26
|
+
* `errors[]` array. NEVER throws (D-01, D-03): every failure path returns via
|
|
27
|
+
* `errors[]`.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} text - raw SKILL.md contents
|
|
30
|
+
* @returns {{ data: object, body: string, errors: Array<{ message: string }> }}
|
|
31
|
+
*/
|
|
32
|
+
export function parseFrontmatter(text) {
|
|
33
|
+
const errors = [];
|
|
34
|
+
|
|
35
|
+
// (1) NORMALIZE FIRST (D-04, D-05): strip a single leading UTF-8 BOM, then
|
|
36
|
+
// CRLF -> LF. Nothing else — no lone-CR handling, no trailing-whitespace
|
|
37
|
+
// munging. All downstream delimiter logic stays LF-anchored.
|
|
38
|
+
let normalized = text;
|
|
39
|
+
if (normalized.charCodeAt(0) === 0xfeff) {
|
|
40
|
+
normalized = normalized.slice(1);
|
|
41
|
+
}
|
|
42
|
+
normalized = normalized.replace(/\r\n/g, "\n");
|
|
43
|
+
|
|
44
|
+
const lines = normalized.split("\n");
|
|
45
|
+
|
|
46
|
+
// (2) OPENING DELIMITER: normalized text must begin with a bare "---" line.
|
|
47
|
+
if (lines[0] !== "---") {
|
|
48
|
+
errors.push({
|
|
49
|
+
message: "missing frontmatter: file must begin with a '--- ... ---' block",
|
|
50
|
+
});
|
|
51
|
+
return { data: {}, body: normalized, errors };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// (3) CLOSING DELIMITER: the first subsequent bare "---" line is the close.
|
|
55
|
+
let close = -1;
|
|
56
|
+
for (let i = 1; i < lines.length; i++) {
|
|
57
|
+
if (lines[i] === "---") {
|
|
58
|
+
close = i;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (close === -1) {
|
|
63
|
+
errors.push({
|
|
64
|
+
message: "unterminated frontmatter: no closing '---' delimiter found",
|
|
65
|
+
});
|
|
66
|
+
return { data: {}, body: normalized, errors };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const block = lines.slice(1, close).join("\n");
|
|
70
|
+
const body = lines.slice(close + 1).join("\n");
|
|
71
|
+
|
|
72
|
+
// (4) STRAY DETECTION (PARSE-04, D-06): a valid file has exactly one opening
|
|
73
|
+
// and one closing bare "---". If the region immediately following the close —
|
|
74
|
+
// up to the next bare "---" — has a node shape consistent with a leaked YAML
|
|
75
|
+
// mapping, it is leaked frontmatter split by a stray delimiter, not body.
|
|
76
|
+
//
|
|
77
|
+
// Node-shape detection (REVIEW-05): instead of calling toJS() (which throws on
|
|
78
|
+
// unresolved aliases such as `key: *foo` or `**Role:**` alias nodes — D-01),
|
|
79
|
+
// we inspect the PARSED NODE TREE directly. The region is treated as a leaked
|
|
80
|
+
// mapping iff:
|
|
81
|
+
// - regionDoc.errors.length === 0 (clean parse — not a syntax error)
|
|
82
|
+
// - YAML.isMap(regionDoc.contents) (top-level node is a mapping, not a scalar)
|
|
83
|
+
// - regionDoc.contents.items.length > 0 (at least one key-value pair present)
|
|
84
|
+
//
|
|
85
|
+
// This catches `key: *foo` (alias value — errors.length 0, isMap true) while
|
|
86
|
+
// leaving body text (Scalar) and horizontal rules (Scalar/null) unflagged (B7).
|
|
87
|
+
// The B8 region ("**Role:** Guide.\n\n") resolves to an Alias node with
|
|
88
|
+
// errors.length > 0, so it stays correctly unflagged (B8).
|
|
89
|
+
//
|
|
90
|
+
// Zero unguarded toJS() calls remain in this file; safeToJS() owns the guard
|
|
91
|
+
// for the main block parse (step 5b above).
|
|
92
|
+
let nextDelim = -1;
|
|
93
|
+
for (let i = close + 1; i < lines.length; i++) {
|
|
94
|
+
if (lines[i] === "---") {
|
|
95
|
+
nextDelim = i;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (nextDelim !== -1) {
|
|
100
|
+
const region = lines.slice(close + 1, nextDelim).join("\n");
|
|
101
|
+
const regionDoc = YAML.parseDocument(region);
|
|
102
|
+
const isLeakedMapping =
|
|
103
|
+
regionDoc.errors.length === 0 &&
|
|
104
|
+
YAML.isMap(regionDoc.contents) &&
|
|
105
|
+
regionDoc.contents.items.length > 0;
|
|
106
|
+
if (isLeakedMapping) {
|
|
107
|
+
errors.push({
|
|
108
|
+
message:
|
|
109
|
+
"stray '---' delimiter in frontmatter: the block must contain exactly one closing '---'",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// (5) PARSE THE BLOCK with YAML.parseDocument (NOT YAML.parse — D-02). Map
|
|
115
|
+
// every parse error into the returned errors[] (D-02, PARSE-03). An empty
|
|
116
|
+
// block yields data {} with NO parse error (D-07). toJS() is deferred to (5b).
|
|
117
|
+
const doc = YAML.parseDocument(block);
|
|
118
|
+
for (const e of doc.errors) {
|
|
119
|
+
errors.push({ message: e.message });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// (5b) RESOLVE to plain JS. safeToJS guards against toJS() throwing on an
|
|
123
|
+
// unresolved alias (e.g. `description: *foo` — D-01, REVIEW-02).
|
|
124
|
+
const { value, threw, message } = safeToJS(doc);
|
|
125
|
+
if (threw) {
|
|
126
|
+
errors.push({ message });
|
|
127
|
+
}
|
|
128
|
+
let data = value;
|
|
129
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
130
|
+
data = {};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { data, body, errors };
|
|
134
|
+
}
|