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