@stritti/vitepress-plugin-openspec 0.7.0 → 0.8.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/dist/index.cjs CHANGED
@@ -5,21 +5,218 @@ Object.defineProperty(exports, '__esModule', { value: true });
5
5
  var fs = require('fs');
6
6
  var path = require('path');
7
7
  var pc = require('picocolors');
8
- var yaml = require('js-yaml');
8
+ var jsYaml = require('js-yaml');
9
9
 
10
10
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
11
11
 
12
12
  var fs__default = /*#__PURE__*/_interopDefault(fs);
13
13
  var path__default = /*#__PURE__*/_interopDefault(path);
14
14
  var pc__default = /*#__PURE__*/_interopDefault(pc);
15
- var yaml__default = /*#__PURE__*/_interopDefault(yaml);
16
15
 
17
16
  // src/plugin.ts
17
+ var PLUGIN_NAME = "vitepress-plugin-openspec";
18
+ function prefix(color) {
19
+ return pc__default.default.bold(color(`[${PLUGIN_NAME}]`));
20
+ }
21
+ function logWarn(message) {
22
+ console.warn(`${prefix(pc__default.default.yellow)} ${message}`);
23
+ }
24
+ function logInfo(message) {
25
+ console.log(`${prefix(pc__default.default.cyan)} ${message}`);
26
+ }
27
+ function logError(message) {
28
+ console.error(`${prefix(pc__default.default.red)} ${message}`);
29
+ }
30
+ var GENERATED_MARKER = "# Generated by vitepress-plugin-openspec";
31
+ function looksLikeOpenSpecDir(dir) {
32
+ const gitignore = path__default.default.join(dir, ".gitignore");
33
+ if (fs__default.default.existsSync(gitignore)) {
34
+ try {
35
+ if (fs__default.default.readFileSync(gitignore, "utf-8").startsWith(GENERATED_MARKER)) return false;
36
+ } catch {
37
+ }
38
+ }
39
+ if (fs__default.default.existsSync(path__default.default.join(dir, "project.md"))) return "strong";
40
+ if (fs__default.default.existsSync(path__default.default.join(dir, "config.yaml")) || fs__default.default.existsSync(path__default.default.join(dir, "config.yml"))) return "strong";
41
+ const changesDir = path__default.default.join(dir, "changes");
42
+ if (fs__default.default.existsSync(changesDir)) {
43
+ try {
44
+ for (const entry of fs__default.default.readdirSync(changesDir, { withFileTypes: true })) {
45
+ if (!entry.isDirectory() && !entry.name.endsWith(".yaml")) continue;
46
+ const marker = path__default.default.join(changesDir, entry.name, ".openspec.yaml");
47
+ const yaml = path__default.default.join(changesDir, `${entry.name}.yaml`);
48
+ if (fs__default.default.existsSync(marker) || fs__default.default.existsSync(yaml)) return "strong";
49
+ }
50
+ } catch {
51
+ }
52
+ }
53
+ const specsDir = path__default.default.join(dir, "specs");
54
+ if (fs__default.default.existsSync(specsDir)) {
55
+ try {
56
+ for (const entry of fs__default.default.readdirSync(specsDir, { withFileTypes: true })) {
57
+ if (!entry.isDirectory()) continue;
58
+ if (fs__default.default.existsSync(path__default.default.join(specsDir, entry.name, "spec.md"))) return "strong";
59
+ }
60
+ } catch {
61
+ }
62
+ }
63
+ const archiveDir = path__default.default.join(changesDir, "archive");
64
+ if (fs__default.default.existsSync(archiveDir)) {
65
+ try {
66
+ if (fs__default.default.readdirSync(archiveDir, { withFileTypes: true }).some((e) => e.isDirectory())) return "weak";
67
+ } catch {
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+ function findOpenSpecDir(start, generatedOutputDir) {
73
+ let current = path__default.default.resolve(start);
74
+ for (; ; ) {
75
+ const candidate = path__default.default.join(current, "openspec");
76
+ if (fs__default.default.existsSync(candidate)) {
77
+ const match = looksLikeOpenSpecDir(candidate);
78
+ if (match === "weak" && generatedOutputDir && realPath(candidate) === realPath(generatedOutputDir)) {
79
+ const parent2 = path__default.default.dirname(current);
80
+ if (parent2 === current) return void 0;
81
+ current = parent2;
82
+ continue;
83
+ }
84
+ if (match === "strong" || match === "weak") return candidate;
85
+ }
86
+ const parent = path__default.default.dirname(current);
87
+ if (parent === current) return void 0;
88
+ current = parent;
89
+ }
90
+ }
91
+ function realPath(target) {
92
+ let current = path__default.default.resolve(target);
93
+ const suffix = [];
94
+ for (; ; ) {
95
+ if (fs__default.default.existsSync(current)) break;
96
+ const parent = path__default.default.dirname(current);
97
+ if (parent === current) return path__default.default.resolve(target);
98
+ suffix.unshift(path__default.default.basename(current));
99
+ current = parent;
100
+ }
101
+ try {
102
+ current = fs__default.default.realpathSync(current);
103
+ } catch {
104
+ return path__default.default.resolve(target);
105
+ }
106
+ return suffix.length ? path__default.default.join(current, ...suffix) : current;
107
+ }
108
+ var srcDirCache = /* @__PURE__ */ new Map();
109
+ function detectSrcDir(cwd) {
110
+ const key = path__default.default.resolve(cwd);
111
+ if (srcDirCache.has(key)) return srcDirCache.get(key);
112
+ const result = resolveSrcDir(key);
113
+ srcDirCache.set(key, result);
114
+ return result;
115
+ }
116
+ function resolveSrcDir(cwd) {
117
+ if (path__default.default.basename(cwd) === ".vitepress") return path__default.default.dirname(cwd);
118
+ if (fs__default.default.existsSync(path__default.default.join(cwd, ".vitepress"))) return cwd;
119
+ const matches = [];
120
+ try {
121
+ for (const entry of fs__default.default.readdirSync(cwd, { withFileTypes: true })) {
122
+ if (!entry.isDirectory()) continue;
123
+ const subDir = path__default.default.join(cwd, entry.name);
124
+ if (fs__default.default.existsSync(path__default.default.join(subDir, ".vitepress"))) matches.push(subDir);
125
+ }
126
+ } catch {
127
+ }
128
+ if (matches.length === 1) return matches[0];
129
+ if (matches.length > 1) {
130
+ const docsMatch = matches.find((m) => path__default.default.basename(m) === "docs");
131
+ if (docsMatch) return docsMatch;
132
+ logWarn(
133
+ `Multiple VitePress source directories found (${matches.map((m) => path__default.default.relative(cwd, m)).join(", ")}) \u2014 pass an explicit \`srcDir\` option. Skipping page generation.`
134
+ );
135
+ return void 0;
136
+ }
137
+ return cwd;
138
+ }
139
+ function detectVitePressRoot(cwd) {
140
+ const srcDir = detectSrcDir(cwd);
141
+ return srcDir && fs__default.default.existsSync(path__default.default.join(srcDir, ".vitepress")) ? srcDir : cwd;
142
+ }
143
+
144
+ // src/lib/options.ts
145
+ function resolveOptions(userOptions = {}, fallbackSrcDir) {
146
+ const outDir = (userOptions.outDir ?? "openspec").replace(/\\/g, "/");
147
+ if (path__default.default.isAbsolute(outDir) || outDir === "" || outDir === "." || outDir === "..") {
148
+ throw new Error(
149
+ `[vitepress-plugin-openspec] Invalid outDir "${outDir}": it must be a relative path inside the VitePress srcDir.`
150
+ );
151
+ }
152
+ const srcDir = userOptions.srcDir ?? fallbackSrcDir ?? detectSrcDir(process.cwd()) ?? process.cwd();
153
+ const absoluteSrcDir = path__default.default.resolve(srcDir);
154
+ const absoluteOutDir = path__default.default.resolve(absoluteSrcDir, outDir);
155
+ const rel = path__default.default.relative(absoluteSrcDir, absoluteOutDir);
156
+ if (rel.startsWith("..") || path__default.default.isAbsolute(rel) || rel === "") {
157
+ throw new Error(
158
+ `[vitepress-plugin-openspec] Invalid outDir "${outDir}": it resolves outside the VitePress srcDir.`
159
+ );
160
+ }
161
+ const specDir = userOptions.specDir ?? findOpenSpecDir(process.cwd(), absoluteOutDir) ?? "./openspec";
162
+ return {
163
+ specDir: path__default.default.resolve(specDir),
164
+ outDir,
165
+ srcDir: absoluteSrcDir,
166
+ absoluteOutDir
167
+ };
168
+ }
169
+ var ALWAYS_GENERATED_BASENAMES = /* @__PURE__ */ new Set(["index.md", ".gitignore", ".openspec-manifest.json"]);
170
+ var ARTIFACT_BASENAMES = /* @__PURE__ */ new Set(["proposal.md", "design.md", "tasks.md"]);
171
+ var generatedDestCache = /* @__PURE__ */ new Map();
172
+ function generatedDestinations(absoluteOutDir) {
173
+ const cached = generatedDestCache.get(absoluteOutDir);
174
+ if (cached) return cached;
175
+ const set = /* @__PURE__ */ new Set();
176
+ for (const entry of readManifest(absoluteOutDir)) {
177
+ set.add(path__default.default.resolve(absoluteOutDir, entry));
178
+ }
179
+ generatedDestCache.set(absoluteOutDir, set);
180
+ return set;
181
+ }
182
+ function invalidateGeneratedDestinations(absoluteOutDir) {
183
+ generatedDestCache.delete(absoluteOutDir);
184
+ }
185
+ function isInside(parent, child) {
186
+ const rel = path__default.default.relative(parent, child);
187
+ return !(rel.startsWith("..") || path__default.default.isAbsolute(rel));
188
+ }
189
+ function classifyLayout(options) {
190
+ if (options.specDir === options.absoluteOutDir) return "equal";
191
+ if (isInside(options.specDir, options.absoluteOutDir)) return "out-inside-spec";
192
+ if (isInside(options.absoluteOutDir, options.specDir)) return "spec-inside-out";
193
+ return "disjoint";
194
+ }
195
+ function isPluginGeneratedFile(options, absFile) {
196
+ if (!isInside(options.absoluteOutDir, absFile)) return false;
197
+ if (generatedDestinations(options.absoluteOutDir).has(path__default.default.resolve(absFile))) return true;
198
+ const base = path__default.default.basename(absFile);
199
+ switch (classifyLayout(options)) {
200
+ case "equal":
201
+ return ALWAYS_GENERATED_BASENAMES.has(base);
202
+ case "out-inside-spec":
203
+ return ALWAYS_GENERATED_BASENAMES.has(base) && !isInside(options.specDir, absFile);
204
+ case "disjoint":
205
+ return ALWAYS_GENERATED_BASENAMES.has(base) || ARTIFACT_BASENAMES.has(base);
206
+ case "spec-inside-out":
207
+ return !isInside(options.specDir, absFile) && (ALWAYS_GENERATED_BASENAMES.has(base) || ARTIFACT_BASENAMES.has(base));
208
+ }
209
+ }
210
+ function isSourceArtifactEvent(options, absFile) {
211
+ if (!isInside(options.specDir, absFile)) return false;
212
+ return !isPluginGeneratedFile(options, absFile);
213
+ }
214
+ var ARTIFACT_NAMES = ["proposal", "design", "tasks"];
18
215
  function readOpenSpecYaml(dir) {
19
216
  const yamlPath = path__default.default.join(dir, ".openspec.yaml");
20
217
  if (!fs__default.default.existsSync(yamlPath)) return {};
21
218
  try {
22
- return yaml__default.default.load(fs__default.default.readFileSync(yamlPath, "utf-8")) ?? {};
219
+ return jsYaml.load(fs__default.default.readFileSync(yamlPath, "utf-8")) ?? {};
23
220
  } catch {
24
221
  return {};
25
222
  }
@@ -68,11 +265,10 @@ function formatDate(val) {
68
265
  return String(val);
69
266
  }
70
267
  function readArtifacts(dir) {
71
- const artifacts = [];
72
- for (const name of ["proposal", "design", "tasks"]) {
73
- if (fs__default.default.existsSync(path__default.default.join(dir, `${name}.md`))) artifacts.push(name);
74
- }
75
- return artifacts;
268
+ return ARTIFACT_NAMES.filter((name) => fs__default.default.existsSync(path__default.default.join(dir, `${name}.md`)));
269
+ }
270
+ function isDirectory(dir, name) {
271
+ return fs__default.default.existsSync(path__default.default.join(dir, name, ".openspec.yaml"));
76
272
  }
77
273
  function readOpenSpecFolder(dir) {
78
274
  const resolved = path__default.default.resolve(dir);
@@ -101,7 +297,7 @@ function readOpenSpecFolder(dir) {
101
297
  for (const entry of fs__default.default.readdirSync(changesDir, { withFileTypes: true })) {
102
298
  if (!entry.isDirectory() || entry.name === "archive") continue;
103
299
  const changeDir = path__default.default.join(changesDir, entry.name);
104
- if (!fs__default.default.existsSync(path__default.default.join(changeDir, ".openspec.yaml"))) continue;
300
+ if (!isDirectory(changeDir, ".")) continue;
105
301
  const meta = readOpenSpecYaml(changeDir);
106
302
  changes.push({
107
303
  name: entry.name,
@@ -118,23 +314,52 @@ function readOpenSpecFolder(dir) {
118
314
  for (const entry of fs__default.default.readdirSync(archiveDir, { withFileTypes: true })) {
119
315
  if (!entry.isDirectory()) continue;
120
316
  const changeDir = path__default.default.join(archiveDir, entry.name);
121
- const match = entry.name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
122
- const archivedDate = match?.[1];
123
- const name = match?.[2] ?? entry.name;
124
317
  const meta = readOpenSpecYaml(changeDir);
318
+ const match = entry.name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
125
319
  archivedChanges.push({
126
- name,
320
+ name: match?.[2] ?? entry.name,
127
321
  title: meta.title ? String(meta.title) : void 0,
128
322
  dir: changeDir,
129
323
  artifacts: readArtifacts(changeDir),
130
324
  createdDate: formatDate(meta.created),
131
- archivedDate,
325
+ archivedDate: match?.[1],
132
326
  archiveFolderName: entry.name
133
327
  });
134
328
  }
135
329
  }
136
330
  return { dir: resolved, specs, changes, archivedChanges };
137
331
  }
332
+ function rewriteRelativeLinks(content, srcFilePath, openspecRootDir, outDir) {
333
+ const srcDir = path__default.default.dirname(srcFilePath);
334
+ return content.replace(
335
+ /(\[[^\]]*\])\(([^)]+)\)/g,
336
+ (_match, linkText, urlPart) => {
337
+ const titleMatch = urlPart.match(/^(.+?)(\s+["'][^"']*["'])?\s*$/);
338
+ if (!titleMatch) return _match;
339
+ const rawUrl = titleMatch[1].trim();
340
+ const title = titleMatch[2] ?? "";
341
+ if (!rawUrl || rawUrl.startsWith("http://") || rawUrl.startsWith("https://") || rawUrl.startsWith("//") || rawUrl.startsWith("/") || rawUrl.startsWith("#") || rawUrl.startsWith("mailto:") || rawUrl.startsWith("tel:")) {
342
+ return _match;
343
+ }
344
+ const hashIdx = rawUrl.indexOf("#");
345
+ const urlWithoutFragment = hashIdx >= 0 ? rawUrl.slice(0, hashIdx) : rawUrl;
346
+ const fragment = hashIdx >= 0 ? rawUrl.slice(hashIdx) : "";
347
+ if (!urlWithoutFragment) {
348
+ return _match;
349
+ }
350
+ const resolvedPath = path__default.default.resolve(srcDir, urlWithoutFragment);
351
+ const relToOpenspec = path__default.default.relative(openspecRootDir, resolvedPath);
352
+ if (relToOpenspec.startsWith("..") || path__default.default.isAbsolute(relToOpenspec)) {
353
+ return _match;
354
+ }
355
+ const vitePath = relToOpenspec.replace(/\.md$/, "").replace(/\\/g, "/");
356
+ const absoluteLink = `/${outDir}/${vitePath}${fragment}`;
357
+ return `${linkText}(${absoluteLink}${title})`;
358
+ }
359
+ );
360
+ }
361
+
362
+ // src/lib/markdown.ts
138
363
  function extractSpecDescription(content) {
139
364
  const reqMatch = content.match(/^### Requirement:[^\n]*\n+([\s\S]*?)(?=\n#{1,4} |\n*$)/m);
140
365
  if (!reqMatch) return void 0;
@@ -147,7 +372,7 @@ function extractSpecDescription(content) {
147
372
  const cut = sentence.lastIndexOf(" ", 160);
148
373
  sentence = (cut > 0 ? sentence.slice(0, cut) : sentence.slice(0, 160)) + "\u2026";
149
374
  }
150
- return sentence.replace(/"/g, '\\"');
375
+ return sentence.replace(/([\\"])/g, "\\$1");
151
376
  }
152
377
  function stripDeltaMarkers(content) {
153
378
  const stripped = content.split("\n").filter((line) => !/^## (ADDED|MODIFIED|REMOVED) Requirements\s*$/.test(line)).join("\n");
@@ -176,6 +401,11 @@ function transformScenarios(content) {
176
401
  if (inScenario) result.push(":::");
177
402
  return result.join("\n");
178
403
  }
404
+ function specTitle(spec) {
405
+ return spec.title ?? humanizeLabel(spec.name);
406
+ }
407
+
408
+ // src/lib/pages.ts
179
409
  function generateSpecPage(spec) {
180
410
  const description = extractSpecDescription(spec.content);
181
411
  const transformed = transformScenarios(stripDeltaMarkers(spec.content));
@@ -186,7 +416,7 @@ function generateSpecPage(spec) {
186
416
  lines.push("---");
187
417
  lines.push("");
188
418
  }
189
- lines.push(`# ${spec.title ?? humanizeLabel(spec.name)}`);
419
+ lines.push(`# ${specTitle(spec)}`);
190
420
  lines.push("");
191
421
  lines.push(transformed.trimEnd());
192
422
  lines.push("");
@@ -202,7 +432,7 @@ function generateSpecsIndexPage(specs, outDir) {
202
432
  lines.push("*No specifications defined yet.*");
203
433
  } else {
204
434
  for (const spec of specs) {
205
- lines.push(`- [${spec.title ?? humanizeLabel(spec.name)}](/${outDir}/specs/${spec.name}/)`);
435
+ lines.push(`- [${specTitle(spec)}](/${outDir}/specs/${spec.name}/)`);
206
436
  }
207
437
  }
208
438
  lines.push("");
@@ -222,10 +452,10 @@ function generateChangeIndexPage(change, outDir) {
222
452
  }
223
453
  lines.push("## Artifacts");
224
454
  lines.push("");
225
- const prefix = change.archiveFolderName ? `/${outDir}/changes/archive/${change.archiveFolderName}` : `/${outDir}/changes/${change.name}`;
455
+ const prefix2 = change.archiveFolderName ? `/${outDir}/changes/archive/${change.archiveFolderName}` : `/${outDir}/changes/${change.name}`;
226
456
  for (const artifact of change.artifacts) {
227
457
  const label = artifact.charAt(0).toUpperCase() + artifact.slice(1);
228
- lines.push(`- [${label}](${prefix}/${artifact})`);
458
+ lines.push(`- [${label}](${prefix2}/${artifact})`);
229
459
  }
230
460
  lines.push("");
231
461
  return lines.join("\n");
@@ -258,46 +488,176 @@ function generateChangesIndexPage(folder, outDir) {
258
488
  lines.push("");
259
489
  return lines.join("\n");
260
490
  }
261
- function rewriteRelativeLinks(content, srcFilePath, openspecRootDir, outDir) {
262
- const srcDir = path__default.default.dirname(srcFilePath);
263
- return content.replace(
264
- /(\[[^\]]*\])\(([^)]+)\)/g,
265
- (_match, linkText, urlPart) => {
266
- const titleMatch = urlPart.match(/^(.+?)(\s+["'][^"']*["'])?\s*$/);
267
- if (!titleMatch) return _match;
268
- const rawUrl = titleMatch[1].trim();
269
- const title = titleMatch[2] ?? "";
270
- if (!rawUrl || rawUrl.startsWith("http://") || rawUrl.startsWith("https://") || rawUrl.startsWith("//") || rawUrl.startsWith("/") || rawUrl.startsWith("#") || rawUrl.startsWith("mailto:") || rawUrl.startsWith("tel:")) {
271
- return _match;
272
- }
273
- const hashIdx = rawUrl.indexOf("#");
274
- const urlWithoutFragment = hashIdx >= 0 ? rawUrl.slice(0, hashIdx) : rawUrl;
275
- const fragment = hashIdx >= 0 ? rawUrl.slice(hashIdx) : "";
276
- if (!urlWithoutFragment) {
277
- return _match;
278
- }
279
- const resolvedPath = path__default.default.resolve(srcDir, urlWithoutFragment);
280
- const relToOpenspec = path__default.default.relative(openspecRootDir, resolvedPath);
281
- if (relToOpenspec.startsWith("..") || path__default.default.isAbsolute(relToOpenspec)) {
282
- return _match;
491
+ function generateRootIndexPage(outDir) {
492
+ return [
493
+ "# Project Documentation",
494
+ "",
495
+ "This section is generated from the project's [OpenSpec](https://openspec.dev/) folder.",
496
+ "OpenSpec is a lightweight, file-based workflow for spec-driven development \u2014",
497
+ "it structures your project's capability specifications and change proposals as plain Markdown files.",
498
+ "",
499
+ `- [Specifications](/${outDir}/specs/) \u2014 canonical capability specs`,
500
+ `- [Changes](/${outDir}/changes/) \u2014 active and archived change proposals`,
501
+ ""
502
+ ].join("\n");
503
+ }
504
+
505
+ // src/lib/generator.ts
506
+ var MANIFEST_FILE = ".openspec-manifest.json";
507
+ var GITIGNORE_CONTENT = "# Generated by vitepress-plugin-openspec \u2014 do not commit generated files.\n*\n!.gitignore\n";
508
+ function writeFile(filePath, content) {
509
+ fs__default.default.mkdirSync(path__default.default.dirname(filePath), { recursive: true });
510
+ fs__default.default.writeFileSync(filePath, content, "utf-8");
511
+ }
512
+ function toRel(absoluteOutDir, filePath) {
513
+ return path__default.default.relative(absoluteOutDir, filePath).split(path__default.default.sep).join("/");
514
+ }
515
+ function pruneEmptyDirs(rootDir, startDir) {
516
+ let dir = startDir;
517
+ while (dir.startsWith(rootDir) && dir !== rootDir) {
518
+ try {
519
+ if (fs__default.default.readdirSync(dir).length > 0) break;
520
+ fs__default.default.rmdirSync(dir);
521
+ } catch {
522
+ break;
523
+ }
524
+ dir = path__default.default.dirname(dir);
525
+ }
526
+ }
527
+ function cleanupPreviousManifest(rootDir) {
528
+ const manifestPath = path__default.default.join(rootDir, MANIFEST_FILE);
529
+ if (!fs__default.default.existsSync(manifestPath)) return [];
530
+ let previous = [];
531
+ try {
532
+ const parsed = JSON.parse(fs__default.default.readFileSync(manifestPath, "utf-8"));
533
+ if (Array.isArray(parsed)) previous = parsed.filter((f) => typeof f === "string");
534
+ } catch {
535
+ return [];
536
+ }
537
+ const retained = [];
538
+ for (const entry of previous) {
539
+ const entryPath = path__default.default.resolve(rootDir, entry);
540
+ const rel = path__default.default.relative(rootDir, entryPath);
541
+ if (rel.startsWith("..") || path__default.default.isAbsolute(rel)) continue;
542
+ try {
543
+ if (fs__default.default.existsSync(entryPath)) {
544
+ fs__default.default.rmSync(entryPath);
545
+ pruneEmptyDirs(rootDir, path__default.default.dirname(entryPath));
283
546
  }
284
- const vitePath = relToOpenspec.replace(/\.md$/, "").replace(/\\/g, "/");
285
- const absoluteLink = `/${outDir}/${vitePath}${fragment}`;
286
- return `${linkText}(${absoluteLink}${title})`;
547
+ } catch {
548
+ retained.push(entry);
287
549
  }
288
- );
550
+ }
551
+ return retained;
552
+ }
553
+ function readManifest(rootDir) {
554
+ try {
555
+ const parsed = JSON.parse(fs__default.default.readFileSync(path__default.default.join(rootDir, MANIFEST_FILE), "utf-8"));
556
+ if (Array.isArray(parsed)) return parsed.filter((f) => typeof f === "string");
557
+ } catch {
558
+ }
559
+ return [];
560
+ }
561
+ function writeManifest(files, rootDir) {
562
+ const manifestPath = path__default.default.join(rootDir, MANIFEST_FILE);
563
+ try {
564
+ writeFile(manifestPath, JSON.stringify(files, null, 2));
565
+ invalidateGeneratedDestinations(rootDir);
566
+ } catch {
567
+ }
568
+ }
569
+ function renderChangePages(change, options, isArchived) {
570
+ const subPath = isArchived ? path__default.default.join("changes", "archive", change.archiveFolderName ?? change.name) : path__default.default.join("changes", change.name);
571
+ const changeOutDir = path__default.default.join(options.absoluteOutDir, subPath);
572
+ const files = [];
573
+ files.push({
574
+ rel: toRel(options.absoluteOutDir, path__default.default.join(changeOutDir, "index.md")),
575
+ content: generateChangeIndexPage(change, options.outDir)
576
+ });
577
+ for (const artifact of change.artifacts) {
578
+ const srcFile = path__default.default.join(change.dir, `${artifact}.md`);
579
+ const destFile = path__default.default.join(changeOutDir, `${artifact}.md`);
580
+ if (path__default.default.resolve(srcFile) === path__default.default.resolve(destFile)) continue;
581
+ const content = fs__default.default.readFileSync(srcFile, "utf-8");
582
+ files.push({
583
+ rel: toRel(options.absoluteOutDir, destFile),
584
+ content: rewriteRelativeLinks(content, srcFile, options.specDir, options.outDir)
585
+ });
586
+ }
587
+ return files;
588
+ }
589
+ function renderAllPages(options) {
590
+ const folder = readOpenSpecFolder(options.specDir);
591
+ const files = [];
592
+ for (const spec of folder.specs) {
593
+ const dest = path__default.default.join(options.absoluteOutDir, "specs", spec.name, "index.md");
594
+ files.push({ rel: toRel(options.absoluteOutDir, dest), content: generateSpecPage(spec) });
595
+ }
596
+ files.push({
597
+ rel: "specs/index.md",
598
+ content: generateSpecsIndexPage(folder.specs, options.outDir)
599
+ });
600
+ for (const change of folder.changes) {
601
+ files.push(...renderChangePages(change, options, false));
602
+ }
603
+ for (const change of folder.archivedChanges) {
604
+ files.push(...renderChangePages(change, options, true));
605
+ }
606
+ files.push({
607
+ rel: "changes/index.md",
608
+ content: generateChangesIndexPage(folder, options.outDir)
609
+ });
610
+ files.push({
611
+ rel: "index.md",
612
+ content: generateRootIndexPage(options.outDir)
613
+ });
614
+ return files;
615
+ }
616
+ function generatePages(options) {
617
+ if (!fs__default.default.existsSync(options.specDir)) {
618
+ logWarn(
619
+ `openspec directory not found: ${path__default.default.relative(options.srcDir, options.specDir)} \u2014 skipping page generation`
620
+ );
621
+ const retained2 = cleanupPreviousManifest(options.absoluteOutDir);
622
+ if (retained2.length > 0 || fs__default.default.existsSync(options.absoluteOutDir)) {
623
+ writeManifest(retained2, options.absoluteOutDir);
624
+ }
625
+ return false;
626
+ }
627
+ let files;
628
+ try {
629
+ files = renderAllPages(options);
630
+ } catch (err) {
631
+ logError(`Failed to process openspec directory "${options.specDir}": ${String(err)}`);
632
+ return false;
633
+ }
634
+ const retained = cleanupPreviousManifest(options.absoluteOutDir);
635
+ try {
636
+ writeFile(path__default.default.join(options.absoluteOutDir, ".gitignore"), GITIGNORE_CONTENT);
637
+ for (const file of files) {
638
+ writeFile(path__default.default.join(options.absoluteOutDir, file.rel), file.content);
639
+ }
640
+ writeManifest([...files.map((f) => f.rel), ...retained], options.absoluteOutDir);
641
+ } catch (err) {
642
+ logError(`Failed to write openspec pages: ${String(err)}`);
643
+ return false;
644
+ }
645
+ logInfo(`Generated docs from ${options.specDir}: ${files.length} file(s)`);
646
+ return true;
289
647
  }
290
648
  function changeItems(change, outDir, isArchived = false) {
291
- const prefix = isArchived ? `/${outDir}/changes/archive/${change.archiveFolderName}` : `/${outDir}/changes/${change.name}`;
649
+ const prefix2 = isArchived ? `/${outDir}/changes/archive/${change.archiveFolderName}` : `/${outDir}/changes/${change.name}`;
292
650
  return change.artifacts.map((a) => ({
293
651
  text: a.charAt(0).toUpperCase() + a.slice(1),
294
- link: `${prefix}/${a}`
652
+ link: `${prefix2}/${a}`
295
653
  }));
296
654
  }
297
655
  function generateOpenSpecSidebar(specDir, options = {}) {
298
656
  const outDir = options.outDir ?? "openspec";
299
657
  if (!fs__default.default.existsSync(path__default.default.resolve(specDir))) {
300
- console.warn(`[vitepress-plugin-openspec] openspec directory not found: ${path__default.default.relative(process.cwd(), path__default.default.resolve(specDir))} \u2014 skipping sidebar generation`);
658
+ logWarn(
659
+ `openspec directory not found: ${path__default.default.relative(process.cwd(), path__default.default.resolve(specDir))} \u2014 skipping sidebar generation`
660
+ );
301
661
  return [];
302
662
  }
303
663
  const folder = readOpenSpecFolder(specDir);
@@ -307,7 +667,7 @@ function generateOpenSpecSidebar(specDir, options = {}) {
307
667
  collapsed: false,
308
668
  items: [
309
669
  { text: "Overview", link: `/${outDir}/specs/` },
310
- ...folder.specs.map((s) => ({ text: s.title ?? humanizeLabel(s.name), link: `/${outDir}/specs/${s.name}/` }))
670
+ ...folder.specs.map((s) => ({ text: specTitle(s), link: `/${outDir}/specs/${s.name}/` }))
311
671
  ]
312
672
  });
313
673
  groups.push({
@@ -338,7 +698,9 @@ function generateOpenSpecSidebar(specDir, options = {}) {
338
698
  function openspecNav(specDir, options = {}) {
339
699
  const outDir = options.outDir ?? "openspec";
340
700
  if (!fs__default.default.existsSync(path__default.default.resolve(specDir))) {
341
- console.warn(`[vitepress-plugin-openspec] openspec directory not found: ${path__default.default.relative(process.cwd(), path__default.default.resolve(specDir))} \u2014 skipping nav generation`);
701
+ logWarn(
702
+ `openspec directory not found: ${path__default.default.relative(process.cwd(), path__default.default.resolve(specDir))} \u2014 skipping nav generation`
703
+ );
342
704
  return null;
343
705
  }
344
706
  return {
@@ -347,113 +709,151 @@ function openspecNav(specDir, options = {}) {
347
709
  };
348
710
  }
349
711
 
350
- // src/plugin.ts
351
- var PLUGIN_NAME = "vitepress-plugin-openspec";
352
- function writeFile(filePath, content) {
353
- fs__default.default.mkdirSync(path__default.default.dirname(filePath), { recursive: true });
354
- fs__default.default.writeFileSync(filePath, content, "utf-8");
355
- }
356
- function generateOpenSpecPages(userOptions = {}) {
357
- const specDir = userOptions.specDir ?? "./openspec";
358
- const outDir = userOptions.outDir ?? "openspec";
359
- const srcDir = userOptions.srcDir ?? process.cwd();
360
- const absoluteOutDir = path__default.default.resolve(srcDir, outDir);
361
- if (!fs__default.default.existsSync(path__default.default.resolve(specDir))) {
362
- console.warn(
363
- `${pc__default.default.bold(pc__default.default.yellow(`[${PLUGIN_NAME}]`))} openspec directory not found: ${path__default.default.relative(srcDir, path__default.default.resolve(specDir))} \u2014 skipping page generation`
364
- );
365
- return;
366
- }
712
+ // src/lib/dev-server.ts
713
+ function readNavigationSignature(options) {
367
714
  try {
368
- const folder = readOpenSpecFolder(specDir);
369
- for (const spec of folder.specs) {
370
- const dest = path__default.default.join(absoluteOutDir, "specs", spec.name, "index.md");
371
- writeFile(dest, generateSpecPage(spec));
372
- }
373
- writeFile(
374
- path__default.default.join(absoluteOutDir, "specs", "index.md"),
375
- generateSpecsIndexPage(folder.specs, outDir)
376
- );
377
- for (const change of folder.changes) {
378
- writeChangePage(change, absoluteOutDir, outDir, false, folder.dir);
379
- }
380
- for (const change of folder.archivedChanges) {
381
- writeChangePage(change, absoluteOutDir, outDir, true, folder.dir);
382
- }
383
- writeFile(
384
- path__default.default.join(absoluteOutDir, "changes", "index.md"),
385
- generateChangesIndexPage(folder, outDir)
386
- );
387
- const rootIndex = [
388
- "# Project Documentation",
389
- "",
390
- "This section is generated from the project's [OpenSpec](https://openspec.dev/) folder.",
391
- "OpenSpec is a lightweight, file-based workflow for spec-driven development \u2014",
392
- "it structures your project's capability specifications and change proposals as plain Markdown files.",
393
- "",
394
- `- [Specifications](/${outDir}/specs/) \u2014 canonical capability specs`,
395
- `- [Changes](/${outDir}/changes/) \u2014 active and archived change proposals`,
396
- ""
397
- ].join("\n");
398
- writeFile(path__default.default.join(absoluteOutDir, "index.md"), rootIndex);
399
- writeFile(
400
- path__default.default.join(absoluteOutDir, ".gitignore"),
401
- "# Generated by vitepress-plugin-openspec \u2014 do not commit generated files.\n*\n!.gitignore\n"
402
- );
403
- console.log(
404
- `${pc__default.default.bold(pc__default.default.cyan(`[${PLUGIN_NAME}]`))} Generated docs from ${pc__default.default.cyan(specDir)}: ${pc__default.default.green(String(folder.specs.length))} spec(s), ${pc__default.default.green(String(folder.changes.length))} change(s), ${pc__default.default.green(String(folder.archivedChanges.length))} archived`
405
- );
406
- } catch (err) {
407
- console.error(
408
- `${pc__default.default.bold(pc__default.default.red(`[${PLUGIN_NAME}]`))} Failed to process openspec directory "${specDir}": ${String(err)}`
409
- );
715
+ const folder = readOpenSpecFolder(options.specDir);
716
+ return JSON.stringify({
717
+ specs: folder.specs.map((s) => [s.name, s.title ?? null]),
718
+ changes: folder.changes.map((c) => [c.name, c.title ?? null, c.createdDate ?? null, c.artifacts]),
719
+ archived: folder.archivedChanges.map((c) => [c.name, c.title ?? null, c.archivedDate ?? null, c.artifacts])
720
+ });
721
+ } catch {
722
+ return "unreadable";
410
723
  }
411
724
  }
725
+ function createScheduler() {
726
+ let timer;
727
+ return {
728
+ schedule(options, server) {
729
+ if (timer) clearTimeout(timer);
730
+ timer = setTimeout(() => {
731
+ timer = void 0;
732
+ generatePages(options);
733
+ invalidateGeneratedDestinations(options.absoluteOutDir);
734
+ server.ws.send({ type: "full-reload" });
735
+ }, 100);
736
+ },
737
+ cancel() {
738
+ if (timer) {
739
+ clearTimeout(timer);
740
+ timer = void 0;
741
+ }
742
+ }
743
+ };
744
+ }
745
+
746
+ // src/plugin.ts
747
+ function generateOpenSpecPages(userOptions = {}) {
748
+ const detectedSrcDir = userOptions.srcDir ?? detectSrcDir(process.cwd());
749
+ if (!detectedSrcDir) return false;
750
+ const options = resolveOptions(userOptions, detectedSrcDir);
751
+ return generatePages(options);
752
+ }
412
753
  function openspec(userOptions = {}) {
754
+ let resolvedOptions;
755
+ let detachListeners;
413
756
  return {
414
757
  name: PLUGIN_NAME,
415
758
  enforce: "pre",
416
759
  configResolved(resolvedConfig) {
760
+ detachListeners?.();
417
761
  const vpConfig = resolvedConfig.vitepress;
418
762
  const srcDir = userOptions.srcDir ?? vpConfig?.srcDir ?? resolvedConfig.root ?? process.cwd();
419
- generateOpenSpecPages({ ...userOptions, srcDir });
763
+ try {
764
+ resolvedOptions = resolveOptions(userOptions, srcDir);
765
+ } catch (err) {
766
+ logWarn(String(err));
767
+ return;
768
+ }
769
+ if (!fs__default.default.existsSync(resolvedOptions.specDir)) {
770
+ logWarn(
771
+ `openspec directory not found: ${resolvedOptions.specDir} \u2014 skipping page generation`
772
+ );
773
+ generatePages(resolvedOptions);
774
+ resolvedOptions = void 0;
775
+ return;
776
+ }
777
+ generatePages(resolvedOptions);
778
+ },
779
+ configureServer(server) {
780
+ if (!resolvedOptions || !fs__default.default.existsSync(resolvedOptions.specDir)) return;
781
+ const options = resolvedOptions;
782
+ server.watcher.add(options.specDir);
783
+ const scheduler = createScheduler();
784
+ let navigationSignature = readNavigationSignature(options);
785
+ const onWatchEvent = (event, file) => {
786
+ const absFile = path__default.default.resolve(file);
787
+ if (!isSourceArtifactEvent(options, absFile)) return;
788
+ const signature = readNavigationSignature(options);
789
+ const affectsNavigation = event !== "change" || signature !== navigationSignature;
790
+ if (affectsNavigation) {
791
+ scheduler.cancel();
792
+ navigationSignature = signature;
793
+ server.restart(true);
794
+ } else {
795
+ scheduler.schedule(options, server);
796
+ }
797
+ };
798
+ const onChange = (f) => onWatchEvent("change", f);
799
+ const onAdd = (f) => onWatchEvent("add", f);
800
+ const onUnlink = (f) => onWatchEvent("unlink", f);
801
+ server.watcher.on("change", onChange);
802
+ server.watcher.on("add", onAdd);
803
+ server.watcher.on("unlink", onUnlink);
804
+ detachListeners = () => {
805
+ scheduler.cancel();
806
+ server.watcher.off("change", onChange);
807
+ server.watcher.off("add", onAdd);
808
+ server.watcher.off("unlink", onUnlink);
809
+ server.httpServer?.removeListener("close", detachListeners);
810
+ detachListeners = void 0;
811
+ };
812
+ server.httpServer?.once("close", detachListeners);
813
+ },
814
+ closeBundle() {
815
+ detachListeners?.();
816
+ detachListeners = void 0;
420
817
  }
421
818
  };
422
819
  }
423
- function writeChangePage(change, absoluteOutDir, outDir, isArchived, openspecRootDir) {
424
- const subPath = isArchived ? path__default.default.join("changes", "archive", `${change.archivedDate}-${change.name}`) : path__default.default.join("changes", change.name);
425
- const changeOutDir = path__default.default.join(absoluteOutDir, subPath);
426
- writeFile(path__default.default.join(changeOutDir, "index.md"), generateChangeIndexPage(change, outDir));
427
- for (const artifact of change.artifacts) {
428
- const srcFile = path__default.default.join(change.dir, `${artifact}.md`);
429
- const destFile = path__default.default.join(changeOutDir, `${artifact}.md`);
430
- const content = fs__default.default.readFileSync(srcFile, "utf-8");
431
- writeFile(destFile, rewriteRelativeLinks(content, srcFile, openspecRootDir, outDir));
432
- }
433
- }
434
820
  function withOpenSpec(config, options = {}) {
435
- const specDir = options.specDir ?? "./openspec";
436
- const outDir = options.outDir ?? "openspec";
437
- const srcDir = options.srcDir ?? process.cwd();
438
- generateOpenSpecPages({ specDir, outDir, srcDir });
821
+ const cwd = process.cwd();
822
+ const configSrcDir = typeof config.srcDir === "string" && config.srcDir ? path__default.default.resolve(detectVitePressRoot(cwd), config.srcDir) : void 0;
823
+ const detectedSrcDir = options.srcDir ?? configSrcDir ?? detectSrcDir(cwd);
439
824
  const result = { ...config };
825
+ const themeConfig = result.themeConfig ?? {};
826
+ if (Array.isArray(themeConfig.sidebar)) {
827
+ themeConfig.sidebar = { "/": themeConfig.sidebar };
828
+ }
829
+ result.themeConfig = themeConfig;
830
+ if (detectedSrcDir === void 0) return result;
831
+ let resolved;
832
+ try {
833
+ resolved = resolveOptions(options, detectedSrcDir);
834
+ } catch (err) {
835
+ logWarn(String(err));
836
+ return result;
837
+ }
838
+ const generated = generatePages(resolved);
440
839
  const vite = result.vite ?? {};
441
840
  const existingPlugins = vite.plugins ?? [];
442
- result.vite = { ...vite, plugins: [...existingPlugins, openspec({ specDir, outDir, srcDir })] };
443
- const themeConfig = result.themeConfig ?? {};
444
- if (options.nav !== false) {
445
- const navEntry = openspecNav(specDir, { outDir });
841
+ result.vite = { ...vite, plugins: [...existingPlugins, openspec({ ...options })] };
842
+ if (generated && options.nav !== false) {
843
+ const navEntry = openspecNav(resolved.specDir, {
844
+ outDir: resolved.outDir,
845
+ text: options.navText
846
+ });
446
847
  const existingNav = themeConfig.nav ?? [];
447
848
  themeConfig.nav = navEntry ? [navEntry, ...existingNav] : existingNav;
448
849
  }
449
- if (options.sidebar !== false && !Array.isArray(themeConfig.sidebar)) {
450
- const sidebarKey = `/${outDir}/`;
451
- const existingSidebar = themeConfig.sidebar ?? {};
452
- if (!existingSidebar[sidebarKey]) {
453
- themeConfig.sidebar = {
454
- ...existingSidebar,
455
- [sidebarKey]: generateOpenSpecSidebar(specDir, { outDir })
456
- };
850
+ const sidebarKey = `/${resolved.outDir}/`;
851
+ const existingSidebar = themeConfig.sidebar ?? {};
852
+ const pluginOwnsSidebarEntry = generated && options.sidebar !== false && !existingSidebar[sidebarKey];
853
+ if (pluginOwnsSidebarEntry) {
854
+ const items = generateOpenSpecSidebar(resolved.specDir, { outDir: resolved.outDir });
855
+ if (items.length > 0) {
856
+ themeConfig.sidebar = { ...existingSidebar, [sidebarKey]: items };
457
857
  }
458
858
  }
459
859
  result.themeConfig = themeConfig;