@stritti/vitepress-plugin-openspec 0.7.1 → 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
@@ -14,6 +14,204 @@ var path__default = /*#__PURE__*/_interopDefault(path);
14
14
  var pc__default = /*#__PURE__*/_interopDefault(pc);
15
15
 
16
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"];
17
215
  function readOpenSpecYaml(dir) {
18
216
  const yamlPath = path__default.default.join(dir, ".openspec.yaml");
19
217
  if (!fs__default.default.existsSync(yamlPath)) return {};
@@ -67,11 +265,10 @@ function formatDate(val) {
67
265
  return String(val);
68
266
  }
69
267
  function readArtifacts(dir) {
70
- const artifacts = [];
71
- for (const name of ["proposal", "design", "tasks"]) {
72
- if (fs__default.default.existsSync(path__default.default.join(dir, `${name}.md`))) artifacts.push(name);
73
- }
74
- 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"));
75
272
  }
76
273
  function readOpenSpecFolder(dir) {
77
274
  const resolved = path__default.default.resolve(dir);
@@ -100,7 +297,7 @@ function readOpenSpecFolder(dir) {
100
297
  for (const entry of fs__default.default.readdirSync(changesDir, { withFileTypes: true })) {
101
298
  if (!entry.isDirectory() || entry.name === "archive") continue;
102
299
  const changeDir = path__default.default.join(changesDir, entry.name);
103
- if (!fs__default.default.existsSync(path__default.default.join(changeDir, ".openspec.yaml"))) continue;
300
+ if (!isDirectory(changeDir, ".")) continue;
104
301
  const meta = readOpenSpecYaml(changeDir);
105
302
  changes.push({
106
303
  name: entry.name,
@@ -117,23 +314,52 @@ function readOpenSpecFolder(dir) {
117
314
  for (const entry of fs__default.default.readdirSync(archiveDir, { withFileTypes: true })) {
118
315
  if (!entry.isDirectory()) continue;
119
316
  const changeDir = path__default.default.join(archiveDir, entry.name);
120
- const match = entry.name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
121
- const archivedDate = match?.[1];
122
- const name = match?.[2] ?? entry.name;
123
317
  const meta = readOpenSpecYaml(changeDir);
318
+ const match = entry.name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
124
319
  archivedChanges.push({
125
- name,
320
+ name: match?.[2] ?? entry.name,
126
321
  title: meta.title ? String(meta.title) : void 0,
127
322
  dir: changeDir,
128
323
  artifacts: readArtifacts(changeDir),
129
324
  createdDate: formatDate(meta.created),
130
- archivedDate,
325
+ archivedDate: match?.[1],
131
326
  archiveFolderName: entry.name
132
327
  });
133
328
  }
134
329
  }
135
330
  return { dir: resolved, specs, changes, archivedChanges };
136
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
137
363
  function extractSpecDescription(content) {
138
364
  const reqMatch = content.match(/^### Requirement:[^\n]*\n+([\s\S]*?)(?=\n#{1,4} |\n*$)/m);
139
365
  if (!reqMatch) return void 0;
@@ -146,7 +372,7 @@ function extractSpecDescription(content) {
146
372
  const cut = sentence.lastIndexOf(" ", 160);
147
373
  sentence = (cut > 0 ? sentence.slice(0, cut) : sentence.slice(0, 160)) + "\u2026";
148
374
  }
149
- return sentence.replace(/"/g, '\\"');
375
+ return sentence.replace(/([\\"])/g, "\\$1");
150
376
  }
151
377
  function stripDeltaMarkers(content) {
152
378
  const stripped = content.split("\n").filter((line) => !/^## (ADDED|MODIFIED|REMOVED) Requirements\s*$/.test(line)).join("\n");
@@ -175,6 +401,11 @@ function transformScenarios(content) {
175
401
  if (inScenario) result.push(":::");
176
402
  return result.join("\n");
177
403
  }
404
+ function specTitle(spec) {
405
+ return spec.title ?? humanizeLabel(spec.name);
406
+ }
407
+
408
+ // src/lib/pages.ts
178
409
  function generateSpecPage(spec) {
179
410
  const description = extractSpecDescription(spec.content);
180
411
  const transformed = transformScenarios(stripDeltaMarkers(spec.content));
@@ -185,7 +416,7 @@ function generateSpecPage(spec) {
185
416
  lines.push("---");
186
417
  lines.push("");
187
418
  }
188
- lines.push(`# ${spec.title ?? humanizeLabel(spec.name)}`);
419
+ lines.push(`# ${specTitle(spec)}`);
189
420
  lines.push("");
190
421
  lines.push(transformed.trimEnd());
191
422
  lines.push("");
@@ -201,7 +432,7 @@ function generateSpecsIndexPage(specs, outDir) {
201
432
  lines.push("*No specifications defined yet.*");
202
433
  } else {
203
434
  for (const spec of specs) {
204
- lines.push(`- [${spec.title ?? humanizeLabel(spec.name)}](/${outDir}/specs/${spec.name}/)`);
435
+ lines.push(`- [${specTitle(spec)}](/${outDir}/specs/${spec.name}/)`);
205
436
  }
206
437
  }
207
438
  lines.push("");
@@ -221,10 +452,10 @@ function generateChangeIndexPage(change, outDir) {
221
452
  }
222
453
  lines.push("## Artifacts");
223
454
  lines.push("");
224
- 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}`;
225
456
  for (const artifact of change.artifacts) {
226
457
  const label = artifact.charAt(0).toUpperCase() + artifact.slice(1);
227
- lines.push(`- [${label}](${prefix}/${artifact})`);
458
+ lines.push(`- [${label}](${prefix2}/${artifact})`);
228
459
  }
229
460
  lines.push("");
230
461
  return lines.join("\n");
@@ -257,46 +488,176 @@ function generateChangesIndexPage(folder, outDir) {
257
488
  lines.push("");
258
489
  return lines.join("\n");
259
490
  }
260
- function rewriteRelativeLinks(content, srcFilePath, openspecRootDir, outDir) {
261
- const srcDir = path__default.default.dirname(srcFilePath);
262
- return content.replace(
263
- /(\[[^\]]*\])\(([^)]+)\)/g,
264
- (_match, linkText, urlPart) => {
265
- const titleMatch = urlPart.match(/^(.+?)(\s+["'][^"']*["'])?\s*$/);
266
- if (!titleMatch) return _match;
267
- const rawUrl = titleMatch[1].trim();
268
- const title = titleMatch[2] ?? "";
269
- if (!rawUrl || rawUrl.startsWith("http://") || rawUrl.startsWith("https://") || rawUrl.startsWith("//") || rawUrl.startsWith("/") || rawUrl.startsWith("#") || rawUrl.startsWith("mailto:") || rawUrl.startsWith("tel:")) {
270
- return _match;
271
- }
272
- const hashIdx = rawUrl.indexOf("#");
273
- const urlWithoutFragment = hashIdx >= 0 ? rawUrl.slice(0, hashIdx) : rawUrl;
274
- const fragment = hashIdx >= 0 ? rawUrl.slice(hashIdx) : "";
275
- if (!urlWithoutFragment) {
276
- return _match;
277
- }
278
- const resolvedPath = path__default.default.resolve(srcDir, urlWithoutFragment);
279
- const relToOpenspec = path__default.default.relative(openspecRootDir, resolvedPath);
280
- if (relToOpenspec.startsWith("..") || path__default.default.isAbsolute(relToOpenspec)) {
281
- 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));
282
546
  }
283
- const vitePath = relToOpenspec.replace(/\.md$/, "").replace(/\\/g, "/");
284
- const absoluteLink = `/${outDir}/${vitePath}${fragment}`;
285
- return `${linkText}(${absoluteLink}${title})`;
547
+ } catch {
548
+ retained.push(entry);
286
549
  }
287
- );
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;
288
647
  }
289
648
  function changeItems(change, outDir, isArchived = false) {
290
- 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}`;
291
650
  return change.artifacts.map((a) => ({
292
651
  text: a.charAt(0).toUpperCase() + a.slice(1),
293
- link: `${prefix}/${a}`
652
+ link: `${prefix2}/${a}`
294
653
  }));
295
654
  }
296
655
  function generateOpenSpecSidebar(specDir, options = {}) {
297
656
  const outDir = options.outDir ?? "openspec";
298
657
  if (!fs__default.default.existsSync(path__default.default.resolve(specDir))) {
299
- 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
+ );
300
661
  return [];
301
662
  }
302
663
  const folder = readOpenSpecFolder(specDir);
@@ -306,7 +667,7 @@ function generateOpenSpecSidebar(specDir, options = {}) {
306
667
  collapsed: false,
307
668
  items: [
308
669
  { text: "Overview", link: `/${outDir}/specs/` },
309
- ...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}/` }))
310
671
  ]
311
672
  });
312
673
  groups.push({
@@ -337,7 +698,9 @@ function generateOpenSpecSidebar(specDir, options = {}) {
337
698
  function openspecNav(specDir, options = {}) {
338
699
  const outDir = options.outDir ?? "openspec";
339
700
  if (!fs__default.default.existsSync(path__default.default.resolve(specDir))) {
340
- 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
+ );
341
704
  return null;
342
705
  }
343
706
  return {
@@ -346,113 +709,151 @@ function openspecNav(specDir, options = {}) {
346
709
  };
347
710
  }
348
711
 
349
- // src/plugin.ts
350
- var PLUGIN_NAME = "vitepress-plugin-openspec";
351
- function writeFile(filePath, content) {
352
- fs__default.default.mkdirSync(path__default.default.dirname(filePath), { recursive: true });
353
- fs__default.default.writeFileSync(filePath, content, "utf-8");
354
- }
355
- function generateOpenSpecPages(userOptions = {}) {
356
- const specDir = userOptions.specDir ?? "./openspec";
357
- const outDir = userOptions.outDir ?? "openspec";
358
- const srcDir = userOptions.srcDir ?? process.cwd();
359
- const absoluteOutDir = path__default.default.resolve(srcDir, outDir);
360
- if (!fs__default.default.existsSync(path__default.default.resolve(specDir))) {
361
- console.warn(
362
- `${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`
363
- );
364
- return;
365
- }
712
+ // src/lib/dev-server.ts
713
+ function readNavigationSignature(options) {
366
714
  try {
367
- const folder = readOpenSpecFolder(specDir);
368
- for (const spec of folder.specs) {
369
- const dest = path__default.default.join(absoluteOutDir, "specs", spec.name, "index.md");
370
- writeFile(dest, generateSpecPage(spec));
371
- }
372
- writeFile(
373
- path__default.default.join(absoluteOutDir, "specs", "index.md"),
374
- generateSpecsIndexPage(folder.specs, outDir)
375
- );
376
- for (const change of folder.changes) {
377
- writeChangePage(change, absoluteOutDir, outDir, false, folder.dir);
378
- }
379
- for (const change of folder.archivedChanges) {
380
- writeChangePage(change, absoluteOutDir, outDir, true, folder.dir);
381
- }
382
- writeFile(
383
- path__default.default.join(absoluteOutDir, "changes", "index.md"),
384
- generateChangesIndexPage(folder, outDir)
385
- );
386
- const rootIndex = [
387
- "# Project Documentation",
388
- "",
389
- "This section is generated from the project's [OpenSpec](https://openspec.dev/) folder.",
390
- "OpenSpec is a lightweight, file-based workflow for spec-driven development \u2014",
391
- "it structures your project's capability specifications and change proposals as plain Markdown files.",
392
- "",
393
- `- [Specifications](/${outDir}/specs/) \u2014 canonical capability specs`,
394
- `- [Changes](/${outDir}/changes/) \u2014 active and archived change proposals`,
395
- ""
396
- ].join("\n");
397
- writeFile(path__default.default.join(absoluteOutDir, "index.md"), rootIndex);
398
- writeFile(
399
- path__default.default.join(absoluteOutDir, ".gitignore"),
400
- "# Generated by vitepress-plugin-openspec \u2014 do not commit generated files.\n*\n!.gitignore\n"
401
- );
402
- console.log(
403
- `${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`
404
- );
405
- } catch (err) {
406
- console.error(
407
- `${pc__default.default.bold(pc__default.default.red(`[${PLUGIN_NAME}]`))} Failed to process openspec directory "${specDir}": ${String(err)}`
408
- );
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";
409
723
  }
410
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
+ }
411
753
  function openspec(userOptions = {}) {
754
+ let resolvedOptions;
755
+ let detachListeners;
412
756
  return {
413
757
  name: PLUGIN_NAME,
414
758
  enforce: "pre",
415
759
  configResolved(resolvedConfig) {
760
+ detachListeners?.();
416
761
  const vpConfig = resolvedConfig.vitepress;
417
762
  const srcDir = userOptions.srcDir ?? vpConfig?.srcDir ?? resolvedConfig.root ?? process.cwd();
418
- 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;
419
817
  }
420
818
  };
421
819
  }
422
- function writeChangePage(change, absoluteOutDir, outDir, isArchived, openspecRootDir) {
423
- const subPath = isArchived ? path__default.default.join("changes", "archive", `${change.archivedDate}-${change.name}`) : path__default.default.join("changes", change.name);
424
- const changeOutDir = path__default.default.join(absoluteOutDir, subPath);
425
- writeFile(path__default.default.join(changeOutDir, "index.md"), generateChangeIndexPage(change, outDir));
426
- for (const artifact of change.artifacts) {
427
- const srcFile = path__default.default.join(change.dir, `${artifact}.md`);
428
- const destFile = path__default.default.join(changeOutDir, `${artifact}.md`);
429
- const content = fs__default.default.readFileSync(srcFile, "utf-8");
430
- writeFile(destFile, rewriteRelativeLinks(content, srcFile, openspecRootDir, outDir));
431
- }
432
- }
433
820
  function withOpenSpec(config, options = {}) {
434
- const specDir = options.specDir ?? "./openspec";
435
- const outDir = options.outDir ?? "openspec";
436
- const srcDir = options.srcDir ?? process.cwd();
437
- 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);
438
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);
439
839
  const vite = result.vite ?? {};
440
840
  const existingPlugins = vite.plugins ?? [];
441
- result.vite = { ...vite, plugins: [...existingPlugins, openspec({ specDir, outDir, srcDir })] };
442
- const themeConfig = result.themeConfig ?? {};
443
- if (options.nav !== false) {
444
- 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
+ });
445
847
  const existingNav = themeConfig.nav ?? [];
446
848
  themeConfig.nav = navEntry ? [navEntry, ...existingNav] : existingNav;
447
849
  }
448
- if (options.sidebar !== false && !Array.isArray(themeConfig.sidebar)) {
449
- const sidebarKey = `/${outDir}/`;
450
- const existingSidebar = themeConfig.sidebar ?? {};
451
- if (!existingSidebar[sidebarKey]) {
452
- themeConfig.sidebar = {
453
- ...existingSidebar,
454
- [sidebarKey]: generateOpenSpecSidebar(specDir, { outDir })
455
- };
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 };
456
857
  }
457
858
  }
458
859
  result.themeConfig = themeConfig;