@the-bearded-bear/claude-craft 7.35.0 → 8.1.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.
Files changed (30) hide show
  1. package/Dev/scripts/install-php-rules.sh +1 -1
  2. package/Dev/scripts/validate-skills-spec.sh +121 -0
  3. package/README.md +13 -11
  4. package/cli/index.js +6 -0
  5. package/cli/kanban/client/index.html +17 -0
  6. package/cli/kanban/client/src/App.svelte +106 -0
  7. package/cli/kanban/client/src/app.css +175 -0
  8. package/cli/kanban/client/src/lib/router.svelte.js +19 -0
  9. package/cli/kanban/client/src/lib/store.svelte.js +132 -0
  10. package/cli/kanban/client/src/main.js +6 -0
  11. package/cli/kanban/client/src/views/BacklogView.svelte +344 -0
  12. package/cli/kanban/client/src/views/BurndownView.svelte +189 -0
  13. package/cli/kanban/client/src/views/DepsView.svelte +334 -0
  14. package/cli/kanban/client/src/views/DocsView.svelte +451 -0
  15. package/cli/kanban/client/src/views/KanbanView.svelte +227 -0
  16. package/cli/kanban/client/vite.config.js +21 -0
  17. package/cli/kanban/server/app.js +201 -0
  18. package/cli/kanban/server/middleware/security.js +53 -0
  19. package/cli/kanban/server/services/event-bus.js +33 -0
  20. package/cli/kanban/server/services/file-scanner.js +113 -0
  21. package/cli/kanban/server/services/file-watcher.js +68 -0
  22. package/cli/kanban/server/services/file-writer.js +107 -0
  23. package/cli/kanban/server/services/frontmatter.js +55 -0
  24. package/cli/kanban/server/services/repository.js +173 -0
  25. package/cli/kanban/server/services/sprint-cache.js +208 -0
  26. package/cli/kanban/server/services/state-machine.js +156 -0
  27. package/cli/kanban/shared/schemas.js +127 -0
  28. package/cli/lib/help.js +4 -0
  29. package/cli/lib/kanban.js +103 -0
  30. package/package.json +21 -3
@@ -0,0 +1,201 @@
1
+ import { Hono } from 'hono';
2
+ import { streamSSE } from 'hono/streaming';
3
+ import { serveStatic } from '@hono/node-server/serve-static';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import fs from 'node:fs';
7
+ import { csrfGuard, readonlyGuard, resolveSafe } from './middleware/security.js';
8
+ import { validateTransition, validateUnblock, computeTransitionPatch } from './services/state-machine.js';
9
+ import { updateFrontmatter, LockedError, MtimeMismatchError } from './services/file-writer.js';
10
+ import { loadSprintStatus, computeBurndown } from './services/sprint-cache.js';
11
+ import { TransitionRequestSchema } from '../shared/schemas.js';
12
+
13
+ /**
14
+ * Build the Hono app. Kept separate from the HTTP server boot so tests can
15
+ * invoke routes via app.request() without opening a socket.
16
+ *
17
+ * @param {object} opts
18
+ * @param {import('./services/repository.js').Repository} opts.repository
19
+ * @param {number} opts.port
20
+ * @param {boolean} [opts.readonly=false]
21
+ * @param {string} [opts.projectRoot] - Parent of project-management/, defaults to parent of repository.rootDir
22
+ * @returns {Hono}
23
+ */
24
+ export function createApp({ repository, port, readonly = false, eventBus = null, projectRoot = null }) {
25
+ const resolvedProjectRoot = projectRoot ?? path.dirname(repository.rootDir);
26
+ const app = new Hono();
27
+
28
+ app.use('*', csrfGuard(port));
29
+ app.use('*', readonlyGuard(readonly));
30
+
31
+ app.get('/api/health', (c) => c.json({ ok: true, readonly }));
32
+
33
+ app.get('/api/stories', (c) => {
34
+ const sprintId = c.req.query('sprint_id') || undefined;
35
+ const status = c.req.query('status') || undefined;
36
+ const epicId = c.req.query('epic_id') || undefined;
37
+ return c.json({
38
+ stories: repository.listStories({ sprintId, status, epicId }),
39
+ epics: repository.listEpics(),
40
+ });
41
+ });
42
+
43
+ app.get('/api/stories/:id', (c) => {
44
+ const id = c.req.param('id');
45
+ const story = repository.getStory(id);
46
+ if (!story) return c.json({ error: 'not_found' }, 404);
47
+ const tasks = repository.listTasksForStory(id);
48
+ return c.json({ story, tasks });
49
+ });
50
+
51
+ app.get('/api/dependencies', (c) => {
52
+ return c.json(repository.buildDependenciesGraph());
53
+ });
54
+
55
+ app.get('/api/docs', (c) => {
56
+ return c.json({ docs: repository.listDocs().map((d) => ({ rel: d.rel })) });
57
+ });
58
+
59
+ app.get('/api/docs/:rel{.+}', async (c) => {
60
+ const rel = c.req.param('rel');
61
+ try {
62
+ resolveSafe(repository.rootDir, rel);
63
+ } catch {
64
+ return c.json({ error: 'invalid_path' }, 400);
65
+ }
66
+ const content = await repository.readDoc(rel);
67
+ if (content === null) return c.json({ error: 'not_found' }, 404);
68
+ return c.text(content);
69
+ });
70
+
71
+ app.patch('/api/stories/:id/status', async (c) => {
72
+ const id = c.req.param('id');
73
+ const filepath = repository.getStoryFile(id);
74
+ const story = repository.getStory(id);
75
+ if (!story || !filepath) return c.json({ error: 'not_found' }, 404);
76
+
77
+ let body;
78
+ try {
79
+ body = await c.req.json();
80
+ } catch {
81
+ return c.json({ error: 'invalid_json' }, 400);
82
+ }
83
+
84
+ const parsed = TransitionRequestSchema.safeParse(body);
85
+ if (!parsed.success) {
86
+ return c.json({ error: 'invalid_payload', issues: parsed.error.issues }, 400);
87
+ }
88
+ const { status: target, blocked_reason } = parsed.data;
89
+
90
+ // Unblock : dispatch when story is blocked and target matches previous_status
91
+ if (story.status === 'blocked' && target !== 'blocked') {
92
+ const unblock = validateUnblock(story);
93
+ if (!unblock.allowed) {
94
+ return c.json({ error: 'invalid_transition', reason: unblock.reason }, 409);
95
+ }
96
+ if (unblock.status !== target) {
97
+ return c.json(
98
+ {
99
+ error: 'invalid_transition',
100
+ reason: `blocked stories may only be restored to previous_status (${unblock.status})`,
101
+ },
102
+ 409
103
+ );
104
+ }
105
+ } else {
106
+ const check = validateTransition(story, target, { blocked_reason });
107
+ if (!check.allowed) {
108
+ return c.json(
109
+ {
110
+ error: 'invalid_transition',
111
+ reason: check.reason,
112
+ gate: check.gate,
113
+ missing: check.missing,
114
+ },
115
+ 409
116
+ );
117
+ }
118
+ }
119
+
120
+ const patch = computeTransitionPatch(story, target, { blocked_reason });
121
+ try {
122
+ await updateFrontmatter(filepath, patch, { expectedMtime: story._mtime });
123
+ await repository.refresh();
124
+ const fresh = repository.getStory(id);
125
+ if (eventBus) eventBus.publish('story:updated', { id, story: fresh });
126
+ return c.json({ story: fresh });
127
+ } catch (err) {
128
+ if (err instanceof LockedError) return c.json({ error: 'locked' }, 423);
129
+ if (err instanceof MtimeMismatchError) return c.json({ error: 'conflict', reason: 'file changed on disk' }, 409);
130
+ return c.json({ error: 'write_failed', message: err.message }, 500);
131
+ }
132
+ });
133
+
134
+ app.get('/api/events', (c) => {
135
+ if (!eventBus) return c.json({ error: 'events_disabled' }, 501);
136
+ return streamSSE(c, async (stream) => {
137
+ const unsubscribe = eventBus.subscribe((msg) => {
138
+ stream
139
+ .writeSSE({
140
+ event: msg.event,
141
+ data: JSON.stringify(msg),
142
+ })
143
+ .catch(() => {
144
+ /* client disconnected */
145
+ });
146
+ });
147
+ stream.onAbort(() => unsubscribe());
148
+ // heartbeat toutes les 30s
149
+ try {
150
+ while (!stream.aborted) {
151
+ await stream.sleep(30_000);
152
+ await stream.writeSSE({ event: 'heartbeat', data: String(Date.now()) });
153
+ }
154
+ } catch {
155
+ /* aborted */
156
+ }
157
+ unsubscribe();
158
+ });
159
+ });
160
+
161
+ app.get('/api/sprints/current', async (c) => {
162
+ const sprintStatus = await loadSprintStatus(resolvedProjectRoot);
163
+ if (!sprintStatus || sprintStatus._invalid) {
164
+ return c.json({ error: 'not_found' }, 404);
165
+ }
166
+
167
+ const sprintId = sprintStatus.metadata.sprint_id;
168
+ const stories = repository.listStories({ sprintId });
169
+ if (stories.length === 0) {
170
+ return c.json({ error: 'not_found' }, 404);
171
+ }
172
+
173
+ const burndown = computeBurndown(sprintStatus, stories);
174
+
175
+ return c.json({
176
+ sprint: sprintStatus.metadata,
177
+ stories: stories.map((s) => ({
178
+ id: s.id,
179
+ title: s.title,
180
+ status: s.status,
181
+ story_points: s.story_points,
182
+ assigned_to: s.assigned_to,
183
+ tasks: s.tasks,
184
+ })),
185
+ burndown,
186
+ });
187
+ });
188
+
189
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
190
+ const clientDist = path.resolve(__dirname, '../client/dist');
191
+ if (fs.existsSync(clientDist)) {
192
+ app.use('/assets/*', serveStatic({ root: path.relative(process.cwd(), clientDist) }));
193
+ app.get('/', (c) => c.html(fs.readFileSync(path.join(clientDist, 'index.html'), 'utf8')));
194
+ } else {
195
+ app.get('/', (c) =>
196
+ c.text('claude-craft kanban : client not built.\n' + 'Run "npm run kanban:build" to build the UI.\n')
197
+ );
198
+ }
199
+
200
+ return app;
201
+ }
@@ -0,0 +1,53 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * Middleware factory : enforces same-origin for mutating requests.
5
+ * Only http://127.0.0.1:<port> and http://localhost:<port> are accepted.
6
+ */
7
+ export function csrfGuard(port) {
8
+ const allowed = new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
9
+ return async (c, next) => {
10
+ if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(c.req.method)) {
11
+ return next();
12
+ }
13
+ const origin = c.req.header('origin');
14
+ const referer = c.req.header('referer');
15
+ const source = origin ?? (referer ? new URL(referer).origin : null);
16
+ if (!source || !allowed.has(source)) {
17
+ return c.json({ error: 'forbidden', reason: 'invalid origin' }, 403);
18
+ }
19
+ return next();
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Resolves a user-provided relative path safely under a base directory.
25
+ * Throws on path traversal attempts.
26
+ * @param {string} baseDir - absolute, trusted
27
+ * @param {string} userPath - user-provided, untrusted
28
+ * @returns {string} absolute resolved path, guaranteed under baseDir
29
+ */
30
+ export function resolveSafe(baseDir, userPath) {
31
+ const base = path.resolve(baseDir);
32
+ const resolved = path.resolve(base, userPath);
33
+ const rel = path.relative(base, resolved);
34
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
35
+ const err = new Error('path traversal detected');
36
+ err.code = 'PATH_TRAVERSAL';
37
+ throw err;
38
+ }
39
+ return resolved;
40
+ }
41
+
42
+ /**
43
+ * Rejects requests when --readonly mode is active.
44
+ */
45
+ export function readonlyGuard(enabled) {
46
+ return async (c, next) => {
47
+ if (!enabled) return next();
48
+ if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(c.req.method)) {
49
+ return c.json({ error: 'readonly', reason: 'server started in --readonly mode' }, 403);
50
+ }
51
+ return next();
52
+ };
53
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Minimal in-memory event bus with SSE-friendly fan-out.
3
+ * Subscribers receive objects ; the route layer serializes them for SSE.
4
+ */
5
+ export class EventBus {
6
+ constructor() {
7
+ this.subscribers = new Set();
8
+ }
9
+
10
+ subscribe(fn) {
11
+ this.subscribers.add(fn);
12
+ return () => this.subscribers.delete(fn);
13
+ }
14
+
15
+ publish(event, payload) {
16
+ const msg = { event, payload, ts: Date.now() };
17
+ for (const fn of [...this.subscribers]) {
18
+ try {
19
+ fn(msg);
20
+ } catch {
21
+ /* isolate subscriber failures */
22
+ }
23
+ }
24
+ }
25
+
26
+ get size() {
27
+ return this.subscribers.size;
28
+ }
29
+
30
+ clear() {
31
+ this.subscribers.clear();
32
+ }
33
+ }
@@ -0,0 +1,113 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Recursive scanner for a project-management/ directory (BMAD v6 layout).
6
+ * Returns categorized file paths with mtime for cache invalidation.
7
+ *
8
+ * Layout :
9
+ * project-management/
10
+ * prd.md, tech-spec.md, personas.md, definition-of-done.md, dependencies-matrix.md
11
+ * architecture/*.md
12
+ * backlog/epics/EPIC-*.md
13
+ * backlog/user-stories/US-*.md
14
+ * sprints/sprint-*\/{sprint-goal,board,status-*}.md
15
+ * sprints/sprint-*\/tasks/TASK-*.md
16
+ */
17
+
18
+ const IGNORE_PATTERNS = [/\.bak$/, /\.lock$/, /^\./];
19
+
20
+ function isIgnored(name) {
21
+ return IGNORE_PATTERNS.some((re) => re.test(name));
22
+ }
23
+
24
+ async function walk(dir, onFile) {
25
+ let entries;
26
+ try {
27
+ entries = await readdir(dir, { withFileTypes: true });
28
+ } catch (err) {
29
+ if (err.code === 'ENOENT') return;
30
+ throw err;
31
+ }
32
+ for (const entry of entries) {
33
+ if (isIgnored(entry.name)) continue;
34
+ const full = path.join(dir, entry.name);
35
+ if (entry.isDirectory()) {
36
+ await walk(full, onFile);
37
+ } else if (entry.isFile()) {
38
+ await onFile(full);
39
+ }
40
+ }
41
+ }
42
+
43
+ function classify(rootDir, absPath) {
44
+ const rel = path.relative(rootDir, absPath).split(path.sep).join('/');
45
+ if (!rel.endsWith('.md') && !rel.endsWith('.yaml') && !rel.endsWith('.yml')) return null;
46
+
47
+ if (rel.startsWith('backlog/user-stories/') && /US-\d+/.test(rel)) return 'story';
48
+ if (rel.startsWith('backlog/epics/') && /EPIC-\d+/.test(rel)) return 'epic';
49
+
50
+ const sprintMatch = rel.match(/^sprints\/([^/]+)\/(.+)$/);
51
+ if (sprintMatch) {
52
+ const [, , rest] = sprintMatch;
53
+ if (rest.startsWith('tasks/') && /TASK-\d+/.test(rest)) return 'task';
54
+ if (rest === 'sprint-goal.md') return 'sprint-goal';
55
+ if (rest === 'board.md') return 'board';
56
+ if (rest.startsWith('status-')) return 'sprint-status-snapshot';
57
+ if (rest === 'sprint-review.md') return 'sprint-review';
58
+ if (rest === 'sprint-dependencies.md') return 'sprint-deps';
59
+ return 'sprint-other';
60
+ }
61
+
62
+ if (rel.startsWith('architecture/')) return 'architecture';
63
+
64
+ const docs = new Set([
65
+ 'prd.md',
66
+ 'tech-spec.md',
67
+ 'personas.md',
68
+ 'definition-of-done.md',
69
+ 'dependencies-matrix.md',
70
+ 'README.md',
71
+ ]);
72
+ if (docs.has(rel)) return 'doc';
73
+ if (rel === 'workflow-status.yaml') return 'workflow-status';
74
+
75
+ return 'other';
76
+ }
77
+
78
+ function sprintIdOf(rootDir, absPath) {
79
+ const rel = path.relative(rootDir, absPath).split(path.sep).join('/');
80
+ const m = rel.match(/^sprints\/([^/]+)\//);
81
+ return m ? m[1] : null;
82
+ }
83
+
84
+ /**
85
+ * @param {string} rootDir - absolute path to project-management/
86
+ * @returns {Promise<{ files: Array<{path:string, category:string, mtime:number, sprintId:string|null}> }>}
87
+ */
88
+ export async function scan(rootDir) {
89
+ const absRoot = path.resolve(rootDir);
90
+ const files = [];
91
+ await walk(absRoot, async (full) => {
92
+ const category = classify(absRoot, full);
93
+ if (!category) return;
94
+ const st = await stat(full);
95
+ files.push({
96
+ path: full,
97
+ category,
98
+ mtime: st.mtimeMs,
99
+ sprintId: sprintIdOf(absRoot, full),
100
+ });
101
+ });
102
+ return { files };
103
+ }
104
+
105
+ export function groupByCategory(files) {
106
+ const groups = {};
107
+ for (const f of files) {
108
+ (groups[f.category] ??= []).push(f);
109
+ }
110
+ return groups;
111
+ }
112
+
113
+ export const _internal = { classify, sprintIdOf };
@@ -0,0 +1,68 @@
1
+ import chokidar from 'chokidar';
2
+ import { _internal } from './file-scanner.js';
3
+
4
+ /**
5
+ * Start watching project-management/ directory for changes.
6
+ * Publishes 'file:changed' events to eventBus after debounce.
7
+ *
8
+ * @param {object} opts
9
+ * @param {string} opts.rootDir - absolute path to watch
10
+ * @param {EventBus} opts.eventBus - bus to publish events
11
+ * @param {Repository} opts.repository - cache to refresh on changes
12
+ * @param {number} [opts.debounceMs=200] - debounce delay per path
13
+ * @returns {object} - { stop: async () => void }
14
+ */
15
+ export function startWatcher({ rootDir, eventBus, repository, debounceMs = 200 }) {
16
+ const debounceTimers = new Map();
17
+ let watcher = null;
18
+
19
+ const onEvent = async (event, filePath) => {
20
+ const category = _internal.classify(rootDir, filePath);
21
+ if (!category || category === 'other') return;
22
+
23
+ if (debounceTimers.has(filePath)) {
24
+ clearTimeout(debounceTimers.get(filePath));
25
+ }
26
+
27
+ debounceTimers.set(
28
+ filePath,
29
+ setTimeout(async () => {
30
+ debounceTimers.delete(filePath);
31
+ try {
32
+ await repository.refresh();
33
+ eventBus.publish('file:changed', { path: filePath, event, category });
34
+ } catch (err) {
35
+ console.error('[file-watcher] Error processing change:', err.message);
36
+ }
37
+ }, debounceMs)
38
+ );
39
+ };
40
+
41
+ try {
42
+ watcher = chokidar.watch(rootDir, {
43
+ ignored: [/\.bak$/, /\.lock$/, /\.tmp-/, /(^|[/\\])\../, /node_modules/],
44
+ ignoreInitial: true,
45
+ persistent: true,
46
+ });
47
+
48
+ watcher
49
+ .on('add', (path) => onEvent('add', path))
50
+ .on('change', (path) => onEvent('change', path))
51
+ .on('unlink', (path) => onEvent('unlink', path));
52
+ } catch (err) {
53
+ console.error('[file-watcher] Failed to start watcher:', err.message);
54
+ }
55
+
56
+ return {
57
+ async stop() {
58
+ for (const timer of debounceTimers.values()) {
59
+ clearTimeout(timer);
60
+ }
61
+ debounceTimers.clear();
62
+ if (watcher) {
63
+ await watcher.close();
64
+ watcher = null;
65
+ }
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,107 @@
1
+ import { open, rename, copyFile, unlink, stat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseFile, stringify } from './frontmatter.js';
4
+
5
+ /**
6
+ * Atomic update of a Markdown file's frontmatter.
7
+ *
8
+ * Safety :
9
+ * 1. Exclusive lock file (<path>.lock, O_CREAT|O_EXCL) — refuses concurrent writes.
10
+ * 2. Optional mtime check (ETag-like) — rejects writes if file changed on disk.
11
+ * 3. Backup (<path>.bak) — restored if the write fails.
12
+ * 4. Write to a temp file then rename — atomic on POSIX.
13
+ * 5. Body of the markdown file is preserved byte-for-byte.
14
+ */
15
+
16
+ class LockedError extends Error {
17
+ constructor(filepath) {
18
+ super(`File is locked: ${filepath}`);
19
+ this.code = 'LOCKED';
20
+ }
21
+ }
22
+
23
+ class MtimeMismatchError extends Error {
24
+ constructor(filepath, expected, actual) {
25
+ super(`mtime mismatch for ${filepath}: expected ${expected}, got ${actual}`);
26
+ this.code = 'MTIME_MISMATCH';
27
+ }
28
+ }
29
+
30
+ async function acquireLock(filepath) {
31
+ const lockPath = `${filepath}.lock`;
32
+ try {
33
+ const fd = await open(lockPath, 'wx');
34
+ await fd.close();
35
+ return lockPath;
36
+ } catch (err) {
37
+ if (err.code === 'EEXIST') throw new LockedError(filepath);
38
+ throw err;
39
+ }
40
+ }
41
+
42
+ async function releaseLock(lockPath) {
43
+ try {
44
+ await unlink(lockPath);
45
+ } catch {
46
+ /* ignore */
47
+ }
48
+ }
49
+
50
+ /**
51
+ * @param {string} filepath
52
+ * @param {object} patch - partial frontmatter to merge
53
+ * @param {object} [opts]
54
+ * @param {number} [opts.expectedMtime] - if provided, write only if current mtime matches
55
+ * @returns {Promise<{ data: object, body: string, mtime: number }>}
56
+ */
57
+ export async function updateFrontmatter(filepath, patch, opts = {}) {
58
+ const abs = path.resolve(filepath);
59
+ const lockPath = await acquireLock(abs);
60
+ const backup = `${abs}.bak`;
61
+ try {
62
+ if (typeof opts.expectedMtime === 'number') {
63
+ const st = await stat(abs);
64
+ if (st.mtimeMs !== opts.expectedMtime) {
65
+ throw new MtimeMismatchError(abs, opts.expectedMtime, st.mtimeMs);
66
+ }
67
+ }
68
+
69
+ const { data, body } = await parseFile(abs);
70
+ await copyFile(abs, backup);
71
+
72
+ const merged = { ...data, ...patch };
73
+ const next = stringify(merged, body);
74
+ const tmp = `${abs}.tmp-${process.pid}-${Date.now()}`;
75
+ try {
76
+ await writeFile(tmp, next, 'utf8');
77
+ await rename(tmp, abs);
78
+ } catch (err) {
79
+ // Rollback from backup
80
+ try {
81
+ await copyFile(backup, abs);
82
+ } catch {
83
+ /* best effort */
84
+ }
85
+ try {
86
+ await unlink(tmp);
87
+ } catch {
88
+ /* ignore */
89
+ }
90
+ throw err;
91
+ }
92
+ await unlink(backup);
93
+ const st = await stat(abs);
94
+ return { data: merged, body, mtime: st.mtimeMs };
95
+ } catch (err) {
96
+ try {
97
+ await unlink(backup);
98
+ } catch {
99
+ /* backup may not exist yet */
100
+ }
101
+ throw err;
102
+ } finally {
103
+ await releaseLock(lockPath);
104
+ }
105
+ }
106
+
107
+ export { LockedError, MtimeMismatchError };
@@ -0,0 +1,55 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import matter from 'gray-matter';
3
+
4
+ /**
5
+ * Parse a Markdown file with YAML frontmatter.
6
+ * Returns { data, body, raw } where `data` is the parsed frontmatter,
7
+ * `body` is everything after the second '---', and `raw` is the full file.
8
+ *
9
+ * gray-matter handles missing frontmatter gracefully (returns data = {}).
10
+ *
11
+ * @param {string} filepath
12
+ * @returns {Promise<{ data: object, body: string, raw: string }>}
13
+ */
14
+ export async function parseFile(filepath) {
15
+ const raw = await readFile(filepath, 'utf8');
16
+ return parseString(raw);
17
+ }
18
+
19
+ /**
20
+ * Parse a Markdown string with YAML frontmatter.
21
+ */
22
+ export function parseString(raw) {
23
+ const { data, content } = matter(raw);
24
+ return { data: data ?? {}, body: content ?? '', raw };
25
+ }
26
+
27
+ /**
28
+ * Serialize frontmatter + body back to a Markdown string.
29
+ * Uses gray-matter's stringify which preserves YAML canonical format.
30
+ *
31
+ * @param {object} data
32
+ * @param {string} body
33
+ * @returns {string}
34
+ */
35
+ export function stringify(data, body) {
36
+ return matter.stringify(body ?? '', data ?? {});
37
+ }
38
+
39
+ /**
40
+ * Parse and validate frontmatter against a Zod schema.
41
+ * @param {string} filepath
42
+ * @param {import('zod').ZodTypeAny} schema
43
+ * @returns {Promise<{ ok: true, data: object, body: string } | { ok: false, errors: Array, body: string, raw: any }>}
44
+ */
45
+ export async function parseAndValidate(filepath, schema) {
46
+ const { data, body } = await parseFile(filepath);
47
+ const result = schema.safeParse(data);
48
+ if (result.success) return { ok: true, data: result.data, body };
49
+ return {
50
+ ok: false,
51
+ errors: result.error.issues,
52
+ body,
53
+ raw: data,
54
+ };
55
+ }