htmlspec-kit 0.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.
@@ -0,0 +1,155 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://htmlspec.dev/schemas/spec.schema.json",
4
+ "title": "HTMLSpec SPEC payload",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["format", "version", "id", "title", "status", "owner", "createdAt", "updatedAt", "summaryHtml", "proposal", "spec", "design", "tasks", "history"],
8
+ "properties": {
9
+ "format": { "const": "htmlspec" },
10
+ "version": { "type": "string" },
11
+ "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
12
+ "title": { "type": "string", "minLength": 1 },
13
+ "status": { "enum": ["draft", "ready", "in-progress", "done", "archived"] },
14
+ "owner": { "type": "string" },
15
+ "createdAt": { "type": "string", "format": "date-time" },
16
+ "updatedAt": { "type": "string", "format": "date-time" },
17
+ "summaryHtml": { "type": "string" },
18
+ "proposal": { "$ref": "#/$defs/proposal" },
19
+ "spec": { "$ref": "#/$defs/spec" },
20
+ "design": { "$ref": "#/$defs/design" },
21
+ "tasks": { "type": "array", "items": { "$ref": "#/$defs/taskGroup" } },
22
+ "history": { "type": "array", "items": { "$ref": "#/$defs/historyEntry" } }
23
+ },
24
+ "$defs": {
25
+ "proposal": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "required": ["whyHtml", "whatChanges", "capabilities", "impact"],
29
+ "properties": {
30
+ "whyHtml": { "type": "string" },
31
+ "whatChanges": { "type": "array", "items": { "type": "string" } },
32
+ "capabilities": {
33
+ "type": "object",
34
+ "additionalProperties": false,
35
+ "required": ["new", "modified"],
36
+ "properties": {
37
+ "new": { "type": "array", "items": { "$ref": "#/$defs/capability" } },
38
+ "modified": { "type": "array", "items": { "$ref": "#/$defs/capability" } }
39
+ }
40
+ },
41
+ "impact": { "type": "array", "items": { "type": "string" } }
42
+ }
43
+ },
44
+ "capability": {
45
+ "type": "object",
46
+ "additionalProperties": false,
47
+ "required": ["name", "description"],
48
+ "properties": {
49
+ "name": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
50
+ "description": { "type": "string" }
51
+ }
52
+ },
53
+ "spec": {
54
+ "type": "object",
55
+ "additionalProperties": false,
56
+ "required": ["overviewHtml", "operations"],
57
+ "properties": {
58
+ "overviewHtml": { "type": "string" },
59
+ "operations": { "type": "array", "items": { "$ref": "#/$defs/requirementDelta" } }
60
+ }
61
+ },
62
+ "requirementDelta": {
63
+ "type": "object",
64
+ "additionalProperties": false,
65
+ "required": ["type", "requirements"],
66
+ "properties": {
67
+ "type": { "enum": ["ADDED", "MODIFIED", "REMOVED", "RENAMED"] },
68
+ "requirements": { "type": "array", "items": { "$ref": "#/$defs/requirement" } }
69
+ }
70
+ },
71
+ "requirement": {
72
+ "type": "object",
73
+ "additionalProperties": false,
74
+ "required": ["id", "title", "bodyHtml", "scenarios"],
75
+ "properties": {
76
+ "id": { "type": "string" },
77
+ "title": { "type": "string" },
78
+ "bodyHtml": { "type": "string" },
79
+ "scenarios": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/scenario" } },
80
+ "reasonHtml": { "type": "string" },
81
+ "migrationHtml": { "type": "string" },
82
+ "from": { "type": "string" },
83
+ "to": { "type": "string" }
84
+ }
85
+ },
86
+ "scenario": {
87
+ "type": "object",
88
+ "additionalProperties": false,
89
+ "required": ["id", "title", "when", "then"],
90
+ "properties": {
91
+ "id": { "type": "string" },
92
+ "title": { "type": "string" },
93
+ "given": { "type": "string" },
94
+ "when": { "type": "string" },
95
+ "then": { "type": "string" }
96
+ }
97
+ },
98
+ "design": {
99
+ "type": "object",
100
+ "additionalProperties": false,
101
+ "required": ["contextHtml", "goals", "nonGoals", "decisions", "risksTradeoffs", "migrationPlan", "openQuestions"],
102
+ "properties": {
103
+ "contextHtml": { "type": "string" },
104
+ "goals": { "type": "array", "items": { "type": "string" } },
105
+ "nonGoals": { "type": "array", "items": { "type": "string" } },
106
+ "decisions": { "type": "array", "items": { "$ref": "#/$defs/designDecision" } },
107
+ "risksTradeoffs": { "type": "array", "items": { "type": "string" } },
108
+ "migrationPlan": { "type": "array", "items": { "type": "string" } },
109
+ "openQuestions": { "type": "array", "items": { "type": "string" } }
110
+ }
111
+ },
112
+ "designDecision": {
113
+ "type": "object",
114
+ "additionalProperties": false,
115
+ "required": ["id", "title", "decisionHtml", "rationaleHtml", "alternatives"],
116
+ "properties": {
117
+ "id": { "type": "string" },
118
+ "title": { "type": "string" },
119
+ "decisionHtml": { "type": "string" },
120
+ "rationaleHtml": { "type": "string" },
121
+ "alternatives": { "type": "array", "items": { "type": "string" } }
122
+ }
123
+ },
124
+ "taskGroup": {
125
+ "type": "object",
126
+ "additionalProperties": false,
127
+ "required": ["id", "title", "items"],
128
+ "properties": {
129
+ "id": { "type": "string" },
130
+ "title": { "type": "string" },
131
+ "items": { "type": "array", "items": { "$ref": "#/$defs/task" } }
132
+ }
133
+ },
134
+ "task": {
135
+ "type": "object",
136
+ "additionalProperties": false,
137
+ "required": ["id", "title", "status"],
138
+ "properties": {
139
+ "id": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" },
140
+ "title": { "type": "string" },
141
+ "status": { "enum": ["todo", "in-progress", "done"] },
142
+ "notes": { "type": "string" }
143
+ }
144
+ },
145
+ "historyEntry": {
146
+ "type": "object",
147
+ "additionalProperties": false,
148
+ "required": ["at", "action"],
149
+ "properties": {
150
+ "at": { "type": "string", "format": "date-time" },
151
+ "action": { "type": "string" }
152
+ }
153
+ }
154
+ }
155
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,422 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { basename, join } from "node:path";
4
+ import { Command } from "commander";
5
+ import { readSpecData, writeSpecData } from "./html-document";
6
+ import { applyJsonPatch, assertJsonPatch } from "./json-patch";
7
+ import {
8
+ addTask,
9
+ createHtmlSpecData,
10
+ setChangeStatus,
11
+ setTaskStatus,
12
+ slugify,
13
+ titleFromId,
14
+ touch,
15
+ validateSpecData,
16
+ } from "./spec-data";
17
+ import type { ChangeStatus, Design, HtmlSpecData, SpecSection, TaskStatus } from "./types";
18
+ import {
19
+ changePath,
20
+ copyTemplate,
21
+ ensureSpecLayout,
22
+ projectPath,
23
+ readInputContent,
24
+ resolveExistingSpecPath,
25
+ specRoot,
26
+ templateSourcePath,
27
+ titleizePath,
28
+ } from "./paths";
29
+
30
+ type ListEntry = {
31
+ kind: "change" | "spec" | "archive";
32
+ id: string;
33
+ path: string;
34
+ data?: HtmlSpecData;
35
+ error?: string;
36
+ };
37
+
38
+ export async function runCli(argv = process.argv): Promise<void> {
39
+ const program = new Command();
40
+
41
+ program
42
+ .name("htmlspec")
43
+ .description("HTML-backed spec-driven development CLI")
44
+ .version("0.1.0");
45
+
46
+ program
47
+ .command("init")
48
+ .description("Create the spec/ workspace and copy the HTMLSpec template")
49
+ .option("-f, --force", "Overwrite spec/templates/change-template.html")
50
+ .action(async (options: { force?: boolean }) => {
51
+ await ensureSpecLayout();
52
+ const template = await copyTemplate(options.force);
53
+ console.log(`Created ${titleizePath(specRoot())}`);
54
+ console.log(`Template ${options.force ? "written" : "available"} at ${titleizePath(template)}`);
55
+ });
56
+
57
+ program
58
+ .command("new <id>")
59
+ .description("Create spec/changes/<id>.html from the HTMLSpec template")
60
+ .option("-t, --title <title>", "Human-readable title")
61
+ .option("-f, --force", "Overwrite an existing change")
62
+ .action(async (rawId: string, options: { title?: string; force?: boolean }) => {
63
+ await ensureSpecLayout();
64
+ const id = slugify(rawId);
65
+ if (!id) throw new Error("Change id cannot be empty");
66
+
67
+ const destination = changePath(id);
68
+ if (!options.force && existsSync(destination)) {
69
+ throw new Error(`${titleizePath(destination)} already exists. Use --force to overwrite.`);
70
+ }
71
+
72
+ const template = await readFile(templateSourcePath(), "utf8");
73
+ await writeFile(destination, template, "utf8");
74
+ await writeSpecData(destination, createHtmlSpecData(id, options.title ?? titleFromId(id)));
75
+ console.log(`Created ${titleizePath(destination)}`);
76
+ });
77
+
78
+ program
79
+ .command("list")
80
+ .description("List HTMLSpec files")
81
+ .option("--json", "Output JSON")
82
+ .action(async (options: { json?: boolean }) => {
83
+ const entries = await listEntries();
84
+ if (options.json) {
85
+ console.log(JSON.stringify(entries, null, 2));
86
+ return;
87
+ }
88
+
89
+ if (entries.length === 0) {
90
+ console.log("No specs found");
91
+ return;
92
+ }
93
+
94
+ for (const entry of entries) {
95
+ const status = entry.data?.status ?? "invalid";
96
+ const title = entry.data?.title ?? entry.error ?? "";
97
+ console.log(`${entry.kind.padEnd(7)} ${entry.id.padEnd(24)} ${status.padEnd(12)} ${title}`);
98
+ }
99
+ });
100
+
101
+ program
102
+ .command("show <id-or-path>")
103
+ .description("Show a spec summary or the raw SPEC JSON")
104
+ .option("--json", "Output the SPEC JSON")
105
+ .action(async (idOrPath: string, options: { json?: boolean }) => {
106
+ const filePath = resolveExistingSpecPath(idOrPath);
107
+ const data = await readSpecData(filePath);
108
+
109
+ if (options.json) {
110
+ console.log(JSON.stringify(data, null, 2));
111
+ return;
112
+ }
113
+
114
+ printSummary(data, filePath);
115
+ });
116
+
117
+ program
118
+ .command("patch <id-or-path> [patch-file]")
119
+ .description("Apply an RFC 6902 JSON Patch to SPEC data, or replace one JSON Pointer path")
120
+ .option("-p, --patch <json>", "Inline JSON Patch array")
121
+ .option("-f, --from <file>", "Read JSON Patch array from file")
122
+ .option("--replace <pointer>", "Replace a JSON Pointer path")
123
+ .option("--value <json>", "JSON value for --replace, for example '\"Ready\"' or '{\"a\":1}'")
124
+ .option("--value-from <file>", "Read JSON value for --replace from file")
125
+ .option("--string-value <text>", "String value for --replace")
126
+ .action(async (
127
+ idOrPath: string,
128
+ patchFile: string | undefined,
129
+ options: { patch?: string; from?: string; replace?: string; value?: string; valueFrom?: string; stringValue?: string },
130
+ ) => {
131
+ const operations = await resolvePatchOperations(patchFile, options);
132
+ await mutateSpec(idOrPath, (data) => touch(applyJsonPatch(data, operations), "patch:applied"));
133
+ });
134
+
135
+ program
136
+ .command("status [id-or-path]")
137
+ .description("Show task status for one change or all changes")
138
+ .action(async (idOrPath?: string) => {
139
+ if (idOrPath) {
140
+ const filePath = resolveExistingSpecPath(idOrPath);
141
+ printTaskStatus(await readSpecData(filePath));
142
+ return;
143
+ }
144
+
145
+ const entries = await listEntries();
146
+ for (const entry of entries.filter((item) => item.data)) {
147
+ const data = entry.data!;
148
+ const counts = countTasks(data);
149
+ console.log(`${entry.id}: ${counts.done}/${counts.total} done, ${counts.inProgress} in progress`);
150
+ }
151
+ });
152
+
153
+ program
154
+ .command("validate [id-or-path]")
155
+ .description("Validate one HTMLSpec file or every file under spec/")
156
+ .option("--json", "Output JSON")
157
+ .action(async (idOrPath: string | undefined, options: { json?: boolean }) => {
158
+ const reports = idOrPath
159
+ ? [await validateFile(resolveExistingSpecPath(idOrPath))]
160
+ : await Promise.all((await listEntries()).map((entry) => validateFile(entry.path)));
161
+
162
+ if (options.json) {
163
+ console.log(JSON.stringify(reports, null, 2));
164
+ process.exitCode = reports.some((report) => !report.valid) ? 1 : 0;
165
+ return;
166
+ }
167
+
168
+ for (const report of reports) {
169
+ if (report.valid) {
170
+ console.log(`OK ${titleizePath(report.path)}`);
171
+ } else {
172
+ console.error(`FAIL ${titleizePath(report.path)}`);
173
+ for (const issue of report.issues) console.error(` - ${issue}`);
174
+ }
175
+ }
176
+ process.exitCode = reports.some((report) => !report.valid) ? 1 : 0;
177
+ });
178
+
179
+ program
180
+ .command("tasks <id-or-path>")
181
+ .description("List tasks for a change")
182
+ .action(async (idOrPath: string) => {
183
+ const data = await readSpecData(resolveExistingSpecPath(idOrPath));
184
+ printTaskStatus(data);
185
+ });
186
+
187
+ const task = program.command("task").description("Add or mark HTMLSpec tasks");
188
+
189
+ task
190
+ .command("add <id-or-path> <title>")
191
+ .description("Add a task to a task group")
192
+ .option("-g, --group <group>", "Task group id or title", "Implementation")
193
+ .option("--task-id <taskId>", "Explicit task id")
194
+ .option("-s, --status <status>", "Initial status: todo, in-progress, done", "todo")
195
+ .action(async (idOrPath: string, title: string, options: { group?: string; taskId?: string; status?: TaskStatus }) => {
196
+ assertTaskStatus(options.status);
197
+ await mutateSpec(idOrPath, (data) => addTask(data, title, options));
198
+ });
199
+
200
+ for (const [name, status] of [
201
+ ["todo", "todo"],
202
+ ["start", "in-progress"],
203
+ ["in-progress", "in-progress"],
204
+ ["done", "done"],
205
+ ] as const) {
206
+ task
207
+ .command(`${name} <id-or-path> <task-id>`)
208
+ .description(`Mark a task ${status}`)
209
+ .action(async (idOrPath: string, taskId: string) => {
210
+ await mutateSpec(idOrPath, (data) => setTaskStatus(data, taskId, status));
211
+ });
212
+ }
213
+
214
+ const spec = program.command("spec").description("Update the spec section");
215
+
216
+ spec
217
+ .command("update <id-or-path>")
218
+ .description("Replace spec.overviewHtml from HTML, or spec from a JSON object")
219
+ .option("-f, --from <file>", "Read content from file")
220
+ .option("-m, --message <html>", "Inline HTML or JSON")
221
+ .action(async (idOrPath: string, options: { from?: string; message?: string }) => {
222
+ const content = await readInputContent(options);
223
+ await mutateSpec(idOrPath, (data) => updateSpecSection(data, content));
224
+ });
225
+
226
+ const design = program.command("design").description("Update the design section");
227
+
228
+ design
229
+ .command("update <id-or-path>")
230
+ .description("Replace design.overviewHtml from HTML, or design from a JSON object")
231
+ .option("-f, --from <file>", "Read content from file")
232
+ .option("-m, --message <html>", "Inline HTML or JSON")
233
+ .action(async (idOrPath: string, options: { from?: string; message?: string }) => {
234
+ const content = await readInputContent(options);
235
+ await mutateSpec(idOrPath, (data) => updateDesignSection(data, content));
236
+ });
237
+
238
+ program
239
+ .command("summary <id-or-path>")
240
+ .description("Update summaryHtml")
241
+ .option("-f, --from <file>", "Read content from file")
242
+ .option("-m, --message <html>", "Inline HTML")
243
+ .action(async (idOrPath: string, options: { from?: string; message?: string }) => {
244
+ const content = await readInputContent(options);
245
+ await mutateSpec(idOrPath, (data) => {
246
+ data.summaryHtml = content.trim();
247
+ return touch(data, "summary:updated");
248
+ });
249
+ });
250
+
251
+ program
252
+ .command("state <id-or-path> <status>")
253
+ .description("Set change status: draft, ready, in-progress, done, archived")
254
+ .action(async (idOrPath: string, status: ChangeStatus) => {
255
+ assertChangeStatus(status);
256
+ await mutateSpec(idOrPath, (data) => setChangeStatus(data, status));
257
+ });
258
+
259
+ await program.parseAsync(argv);
260
+ }
261
+
262
+ async function mutateSpec(idOrPath: string, mutate: (data: HtmlSpecData) => HtmlSpecData): Promise<void> {
263
+ const filePath = resolveExistingSpecPath(idOrPath);
264
+ const data = mutate(await readSpecData(filePath));
265
+ await writeSpecData(filePath, data);
266
+ console.log(`Updated ${titleizePath(filePath)}`);
267
+ }
268
+
269
+ async function resolvePatchOperations(
270
+ patchFile: string | undefined,
271
+ options: { patch?: string; from?: string; replace?: string; value?: string; valueFrom?: string; stringValue?: string },
272
+ ) {
273
+ const sources = [patchFile, options.from, options.patch, options.replace].filter(Boolean);
274
+ if (sources.length !== 1) {
275
+ throw new Error("Provide exactly one of [patch-file], --from, --patch, or --replace");
276
+ }
277
+
278
+ if (options.replace) {
279
+ const valueOptions = [options.value, options.valueFrom, options.stringValue].filter((value) => value !== undefined);
280
+ if (valueOptions.length !== 1) {
281
+ throw new Error("Use --replace with exactly one of --value, --value-from, or --string-value");
282
+ }
283
+
284
+ return assertJsonPatch([
285
+ {
286
+ op: "replace",
287
+ path: options.replace,
288
+ value: await resolveReplacementValue(options),
289
+ },
290
+ ]);
291
+ }
292
+
293
+ const raw = options.patch ?? await readFile(options.from ?? patchFile!, "utf8");
294
+ return assertJsonPatch(JSON.parse(raw));
295
+ }
296
+
297
+ async function resolveReplacementValue(options: { value?: string; valueFrom?: string; stringValue?: string }): Promise<unknown> {
298
+ if (options.stringValue !== undefined) return options.stringValue;
299
+ if (options.valueFrom) return JSON.parse(await readFile(options.valueFrom, "utf8"));
300
+ if (options.value !== undefined) return JSON.parse(options.value);
301
+ throw new Error("Missing replacement value");
302
+ }
303
+
304
+ function updateSpecSection(data: HtmlSpecData, content: string): HtmlSpecData {
305
+ const trimmed = content.trim();
306
+ const parsed = tryParseJson(trimmed);
307
+
308
+ if (parsed && Array.isArray(parsed)) {
309
+ data.spec.operations = parsed.every((item) => item && typeof item === "object" && "type" in item)
310
+ ? parsed as SpecSection["operations"]
311
+ : [{ type: "ADDED", requirements: parsed as SpecSection["operations"][number]["requirements"] }];
312
+ } else if (parsed && typeof parsed === "object") {
313
+ data.spec = parsed as SpecSection;
314
+ } else {
315
+ data.spec.overviewHtml = trimmed;
316
+ }
317
+
318
+ return touch(data, "spec:updated");
319
+ }
320
+
321
+ function updateDesignSection(data: HtmlSpecData, content: string): HtmlSpecData {
322
+ const trimmed = content.trim();
323
+ const parsed = tryParseJson(trimmed);
324
+
325
+ if (parsed && Array.isArray(parsed)) {
326
+ data.design.decisions = parsed;
327
+ } else if (parsed && typeof parsed === "object") {
328
+ data.design = parsed as Design;
329
+ } else {
330
+ data.design.contextHtml = trimmed;
331
+ }
332
+
333
+ return touch(data, "design:updated");
334
+ }
335
+
336
+ function tryParseJson(content: string): unknown | undefined {
337
+ if (!content.startsWith("{") && !content.startsWith("[")) return undefined;
338
+ return JSON.parse(content);
339
+ }
340
+
341
+ async function listEntries(): Promise<ListEntry[]> {
342
+ const dirs: Array<[ListEntry["kind"], string]> = [
343
+ ["change", projectPath("spec", "changes")],
344
+ ["spec", projectPath("spec", "specs")],
345
+ ["archive", projectPath("spec", "archive")],
346
+ ];
347
+ const entries: ListEntry[] = [];
348
+
349
+ for (const [kind, dir] of dirs) {
350
+ if (!existsSync(dir)) continue;
351
+ const files = (await readdir(dir)).filter((file) => file.endsWith(".html")).sort();
352
+ for (const file of files) {
353
+ const filePath = join(dir, file);
354
+ const id = basename(file, ".html");
355
+ try {
356
+ entries.push({ kind, id, path: filePath, data: await readSpecData(filePath) });
357
+ } catch (error) {
358
+ const message = error instanceof Error ? error.message : String(error);
359
+ entries.push({ kind, id, path: filePath, error: message });
360
+ }
361
+ }
362
+ }
363
+
364
+ return entries;
365
+ }
366
+
367
+ async function validateFile(path: string): Promise<{ path: string; valid: boolean; issues: string[] }> {
368
+ try {
369
+ const data = await readSpecData(path);
370
+ const issues = validateSpecData(data);
371
+ return { path, valid: issues.length === 0, issues };
372
+ } catch (error) {
373
+ const message = error instanceof Error ? error.message : String(error);
374
+ return { path, valid: false, issues: [message] };
375
+ }
376
+ }
377
+
378
+ function printSummary(data: HtmlSpecData, filePath: string): void {
379
+ const counts = countTasks(data);
380
+ console.log(`${data.title} (${data.id})`);
381
+ console.log(`Path: ${titleizePath(filePath)}`);
382
+ console.log(`Status: ${data.status}`);
383
+ console.log(`Tasks: ${counts.done}/${counts.total} done, ${counts.inProgress} in progress`);
384
+ console.log(`Updated: ${data.updatedAt}`);
385
+ }
386
+
387
+ function printTaskStatus(data: HtmlSpecData): void {
388
+ console.log(`${data.title} (${data.id})`);
389
+ for (const group of data.tasks) {
390
+ console.log(`\n${group.id}. ${group.title}`);
391
+ for (const item of group.items) {
392
+ console.log(`${taskMark(item.status)} ${item.id} ${item.title}`);
393
+ }
394
+ }
395
+ }
396
+
397
+ function taskMark(status: TaskStatus): string {
398
+ if (status === "done") return "[x]";
399
+ if (status === "in-progress") return "[~]";
400
+ return "[ ]";
401
+ }
402
+
403
+ function countTasks(data: HtmlSpecData): { total: number; done: number; inProgress: number } {
404
+ const tasks = data.tasks.flatMap((group) => group.items);
405
+ return {
406
+ total: tasks.length,
407
+ done: tasks.filter((task) => task.status === "done").length,
408
+ inProgress: tasks.filter((task) => task.status === "in-progress").length,
409
+ };
410
+ }
411
+
412
+ function assertTaskStatus(status: string | undefined): asserts status is TaskStatus {
413
+ if (!status || !["todo", "in-progress", "done"].includes(status)) {
414
+ throw new Error("Task status must be todo, in-progress, or done");
415
+ }
416
+ }
417
+
418
+ function assertChangeStatus(status: string): asserts status is ChangeStatus {
419
+ if (!["draft", "ready", "in-progress", "done", "archived"].includes(status)) {
420
+ throw new Error("Status must be draft, ready, in-progress, done, or archived");
421
+ }
422
+ }
@@ -0,0 +1,56 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { parse } from "parse5";
3
+ import type { HtmlSpecData } from "./types";
4
+
5
+ function findSpecScript(node: any): any | undefined {
6
+ if (node?.tagName === "script") {
7
+ const id = node.attrs?.find((attr: { name: string; value: string }) => attr.name === "id")?.value;
8
+ if (id === "SPEC") return node;
9
+ }
10
+
11
+ for (const child of node?.childNodes ?? []) {
12
+ const result = findSpecScript(child);
13
+ if (result) return result;
14
+ }
15
+
16
+ return undefined;
17
+ }
18
+
19
+ function getSpecJsonRange(html: string): { start: number; end: number } {
20
+ const document = parse(html, { sourceCodeLocationInfo: true });
21
+ const script = findSpecScript(document);
22
+
23
+ if (!script?.sourceCodeLocation?.startTag || !script?.sourceCodeLocation?.endTag) {
24
+ throw new Error('Missing <script id="SPEC" type="application/json"> payload');
25
+ }
26
+
27
+ const start = script.sourceCodeLocation.startTag.endOffset;
28
+ const end = script.sourceCodeLocation.endTag.startOffset;
29
+
30
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start > end) {
31
+ throw new Error("Could not locate SPEC script content range");
32
+ }
33
+
34
+ return { start, end };
35
+ }
36
+
37
+ export async function readSpecData(filePath: string): Promise<HtmlSpecData> {
38
+ const html = await readFile(filePath, "utf8");
39
+ const { start, end } = getSpecJsonRange(html);
40
+ const rawJson = html.slice(start, end).trim();
41
+
42
+ try {
43
+ return JSON.parse(rawJson) as HtmlSpecData;
44
+ } catch (error) {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ throw new Error(`Invalid SPEC JSON in ${filePath}: ${message}`);
47
+ }
48
+ }
49
+
50
+ export async function writeSpecData(filePath: string, data: HtmlSpecData): Promise<void> {
51
+ const html = await readFile(filePath, "utf8");
52
+ const { start, end } = getSpecJsonRange(html);
53
+ const nextJson = JSON.stringify(data, null, 2).replace(/<\/script/gi, "<\\/script");
54
+ const nextHtml = `${html.slice(0, start)}\n${nextJson}\n${html.slice(end)}`;
55
+ await writeFile(filePath, nextHtml, "utf8");
56
+ }