@refrakt-md/plan 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/dist/cli-plugin.d.ts +12 -0
  2. package/dist/cli-plugin.d.ts.map +1 -0
  3. package/dist/cli-plugin.js +494 -0
  4. package/dist/cli-plugin.js.map +1 -0
  5. package/dist/commands/build.d.ts +14 -0
  6. package/dist/commands/build.d.ts.map +1 -0
  7. package/dist/commands/build.js +57 -0
  8. package/dist/commands/build.js.map +1 -0
  9. package/dist/commands/bundle-behaviors.d.ts +6 -0
  10. package/dist/commands/bundle-behaviors.d.ts.map +1 -0
  11. package/dist/commands/bundle-behaviors.js +24 -0
  12. package/dist/commands/bundle-behaviors.js.map +1 -0
  13. package/dist/commands/create.d.ts +21 -0
  14. package/dist/commands/create.d.ts.map +1 -0
  15. package/dist/commands/create.js +50 -0
  16. package/dist/commands/create.js.map +1 -0
  17. package/dist/commands/init.d.ts +17 -0
  18. package/dist/commands/init.d.ts.map +1 -0
  19. package/dist/commands/init.js +109 -0
  20. package/dist/commands/init.js.map +1 -0
  21. package/dist/commands/next.d.ts +34 -0
  22. package/dist/commands/next.d.ts.map +1 -0
  23. package/dist/commands/next.js +100 -0
  24. package/dist/commands/next.js.map +1 -0
  25. package/dist/commands/plan-behaviors.d.ts +2 -0
  26. package/dist/commands/plan-behaviors.d.ts.map +1 -0
  27. package/dist/commands/plan-behaviors.js +7 -0
  28. package/dist/commands/plan-behaviors.js.map +1 -0
  29. package/dist/commands/render-pipeline.d.ts +70 -0
  30. package/dist/commands/render-pipeline.d.ts.map +1 -0
  31. package/dist/commands/render-pipeline.js +1173 -0
  32. package/dist/commands/render-pipeline.js.map +1 -0
  33. package/dist/commands/serve.d.ts +13 -0
  34. package/dist/commands/serve.d.ts.map +1 -0
  35. package/dist/commands/serve.js +167 -0
  36. package/dist/commands/serve.js.map +1 -0
  37. package/dist/commands/status.d.ts +53 -0
  38. package/dist/commands/status.d.ts.map +1 -0
  39. package/dist/commands/status.js +181 -0
  40. package/dist/commands/status.js.map +1 -0
  41. package/dist/commands/templates.d.ts +37 -0
  42. package/dist/commands/templates.d.ts.map +1 -0
  43. package/dist/commands/templates.js +160 -0
  44. package/dist/commands/templates.js.map +1 -0
  45. package/dist/commands/update.d.ts +29 -0
  46. package/dist/commands/update.d.ts.map +1 -0
  47. package/dist/commands/update.js +238 -0
  48. package/dist/commands/update.js.map +1 -0
  49. package/dist/commands/validate.d.ts +29 -0
  50. package/dist/commands/validate.d.ts.map +1 -0
  51. package/dist/commands/validate.js +298 -0
  52. package/dist/commands/validate.js.map +1 -0
  53. package/dist/config.d.ts +3 -0
  54. package/dist/config.d.ts.map +1 -0
  55. package/dist/config.js +163 -0
  56. package/dist/config.js.map +1 -0
  57. package/dist/filter.d.ts +17 -0
  58. package/dist/filter.d.ts.map +1 -0
  59. package/dist/filter.js +72 -0
  60. package/dist/filter.js.map +1 -0
  61. package/dist/index.d.ts +7 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +144 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/pipeline.d.ts +23 -0
  66. package/dist/pipeline.d.ts.map +1 -0
  67. package/dist/pipeline.js +720 -0
  68. package/dist/pipeline.js.map +1 -0
  69. package/dist/scanner.d.ts +9 -0
  70. package/dist/scanner.d.ts.map +1 -0
  71. package/dist/scanner.js +234 -0
  72. package/dist/scanner.js.map +1 -0
  73. package/dist/schema/backlog.d.ts +7 -0
  74. package/dist/schema/backlog.d.ts.map +1 -0
  75. package/dist/schema/backlog.js +9 -0
  76. package/dist/schema/backlog.js.map +1 -0
  77. package/dist/schema/bug.d.ts +9 -0
  78. package/dist/schema/bug.d.ts.map +1 -0
  79. package/dist/schema/bug.js +11 -0
  80. package/dist/schema/bug.js.map +1 -0
  81. package/dist/schema/decision-log.d.ts +5 -0
  82. package/dist/schema/decision-log.d.ts.map +1 -0
  83. package/dist/schema/decision-log.js +7 -0
  84. package/dist/schema/decision-log.js.map +1 -0
  85. package/dist/schema/decision.d.ts +8 -0
  86. package/dist/schema/decision.d.ts.map +1 -0
  87. package/dist/schema/decision.js +10 -0
  88. package/dist/schema/decision.js.map +1 -0
  89. package/dist/schema/milestone.d.ts +6 -0
  90. package/dist/schema/milestone.d.ts.map +1 -0
  91. package/dist/schema/milestone.js +8 -0
  92. package/dist/schema/milestone.js.map +1 -0
  93. package/dist/schema/plan-activity.d.ts +4 -0
  94. package/dist/schema/plan-activity.d.ts.map +1 -0
  95. package/dist/schema/plan-activity.js +6 -0
  96. package/dist/schema/plan-activity.js.map +1 -0
  97. package/dist/schema/plan-progress.d.ts +4 -0
  98. package/dist/schema/plan-progress.d.ts.map +1 -0
  99. package/dist/schema/plan-progress.js +6 -0
  100. package/dist/schema/plan-progress.js.map +1 -0
  101. package/dist/schema/spec.d.ts +8 -0
  102. package/dist/schema/spec.d.ts.map +1 -0
  103. package/dist/schema/spec.js +10 -0
  104. package/dist/schema/spec.js.map +1 -0
  105. package/dist/schema/work.d.ts +10 -0
  106. package/dist/schema/work.d.ts.map +1 -0
  107. package/dist/schema/work.js +12 -0
  108. package/dist/schema/work.js.map +1 -0
  109. package/dist/tags/backlog.d.ts +4 -0
  110. package/dist/tags/backlog.d.ts.map +1 -0
  111. package/dist/tags/backlog.js +41 -0
  112. package/dist/tags/backlog.js.map +1 -0
  113. package/dist/tags/bug.d.ts +3 -0
  114. package/dist/tags/bug.d.ts.map +1 -0
  115. package/dist/tags/bug.js +58 -0
  116. package/dist/tags/bug.js.map +1 -0
  117. package/dist/tags/decision-log.d.ts +4 -0
  118. package/dist/tags/decision-log.d.ts.map +1 -0
  119. package/dist/tags/decision-log.js +35 -0
  120. package/dist/tags/decision-log.js.map +1 -0
  121. package/dist/tags/decision.d.ts +3 -0
  122. package/dist/tags/decision.d.ts.map +1 -0
  123. package/dist/tags/decision.js +54 -0
  124. package/dist/tags/decision.js.map +1 -0
  125. package/dist/tags/milestone.d.ts +3 -0
  126. package/dist/tags/milestone.d.ts.map +1 -0
  127. package/dist/tags/milestone.js +52 -0
  128. package/dist/tags/milestone.js.map +1 -0
  129. package/dist/tags/plan-activity.d.ts +4 -0
  130. package/dist/tags/plan-activity.d.ts.map +1 -0
  131. package/dist/tags/plan-activity.js +31 -0
  132. package/dist/tags/plan-activity.js.map +1 -0
  133. package/dist/tags/plan-progress.d.ts +4 -0
  134. package/dist/tags/plan-progress.d.ts.map +1 -0
  135. package/dist/tags/plan-progress.js +31 -0
  136. package/dist/tags/plan-progress.js.map +1 -0
  137. package/dist/tags/spec.d.ts +3 -0
  138. package/dist/tags/spec.d.ts.map +1 -0
  139. package/dist/tags/spec.js +57 -0
  140. package/dist/tags/spec.js.map +1 -0
  141. package/dist/tags/work.d.ts +3 -0
  142. package/dist/tags/work.d.ts.map +1 -0
  143. package/dist/tags/work.js +68 -0
  144. package/dist/tags/work.js.map +1 -0
  145. package/dist/types.d.ts +69 -0
  146. package/dist/types.d.ts.map +1 -0
  147. package/dist/types.js +22 -0
  148. package/dist/types.js.map +1 -0
  149. package/dist/util.d.ts +8 -0
  150. package/dist/util.d.ts.map +1 -0
  151. package/dist/util.js +32 -0
  152. package/dist/util.js.map +1 -0
  153. package/package.json +47 -0
  154. package/styles/default.css +580 -0
  155. package/styles/minimal.css +379 -0
  156. package/styles/tokens.css +13 -0
@@ -0,0 +1,1173 @@
1
+ import 'reflect-metadata';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import Markdoc from '@markdoc/markdoc';
6
+ import { tags as coreTags, nodes, extractHeadings, runeTagMap, defineRune, serializeTree, coreConfig, escapeFenceTags } from '@refrakt-md/runes';
7
+ import { createTransform, mergeThemeConfig, planLayout } from '@refrakt-md/transform';
8
+ import { renderFullPage as htmlRenderFullPage } from '@refrakt-md/html';
9
+ import { createHighlightTransform } from '@refrakt-md/highlight';
10
+ import { plan } from '../index.js';
11
+ import { planPipelineHooks } from '../pipeline.js';
12
+ import { scanPlanFiles } from '../scanner.js';
13
+ // --- Markdoc tag registry (built once) ---
14
+ const packageRunes = {};
15
+ for (const [name, entry] of Object.entries(plan.runes)) {
16
+ packageRunes[name] = defineRune({ name, schema: entry.transform, aliases: entry.aliases });
17
+ }
18
+ const tags = { ...coreTags, ...runeTagMap(packageRunes), ...Markdoc.tags };
19
+ // --- Theme resolution ---
20
+ function inlineCssImports(css, cssDir) {
21
+ return css.replace(/@import\s+['"]\.\/([^'"]+)['"]\s*;/g, (_match, file) => {
22
+ const importPath = path.join(cssDir, file);
23
+ if (fs.existsSync(importPath)) {
24
+ return fs.readFileSync(importPath, 'utf-8');
25
+ }
26
+ return _match;
27
+ });
28
+ }
29
+ /** Well-known shorthand names mapped to npm package names. */
30
+ const THEME_SHORTHANDS = {
31
+ lumina: '@refrakt-md/lumina',
32
+ };
33
+ /**
34
+ * Try to read the `theme` field from refrakt.config.json in the working directory.
35
+ * Returns the theme package name or undefined if no config / no theme field.
36
+ */
37
+ function readConfigTheme() {
38
+ const candidates = [
39
+ path.resolve('refrakt.config.json'),
40
+ path.resolve('site', 'refrakt.config.json'),
41
+ ];
42
+ for (const configPath of candidates) {
43
+ if (!fs.existsSync(configPath))
44
+ continue;
45
+ try {
46
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
47
+ if (typeof raw === 'object' && raw !== null && typeof raw.theme === 'string' && raw.theme) {
48
+ return raw.theme;
49
+ }
50
+ }
51
+ catch {
52
+ // Malformed config — silently skip
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+ /**
58
+ * Resolve a theme package name to its base CSS content.
59
+ * Tries `require.resolve(pkg + '/base.css')` first, then the main export.
60
+ * Returns undefined if the package can't be resolved.
61
+ */
62
+ function resolvePackageThemeCss(packageName) {
63
+ // Try main export first (full theme with rune styles), then base.css (tokens only)
64
+ for (const subpath of ['', '/base.css']) {
65
+ try {
66
+ const entry = subpath
67
+ ? import.meta.resolve(packageName + subpath)
68
+ : import.meta.resolve(packageName);
69
+ const resolved = entry.startsWith('file://') ? fileURLToPath(entry) : entry;
70
+ if (resolved.endsWith('.css') && fs.existsSync(resolved)) {
71
+ return inlineCssImports(fs.readFileSync(resolved, 'utf-8'), path.dirname(resolved));
72
+ }
73
+ }
74
+ catch {
75
+ // Not resolvable — continue
76
+ }
77
+ }
78
+ return undefined;
79
+ }
80
+ function resolveThemeCss(theme) {
81
+ const __filename = fileURLToPath(import.meta.url);
82
+ const __dirname = path.dirname(__filename);
83
+ const stylesDir = path.resolve(__dirname, '../../styles');
84
+ // Minimal is self-contained — no shell or theme layering needed
85
+ if (theme === 'minimal') {
86
+ return inlineCssImports(fs.readFileSync(path.join(stylesDir, 'minimal.css'), 'utf-8'), stylesDir);
87
+ }
88
+ // Shell CSS (CLI layout: sidebar, dashboard, TOC, cross-refs, relationships)
89
+ // is appended to all non-minimal themes. Rune styling comes from the theme.
90
+ const shellCss = inlineCssImports(fs.readFileSync(path.join(stylesDir, 'default.css'), 'utf-8'), stylesDir);
91
+ // Resolve the theme CSS (rune styling + design tokens)
92
+ let themeCss;
93
+ if (theme === 'default') {
94
+ // Default theme: use Lumina for rune styling
95
+ themeCss = resolvePackageThemeCss('@refrakt-md/lumina');
96
+ if (!themeCss) {
97
+ console.warn('[plan] Warning: @refrakt-md/lumina could not be resolved. Rune styling will be missing.');
98
+ }
99
+ }
100
+ else if (theme === 'auto') {
101
+ // Auto: read from refrakt.config.json, fall back to Lumina
102
+ const configTheme = readConfigTheme();
103
+ if (configTheme) {
104
+ themeCss = resolvePackageThemeCss(configTheme);
105
+ if (!themeCss) {
106
+ console.warn(`[plan] Warning: Could not resolve theme "${configTheme}" from config. Falling back to Lumina.`);
107
+ }
108
+ }
109
+ if (!themeCss) {
110
+ themeCss = resolvePackageThemeCss('@refrakt-md/lumina');
111
+ }
112
+ }
113
+ else {
114
+ // Shorthand name (e.g., "lumina" → "@refrakt-md/lumina")
115
+ const expanded = THEME_SHORTHANDS[theme];
116
+ if (expanded) {
117
+ themeCss = resolvePackageThemeCss(expanded);
118
+ if (!themeCss) {
119
+ throw new Error(`Theme "${theme}" (${expanded}) could not be resolved. Is it installed?`);
120
+ }
121
+ }
122
+ else if (theme.startsWith('@') || theme.includes('/')) {
123
+ // npm package name
124
+ themeCss = resolvePackageThemeCss(theme);
125
+ if (!themeCss) {
126
+ throw new Error(`Theme package "${theme}" could not be resolved. Is it installed?`);
127
+ }
128
+ }
129
+ else if (fs.existsSync(theme)) {
130
+ // File path
131
+ themeCss = inlineCssImports(fs.readFileSync(theme, 'utf-8'), path.dirname(theme));
132
+ }
133
+ else {
134
+ throw new Error(`Theme not found: "${theme}". Use "default", "minimal", a package name, or a path to a CSS file.`);
135
+ }
136
+ }
137
+ return (themeCss ? themeCss + '\n' : '') + shellCss;
138
+ }
139
+ function buildThemeConfig() {
140
+ const planRuneConfigs = plan.theme?.runes ?? {};
141
+ return mergeThemeConfig(coreConfig, { runes: planRuneConfigs });
142
+ }
143
+ // --- Markdoc rendering ---
144
+ function parseAndTransform(content, filePath) {
145
+ const ast = Markdoc.parse(escapeFenceTags(content));
146
+ const headings = extractHeadings(ast);
147
+ const config = {
148
+ tags,
149
+ nodes,
150
+ variables: {
151
+ generatedIds: new Set(),
152
+ path: filePath,
153
+ headings,
154
+ __source: content,
155
+ },
156
+ };
157
+ const renderable = Markdoc.transform(ast, config);
158
+ const title = headings.length > 0 ? headings[0].text : '';
159
+ return { renderable, title, headings };
160
+ }
161
+ // --- Entity registry ---
162
+ function createRegistry() {
163
+ const entries = [];
164
+ const byTypeAndId = new Map();
165
+ const byTypeAndUrl = new Map();
166
+ const registry = {
167
+ register(entry) {
168
+ entries.push(entry);
169
+ if (!byTypeAndId.has(entry.type))
170
+ byTypeAndId.set(entry.type, new Map());
171
+ // Keep the first registration (entity's own page) — don't overwrite with
172
+ // duplicates from dashboard, status filter pages, etc.
173
+ if (!byTypeAndId.get(entry.type).has(entry.id)) {
174
+ byTypeAndId.get(entry.type).set(entry.id, entry);
175
+ }
176
+ if (!byTypeAndUrl.has(entry.type))
177
+ byTypeAndUrl.set(entry.type, new Map());
178
+ if (!byTypeAndUrl.get(entry.type).has(entry.sourceUrl))
179
+ byTypeAndUrl.get(entry.type).set(entry.sourceUrl, []);
180
+ byTypeAndUrl.get(entry.type).get(entry.sourceUrl).push(entry);
181
+ },
182
+ getAll(type) {
183
+ return [...(byTypeAndId.get(type)?.values() ?? [])];
184
+ },
185
+ getById(type, id) {
186
+ return byTypeAndId.get(type)?.get(id);
187
+ },
188
+ getByUrl(type, url) {
189
+ return byTypeAndUrl.get(type)?.get(url) ?? [];
190
+ },
191
+ getTypes() {
192
+ return [...byTypeAndId.keys()];
193
+ },
194
+ };
195
+ return { registry, entries };
196
+ }
197
+ // --- Navigation builder ---
198
+ const NAV_ORDER = { milestone: 0, work: 1, bug: 2, spec: 3, decision: 4 };
199
+ const NAV_TITLES = {
200
+ milestone: 'Milestones',
201
+ work: 'Work Items',
202
+ bug: 'Bugs',
203
+ spec: 'Specs',
204
+ decision: 'Decisions',
205
+ };
206
+ /** Terminal statuses — collapsed by default in sidebar */
207
+ const TERMINAL_STATUSES = new Set([
208
+ 'done', 'fixed', 'accepted', 'complete', 'superseded', 'deprecated', 'wontfix', 'duplicate',
209
+ ]);
210
+ /** Status ordering — active statuses first, terminal last */
211
+ const STATUS_ORDER = {
212
+ 'in-progress': 0, confirmed: 1, review: 2, ready: 3, reported: 4,
213
+ active: 5, proposed: 6, planning: 7, draft: 8, pending: 9, blocked: 10,
214
+ done: 20, fixed: 21, accepted: 22, complete: 23,
215
+ superseded: 30, deprecated: 31, wontfix: 32, duplicate: 33,
216
+ };
217
+ /** Human-readable status labels for group headers */
218
+ const STATUS_LABELS_DISPLAY = {
219
+ 'in-progress': 'In Progress',
220
+ confirmed: 'Confirmed', review: 'Review', ready: 'Ready', reported: 'Reported',
221
+ active: 'Active', proposed: 'Proposed', planning: 'Planning', draft: 'Draft',
222
+ pending: 'Pending', blocked: 'Blocked',
223
+ done: 'Done', fixed: 'Fixed', accepted: 'Accepted', complete: 'Complete',
224
+ superseded: 'Superseded', deprecated: 'Deprecated', wontfix: "Won't Fix", duplicate: 'Duplicate',
225
+ };
226
+ /** Status ordering per entity type (for nav display) */
227
+ const STATUS_ORDER_BY_TYPE = {
228
+ work: ['in-progress', 'review', 'ready', 'blocked', 'draft', 'pending', 'done'],
229
+ bug: ['in-progress', 'confirmed', 'reported', 'fixed', 'wontfix', 'duplicate'],
230
+ spec: ['review', 'draft', 'accepted', 'superseded', 'deprecated'],
231
+ decision: ['proposed', 'accepted', 'superseded', 'deprecated'],
232
+ milestone: ['active', 'planning', 'complete'],
233
+ };
234
+ function buildNavigation(entities, baseUrl) {
235
+ const groups = new Map();
236
+ for (const entity of entities) {
237
+ const type = entity.type;
238
+ if (!groups.has(type))
239
+ groups.set(type, []);
240
+ const id = entity.attributes.id || entity.attributes.name || '';
241
+ groups.get(type).push({
242
+ url: `${baseUrl}${type}/${slugify(id)}.html`,
243
+ label: `${id}${entity.title ? ' ' + entity.title : ''}`,
244
+ id,
245
+ status: entity.attributes.status || '',
246
+ priority: entity.attributes.priority,
247
+ tags: entity.attributes.tags,
248
+ assignee: entity.attributes.assignee,
249
+ milestone: entity.attributes.milestone,
250
+ severity: entity.attributes.severity,
251
+ });
252
+ }
253
+ return [...groups.entries()]
254
+ .sort(([a], [b]) => (NAV_ORDER[a] ?? 99) - (NAV_ORDER[b] ?? 99))
255
+ .map(([type, items]) => {
256
+ const sorted = items.sort((a, b) => a.id.localeCompare(b.id));
257
+ // Group items by status
258
+ const byStatus = new Map();
259
+ for (const item of sorted) {
260
+ const s = item.status || 'unknown';
261
+ if (!byStatus.has(s))
262
+ byStatus.set(s, []);
263
+ byStatus.get(s).push(item);
264
+ }
265
+ const statusGroups = [...byStatus.entries()]
266
+ .sort(([a], [b]) => (STATUS_ORDER[a] ?? 15) - (STATUS_ORDER[b] ?? 15))
267
+ .map(([status, statusItems]) => ({
268
+ status,
269
+ label: STATUS_LABELS_DISPLAY[status] || status,
270
+ items: statusItems,
271
+ collapsed: TERMINAL_STATUSES.has(status),
272
+ }));
273
+ return {
274
+ title: NAV_TITLES[type] || type,
275
+ type,
276
+ items: sorted,
277
+ statusGroups,
278
+ };
279
+ });
280
+ }
281
+ /**
282
+ * Convert NavGroup[] to a serialized tag tree for the layout's nav region.
283
+ * Produces BEM-classed elements matching the existing .rf-plan-sidebar__* selectors.
284
+ */
285
+ function buildNavRegion(groups, baseUrl, activeUrl, viewDefs, statusFilterPages) {
286
+ const children = [];
287
+ // Title
288
+ children.push({
289
+ $$mdtype: 'Tag',
290
+ name: 'div',
291
+ attributes: { class: 'rf-plan-sidebar__title' },
292
+ children: ['Plan'],
293
+ });
294
+ // Search input
295
+ children.push({
296
+ $$mdtype: 'Tag',
297
+ name: 'input',
298
+ attributes: {
299
+ class: 'rf-plan-sidebar__search',
300
+ type: 'text',
301
+ placeholder: 'Filter… (/ to focus)',
302
+ 'aria-label': 'Filter sidebar items',
303
+ },
304
+ children: [],
305
+ });
306
+ // Dashboard link
307
+ const dashActive = activeUrl === baseUrl || activeUrl === `${baseUrl}index.html`;
308
+ children.push({
309
+ $$mdtype: 'Tag',
310
+ name: 'a',
311
+ attributes: {
312
+ class: `rf-plan-sidebar__link${dashActive ? ' rf-plan-sidebar__link--active' : ''}`,
313
+ href: baseUrl,
314
+ },
315
+ children: ['Dashboard'],
316
+ });
317
+ // Entity groups
318
+ for (const group of groups) {
319
+ const groupChildren = [];
320
+ groupChildren.push({
321
+ $$mdtype: 'Tag',
322
+ name: 'div',
323
+ attributes: { class: 'rf-plan-sidebar__group-title' },
324
+ children: [group.title],
325
+ });
326
+ // Status links — each links to a filter page instead of listing individual items
327
+ const typeFilterPages = (statusFilterPages ?? []).filter(p => p.type === group.type);
328
+ if (typeFilterPages.length > 0) {
329
+ for (const fp of typeFilterPages) {
330
+ const isActive = fp.url === activeUrl;
331
+ groupChildren.push({
332
+ $$mdtype: 'Tag',
333
+ name: 'a',
334
+ attributes: {
335
+ class: `rf-plan-sidebar__link rf-plan-sidebar__status-link${isActive ? ' rf-plan-sidebar__link--active' : ''}`,
336
+ href: fp.url,
337
+ 'data-status': fp.status,
338
+ },
339
+ children: [
340
+ {
341
+ $$mdtype: 'Tag',
342
+ name: 'span',
343
+ attributes: { class: 'rf-plan-sidebar__status-label' },
344
+ children: [STATUS_LABELS_DISPLAY[fp.status] || fp.status],
345
+ },
346
+ {
347
+ $$mdtype: 'Tag',
348
+ name: 'span',
349
+ attributes: { class: 'rf-plan-sidebar__status-count' },
350
+ children: [String(fp.count)],
351
+ },
352
+ ],
353
+ });
354
+ }
355
+ }
356
+ else {
357
+ // Fallback: render status sub-groups with individual item links (legacy behavior)
358
+ for (const sg of group.statusGroups) {
359
+ const subGroupChildren = [];
360
+ subGroupChildren.push({
361
+ $$mdtype: 'Tag',
362
+ name: 'button',
363
+ attributes: {
364
+ class: 'rf-plan-sidebar__status-header',
365
+ type: 'button',
366
+ 'data-status': sg.status,
367
+ 'aria-expanded': sg.collapsed ? 'false' : 'true',
368
+ },
369
+ children: [
370
+ {
371
+ $$mdtype: 'Tag',
372
+ name: 'span',
373
+ attributes: { class: 'rf-plan-sidebar__status-label' },
374
+ children: [sg.label],
375
+ },
376
+ {
377
+ $$mdtype: 'Tag',
378
+ name: 'span',
379
+ attributes: { class: 'rf-plan-sidebar__status-count' },
380
+ children: [String(sg.items.length)],
381
+ },
382
+ ],
383
+ });
384
+ const itemNodes = [];
385
+ for (const item of sg.items) {
386
+ const isActive = item.url === activeUrl;
387
+ const blockerClass = item.hasUnresolvedBlockers ? ' rf-plan-sidebar__link--blocked' : '';
388
+ const attrs = {
389
+ class: `rf-plan-sidebar__link${isActive ? ' rf-plan-sidebar__link--active' : ''}${blockerClass}`,
390
+ href: item.url,
391
+ 'data-id': item.id,
392
+ 'data-status': item.status,
393
+ };
394
+ if (item.priority)
395
+ attrs['data-priority'] = item.priority;
396
+ if (item.tags)
397
+ attrs['data-tags'] = item.tags;
398
+ if (item.assignee)
399
+ attrs['data-assignee'] = item.assignee;
400
+ if (item.milestone)
401
+ attrs['data-milestone'] = item.milestone;
402
+ if (item.severity)
403
+ attrs['data-severity'] = item.severity;
404
+ if (item.hasUnresolvedBlockers)
405
+ attrs['data-has-blockers'] = 'true';
406
+ const linkChildren = [item.label];
407
+ if (item.hasUnresolvedBlockers) {
408
+ linkChildren.push({
409
+ $$mdtype: 'Tag',
410
+ name: 'span',
411
+ attributes: { class: 'rf-plan-sidebar__blocker-icon', 'aria-label': 'Has unresolved blockers' },
412
+ children: ['\u26A0'],
413
+ });
414
+ }
415
+ itemNodes.push({
416
+ $$mdtype: 'Tag',
417
+ name: 'a',
418
+ attributes: attrs,
419
+ children: linkChildren,
420
+ });
421
+ }
422
+ subGroupChildren.push({
423
+ $$mdtype: 'Tag',
424
+ name: 'div',
425
+ attributes: {
426
+ class: 'rf-plan-sidebar__status-items',
427
+ ...(sg.collapsed ? { hidden: '' } : {}),
428
+ },
429
+ children: itemNodes,
430
+ });
431
+ groupChildren.push({
432
+ $$mdtype: 'Tag',
433
+ name: 'div',
434
+ attributes: {
435
+ class: 'rf-plan-sidebar__status-group',
436
+ 'data-status': sg.status,
437
+ },
438
+ children: subGroupChildren,
439
+ });
440
+ }
441
+ }
442
+ children.push({
443
+ $$mdtype: 'Tag',
444
+ name: 'div',
445
+ attributes: { class: 'rf-plan-sidebar__group', 'data-type': group.type },
446
+ children: groupChildren,
447
+ });
448
+ }
449
+ // Views section
450
+ if (viewDefs && viewDefs.length > 0) {
451
+ const viewChildren = [];
452
+ viewChildren.push({
453
+ $$mdtype: 'Tag',
454
+ name: 'div',
455
+ attributes: { class: 'rf-plan-sidebar__group-title' },
456
+ children: ['Views'],
457
+ });
458
+ // Group view defs by field type
459
+ const VIEW_FIELD_LABELS = { tag: 'By Tag', assignee: 'By Assignee', milestone: 'By Milestone' };
460
+ const byField = new Map();
461
+ for (const v of viewDefs) {
462
+ if (!byField.has(v.field))
463
+ byField.set(v.field, []);
464
+ byField.get(v.field).push(v);
465
+ }
466
+ for (const [field, defs] of byField) {
467
+ const subChildren = [];
468
+ subChildren.push({
469
+ $$mdtype: 'Tag',
470
+ name: 'div',
471
+ attributes: { class: 'rf-plan-sidebar__view-header' },
472
+ children: [VIEW_FIELD_LABELS[field] || field],
473
+ });
474
+ const linkNodes = defs.map(v => ({
475
+ $$mdtype: 'Tag',
476
+ name: 'a',
477
+ attributes: {
478
+ class: `rf-plan-sidebar__link${v.url === activeUrl ? ' rf-plan-sidebar__link--active' : ''}`,
479
+ href: v.url,
480
+ },
481
+ children: [
482
+ {
483
+ $$mdtype: 'Tag',
484
+ name: 'span',
485
+ attributes: { class: 'rf-plan-sidebar__view-label' },
486
+ children: [v.value],
487
+ },
488
+ {
489
+ $$mdtype: 'Tag',
490
+ name: 'span',
491
+ attributes: { class: 'rf-plan-sidebar__view-count' },
492
+ children: [String(v.count)],
493
+ },
494
+ ],
495
+ }));
496
+ subChildren.push({
497
+ $$mdtype: 'Tag',
498
+ name: 'div',
499
+ attributes: { class: 'rf-plan-sidebar__view-items' },
500
+ children: linkNodes,
501
+ });
502
+ viewChildren.push({
503
+ $$mdtype: 'Tag',
504
+ name: 'div',
505
+ attributes: { class: 'rf-plan-sidebar__view-group', 'data-field': field },
506
+ children: subChildren,
507
+ });
508
+ }
509
+ children.push({
510
+ $$mdtype: 'Tag',
511
+ name: 'div',
512
+ attributes: { class: 'rf-plan-sidebar__group', 'data-type': 'views' },
513
+ children: viewChildren,
514
+ });
515
+ }
516
+ return children;
517
+ }
518
+ function slugify(text) {
519
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
520
+ }
521
+ // --- Dashboard generation ---
522
+ function generateDashboardContent(entities) {
523
+ const milestones = entities.filter(e => e.type === 'milestone');
524
+ const activeMilestones = milestones.filter(m => m.attributes.status === 'active');
525
+ const allMilestones = milestones.filter(m => m.attributes.status !== 'complete');
526
+ const hasMultipleMilestones = allMilestones.length > 1;
527
+ let md = '# Plan Dashboard\n\n';
528
+ // Progress summary
529
+ md += `{% plan-progress /%}\n\n`;
530
+ // Active milestone(s)
531
+ for (const ms of activeMilestones) {
532
+ md += `## Active Milestone\n\n`;
533
+ md += `{% milestone name="${ms.attributes.name}" status="${ms.attributes.status}" target="${ms.attributes.target || ''}" %}\n`;
534
+ md += `# ${ms.title || ms.attributes.name}\n`;
535
+ md += `{% /milestone %}\n\n`;
536
+ }
537
+ // Blocked items callout
538
+ const hasBlocked = entities.some(e => (e.type === 'work' || e.type === 'bug') && e.attributes.status === 'blocked');
539
+ if (hasBlocked) {
540
+ md += `## Blocked\n\n`;
541
+ md += `{% backlog filter="status:blocked" sort="priority" /%}\n\n`;
542
+ }
543
+ // Per-milestone grouping or flat layout
544
+ if (hasMultipleMilestones) {
545
+ // Group work items by milestone
546
+ for (const ms of allMilestones) {
547
+ const name = ms.attributes.name;
548
+ md += `## ${ms.title || name}\n\n`;
549
+ md += `{% backlog filter="milestone:${name}" sort="priority" group="status" /%}\n\n`;
550
+ }
551
+ // Unassigned items (no milestone)
552
+ md += `## Unassigned\n\n`;
553
+ md += `{% backlog filter="milestone:" sort="priority" group="status" /%}\n\n`;
554
+ }
555
+ else {
556
+ md += `## In Progress\n\n`;
557
+ md += `{% backlog filter="status:in-progress" sort="priority" /%}\n\n`;
558
+ md += `## Ready for Work\n\n`;
559
+ md += `{% backlog filter="status:ready" sort="priority" /%}\n\n`;
560
+ }
561
+ md += `## Recent Decisions\n\n`;
562
+ md += `{% decision-log sort="date" /%}\n\n`;
563
+ md += `## Recent Activity\n\n`;
564
+ md += `{% plan-activity limit="10" /%}\n`;
565
+ return md;
566
+ }
567
+ function generateStatusFilterPages(entities, baseUrl) {
568
+ const pages = [];
569
+ // Group entities by type, then by status
570
+ const byType = new Map();
571
+ for (const entity of entities) {
572
+ const type = entity.type;
573
+ if (!byType.has(type))
574
+ byType.set(type, new Map());
575
+ const byStatus = byType.get(type);
576
+ const status = entity.attributes.status || 'unknown';
577
+ if (!byStatus.has(status))
578
+ byStatus.set(status, []);
579
+ byStatus.get(status).push(entity);
580
+ }
581
+ // Sort fields by type
582
+ const SORT_FIELDS = {
583
+ work: 'priority', bug: 'priority',
584
+ spec: 'id', decision: 'id', milestone: 'id',
585
+ };
586
+ // Show attribute — work/bug use "all" (to include both), others filter to their own type
587
+ const SHOW_ATTRS = {
588
+ work: ' show="all"', bug: ' show="all"',
589
+ spec: ' show="spec"', decision: ' show="decision"', milestone: ' show="milestone"',
590
+ };
591
+ for (const [type, byStatus] of byType) {
592
+ const statusOrder = STATUS_ORDER_BY_TYPE[type] ?? [];
593
+ const sortedStatuses = [...byStatus.keys()].sort((a, b) => {
594
+ const ai = statusOrder.indexOf(a);
595
+ const bi = statusOrder.indexOf(b);
596
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
597
+ });
598
+ for (const status of sortedStatuses) {
599
+ const items = byStatus.get(status);
600
+ const label = STATUS_LABELS_DISPLAY[status] || status;
601
+ const sort = SORT_FIELDS[type] || 'priority';
602
+ const show = SHOW_ATTRS[type] || '';
603
+ const slug = slugify(status);
604
+ pages.push({
605
+ url: `${baseUrl}${type}/${slug}.html`,
606
+ title: `${label} ${NAV_TITLES[type] || type}`,
607
+ type,
608
+ status,
609
+ count: items.length,
610
+ content: `# ${label}\n\n{% backlog filter="status:${status}" sort="${sort}"${show} /%}\n`,
611
+ });
612
+ }
613
+ }
614
+ return pages;
615
+ }
616
+ function generateViewPages(entities, baseUrl) {
617
+ const views = [];
618
+ // Collect distinct values for tags, assignees, milestones
619
+ const tagCounts = new Map();
620
+ const assigneeCounts = new Map();
621
+ const milestoneCounts = new Map();
622
+ for (const entity of entities) {
623
+ // Tags (comma-separated)
624
+ const tags = entity.attributes.tags;
625
+ if (tags) {
626
+ for (const tag of tags.split(',').map(t => t.trim()).filter(Boolean)) {
627
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
628
+ }
629
+ }
630
+ // Assignee
631
+ const assignee = entity.attributes.assignee;
632
+ if (assignee) {
633
+ assigneeCounts.set(assignee, (assigneeCounts.get(assignee) ?? 0) + 1);
634
+ }
635
+ // Milestone
636
+ if (entity.type === 'work' || entity.type === 'bug') {
637
+ const milestone = entity.attributes.milestone;
638
+ if (milestone) {
639
+ milestoneCounts.set(milestone, (milestoneCounts.get(milestone) ?? 0) + 1);
640
+ }
641
+ }
642
+ }
643
+ // Generate tag view pages (threshold: 3+ distinct tags)
644
+ if (tagCounts.size >= 3) {
645
+ for (const [tag, count] of [...tagCounts.entries()].sort((a, b) => b[1] - a[1])) {
646
+ const slug = slugify(tag);
647
+ views.push({
648
+ url: `${baseUrl}view/tag/${slug}.html`,
649
+ title: `Tag: ${tag}`,
650
+ field: 'tag',
651
+ value: tag,
652
+ count,
653
+ content: `# Tag: ${tag}\n\nAll items tagged **${tag}** (${count}).\n\n{% backlog filter="tags:${tag}" sort="priority" group="status" /%}\n`,
654
+ });
655
+ }
656
+ }
657
+ // Generate assignee view pages (threshold: 2+ distinct assignees)
658
+ if (assigneeCounts.size >= 2) {
659
+ for (const [assignee, count] of [...assigneeCounts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
660
+ const slug = slugify(assignee);
661
+ views.push({
662
+ url: `${baseUrl}view/assignee/${slug}.html`,
663
+ title: `Assignee: ${assignee}`,
664
+ field: 'assignee',
665
+ value: assignee,
666
+ count,
667
+ content: `# Assignee: ${assignee}\n\nAll items assigned to **${assignee}** (${count}).\n\n{% backlog filter="assignee:${assignee}" sort="priority" group="status" /%}\n`,
668
+ });
669
+ }
670
+ }
671
+ // Generate milestone view pages (threshold: 2+ milestones)
672
+ if (milestoneCounts.size >= 2) {
673
+ for (const [milestone, count] of [...milestoneCounts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
674
+ const slug = slugify(milestone);
675
+ views.push({
676
+ url: `${baseUrl}view/milestone/${slug}.html`,
677
+ title: `Milestone: ${milestone}`,
678
+ field: 'milestone',
679
+ value: milestone,
680
+ count,
681
+ content: `# Milestone: ${milestone}\n\nAll items in milestone **${milestone}** (${count}).\n\n{% backlog filter="milestone:${milestone}" sort="priority" group="status" /%}\n`,
682
+ });
683
+ }
684
+ }
685
+ return views;
686
+ }
687
+ const SIDEBAR_BEHAVIOR_SCRIPT = `<script>
688
+ (function() {
689
+ var STORAGE_KEY = 'plan-sidebar-collapse';
690
+
691
+ // --- Collapse toggling ---
692
+ function loadState() {
693
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch(e) { return {}; }
694
+ }
695
+ function saveState(state) {
696
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch(e) {}
697
+ }
698
+
699
+ var state = loadState();
700
+ var headers = document.querySelectorAll('.rf-plan-sidebar__status-header');
701
+
702
+ headers.forEach(function(btn) {
703
+ var group = btn.closest('.rf-plan-sidebar__status-group');
704
+ if (!group) return;
705
+ var type = group.closest('.rf-plan-sidebar__group');
706
+ var typeKey = type ? type.getAttribute('data-type') : '';
707
+ var status = group.getAttribute('data-status');
708
+ var key = typeKey + ':' + status;
709
+ var items = group.querySelector('.rf-plan-sidebar__status-items');
710
+ if (!items) return;
711
+
712
+ // Restore saved state (overrides server default)
713
+ if (key in state) {
714
+ if (state[key]) {
715
+ items.setAttribute('hidden', '');
716
+ btn.setAttribute('aria-expanded', 'false');
717
+ } else {
718
+ items.removeAttribute('hidden');
719
+ btn.setAttribute('aria-expanded', 'true');
720
+ }
721
+ }
722
+
723
+ btn.addEventListener('click', function() {
724
+ var isHidden = items.hasAttribute('hidden');
725
+ if (isHidden) {
726
+ items.removeAttribute('hidden');
727
+ btn.setAttribute('aria-expanded', 'true');
728
+ state[key] = false;
729
+ } else {
730
+ items.setAttribute('hidden', '');
731
+ btn.setAttribute('aria-expanded', 'false');
732
+ state[key] = true;
733
+ }
734
+ saveState(state);
735
+ });
736
+ });
737
+
738
+ // --- Sidebar search/filter ---
739
+ var searchInput = document.querySelector('.rf-plan-sidebar__search');
740
+ if (searchInput) {
741
+ // Focus with /
742
+ document.addEventListener('keydown', function(e) {
743
+ if (e.key === '/' && document.activeElement !== searchInput &&
744
+ !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)) {
745
+ e.preventDefault();
746
+ searchInput.focus();
747
+ }
748
+ });
749
+ // Clear with Escape
750
+ searchInput.addEventListener('keydown', function(e) {
751
+ if (e.key === 'Escape') {
752
+ searchInput.value = '';
753
+ searchInput.dispatchEvent(new Event('input'));
754
+ searchInput.blur();
755
+ }
756
+ });
757
+
758
+ searchInput.addEventListener('input', function() {
759
+ var raw = searchInput.value.trim().toLowerCase();
760
+ var filters = parseFilters(raw);
761
+ var links = document.querySelectorAll('.rf-plan-sidebar__link[data-id]');
762
+
763
+ links.forEach(function(link) {
764
+ var match = matchesFilters(link, filters);
765
+ link.style.display = match ? '' : 'none';
766
+ });
767
+
768
+ // Auto-expand groups with matches, update counts
769
+ document.querySelectorAll('.rf-plan-sidebar__status-group').forEach(function(sg) {
770
+ var items = sg.querySelector('.rf-plan-sidebar__status-items');
771
+ var header = sg.querySelector('.rf-plan-sidebar__status-header');
772
+ if (!items || !header) return;
773
+ var visible = items.querySelectorAll('.rf-plan-sidebar__link[data-id]:not([style*="display: none"])');
774
+ var count = header.querySelector('.rf-plan-sidebar__status-count');
775
+ if (count) count.textContent = String(visible.length);
776
+ if (raw && visible.length > 0) {
777
+ items.removeAttribute('hidden');
778
+ header.setAttribute('aria-expanded', 'true');
779
+ }
780
+ // Hide empty groups entirely when filtering
781
+ sg.style.display = (raw && visible.length === 0) ? 'none' : '';
782
+ });
783
+
784
+ // Hide empty entity groups
785
+ document.querySelectorAll('.rf-plan-sidebar__group').forEach(function(g) {
786
+ var visibleSG = g.querySelectorAll('.rf-plan-sidebar__status-group:not([style*="display: none"])');
787
+ var title = g.querySelector('.rf-plan-sidebar__group-title');
788
+ if (title) title.style.display = (raw && visibleSG.length === 0) ? 'none' : '';
789
+ });
790
+ });
791
+ }
792
+
793
+ function parseFilters(raw) {
794
+ var tokens = raw.split(/\\s+/).filter(Boolean);
795
+ var fields = {};
796
+ var text = [];
797
+ tokens.forEach(function(t) {
798
+ var idx = t.indexOf(':');
799
+ if (idx > 0) {
800
+ var k = t.slice(0, idx);
801
+ var v = t.slice(idx + 1);
802
+ if (!fields[k]) fields[k] = [];
803
+ fields[k].push(v);
804
+ } else {
805
+ text.push(t);
806
+ }
807
+ });
808
+ return { fields: fields, text: text };
809
+ }
810
+
811
+ function matchesFilters(link, filters) {
812
+ // Field filters — AND across fields, OR within same field
813
+ for (var field in filters.fields) {
814
+ var vals = filters.fields[field];
815
+ var attr = field === 'tag' || field === 'tags' ? 'data-tags' : 'data-' + field;
816
+ var data = (link.getAttribute(attr) || '').toLowerCase();
817
+ var match = vals.some(function(v) { return data.indexOf(v) !== -1; });
818
+ if (!match) return false;
819
+ }
820
+ // Text filters — AND, match against id + label + tags
821
+ var haystack = ((link.getAttribute('data-id') || '') + ' ' + link.textContent + ' ' + (link.getAttribute('data-tags') || '')).toLowerCase();
822
+ for (var i = 0; i < filters.text.length; i++) {
823
+ if (haystack.indexOf(filters.text[i]) === -1) return false;
824
+ }
825
+ return true;
826
+ }
827
+
828
+ // --- Keyboard navigation ---
829
+ var focusIndex = -1;
830
+
831
+ function getVisibleLinks() {
832
+ return Array.from(document.querySelectorAll('.rf-plan-sidebar__link')).filter(function(el) {
833
+ return el.offsetParent !== null && el.style.display !== 'none' && !el.closest('[hidden]');
834
+ });
835
+ }
836
+
837
+ function getGroupHeaders() {
838
+ return Array.from(document.querySelectorAll('.rf-plan-sidebar__status-header')).filter(function(el) {
839
+ return el.offsetParent !== null;
840
+ });
841
+ }
842
+
843
+ function setFocus(links, idx) {
844
+ // Clear previous
845
+ var prev = document.querySelector('.rf-plan-sidebar__link--focused');
846
+ if (prev) prev.classList.remove('rf-plan-sidebar__link--focused');
847
+ focusIndex = idx;
848
+ if (idx >= 0 && idx < links.length) {
849
+ links[idx].classList.add('rf-plan-sidebar__link--focused');
850
+ links[idx].scrollIntoView({ block: 'nearest' });
851
+ }
852
+ }
853
+
854
+ document.addEventListener('keydown', function(e) {
855
+ // Don't intercept when typing in inputs (except Escape handled above)
856
+ var tag = document.activeElement.tagName;
857
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
858
+
859
+ var links = getVisibleLinks();
860
+
861
+ if (e.key === 'j') {
862
+ e.preventDefault();
863
+ var next = focusIndex + 1;
864
+ if (next >= links.length) next = 0;
865
+ setFocus(links, next);
866
+ } else if (e.key === 'k') {
867
+ e.preventDefault();
868
+ var prev = focusIndex - 1;
869
+ if (prev < 0) prev = links.length - 1;
870
+ setFocus(links, prev);
871
+ } else if (e.key === 'Enter' && focusIndex >= 0 && focusIndex < links.length) {
872
+ e.preventDefault();
873
+ links[focusIndex].click();
874
+ } else if (e.key === ']') {
875
+ e.preventDefault();
876
+ var headers = getGroupHeaders();
877
+ if (headers.length === 0) return;
878
+ // Find current group
879
+ var currentLink = focusIndex >= 0 && focusIndex < links.length ? links[focusIndex] : null;
880
+ var currentGroup = currentLink ? currentLink.closest('.rf-plan-sidebar__status-group') : null;
881
+ var currentHeader = currentGroup ? currentGroup.querySelector('.rf-plan-sidebar__status-header') : null;
882
+ var hi = currentHeader ? headers.indexOf(currentHeader) : -1;
883
+ var nextHi = hi + 1 < headers.length ? hi + 1 : 0;
884
+ // Find first visible link in that group
885
+ var targetGroup = headers[nextHi].closest('.rf-plan-sidebar__status-group');
886
+ var targetItems = targetGroup ? targetGroup.querySelector('.rf-plan-sidebar__status-items') : null;
887
+ if (targetItems && targetItems.hasAttribute('hidden')) {
888
+ // Expand the group first
889
+ targetItems.removeAttribute('hidden');
890
+ headers[nextHi].setAttribute('aria-expanded', 'true');
891
+ }
892
+ var firstLink = targetGroup ? targetGroup.querySelector('.rf-plan-sidebar__link') : null;
893
+ if (firstLink) {
894
+ var li = links.indexOf(firstLink);
895
+ if (li === -1) { links = getVisibleLinks(); li = links.indexOf(firstLink); }
896
+ setFocus(links, li >= 0 ? li : 0);
897
+ }
898
+ } else if (e.key === '[') {
899
+ e.preventDefault();
900
+ var headers2 = getGroupHeaders();
901
+ if (headers2.length === 0) return;
902
+ var currentLink2 = focusIndex >= 0 && focusIndex < links.length ? links[focusIndex] : null;
903
+ var currentGroup2 = currentLink2 ? currentLink2.closest('.rf-plan-sidebar__status-group') : null;
904
+ var currentHeader2 = currentGroup2 ? currentGroup2.querySelector('.rf-plan-sidebar__status-header') : null;
905
+ var hi2 = currentHeader2 ? headers2.indexOf(currentHeader2) : headers2.length;
906
+ var prevHi = hi2 - 1 >= 0 ? hi2 - 1 : headers2.length - 1;
907
+ var targetGroup2 = headers2[prevHi].closest('.rf-plan-sidebar__status-group');
908
+ var targetItems2 = targetGroup2 ? targetGroup2.querySelector('.rf-plan-sidebar__status-items') : null;
909
+ if (targetItems2 && targetItems2.hasAttribute('hidden')) {
910
+ targetItems2.removeAttribute('hidden');
911
+ headers2[prevHi].setAttribute('aria-expanded', 'true');
912
+ }
913
+ var firstLink2 = targetGroup2 ? targetGroup2.querySelector('.rf-plan-sidebar__link') : null;
914
+ if (firstLink2) {
915
+ var li2 = links.indexOf(firstLink2);
916
+ if (li2 === -1) { links = getVisibleLinks(); li2 = links.indexOf(firstLink2); }
917
+ setFocus(links, li2 >= 0 ? li2 : 0);
918
+ }
919
+ } else if (e.key === 'o') {
920
+ e.preventDefault();
921
+ var currentLink3 = focusIndex >= 0 && focusIndex < links.length ? links[focusIndex] : null;
922
+ var sg = currentLink3 ? currentLink3.closest('.rf-plan-sidebar__status-group') : null;
923
+ if (!sg) return;
924
+ var btn = sg.querySelector('.rf-plan-sidebar__status-header');
925
+ if (btn) btn.click();
926
+ }
927
+ });
928
+ })();
929
+ </script>`;
930
+ // --- HtmlTheme for the plan site ---
931
+ const planTheme = {
932
+ manifest: {
933
+ name: 'plan',
934
+ version: '0.0.0',
935
+ target: 'html',
936
+ designTokens: '',
937
+ layouts: {},
938
+ components: {},
939
+ routeRules: [{ pattern: '**', layout: 'plan' }],
940
+ },
941
+ layouts: {
942
+ plan: planLayout,
943
+ },
944
+ };
945
+ // --- Main pipeline ---
946
+ export async function runPipeline(options) {
947
+ const { dir, specsDir, theme, baseUrl } = options;
948
+ const ctx = {
949
+ info: () => { },
950
+ warn: (msg) => { console.warn(`[plan] warn: ${msg}`); },
951
+ error: (msg) => { console.error(`[plan] error: ${msg}`); },
952
+ };
953
+ // 1. Scan plan files
954
+ const entities = scanPlanFiles(dir);
955
+ let specsEntities = [];
956
+ if (specsDir && specsDir !== dir && fs.existsSync(specsDir)) {
957
+ specsEntities = scanPlanFiles(specsDir);
958
+ }
959
+ const allEntities = [...entities, ...specsEntities];
960
+ // 2. Parse and transform each entity file
961
+ const transformedPages = [];
962
+ const pageMap = new Map();
963
+ for (const entity of allEntities) {
964
+ const filePath = path.resolve(dir, entity.file);
965
+ const content = fs.readFileSync(filePath, 'utf-8');
966
+ const url = `${baseUrl}${entity.type}/${slugify(entity.attributes.id || entity.attributes.name || '')}.html`;
967
+ const { renderable, title, headings } = parseAndTransform(content, entity.file);
968
+ const page = {
969
+ url,
970
+ title: title || entity.title || '',
971
+ headings,
972
+ frontmatter: {},
973
+ renderable,
974
+ };
975
+ transformedPages.push(page);
976
+ pageMap.set(url, { entity, page });
977
+ }
978
+ // 3. Check for user dashboard or generate one
979
+ const indexPath = path.join(dir, 'index.md');
980
+ const hasIndex = fs.existsSync(indexPath);
981
+ let dashboardContent;
982
+ if (hasIndex) {
983
+ dashboardContent = fs.readFileSync(indexPath, 'utf-8');
984
+ }
985
+ else {
986
+ dashboardContent = generateDashboardContent(allEntities);
987
+ }
988
+ const dashboardUrl = `${baseUrl}index.html`;
989
+ const { renderable: dashRenderable, title: dashTitle } = parseAndTransform(dashboardContent, 'index.md');
990
+ const dashboardPage = {
991
+ url: dashboardUrl,
992
+ title: dashTitle || 'Plan Dashboard',
993
+ headings: [],
994
+ frontmatter: {},
995
+ renderable: dashRenderable,
996
+ };
997
+ transformedPages.push(dashboardPage);
998
+ // 3b. Generate status filter pages
999
+ const statusFilterPages = generateStatusFilterPages(allEntities, baseUrl);
1000
+ const statusFilterMap = new Map(statusFilterPages.map(p => [p.url, p]));
1001
+ for (const sfp of statusFilterPages) {
1002
+ const { renderable: sfRenderable, title: sfTitle, headings: sfHeadings } = parseAndTransform(sfp.content, `${sfp.type}/${slugify(sfp.status)}.md`);
1003
+ const sfPage = {
1004
+ url: sfp.url,
1005
+ title: sfTitle || sfp.title,
1006
+ headings: sfHeadings,
1007
+ frontmatter: {},
1008
+ renderable: sfRenderable,
1009
+ };
1010
+ transformedPages.push(sfPage);
1011
+ }
1012
+ // 3c. Generate view pages (by tag, assignee, milestone)
1013
+ const viewDefs = generateViewPages(allEntities, baseUrl);
1014
+ const viewDefMap = new Map(viewDefs.map(v => [v.url, v]));
1015
+ for (const viewDef of viewDefs) {
1016
+ const { renderable: viewRenderable, title: viewTitle, headings: viewHeadings } = parseAndTransform(viewDef.content, `view/${viewDef.field}/${slugify(viewDef.value)}.md`);
1017
+ const viewPage = {
1018
+ url: viewDef.url,
1019
+ title: viewTitle || viewDef.title,
1020
+ headings: viewHeadings,
1021
+ frontmatter: {},
1022
+ renderable: viewRenderable,
1023
+ };
1024
+ transformedPages.push(viewPage);
1025
+ }
1026
+ // 4. Run pipeline hooks: register → aggregate → postProcess
1027
+ const { registry } = createRegistry();
1028
+ planPipelineHooks.register(transformedPages, registry, ctx);
1029
+ const aggregated = {};
1030
+ const planAggregated = planPipelineHooks.aggregate(registry, ctx);
1031
+ // Inject mtime into entity data for activity tracking
1032
+ const mtimeMap = new Map();
1033
+ for (const entity of allEntities) {
1034
+ const entityId = entity.attributes.id || entity.attributes.name;
1035
+ if (entityId && entity.mtime) {
1036
+ mtimeMap.set(entityId, entity.mtime);
1037
+ }
1038
+ }
1039
+ for (const key of ['workEntities', 'bugEntities', 'decisionEntities', 'specEntities']) {
1040
+ const entities = planAggregated[key];
1041
+ if (!entities)
1042
+ continue;
1043
+ for (const e of entities) {
1044
+ const mtime = mtimeMap.get(e.id);
1045
+ if (mtime)
1046
+ e.data.mtime = mtime;
1047
+ }
1048
+ }
1049
+ aggregated['plan'] = planAggregated;
1050
+ const processedPages = transformedPages.map(p => planPipelineHooks.postProcess ? planPipelineHooks.postProcess(p, aggregated, ctx) : p);
1051
+ // 5. Identity transform + syntax highlighting
1052
+ const themeConfig = buildThemeConfig();
1053
+ const identityTransform = createTransform(themeConfig);
1054
+ const themeCss = resolveThemeCss(theme);
1055
+ const nav = buildNavigation(allEntities, baseUrl);
1056
+ // Mark nav items with unresolved blockers
1057
+ const typedPlanData = planAggregated;
1058
+ const planRels = typedPlanData.relationships;
1059
+ if (planRels) {
1060
+ const RESOLVED_STATUSES = new Set(['done', 'fixed', 'accepted', 'complete', 'wontfix', 'duplicate', 'superseded', 'deprecated']);
1061
+ for (const group of nav) {
1062
+ for (const sg of group.statusGroups) {
1063
+ for (const item of sg.items) {
1064
+ const rels = planRels.get(item.id);
1065
+ if (!rels)
1066
+ continue;
1067
+ const blockedBy = rels.filter(r => r.kind === 'blocked-by');
1068
+ if (blockedBy.length === 0)
1069
+ continue;
1070
+ // Check if any blocker is unresolved
1071
+ const hasUnresolved = blockedBy.some(r => {
1072
+ const allArrays = [
1073
+ typedPlanData.workEntities,
1074
+ typedPlanData.bugEntities,
1075
+ typedPlanData.decisionEntities,
1076
+ typedPlanData.specEntities,
1077
+ ];
1078
+ for (const arr of allArrays) {
1079
+ const target = arr.find(e => e.id === r.toId);
1080
+ if (target)
1081
+ return !RESOLVED_STATUSES.has(String(target.data.status ?? ''));
1082
+ }
1083
+ return true; // Unknown target — treat as unresolved
1084
+ });
1085
+ if (hasUnresolved)
1086
+ item.hasUnresolvedBlockers = true;
1087
+ }
1088
+ }
1089
+ }
1090
+ }
1091
+ // Initialize syntax highlighting
1092
+ let highlightTransform = null;
1093
+ let highlightCss = '';
1094
+ try {
1095
+ highlightTransform = await createHighlightTransform();
1096
+ highlightCss = highlightTransform.css;
1097
+ }
1098
+ catch {
1099
+ // Highlight not available — render without syntax highlighting
1100
+ }
1101
+ const pages = [];
1102
+ let dashboardProcessed;
1103
+ for (const page of processedPages) {
1104
+ const serialized = serializeTree(page.renderable);
1105
+ let transformed = identityTransform(serialized);
1106
+ // Apply syntax highlighting
1107
+ if (highlightTransform) {
1108
+ transformed = highlightTransform(transformed);
1109
+ }
1110
+ const mapEntry = pageMap.get(page.url);
1111
+ const viewDef = viewDefMap.get(page.url);
1112
+ const statusFilterDef = statusFilterMap.get(page.url);
1113
+ const processed = {
1114
+ url: page.url,
1115
+ title: page.title,
1116
+ type: mapEntry?.entity.type ?? (statusFilterDef ? statusFilterDef.type : viewDef ? 'view' : 'dashboard'),
1117
+ entityId: mapEntry?.entity.attributes.id || mapEntry?.entity.attributes.name || '',
1118
+ status: mapEntry?.entity.attributes.status || (statusFilterDef?.status ?? ''),
1119
+ renderable: transformed,
1120
+ filePath: mapEntry?.entity.file ?? (statusFilterDef ? `${statusFilterDef.type}/${slugify(statusFilterDef.status)}.md` : viewDef ? `view/${viewDef.field}/${slugify(viewDef.value)}.md` : 'index.md'),
1121
+ headings: page.headings ?? [],
1122
+ };
1123
+ if (page.url === dashboardUrl) {
1124
+ dashboardProcessed = processed;
1125
+ }
1126
+ else {
1127
+ pages.push(processed);
1128
+ }
1129
+ }
1130
+ return {
1131
+ pages,
1132
+ dashboard: dashboardProcessed,
1133
+ nav,
1134
+ navRegion: buildNavRegion(nav, baseUrl, undefined, viewDefs, statusFilterPages),
1135
+ themeCss,
1136
+ highlightCss,
1137
+ };
1138
+ }
1139
+ /**
1140
+ * Render a processed page to a full HTML document using the HTML adapter.
1141
+ */
1142
+ export function renderPage(page, navRegion, allPageUrls, opts) {
1143
+ const layoutPageData = {
1144
+ renderable: page.renderable,
1145
+ regions: {
1146
+ nav: {
1147
+ name: 'nav',
1148
+ mode: 'replace',
1149
+ content: navRegion,
1150
+ },
1151
+ },
1152
+ title: page.title,
1153
+ url: page.url,
1154
+ pages: allPageUrls,
1155
+ frontmatter: {},
1156
+ headings: page.headings,
1157
+ };
1158
+ const shellOptions = {
1159
+ stylesheets: opts?.stylesheets ?? [],
1160
+ scripts: opts?.scripts ?? [],
1161
+ bodyExtra: SIDEBAR_BEHAVIOR_SCRIPT,
1162
+ };
1163
+ // Hot reload SSE script
1164
+ if (opts?.hotReload) {
1165
+ const hotReloadScript = `<script>(function(){var es=new EventSource('/__plan-reload');es.onmessage=function(e){if(e.data==='reload')location.reload();};window.addEventListener('beforeunload',function(){es.close();});})()</script>`;
1166
+ shellOptions.headExtra = hotReloadScript;
1167
+ }
1168
+ return htmlRenderFullPage({ theme: planTheme, page: layoutPageData }, shellOptions);
1169
+ }
1170
+ export function getThemeCss(theme) {
1171
+ return resolveThemeCss(theme);
1172
+ }
1173
+ //# sourceMappingURL=render-pipeline.js.map