@stritti/vitepress-plugin-openspec 0.3.1

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,160 @@
1
+ import { Plugin } from 'vite';
2
+
3
+ /**
4
+ * Configuration options for the vitepress-plugin-openspec plugin.
5
+ */
6
+ interface OpenSpecPluginOptions {
7
+ /**
8
+ * Path to the openspec/ directory of the project.
9
+ * @default './openspec'
10
+ */
11
+ specDir?: string;
12
+ /**
13
+ * Output directory (relative to VitePress `srcDir`) where the generated
14
+ * Markdown pages will be written.
15
+ * @default 'openspec'
16
+ */
17
+ outDir?: string;
18
+ /**
19
+ * VitePress source directory — the directory containing your `.md` files
20
+ * (i.e. the `docs/` folder). Required when calling `generateOpenSpecPages()`
21
+ * at config evaluation time so it knows where to write the generated files.
22
+ *
23
+ * @default process.cwd()
24
+ * @example path.resolve(__dirname, '..') // from docs/.vitepress/config.ts
25
+ */
26
+ srcDir?: string;
27
+ }
28
+ /**
29
+ * A canonical capability specification from openspec/specs/<name>/spec.md
30
+ */
31
+ interface CapabilitySpec {
32
+ /** Folder name (kebab-case capability identifier) */
33
+ name: string;
34
+ /** Optional display title from spec.md frontmatter. Overrides humanized name. */
35
+ title?: string;
36
+ /** Absolute path to the spec.md file */
37
+ specPath: string;
38
+ /** Raw Markdown content of the spec */
39
+ content: string;
40
+ }
41
+ /**
42
+ * A single artifact (file) belonging to a Change.
43
+ */
44
+ type ChangeArtifact = 'proposal' | 'design' | 'tasks';
45
+ /**
46
+ * An OpenSpec change (active or archived).
47
+ */
48
+ interface Change {
49
+ /** Change directory name (e.g. "my-feature" or for archived: "my-feature" extracted from "2026-03-10-my-feature") */
50
+ name: string;
51
+ /** Optional display title from .openspec.yaml. Overrides humanized name. */
52
+ title?: string;
53
+ /** Absolute path to the change directory */
54
+ dir: string;
55
+ /** Which artifact files are present */
56
+ artifacts: ChangeArtifact[];
57
+ /** Creation date from .openspec.yaml, if available */
58
+ createdDate?: string;
59
+ /** For archived changes: the archive date (YYYY-MM-DD) parsed from directory name */
60
+ archivedDate?: string;
61
+ /**
62
+ * For archived changes: the original archive folder name (e.g. "2026-01-15-my-feature" or
63
+ * "legacy-feature" for non-standard names). Used to build correct archive URLs.
64
+ */
65
+ archiveFolderName?: string;
66
+ }
67
+ /**
68
+ * The full parsed structure of an openspec/ directory.
69
+ */
70
+ interface OpenSpecFolder {
71
+ /** Absolute path to the openspec/ root */
72
+ dir: string;
73
+ /** Canonical capability specs from openspec/specs/ */
74
+ specs: CapabilitySpec[];
75
+ /** Active changes from openspec/changes/ (excluding archive/) */
76
+ changes: Change[];
77
+ /** Archived changes from openspec/changes/archive/ */
78
+ archivedChanges: Change[];
79
+ }
80
+ /**
81
+ * A nav item compatible with VitePress `DefaultTheme.NavItem`.
82
+ */
83
+ interface NavItem {
84
+ text: string;
85
+ link: string;
86
+ }
87
+ /**
88
+ * A sidebar item compatible with VitePress `DefaultTheme.SidebarItem`.
89
+ */
90
+ interface SidebarItem {
91
+ text: string;
92
+ link?: string;
93
+ collapsed?: boolean;
94
+ items?: SidebarItem[];
95
+ }
96
+
97
+ /**
98
+ * Synchronously generates all VitePress Markdown pages from the openspec/
99
+ * directory and writes them to disk.
100
+ *
101
+ * Call this at the top of your `docs/.vitepress/config.ts` **before**
102
+ * `defineConfig()` so the files exist when VitePress scans the source
103
+ * directory for routes.
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * // docs/.vitepress/config.ts
108
+ * import { defineConfig } from 'vitepress'
109
+ * import path from 'node:path'
110
+ * import { fileURLToPath } from 'node:url'
111
+ * import openspec, { generateOpenSpecPages } from 'vitepress-plugin-openspec'
112
+ *
113
+ * const __dirname = path.dirname(fileURLToPath(import.meta.url))
114
+ * const specDir = path.resolve(__dirname, '../../openspec')
115
+ *
116
+ * // Generate pages before VitePress scans srcDir for routes
117
+ * generateOpenSpecPages({ specDir, outDir: 'openspec', srcDir: path.resolve(__dirname, '..') })
118
+ *
119
+ * export default defineConfig({ ... })
120
+ * ```
121
+ */
122
+ declare function generateOpenSpecPages(userOptions?: OpenSpecPluginOptions): void;
123
+ /**
124
+ * VitePress plugin that reads an openspec/ directory and generates structured
125
+ * Markdown documentation pages inside the VitePress source directory.
126
+ *
127
+ * For the pages to be available on the first build (including CI), also call
128
+ * `generateOpenSpecPages()` at the top of your `config.ts` before `defineConfig`.
129
+ *
130
+ * @example
131
+ * ```typescript
132
+ * // .vitepress/config.ts
133
+ * import { defineConfig } from 'vitepress'
134
+ * import openspec, { generateOpenSpecPages } from 'vitepress-plugin-openspec'
135
+ *
136
+ * generateOpenSpecPages({ specDir, outDir: 'openspec', srcDir: path.resolve(__dirname, '..') })
137
+ *
138
+ * export default defineConfig({
139
+ * vite: { plugins: [openspec({ specDir: './openspec', outDir: 'project-docs' })] },
140
+ * })
141
+ * ```
142
+ */
143
+ declare function openspec(userOptions?: OpenSpecPluginOptions): Plugin;
144
+
145
+ /**
146
+ * Returns a VitePress sidebar configuration for the OpenSpec documentation.
147
+ * Includes groups for Specifications, active Changes, and archived Changes.
148
+ */
149
+ declare function generateOpenSpecSidebar(specDir: string, options?: {
150
+ outDir?: string;
151
+ }): SidebarItem[];
152
+ /**
153
+ * Returns a VitePress nav entry for the OpenSpec documentation section.
154
+ */
155
+ declare function openspecNav(specDir: string, options?: {
156
+ outDir?: string;
157
+ text?: string;
158
+ }): NavItem;
159
+
160
+ export { type CapabilitySpec, type Change, type ChangeArtifact, type NavItem, type OpenSpecFolder, type OpenSpecPluginOptions, type SidebarItem, openspec as default, generateOpenSpecPages, generateOpenSpecSidebar, openspec, openspecNav };
@@ -0,0 +1,160 @@
1
+ import { Plugin } from 'vite';
2
+
3
+ /**
4
+ * Configuration options for the vitepress-plugin-openspec plugin.
5
+ */
6
+ interface OpenSpecPluginOptions {
7
+ /**
8
+ * Path to the openspec/ directory of the project.
9
+ * @default './openspec'
10
+ */
11
+ specDir?: string;
12
+ /**
13
+ * Output directory (relative to VitePress `srcDir`) where the generated
14
+ * Markdown pages will be written.
15
+ * @default 'openspec'
16
+ */
17
+ outDir?: string;
18
+ /**
19
+ * VitePress source directory — the directory containing your `.md` files
20
+ * (i.e. the `docs/` folder). Required when calling `generateOpenSpecPages()`
21
+ * at config evaluation time so it knows where to write the generated files.
22
+ *
23
+ * @default process.cwd()
24
+ * @example path.resolve(__dirname, '..') // from docs/.vitepress/config.ts
25
+ */
26
+ srcDir?: string;
27
+ }
28
+ /**
29
+ * A canonical capability specification from openspec/specs/<name>/spec.md
30
+ */
31
+ interface CapabilitySpec {
32
+ /** Folder name (kebab-case capability identifier) */
33
+ name: string;
34
+ /** Optional display title from spec.md frontmatter. Overrides humanized name. */
35
+ title?: string;
36
+ /** Absolute path to the spec.md file */
37
+ specPath: string;
38
+ /** Raw Markdown content of the spec */
39
+ content: string;
40
+ }
41
+ /**
42
+ * A single artifact (file) belonging to a Change.
43
+ */
44
+ type ChangeArtifact = 'proposal' | 'design' | 'tasks';
45
+ /**
46
+ * An OpenSpec change (active or archived).
47
+ */
48
+ interface Change {
49
+ /** Change directory name (e.g. "my-feature" or for archived: "my-feature" extracted from "2026-03-10-my-feature") */
50
+ name: string;
51
+ /** Optional display title from .openspec.yaml. Overrides humanized name. */
52
+ title?: string;
53
+ /** Absolute path to the change directory */
54
+ dir: string;
55
+ /** Which artifact files are present */
56
+ artifacts: ChangeArtifact[];
57
+ /** Creation date from .openspec.yaml, if available */
58
+ createdDate?: string;
59
+ /** For archived changes: the archive date (YYYY-MM-DD) parsed from directory name */
60
+ archivedDate?: string;
61
+ /**
62
+ * For archived changes: the original archive folder name (e.g. "2026-01-15-my-feature" or
63
+ * "legacy-feature" for non-standard names). Used to build correct archive URLs.
64
+ */
65
+ archiveFolderName?: string;
66
+ }
67
+ /**
68
+ * The full parsed structure of an openspec/ directory.
69
+ */
70
+ interface OpenSpecFolder {
71
+ /** Absolute path to the openspec/ root */
72
+ dir: string;
73
+ /** Canonical capability specs from openspec/specs/ */
74
+ specs: CapabilitySpec[];
75
+ /** Active changes from openspec/changes/ (excluding archive/) */
76
+ changes: Change[];
77
+ /** Archived changes from openspec/changes/archive/ */
78
+ archivedChanges: Change[];
79
+ }
80
+ /**
81
+ * A nav item compatible with VitePress `DefaultTheme.NavItem`.
82
+ */
83
+ interface NavItem {
84
+ text: string;
85
+ link: string;
86
+ }
87
+ /**
88
+ * A sidebar item compatible with VitePress `DefaultTheme.SidebarItem`.
89
+ */
90
+ interface SidebarItem {
91
+ text: string;
92
+ link?: string;
93
+ collapsed?: boolean;
94
+ items?: SidebarItem[];
95
+ }
96
+
97
+ /**
98
+ * Synchronously generates all VitePress Markdown pages from the openspec/
99
+ * directory and writes them to disk.
100
+ *
101
+ * Call this at the top of your `docs/.vitepress/config.ts` **before**
102
+ * `defineConfig()` so the files exist when VitePress scans the source
103
+ * directory for routes.
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * // docs/.vitepress/config.ts
108
+ * import { defineConfig } from 'vitepress'
109
+ * import path from 'node:path'
110
+ * import { fileURLToPath } from 'node:url'
111
+ * import openspec, { generateOpenSpecPages } from 'vitepress-plugin-openspec'
112
+ *
113
+ * const __dirname = path.dirname(fileURLToPath(import.meta.url))
114
+ * const specDir = path.resolve(__dirname, '../../openspec')
115
+ *
116
+ * // Generate pages before VitePress scans srcDir for routes
117
+ * generateOpenSpecPages({ specDir, outDir: 'openspec', srcDir: path.resolve(__dirname, '..') })
118
+ *
119
+ * export default defineConfig({ ... })
120
+ * ```
121
+ */
122
+ declare function generateOpenSpecPages(userOptions?: OpenSpecPluginOptions): void;
123
+ /**
124
+ * VitePress plugin that reads an openspec/ directory and generates structured
125
+ * Markdown documentation pages inside the VitePress source directory.
126
+ *
127
+ * For the pages to be available on the first build (including CI), also call
128
+ * `generateOpenSpecPages()` at the top of your `config.ts` before `defineConfig`.
129
+ *
130
+ * @example
131
+ * ```typescript
132
+ * // .vitepress/config.ts
133
+ * import { defineConfig } from 'vitepress'
134
+ * import openspec, { generateOpenSpecPages } from 'vitepress-plugin-openspec'
135
+ *
136
+ * generateOpenSpecPages({ specDir, outDir: 'openspec', srcDir: path.resolve(__dirname, '..') })
137
+ *
138
+ * export default defineConfig({
139
+ * vite: { plugins: [openspec({ specDir: './openspec', outDir: 'project-docs' })] },
140
+ * })
141
+ * ```
142
+ */
143
+ declare function openspec(userOptions?: OpenSpecPluginOptions): Plugin;
144
+
145
+ /**
146
+ * Returns a VitePress sidebar configuration for the OpenSpec documentation.
147
+ * Includes groups for Specifications, active Changes, and archived Changes.
148
+ */
149
+ declare function generateOpenSpecSidebar(specDir: string, options?: {
150
+ outDir?: string;
151
+ }): SidebarItem[];
152
+ /**
153
+ * Returns a VitePress nav entry for the OpenSpec documentation section.
154
+ */
155
+ declare function openspecNav(specDir: string, options?: {
156
+ outDir?: string;
157
+ text?: string;
158
+ }): NavItem;
159
+
160
+ export { type CapabilitySpec, type Change, type ChangeArtifact, type NavItem, type OpenSpecFolder, type OpenSpecPluginOptions, type SidebarItem, openspec as default, generateOpenSpecPages, generateOpenSpecSidebar, openspec, openspecNav };
package/dist/index.js ADDED
@@ -0,0 +1,387 @@
1
+ import fs from 'fs';
2
+ import path2 from 'path';
3
+ import pc from 'picocolors';
4
+ import yaml from 'js-yaml';
5
+
6
+ // src/plugin.ts
7
+ function readOpenSpecYaml(dir) {
8
+ const yamlPath = path2.join(dir, ".openspec.yaml");
9
+ if (!fs.existsSync(yamlPath)) return {};
10
+ try {
11
+ return yaml.load(fs.readFileSync(yamlPath, "utf-8")) ?? {};
12
+ } catch {
13
+ return {};
14
+ }
15
+ }
16
+ var ACRONYM_DICT = {
17
+ api: "API",
18
+ rest: "REST",
19
+ graphql: "GraphQL",
20
+ grpc: "gRPC",
21
+ openapi: "OpenAPI",
22
+ oauth: "OAuth",
23
+ oauth2: "OAuth2",
24
+ http: "HTTP",
25
+ https: "HTTPS",
26
+ url: "URL",
27
+ uri: "URI",
28
+ sdk: "SDK",
29
+ ui: "UI",
30
+ ux: "UX",
31
+ id: "ID",
32
+ db: "DB",
33
+ sql: "SQL",
34
+ css: "CSS",
35
+ html: "HTML",
36
+ json: "JSON",
37
+ yaml: "YAML",
38
+ xml: "XML",
39
+ jwt: "JWT",
40
+ ci: "CI",
41
+ cd: "CD"
42
+ };
43
+ function humanizeLabel(name) {
44
+ if (!name) return "";
45
+ return name.split("-").map((word) => {
46
+ if (/^v\d+$/.test(word)) return word;
47
+ return ACRONYM_DICT[word] ?? word.charAt(0).toUpperCase() + word.slice(1);
48
+ }).join(" ");
49
+ }
50
+ function parseFrontmatterTitle(content) {
51
+ const match = content.match(/^---\s*\n(?:.*\n)*?title:\s*['"]?([^\n'"]+)['"]?\s*\n/);
52
+ return match?.[1]?.trim() || void 0;
53
+ }
54
+ function formatDate(val) {
55
+ if (!val) return void 0;
56
+ if (val instanceof Date) return val.toISOString().slice(0, 10);
57
+ return String(val);
58
+ }
59
+ function readArtifacts(dir) {
60
+ const artifacts = [];
61
+ for (const name of ["proposal", "design", "tasks"]) {
62
+ if (fs.existsSync(path2.join(dir, `${name}.md`))) artifacts.push(name);
63
+ }
64
+ return artifacts;
65
+ }
66
+ function readOpenSpecFolder(dir) {
67
+ const resolved = path2.resolve(dir);
68
+ if (!fs.existsSync(resolved)) {
69
+ throw new Error(`[vitepress-plugin-openspec] openspec directory not found: ${resolved}`);
70
+ }
71
+ const specs = [];
72
+ const specsDir = path2.join(resolved, "specs");
73
+ if (fs.existsSync(specsDir)) {
74
+ for (const entry of fs.readdirSync(specsDir, { withFileTypes: true })) {
75
+ if (!entry.isDirectory()) continue;
76
+ const specPath = path2.join(specsDir, entry.name, "spec.md");
77
+ if (!fs.existsSync(specPath)) continue;
78
+ const content = fs.readFileSync(specPath, "utf-8");
79
+ specs.push({
80
+ name: entry.name,
81
+ title: parseFrontmatterTitle(content),
82
+ specPath,
83
+ content
84
+ });
85
+ }
86
+ }
87
+ const changes = [];
88
+ const changesDir = path2.join(resolved, "changes");
89
+ if (fs.existsSync(changesDir)) {
90
+ for (const entry of fs.readdirSync(changesDir, { withFileTypes: true })) {
91
+ if (!entry.isDirectory() || entry.name === "archive") continue;
92
+ const changeDir = path2.join(changesDir, entry.name);
93
+ if (!fs.existsSync(path2.join(changeDir, ".openspec.yaml"))) continue;
94
+ const meta = readOpenSpecYaml(changeDir);
95
+ changes.push({
96
+ name: entry.name,
97
+ title: meta.title ? String(meta.title) : void 0,
98
+ dir: changeDir,
99
+ artifacts: readArtifacts(changeDir),
100
+ createdDate: formatDate(meta.created)
101
+ });
102
+ }
103
+ }
104
+ const archivedChanges = [];
105
+ const archiveDir = path2.join(changesDir, "archive");
106
+ if (fs.existsSync(archiveDir)) {
107
+ for (const entry of fs.readdirSync(archiveDir, { withFileTypes: true })) {
108
+ if (!entry.isDirectory()) continue;
109
+ const changeDir = path2.join(archiveDir, entry.name);
110
+ const match = entry.name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
111
+ const archivedDate = match?.[1];
112
+ const name = match?.[2] ?? entry.name;
113
+ const meta = readOpenSpecYaml(changeDir);
114
+ archivedChanges.push({
115
+ name,
116
+ title: meta.title ? String(meta.title) : void 0,
117
+ dir: changeDir,
118
+ artifacts: readArtifacts(changeDir),
119
+ createdDate: formatDate(meta.created),
120
+ archivedDate,
121
+ archiveFolderName: entry.name
122
+ });
123
+ }
124
+ }
125
+ return { dir: resolved, specs, changes, archivedChanges };
126
+ }
127
+ function extractSpecDescription(content) {
128
+ const reqMatch = content.match(/^### Requirement:[^\n]*\n+([\s\S]*?)(?=\n#{1,4} |\n*$)/m);
129
+ if (!reqMatch) return void 0;
130
+ const para = reqMatch[1].trim();
131
+ if (!para) return void 0;
132
+ const sentenceMatch = para.match(/^([^.?!]+[.?!])/);
133
+ if (!sentenceMatch) return void 0;
134
+ let sentence = sentenceMatch[1].trim();
135
+ if (sentence.length > 160) {
136
+ const cut = sentence.lastIndexOf(" ", 160);
137
+ sentence = (cut > 0 ? sentence.slice(0, cut) : sentence.slice(0, 160)) + "\u2026";
138
+ }
139
+ return sentence.replace(/"/g, '\\"');
140
+ }
141
+ function stripDeltaMarkers(content) {
142
+ const stripped = content.split("\n").filter((line) => !/^## (ADDED|MODIFIED|REMOVED) Requirements\s*$/.test(line)).join("\n");
143
+ return stripped.replace(/\n{3,}/g, "\n\n");
144
+ }
145
+ function transformScenarios(content) {
146
+ const lines = content.split("\n");
147
+ const result = [];
148
+ let inScenario = false;
149
+ for (const line of lines) {
150
+ const scenarioMatch = line.match(/^#### Scenario: (.+)$/);
151
+ const isHeading = /^#{1,6} /.test(line);
152
+ if (scenarioMatch) {
153
+ if (inScenario) result.push(":::");
154
+ result.push(`:::details ${scenarioMatch[1]}`);
155
+ inScenario = true;
156
+ } else if (isHeading && inScenario) {
157
+ result.push(":::");
158
+ result.push("");
159
+ result.push(line);
160
+ inScenario = false;
161
+ } else {
162
+ result.push(line);
163
+ }
164
+ }
165
+ if (inScenario) result.push(":::");
166
+ return result.join("\n");
167
+ }
168
+ function generateSpecPage(spec) {
169
+ const description = extractSpecDescription(spec.content);
170
+ const transformed = transformScenarios(stripDeltaMarkers(spec.content));
171
+ const lines = [];
172
+ if (description) {
173
+ lines.push("---");
174
+ lines.push(`description: "${description}"`);
175
+ lines.push("---");
176
+ lines.push("");
177
+ }
178
+ lines.push(`# ${spec.title ?? humanizeLabel(spec.name)}`);
179
+ lines.push("");
180
+ lines.push(transformed.trimEnd());
181
+ lines.push("");
182
+ return lines.join("\n");
183
+ }
184
+ function generateSpecsIndexPage(specs, outDir) {
185
+ const lines = [];
186
+ lines.push("# Specifications");
187
+ lines.push("");
188
+ lines.push("Canonical capability specifications for this project.");
189
+ lines.push("");
190
+ if (specs.length === 0) {
191
+ lines.push("*No specifications defined yet.*");
192
+ } else {
193
+ for (const spec of specs) {
194
+ lines.push(`- [${spec.title ?? humanizeLabel(spec.name)}](/${outDir}/specs/${spec.name}/)`);
195
+ }
196
+ }
197
+ lines.push("");
198
+ return lines.join("\n");
199
+ }
200
+ function generateChangeIndexPage(change, outDir) {
201
+ const lines = [];
202
+ lines.push(`# ${change.title ?? humanizeLabel(change.name)}`);
203
+ lines.push("");
204
+ if (change.createdDate) {
205
+ lines.push(`**Created:** ${change.createdDate}`);
206
+ lines.push("");
207
+ }
208
+ if (change.archivedDate) {
209
+ lines.push(`**Archived:** ${change.archivedDate}`);
210
+ lines.push("");
211
+ }
212
+ lines.push("## Artifacts");
213
+ lines.push("");
214
+ const prefix = change.archiveFolderName ? `/${outDir}/changes/archive/${change.archiveFolderName}` : `/${outDir}/changes/${change.name}`;
215
+ for (const artifact of change.artifacts) {
216
+ const label = artifact.charAt(0).toUpperCase() + artifact.slice(1);
217
+ lines.push(`- [${label}](${prefix}/${artifact})`);
218
+ }
219
+ lines.push("");
220
+ return lines.join("\n");
221
+ }
222
+ function generateChangesIndexPage(folder, outDir) {
223
+ const lines = [];
224
+ lines.push("# Changes");
225
+ lines.push("");
226
+ if (folder.changes.length === 0) {
227
+ lines.push("*No active changes.*");
228
+ } else {
229
+ lines.push("## Active");
230
+ lines.push("");
231
+ for (const change of folder.changes) {
232
+ const date = change.createdDate ? ` *(${change.createdDate})*` : "";
233
+ lines.push(`- [${change.title ?? humanizeLabel(change.name)}](/${outDir}/changes/${change.name}/)${date}`);
234
+ }
235
+ }
236
+ if (folder.archivedChanges.length > 0) {
237
+ lines.push("");
238
+ lines.push("## Archiv");
239
+ lines.push("");
240
+ for (const change of folder.archivedChanges) {
241
+ const date = change.archivedDate ? ` *(archiviert: ${change.archivedDate})*` : "";
242
+ lines.push(
243
+ `- [${change.title ?? humanizeLabel(change.name)}](/${outDir}/changes/archive/${change.archiveFolderName}/)${date}`
244
+ );
245
+ }
246
+ }
247
+ lines.push("");
248
+ return lines.join("\n");
249
+ }
250
+ function changeItems(change, outDir, isArchived = false) {
251
+ const prefix = isArchived ? `/${outDir}/changes/archive/${change.archiveFolderName}` : `/${outDir}/changes/${change.name}`;
252
+ return change.artifacts.map((a) => ({
253
+ text: a.charAt(0).toUpperCase() + a.slice(1),
254
+ link: `${prefix}/${a}`
255
+ }));
256
+ }
257
+ function generateOpenSpecSidebar(specDir, options = {}) {
258
+ const outDir = options.outDir ?? "openspec";
259
+ const folder = readOpenSpecFolder(specDir);
260
+ const groups = [];
261
+ groups.push({
262
+ text: "Specifications",
263
+ collapsed: false,
264
+ items: [
265
+ { text: "Overview", link: `/${outDir}/specs/` },
266
+ ...folder.specs.map((s) => ({ text: s.title ?? humanizeLabel(s.name), link: `/${outDir}/specs/${s.name}/` }))
267
+ ]
268
+ });
269
+ groups.push({
270
+ text: "Changes",
271
+ collapsed: false,
272
+ items: [
273
+ { text: "Overview", link: `/${outDir}/changes/` },
274
+ ...folder.changes.map((c) => ({
275
+ text: c.title ?? humanizeLabel(c.name),
276
+ collapsed: true,
277
+ items: changeItems(c, outDir)
278
+ }))
279
+ ]
280
+ });
281
+ if (folder.archivedChanges.length > 0) {
282
+ groups.push({
283
+ text: "Archiv",
284
+ collapsed: true,
285
+ items: folder.archivedChanges.map((c) => ({
286
+ text: c.title ?? humanizeLabel(c.name),
287
+ collapsed: true,
288
+ items: changeItems(c, outDir, true)
289
+ }))
290
+ });
291
+ }
292
+ return groups;
293
+ }
294
+ function openspecNav(specDir, options = {}) {
295
+ const outDir = options.outDir ?? "openspec";
296
+ if (!fs.existsSync(path2.resolve(specDir))) {
297
+ throw new Error(
298
+ `[vitepress-plugin-openspec] openspec directory not found: ${path2.resolve(specDir)}`
299
+ );
300
+ }
301
+ return {
302
+ text: options.text ?? "Docs",
303
+ link: `/${outDir}/`
304
+ };
305
+ }
306
+
307
+ // src/plugin.ts
308
+ var PLUGIN_NAME = "vitepress-plugin-openspec";
309
+ function writeFile(filePath, content) {
310
+ fs.mkdirSync(path2.dirname(filePath), { recursive: true });
311
+ fs.writeFileSync(filePath, content, "utf-8");
312
+ }
313
+ function copyFile(src, dest) {
314
+ fs.mkdirSync(path2.dirname(dest), { recursive: true });
315
+ fs.copyFileSync(src, dest);
316
+ }
317
+ function generateOpenSpecPages(userOptions = {}) {
318
+ const specDir = userOptions.specDir ?? "./openspec";
319
+ const outDir = userOptions.outDir ?? "openspec";
320
+ const srcDir = userOptions.srcDir ?? process.cwd();
321
+ const absoluteOutDir = path2.resolve(srcDir, outDir);
322
+ try {
323
+ const folder = readOpenSpecFolder(specDir);
324
+ for (const spec of folder.specs) {
325
+ const dest = path2.join(absoluteOutDir, "specs", spec.name, "index.md");
326
+ writeFile(dest, generateSpecPage(spec));
327
+ }
328
+ writeFile(
329
+ path2.join(absoluteOutDir, "specs", "index.md"),
330
+ generateSpecsIndexPage(folder.specs, outDir)
331
+ );
332
+ for (const change of folder.changes) {
333
+ writeChangePage(change, absoluteOutDir, outDir, false);
334
+ }
335
+ for (const change of folder.archivedChanges) {
336
+ writeChangePage(change, absoluteOutDir, outDir, true);
337
+ }
338
+ writeFile(
339
+ path2.join(absoluteOutDir, "changes", "index.md"),
340
+ generateChangesIndexPage(folder, outDir)
341
+ );
342
+ const rootIndex = [
343
+ "# Project Documentation",
344
+ "",
345
+ "This section is generated from the project's [OpenSpec](https://openspec.dev/) folder.",
346
+ "OpenSpec is a lightweight, file-based workflow for spec-driven development \u2014",
347
+ "it structures your project's capability specifications and change proposals as plain Markdown files.",
348
+ "",
349
+ `- [Specifications](/${outDir}/specs/) \u2014 canonical capability specs`,
350
+ `- [Changes](/${outDir}/changes/) \u2014 active and archived change proposals`,
351
+ ""
352
+ ].join("\n");
353
+ writeFile(path2.join(absoluteOutDir, "index.md"), rootIndex);
354
+ console.log(
355
+ `${pc.bold(pc.cyan(`[${PLUGIN_NAME}]`))} Generated docs from ${pc.cyan(specDir)}: ${pc.green(String(folder.specs.length))} spec(s), ${pc.green(String(folder.changes.length))} change(s), ${pc.green(String(folder.archivedChanges.length))} archived`
356
+ );
357
+ } catch (err) {
358
+ console.error(
359
+ `${pc.bold(pc.red(`[${PLUGIN_NAME}]`))} Failed to process openspec directory "${specDir}": ${String(err)}`
360
+ );
361
+ }
362
+ }
363
+ function openspec(userOptions = {}) {
364
+ return {
365
+ name: PLUGIN_NAME,
366
+ enforce: "pre",
367
+ configResolved(resolvedConfig) {
368
+ const vpConfig = resolvedConfig.vitepress;
369
+ const srcDir = userOptions.srcDir ?? vpConfig?.srcDir ?? resolvedConfig.root ?? process.cwd();
370
+ generateOpenSpecPages({ ...userOptions, srcDir });
371
+ }
372
+ };
373
+ }
374
+ function writeChangePage(change, absoluteOutDir, outDir, isArchived) {
375
+ const subPath = isArchived ? path2.join("changes", "archive", `${change.archivedDate}-${change.name}`) : path2.join("changes", change.name);
376
+ const changeOutDir = path2.join(absoluteOutDir, subPath);
377
+ writeFile(path2.join(changeOutDir, "index.md"), generateChangeIndexPage(change, outDir));
378
+ for (const artifact of change.artifacts) {
379
+ const srcFile = path2.join(change.dir, `${artifact}.md`);
380
+ const destFile = path2.join(changeOutDir, `${artifact}.md`);
381
+ copyFile(srcFile, destFile);
382
+ }
383
+ }
384
+
385
+ export { openspec as default, generateOpenSpecPages, generateOpenSpecSidebar, openspec, openspecNav };
386
+ //# sourceMappingURL=index.js.map
387
+ //# sourceMappingURL=index.js.map