@refrakt-md/plan 0.14.4 → 0.16.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 (48) hide show
  1. package/dist/cli-plugin.js +1 -1
  2. package/dist/cli-plugin.js.map +1 -1
  3. package/dist/commands/build.d.ts.map +1 -1
  4. package/dist/commands/build.js +5 -0
  5. package/dist/commands/build.js.map +1 -1
  6. package/dist/commands/plan-behaviors.js +5 -4
  7. package/dist/commands/plan-behaviors.js.map +1 -1
  8. package/dist/commands/render-pipeline.d.ts.map +1 -1
  9. package/dist/commands/render-pipeline.js +40 -9
  10. package/dist/commands/render-pipeline.js.map +1 -1
  11. package/dist/commands/serve.d.ts.map +1 -1
  12. package/dist/commands/serve.js +5 -0
  13. package/dist/commands/serve.js.map +1 -1
  14. package/dist/filter.d.ts +6 -11
  15. package/dist/filter.d.ts.map +1 -1
  16. package/dist/filter.js +7 -65
  17. package/dist/filter.js.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +14 -7
  20. package/dist/index.js.map +1 -1
  21. package/dist/pipeline.d.ts +10 -1
  22. package/dist/pipeline.d.ts.map +1 -1
  23. package/dist/pipeline.js +279 -441
  24. package/dist/pipeline.js.map +1 -1
  25. package/dist/scanner-core.d.ts.map +1 -1
  26. package/dist/scanner-core.js +10 -2
  27. package/dist/scanner-core.js.map +1 -1
  28. package/dist/tags/backlog.d.ts +0 -1
  29. package/dist/tags/backlog.d.ts.map +1 -1
  30. package/dist/tags/backlog.js +62 -28
  31. package/dist/tags/backlog.js.map +1 -1
  32. package/dist/tags/decision-log.d.ts +0 -1
  33. package/dist/tags/decision-log.d.ts.map +1 -1
  34. package/dist/tags/decision-log.js +54 -22
  35. package/dist/tags/decision-log.js.map +1 -1
  36. package/dist/tags/plan-activity.d.ts +0 -1
  37. package/dist/tags/plan-activity.d.ts.map +1 -1
  38. package/dist/tags/plan-activity.js +49 -18
  39. package/dist/tags/plan-activity.js.map +1 -1
  40. package/package.json +8 -8
  41. package/dist/cards.d.ts +0 -23
  42. package/dist/cards.d.ts.map +0 -1
  43. package/dist/cards.js +0 -150
  44. package/dist/cards.js.map +0 -1
  45. package/dist/entity-tabs-behavior.d.ts +0 -13
  46. package/dist/entity-tabs-behavior.d.ts.map +0 -1
  47. package/dist/entity-tabs-behavior.js +0 -94
  48. package/dist/entity-tabs-behavior.js.map +0 -1
package/dist/pipeline.js CHANGED
@@ -1,14 +1,12 @@
1
1
  import Markdoc from '@markdoc/markdoc';
2
- import { BACKLOG_SENTINEL } from './tags/backlog.js';
3
- import { DECISION_LOG_SENTINEL } from './tags/decision-log.js';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
4
  import { PLAN_PROGRESS_SENTINEL } from './tags/plan-progress.js';
5
- import { PLAN_ACTIVITY_SENTINEL } from './tags/plan-activity.js';
6
5
  import { PLAN_HISTORY_SENTINEL } from './tags/plan-history.js';
7
- import { parseFilter, matchesFilter, sortEntities, groupEntities } from './filter.js';
8
6
  import { execSync } from 'node:child_process';
9
7
  import { extractBatchHistory, readHistoryCache, writeHistoryCache, } from './history.js';
10
8
  import { buildRelationships } from './relationships.js';
11
- import { buildEntityCard, buildDecisionEntry } from './cards.js';
9
+ import { parseFileContent } from './scanner-core.js';
12
10
  const { Tag } = Markdoc;
13
11
  const PLAN_RUNE_TYPES = new Set(['spec', 'work', 'bug', 'decision', 'milestone']);
14
12
  /** Fields to extract from each rune type's property meta tags */
@@ -133,13 +131,25 @@ export function setScannerDependencies(deps) {
133
131
  }
134
132
  /**
135
133
  * Module-level store for the plan directory path.
136
- * Set by render-pipeline.ts before aggregate() runs.
134
+ * Set by render-pipeline.ts before aggregate() runs (CLI path) or by the
135
+ * `configure` pipeline hook when refrakt's content loader runs the plan
136
+ * plugin (build path). See {@link planPipelineHooks.configure}.
137
137
  */
138
138
  let _planDir;
139
+ /** Project root — used to compute project-root-relative `sourceFile` paths
140
+ * for plan entities registered via the unconditional scan. Set by
141
+ * {@link planPipelineHooks.configure} when running through refract-loader. */
142
+ let _projectRoot;
139
143
  /** Set the plan directory path for the pipeline's aggregate() hook to consume */
140
144
  export function setPlanDir(dir) {
141
145
  _planDir = dir;
142
146
  }
147
+ /** Set the project root used for `sourceFile` path computation. Mirrors
148
+ * {@link setPlanDir}; the configure hook sets both, CLI paths can set
149
+ * whichever they need. */
150
+ export function setProjectRoot(root) {
151
+ _projectRoot = root;
152
+ }
143
153
  /** Parse a comma-separated `source` attribute into typed ID references */
144
154
  function parseSourceIds(source) {
145
155
  if (!source)
@@ -170,10 +180,213 @@ const _idReferences = new Map();
170
180
  * These produce 'implements' / 'implemented-by' relationships.
171
181
  */
172
182
  const _sourceReferences = new Map();
183
+ /** Map of plan rune type to canonical entity-type string. */
184
+ const PLAN_RUNE_TYPE_BY_DIR = {
185
+ specs: 'spec',
186
+ work: 'work',
187
+ bug: 'bug',
188
+ bugs: 'bug',
189
+ decisions: 'decision',
190
+ milestones: 'milestone',
191
+ };
192
+ /** Convert host-OS file path to POSIX form (forward slashes). */
193
+ function posixPath(p) {
194
+ return path.sep === '/' ? p : p.split(path.sep).join('/');
195
+ }
196
+ /** Walk a directory recursively, calling `cb` for every regular file. */
197
+ function walkFiles(dir, cb) {
198
+ let entries;
199
+ try {
200
+ entries = fs.readdirSync(dir, { withFileTypes: true });
201
+ }
202
+ catch {
203
+ return;
204
+ }
205
+ for (const entry of entries) {
206
+ const full = path.join(dir, entry.name);
207
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
208
+ walkFiles(full, cb);
209
+ }
210
+ else if (entry.isFile()) {
211
+ cb(full);
212
+ }
213
+ }
214
+ }
215
+ /** Scan `plan.dir` for `.md` files containing top-level plan runes and
216
+ * register each one into the entity registry. Files that already correspond
217
+ * to entities registered via the site-load path (matched by `sourceFile`)
218
+ * are skipped — the site-load registration has a real `sourceUrl` and wins.
219
+ *
220
+ * Files in `plan.dir` that contain no parseable top-level plan rune are
221
+ * skipped silently (debug-level — they're usually READMEs or other auxiliary
222
+ * content). Files whose filename doesn't match the auto-ID convention are
223
+ * STILL registered if the rune body has a valid `id=` attribute — the rune
224
+ * is the source of truth for entity identity; the filename is a hint. */
225
+ function performUnconditionalScan(planDir, projectRoot, registry, ctx) {
226
+ let absPlanDir;
227
+ try {
228
+ absPlanDir = path.resolve(planDir);
229
+ const stat = fs.statSync(absPlanDir);
230
+ if (!stat.isDirectory())
231
+ return; // silent no-op
232
+ }
233
+ catch {
234
+ // Plan directory doesn't exist — silent no-op (matches SPEC-064:
235
+ // "Projects without a plan/ directory shouldn't see any errors").
236
+ return;
237
+ }
238
+ walkFiles(absPlanDir, (absPath) => {
239
+ if (!absPath.endsWith('.md'))
240
+ return;
241
+ // Project-root-relative POSIX path for sourceFile + dedup messaging.
242
+ const sourceFile = projectRoot
243
+ ? posixPath(path.relative(projectRoot, absPath))
244
+ : posixPath(path.relative(absPlanDir, absPath));
245
+ let raw;
246
+ try {
247
+ raw = fs.readFileSync(absPath, 'utf-8');
248
+ }
249
+ catch (err) {
250
+ ctx.warn(`Plan scan: failed to read ${sourceFile}: ${err.message}`);
251
+ return;
252
+ }
253
+ // Use the same scanner-core parser that the CLI uses — single source
254
+ // of truth for what counts as a parseable plan entity.
255
+ const entity = parseFileContent(raw, sourceFile);
256
+ if (!entity) {
257
+ // File in plan.dir with no parseable top-level plan rune. README,
258
+ // notes, accidental file — skip without error. (Per SPEC-064.)
259
+ return;
260
+ }
261
+ const runeType = entity.type;
262
+ const id = runeType === 'milestone'
263
+ ? (entity.attributes.name ?? '')
264
+ : (entity.attributes.id ?? '');
265
+ if (!id) {
266
+ ctx.warn(`Plan scan: ${sourceFile} has a ${runeType} rune missing ${runeType === 'milestone' ? 'name' : 'id'} attribute`);
267
+ return;
268
+ }
269
+ // Populate the relationship maps so `buildRelationships` produces edges
270
+ // for entities reached via the scan (the standard-load case for sites
271
+ // whose plan/ tree is outside the content dir — e.g. the plan-site
272
+ // dogfood). This runs **before** the early-return registry checks so
273
+ // the cross-page pipeline's "second register pass" (which fires after
274
+ // contributePages and re-clears the maps in `register`) still gets the
275
+ // edges repopulated even when the registry entries already exist.
276
+ const refs = entity.refs
277
+ .map((refId) => {
278
+ const prefix = refId.match(/^([A-Z]+)-/)?.[1];
279
+ const type = prefix ? ID_PREFIX_TO_TYPE[prefix] : undefined;
280
+ return type && refId !== id ? { id: refId, type } : null;
281
+ })
282
+ .filter((r) => r !== null);
283
+ if (refs.length > 0)
284
+ _idReferences.set(id, refs);
285
+ const sourceRefs = parseSourceIds(String(entity.attributes.source ?? '')).filter((r) => r.id !== id);
286
+ if (sourceRefs.length > 0)
287
+ _sourceReferences.set(id, sourceRefs);
288
+ const depRefs = (entity.scopedRefs ?? [])
289
+ .filter((r) => r.section === 'Dependencies')
290
+ .map((r) => r.id)
291
+ .filter((depId) => depId !== id);
292
+ if (depRefs.length > 0)
293
+ _scannerDependencies.set(id, depRefs);
294
+ // Site-load registration wins if both paths produced the same entity.
295
+ const existing = registry.getById(runeType, id);
296
+ if (existing && existing.sourceUrl) {
297
+ // Already registered with a real URL — site-load path beat us.
298
+ return;
299
+ }
300
+ if (existing && existing.sourceFile === sourceFile) {
301
+ // Already registered via the unconditional scan (idempotency). Skip
302
+ // the re-register; the maps above still get repopulated for the
303
+ // pipeline's second register pass.
304
+ return;
305
+ }
306
+ if (existing && existing.sourceFile && existing.sourceFile !== sourceFile) {
307
+ // Duplicate ID across two different plan files — surface clearly.
308
+ ctx.error(`Plan scan: duplicate ${runeType} id "${id}" found in both "${existing.sourceFile}" and "${sourceFile}"`);
309
+ return;
310
+ }
311
+ // Build the data payload from rune attributes + title.
312
+ const data = { title: entity.title };
313
+ for (const [key, value] of Object.entries(entity.attributes)) {
314
+ data[key] = value;
315
+ }
316
+ // Backfill `data.modified` from file mtime when the rune doesn't set
317
+ // it explicitly. Plan-activity's default template renders this column
318
+ // and would otherwise show blank for every entity that hasn't been
319
+ // hand-stamped — the bespoke `plan build` CLI already does the same
320
+ // (via mtimeMap in commands/render-pipeline.ts), so this brings the
321
+ // standard refrakt pipeline to parity. Falls back silently if stat
322
+ // fails (e.g. file vanished mid-scan).
323
+ if (!data.modified) {
324
+ try {
325
+ const mtimeMs = fs.statSync(absPath).mtimeMs;
326
+ data.modified = new Date(mtimeMs).toISOString().slice(0, 10);
327
+ }
328
+ catch { /* ignore */ }
329
+ }
330
+ // Count acceptance-criteria checkboxes for work + bug items so the
331
+ // milestone progress rollup (aggregate, see below) sees them. The
332
+ // page-walk register loop counts via `countCheckboxes(tag)` on the
333
+ // rendered AST; the unconditional-scan path counts from the
334
+ // `entity.criteria` array `parseFileContent` already produced. Same
335
+ // shape on data — `checkedCount` / `totalCount` — so downstream
336
+ // readers (milestone aggregate, the rune-injected backlog while it
337
+ // still exists) don't care which path populated them.
338
+ if ((runeType === 'work' || runeType === 'bug') && entity.criteria.length > 0) {
339
+ data.checkedCount = entity.criteria.filter((c) => c.checked).length;
340
+ data.totalCount = entity.criteria.length;
341
+ }
342
+ // Closure-captured extractor — returns the top-level plan rune AST
343
+ // node from a re-parsed source file, or null if the file's structure
344
+ // has been edited away from the expected shape. Used by expand
345
+ // (SPEC-066) for inline embedding.
346
+ const extract = (parsedSource) => {
347
+ for (const child of parsedSource.children) {
348
+ if (child.type === 'tag' && child.tag === runeType) {
349
+ const childId = runeType === 'milestone'
350
+ ? String(child.attributes.name ?? '')
351
+ : String(child.attributes.id ?? '');
352
+ if (childId === id)
353
+ return child;
354
+ }
355
+ }
356
+ return null;
357
+ };
358
+ registry.register({
359
+ type: runeType,
360
+ id,
361
+ sourceFile,
362
+ extract,
363
+ data,
364
+ });
365
+ });
366
+ }
173
367
  export const planPipelineHooks = {
368
+ async configure(opts) {
369
+ const config = opts.config;
370
+ const planDirRelative = config?.plan?.dir;
371
+ _projectRoot = opts.configDir;
372
+ if (planDirRelative) {
373
+ const absPlanDir = path.resolve(opts.configDir, planDirRelative);
374
+ _planDir = absPlanDir;
375
+ // Register `plan:` as a file-root namespace pointing at the user's
376
+ // actual plan directory. Partials (and snippet's v2) can then
377
+ // reference plan content as `{% partial file="plan:SPEC-001-foo.md" /%}`.
378
+ // User config `fileRoots.plan` still wins via mergeFileRoots if
379
+ // they want to point the namespace somewhere else.
380
+ opts.registerFileRoot?.('plan', absPlanDir);
381
+ }
382
+ },
174
383
  register(pages, registry, ctx) {
175
384
  _idReferences.clear();
176
385
  _sourceReferences.clear();
386
+ // `_scannerDependencies` is intentionally NOT cleared here — the bespoke
387
+ // `plan build` path seeds it via `setScannerDependencies()` *before*
388
+ // calling `register()` (see render-pipeline.ts). The standard-load path
389
+ // populates it via `performUnconditionalScan` below.
177
390
  for (const page of pages) {
178
391
  walkTags(page.renderable, (tag) => {
179
392
  const runeType = tag.attributes['data-rune'];
@@ -222,6 +435,41 @@ export const planPipelineHooks = {
222
435
  });
223
436
  });
224
437
  }
438
+ // Unconditional scan of plan.dir — registers every plan entity in the
439
+ // configured plan directory, regardless of whether it was published
440
+ // to the site's content tree. Site-load registrations (above) win
441
+ // any duplicate via the registry.getById check inside the scan;
442
+ // unconditional-scan registrations include sourceFile + extract so
443
+ // expand (SPEC-066) can substitute their content inline. Silent
444
+ // no-op when plan.dir isn't configured or doesn't exist.
445
+ if (_planDir) {
446
+ performUnconditionalScan(_planDir, _projectRoot, registry, ctx);
447
+ }
448
+ // SPEC-072 / WORK-281 — roll the criteria-checkbox progress per milestone
449
+ // onto the milestone entity's `data`, so the milestone render-template
450
+ // can drive a generic `{% progress value=... max=... /%}` rune instead
451
+ // of relying on the rune-injected backlog's bespoke progress bar. Runs
452
+ // at the end of `register` (rather than in `aggregate`) so the values
453
+ // land on the entity before `entityRoutes` contributePages snapshots
454
+ // `$item` into the contributed page's variables — the progress rune is
455
+ // identity-transform-only, so its attributes resolve at transform time,
456
+ // not postProcess. Sums `checkedCount` / `totalCount` populated above.
457
+ const workAndBug = [...registry.getAll('work'), ...registry.getAll('bug')];
458
+ for (const milestone of registry.getAll('milestone')) {
459
+ const members = workAndBug.filter((e) => String(e.data.milestone ?? '') === milestone.id);
460
+ let done = 0;
461
+ let total = 0;
462
+ for (const m of members) {
463
+ const c = Number(m.data.checkedCount ?? 0);
464
+ const t = Number(m.data.totalCount ?? 0);
465
+ if (t > 0) {
466
+ done += c;
467
+ total += t;
468
+ }
469
+ }
470
+ milestone.data.progressDone = done;
471
+ milestone.data.progressTotal = total;
472
+ }
225
473
  },
226
474
  aggregate(registry, ctx) {
227
475
  // Build a lookup of all registered entities for relationship building
@@ -233,6 +481,17 @@ export const planPipelineHooks = {
233
481
  }
234
482
  // Build bidirectional relationship index using extracted module
235
483
  const relationships = buildRelationships(allEntities, _sourceReferences, _scannerDependencies, _idReferences);
484
+ // SPEC-072 / WORK-279 — contribute the (already bidirectional) plan edges
485
+ // to the core registry graph so the generic `relationships` rune can query
486
+ // them via getRelated(). The local `relationships` map still drives the
487
+ // plugin's own relationship sections until the ADR-011 consumer work lands.
488
+ if (registry.relate) {
489
+ for (const edges of relationships.values()) {
490
+ for (const e of edges) {
491
+ registry.relate({ fromId: e.fromId, toId: e.toId, kind: e.kind, fromType: e.fromType, toType: e.toType });
492
+ }
493
+ }
494
+ }
236
495
  // Extract git history for all entities
237
496
  let history = new Map();
238
497
  let repositoryUrl;
@@ -281,79 +540,30 @@ export const planPipelineHooks = {
281
540
  return page;
282
541
  let modified = false;
283
542
  const newRenderable = mapTags(page.renderable, (tag) => {
284
- // Handle backlog sentinel
285
- if (tag.attributes['data-rune'] === 'backlog' && hasSentinel(tag, BACKLOG_SENTINEL)) {
286
- modified = true;
287
- return resolveBacklog(tag, planData);
288
- }
289
- // Handle decision-log sentinel
290
- if (tag.attributes['data-rune'] === 'decision-log' && hasSentinel(tag, DECISION_LOG_SENTINEL)) {
291
- modified = true;
292
- return resolveDecisionLog(tag, planData);
293
- }
543
+ // `backlog`, `decision-log`, and `plan-activity` are now thin sugar
544
+ // over `collection` (SPEC-072 / WORK-284): their schemas emit a
545
+ // `data-rune="collection"` renderable with COLLECTION_SENTINEL, so
546
+ // they're picked up by the core `resolveCollections` postProcess —
547
+ // no plan-side branch needed.
294
548
  // Handle plan-progress sentinel
295
549
  if (tag.attributes['data-rune'] === 'plan-progress' && hasSentinel(tag, PLAN_PROGRESS_SENTINEL)) {
296
550
  modified = true;
297
551
  return resolvePlanProgress(tag, planData);
298
552
  }
299
- // Handle plan-activity sentinel
300
- if (tag.attributes['data-rune'] === 'plan-activity' && hasSentinel(tag, PLAN_ACTIVITY_SENTINEL)) {
301
- modified = true;
302
- return resolvePlanActivity(tag, planData);
303
- }
304
553
  // Handle plan-history sentinel
305
554
  if (tag.attributes['data-rune'] === 'plan-history' && hasSentinel(tag, PLAN_HISTORY_SENTINEL)) {
306
555
  modified = true;
307
556
  return resolvePlanHistory(tag, planData);
308
557
  }
309
- // Inject auto-backlog into milestone rune tags
310
- if (tag.attributes['data-rune'] === 'milestone') {
311
- const milestoneName = readField(tag, 'name');
312
- if (milestoneName) {
313
- const backlog = buildMilestoneBacklog(milestoneName, planData);
314
- if (backlog) {
315
- modified = true;
316
- tag = new Tag(tag.name, tag.attributes, [...tag.children, backlog]);
317
- }
318
- }
319
- }
320
- // Wrap entity content in tab-group with Overview / Relationships / History panels
321
- if (PLAN_RUNE_TYPES.has(tag.attributes['data-rune'])) {
322
- const runeType = tag.attributes['data-rune'];
323
- const entityId = runeType === 'milestone'
324
- ? readField(tag, 'name')
325
- : readField(tag, 'id');
326
- if (entityId) {
327
- const rels = planData.relationships.get(entityId);
328
- const relationshipsSection = (rels && rels.length > 0)
329
- ? buildRelationshipsSection(rels, planData)
330
- : null;
331
- const historySection = buildAutoHistorySection(entityId, planData);
332
- // Only add tabs if there is content for at least one extra panel
333
- if (relationshipsSection || historySection) {
334
- modified = true;
335
- // Partition children: structural (meta fields, title, blurb) stay at top;
336
- // body content goes into the Overview tab panel.
337
- // Note: postProcess runs BEFORE the identity transform, so children
338
- // have data-name/data-field from createComponentRenderable but no
339
- // data-section or BEM classes yet.
340
- const PREAMBLE_NAMES = new Set(['title', 'blurb']);
341
- const structural = [];
342
- const bodyContent = [];
343
- for (const child of tag.children) {
344
- if (Markdoc.Tag.isTag(child) && (child.attributes['data-field'] != null ||
345
- PREAMBLE_NAMES.has(child.attributes['data-name']))) {
346
- structural.push(child);
347
- }
348
- else {
349
- bodyContent.push(child);
350
- }
351
- }
352
- const tabWrapper = buildEntityTabGroup(bodyContent, relationshipsSection, historySection);
353
- return new Tag(tag.name, tag.attributes, [...structural, tabWrapper]);
354
- }
355
- }
356
- }
558
+ // SPEC-072 / WORK-282 the plan plugin no longer injects the
559
+ // milestone auto-backlog or the Overview/Relationships/History
560
+ // tab wrapper. Plan-site detail pages compose those panels at
561
+ // the `entityRoutes` render-template level (WORK-280 / WORK-281)
562
+ // using the generic `relationships` + `plan-history` + `collection`
563
+ // + `progress` runes. The plugin's postProcess now only resolves
564
+ // its own rune sentinels (backlog / decision-log / plan-progress /
565
+ // plan-activity / plan-history) no more renderable mutation
566
+ // targeting other runes.
357
567
  return tag;
358
568
  });
359
569
  if (!modified)
@@ -361,151 +571,6 @@ export const planPipelineHooks = {
361
571
  return { ...page, renderable: newRenderable };
362
572
  },
363
573
  };
364
- function resolveBacklog(tag, data) {
365
- const filterExpr = readField(tag, 'filter');
366
- const sortField = readField(tag, 'sort') || 'priority';
367
- const groupField = readField(tag, 'group');
368
- const show = readField(tag, 'show') || 'all';
369
- // Collect entities by type
370
- // "all" defaults to work+bug for backward compatibility; other types must be explicit
371
- let entities = [];
372
- if (show === 'all' || show === 'work')
373
- entities.push(...data.workEntities);
374
- if (show === 'all' || show === 'bug')
375
- entities.push(...data.bugEntities);
376
- if (show === 'spec')
377
- entities.push(...data.specEntities);
378
- if (show === 'decision')
379
- entities.push(...data.decisionEntities);
380
- if (show === 'milestone')
381
- entities.push(...data.milestoneEntities);
382
- // Apply filter
383
- const filter = parseFilter(filterExpr);
384
- entities = entities.filter(e => matchesFilter(e, filter));
385
- // Sort
386
- entities = sortEntities(entities, sortField);
387
- // Build output
388
- let children;
389
- if (groupField) {
390
- const groups = groupEntities(entities, groupField);
391
- children = [];
392
- for (const [groupName, groupEntities_] of groups) {
393
- const groupTitle = new Tag('h3', { class: 'rf-backlog__group-title' }, [groupName]);
394
- const cards = groupEntities_.map(e => buildEntityCard(e));
395
- const groupDiv = new Tag('div', { class: 'rf-backlog__group', 'data-group': groupName }, [groupTitle, ...cards]);
396
- children.push(groupDiv);
397
- }
398
- }
399
- else {
400
- children = entities.map(e => buildEntityCard(e));
401
- }
402
- const itemsDiv = new Tag('div', { 'data-name': 'items' }, children);
403
- // Rebuild tag, replacing the sentinel and placeholder with resolved content
404
- const newChildren = tag.children.filter((c) => !(Markdoc.Tag.isTag(c) && (c.attributes['data-field'] === BACKLOG_SENTINEL ||
405
- c.attributes['data-name'] === 'items')));
406
- newChildren.push(itemsDiv);
407
- return new Tag(tag.name, tag.attributes, newChildren);
408
- }
409
- /** Build auto-backlog section for a milestone, showing assigned work/bug items grouped by status */
410
- function buildMilestoneBacklog(milestoneName, data) {
411
- // Collect work and bug entities assigned to this milestone
412
- let entities = [
413
- ...data.workEntities,
414
- ...data.bugEntities,
415
- ].filter(e => String(e.data.milestone ?? '') === milestoneName);
416
- if (entities.length === 0)
417
- return null;
418
- // Sort by priority within each group
419
- entities = sortEntities(entities, 'priority');
420
- // Group by status
421
- const groups = groupEntities(entities, 'status');
422
- // Calculate aggregate progress from checklist counts
423
- let totalChecked = 0;
424
- let totalCheckboxes = 0;
425
- for (const e of entities) {
426
- const checked = Number(e.data.checkedCount ?? 0);
427
- const total = Number(e.data.totalCount ?? 0);
428
- if (total > 0) {
429
- totalChecked += checked;
430
- totalCheckboxes += total;
431
- }
432
- }
433
- const children = [];
434
- // Add aggregate progress if any items have checklists
435
- if (totalCheckboxes > 0) {
436
- const fraction = `${totalChecked}/${totalCheckboxes}`;
437
- const pct = Math.round((totalChecked / totalCheckboxes) * 100);
438
- children.push(new Tag('div', {
439
- class: 'rf-milestone__progress',
440
- 'data-progress-checked': String(totalChecked),
441
- 'data-progress-total': String(totalCheckboxes),
442
- 'data-percent': String(pct),
443
- }, [
444
- new Tag('div', { class: 'rf-milestone__progress-header' }, [
445
- new Tag('span', { class: 'rf-milestone__progress-label' }, ['Progress']),
446
- new Tag('span', { class: 'rf-milestone__progress-count' }, [`${fraction} criteria`]),
447
- ]),
448
- new Tag('span', { class: 'rf-milestone__progress-bar', style: `--rf-progress: ${pct}%` }, []),
449
- ]));
450
- }
451
- // Build status-grouped cards as tabs (or flat list for single group)
452
- const groupEntries = [...groups.entries()];
453
- if (groupEntries.length === 1) {
454
- // Single status — no tabs needed, render flat
455
- const [groupName, groupItems] = groupEntries[0];
456
- const cards = groupItems.map(e => buildEntityCard(e));
457
- children.push(new Tag('div', {
458
- class: 'rf-milestone__backlog-group',
459
- 'data-status': groupName,
460
- }, [new Tag('h3', { class: 'rf-milestone__backlog-group-label' }, [groupName]), ...cards]));
461
- }
462
- else {
463
- // Multiple statuses — render as tabs
464
- const tabButtons = [];
465
- const tabPanels = [];
466
- for (const [groupName, groupItems] of groupEntries) {
467
- const label = groupName.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
468
- tabButtons.push(new Tag('button', {
469
- role: 'tab',
470
- class: 'rf-milestone__tab',
471
- 'data-status': groupName,
472
- }, [`${label} (${groupItems.length})`]));
473
- const cards = groupItems.map(e => buildEntityCard(e));
474
- tabPanels.push(new Tag('div', {
475
- role: 'tabpanel',
476
- class: 'rf-milestone__panel',
477
- 'data-status': groupName,
478
- }, cards));
479
- }
480
- children.push(new Tag('div', {
481
- 'data-name': 'tabs',
482
- role: 'tablist',
483
- class: 'rf-milestone__tabs',
484
- }, tabButtons));
485
- children.push(new Tag('div', {
486
- 'data-name': 'panels',
487
- class: 'rf-milestone__panels',
488
- }, tabPanels));
489
- }
490
- return new Tag('div', { class: 'rf-milestone__backlog', 'data-name': 'backlog', 'data-rune': 'milestone-backlog' }, children);
491
- }
492
- function resolveDecisionLog(tag, data) {
493
- const filterExpr = readField(tag, 'filter');
494
- const sortField = readField(tag, 'sort') || 'date';
495
- let entities = [...data.decisionEntities];
496
- // Apply filter
497
- const filter = parseFilter(filterExpr);
498
- entities = entities.filter(e => matchesFilter(e, filter));
499
- // Sort
500
- entities = sortEntities(entities, sortField);
501
- const entries = entities.map(e => buildDecisionEntry(e));
502
- const list = new Tag('ol', { 'data-name': 'items', class: 'rf-decision-log__list' }, entries);
503
- // Rebuild tag
504
- const newChildren = tag.children.filter((c) => !(Markdoc.Tag.isTag(c) && (c.attributes['data-field'] === DECISION_LOG_SENTINEL ||
505
- c.attributes['data-name'] === 'items')));
506
- newChildren.push(list);
507
- return new Tag(tag.name, tag.attributes, newChildren);
508
- }
509
574
  // --- Status labels for display ---
510
575
  const STATUS_LABELS = {
511
576
  work: ['done', 'in-progress', 'review', 'ready', 'blocked', 'draft', 'pending'],
@@ -578,60 +643,6 @@ function resolvePlanProgress(tag, data) {
578
643
  newChildren.push(itemsDiv);
579
644
  return new Tag(tag.name, tag.attributes, newChildren);
580
645
  }
581
- function resolvePlanActivity(tag, data) {
582
- const limit = parseInt(readField(tag, 'limit') || '10', 10);
583
- // Collect all entities with mtime from their source URL registration data
584
- const allEntities = [
585
- ...data.workEntities,
586
- ...data.bugEntities,
587
- ...data.decisionEntities,
588
- ...data.specEntities,
589
- ...data.milestoneEntities,
590
- ];
591
- // Sort by modified date descending (entities with modification data)
592
- const withModified = allEntities
593
- .filter(e => {
594
- const mod = e.data.modified ?? e.data.mtime;
595
- if (mod == null)
596
- return false;
597
- if (typeof mod === 'string')
598
- return mod.length > 0;
599
- return Number(mod) > 0;
600
- })
601
- .sort((a, b) => {
602
- const aDate = parseModifiedDate(a.data.modified ?? a.data.mtime);
603
- const bDate = parseModifiedDate(b.data.modified ?? b.data.mtime);
604
- return bDate - aDate;
605
- })
606
- .slice(0, limit);
607
- const entries = withModified.map(e => {
608
- const id = String(e.data.id ?? e.id);
609
- const title = String(e.data.title ?? '');
610
- const status = String(e.data.status ?? '');
611
- const type = e.type;
612
- const dateStr = formatModifiedDate(e.data.modified ?? e.data.mtime);
613
- const innerChildren = [
614
- new Tag('time', { class: 'rf-plan-activity__date' }, [dateStr]),
615
- new Tag('span', { class: 'rf-plan-activity__type' }, [type]),
616
- new Tag('span', { class: 'rf-plan-activity__id' }, [id]),
617
- new Tag('span', { class: 'rf-plan-activity__status', 'data-status': status }, [status]),
618
- new Tag('span', { class: 'rf-plan-activity__title' }, [title]),
619
- ];
620
- const children = e.sourceUrl
621
- ? [new Tag('a', { class: 'rf-plan-activity__link', href: e.sourceUrl }, innerChildren)]
622
- : innerChildren;
623
- return new Tag('li', {
624
- class: 'rf-plan-activity__entry',
625
- 'data-type': type,
626
- 'data-status': status,
627
- }, children);
628
- });
629
- const list = new Tag('ol', { 'data-name': 'items', class: 'rf-plan-activity__list' }, entries);
630
- const newChildren = tag.children.filter((c) => !(Markdoc.Tag.isTag(c) && (c.attributes['data-field'] === PLAN_ACTIVITY_SENTINEL ||
631
- c.attributes['data-name'] === 'items')));
632
- newChildren.push(list);
633
- return new Tag(tag.name, tag.attributes, newChildren);
634
- }
635
646
  // ─── Plan History Resolution ───
636
647
  function formatHistoryDate(isoDate) {
637
648
  const d = new Date(isoDate);
@@ -855,177 +866,4 @@ function resolvePlanHistory(tag, data) {
855
866
  newChildren.push(listContent);
856
867
  return new Tag(tag.name, attrs, newChildren);
857
868
  }
858
- /**
859
- * Build a tab-group wrapper for entity pages with Overview, Relationships, and History panels.
860
- * Emits the same HTML contract that tabsBehavior expects.
861
- */
862
- function buildEntityTabGroup(bodyContent, relationshipsSection, historySection) {
863
- const tabButtons = [];
864
- const tabPanels = [];
865
- // Overview tab (always present)
866
- tabButtons.push(new Tag('button', {
867
- role: 'tab',
868
- class: 'rf-plan-entity-tabs__tab',
869
- 'data-tab': 'overview',
870
- }, ['Overview']));
871
- tabPanels.push(new Tag('div', {
872
- role: 'tabpanel',
873
- class: 'rf-plan-entity-tabs__panel',
874
- 'data-tab': 'overview',
875
- }, bodyContent));
876
- // Relationships tab (only if there are relationships)
877
- if (relationshipsSection) {
878
- tabButtons.push(new Tag('button', {
879
- role: 'tab',
880
- class: 'rf-plan-entity-tabs__tab',
881
- 'data-tab': 'relationships',
882
- }, ['Relationships']));
883
- tabPanels.push(new Tag('div', {
884
- role: 'tabpanel',
885
- class: 'rf-plan-entity-tabs__panel',
886
- 'data-tab': 'relationships',
887
- }, [relationshipsSection]));
888
- }
889
- // History tab (only if there is history)
890
- if (historySection) {
891
- tabButtons.push(new Tag('button', {
892
- role: 'tab',
893
- class: 'rf-plan-entity-tabs__tab',
894
- 'data-tab': 'history',
895
- }, ['History']));
896
- tabPanels.push(new Tag('div', {
897
- role: 'tabpanel',
898
- class: 'rf-plan-entity-tabs__panel',
899
- 'data-tab': 'history',
900
- }, [historySection]));
901
- }
902
- const tabList = new Tag('div', {
903
- 'data-name': 'tabs',
904
- role: 'tablist',
905
- class: 'rf-plan-entity-tabs__tabs',
906
- }, tabButtons);
907
- const panels = new Tag('div', {
908
- 'data-name': 'panels',
909
- class: 'rf-plan-entity-tabs__panels',
910
- }, tabPanels);
911
- return new Tag('div', {
912
- class: 'rf-plan-entity-tabs',
913
- 'data-rune': 'plan-entity-tabs',
914
- }, [tabList, panels]);
915
- }
916
- const KIND_ORDER = { 'blocked-by': 0, 'blocks': 1, 'depends-on': 2, 'dependency-of': 3, 'implements': 4, 'implemented-by': 5, 'informs': 6, 'informed-by': 7, 'related': 8 };
917
- const KIND_LABELS = { 'blocked-by': 'Blocked by', 'blocks': 'Blocks', 'depends-on': 'Depends on', 'dependency-of': 'Dependency of', 'implements': 'Implements', 'implemented-by': 'Implemented by', 'informs': 'Informs', 'informed-by': 'Decisions', 'related': 'Related' };
918
- /** Look up an entity across all aggregated type arrays */
919
- function findEntity(id, data) {
920
- const allArrays = [data.workEntities, data.bugEntities, data.decisionEntities, data.specEntities, data.milestoneEntities];
921
- for (const arr of allArrays) {
922
- const found = arr.find(e => e.id === id);
923
- if (found)
924
- return found;
925
- }
926
- return undefined;
927
- }
928
- function buildRelationshipsSection(rels, data) {
929
- // Group by kind
930
- const byKind = new Map();
931
- for (const rel of rels) {
932
- const kind = rel.kind;
933
- if (!byKind.has(kind))
934
- byKind.set(kind, []);
935
- byKind.get(kind).push(rel);
936
- }
937
- // Deduplicate: same target ID within a kind group
938
- for (const [kind, kindRels] of byKind) {
939
- const seen = new Set();
940
- byKind.set(kind, kindRels.filter(r => {
941
- const key = r.toId;
942
- if (seen.has(key))
943
- return false;
944
- seen.add(key);
945
- return true;
946
- }));
947
- }
948
- const groups = [];
949
- const sortedKinds = [...byKind.keys()].sort((a, b) => (KIND_ORDER[a] ?? 9) - (KIND_ORDER[b] ?? 9));
950
- for (const kind of sortedKinds) {
951
- const kindRels = byKind.get(kind);
952
- const label = KIND_LABELS[kind] || kind;
953
- // "Informed by" renders decision entry cards
954
- if (kind === 'informed-by') {
955
- const entries = [];
956
- for (const rel of kindRels) {
957
- const target = findEntity(rel.toId, data);
958
- if (target) {
959
- entries.push(buildDecisionEntry(target));
960
- }
961
- }
962
- if (entries.length > 0) {
963
- groups.push(new Tag('div', {
964
- class: 'rf-plan-relationships__group',
965
- 'data-kind': kind,
966
- }, [
967
- new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
968
- new Tag('ol', { class: 'rf-plan-relationships__decisions' }, entries),
969
- ]));
970
- }
971
- continue;
972
- }
973
- const cards = [];
974
- for (const rel of kindRels) {
975
- const target = findEntity(rel.toId, data);
976
- if (target) {
977
- cards.push(buildEntityCard(target));
978
- }
979
- }
980
- if (cards.length > 0) {
981
- groups.push(new Tag('div', {
982
- class: 'rf-plan-relationships__group',
983
- 'data-kind': kind,
984
- }, [
985
- new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
986
- new Tag('div', { class: 'rf-plan-relationships__cards' }, cards),
987
- ]));
988
- }
989
- }
990
- if (groups.length === 0)
991
- return null;
992
- return new Tag('section', {
993
- class: 'rf-plan-relationships',
994
- 'data-name': 'relationships',
995
- }, [
996
- new Tag('h2', { class: 'rf-plan-relationships__heading' }, ['Relationships']),
997
- ...groups,
998
- ]);
999
- }
1000
- /**
1001
- * Build an auto-injected History section for an entity page.
1002
- * Returns null for entities with only a single commit (created and never modified).
1003
- */
1004
- function buildAutoHistorySection(entityId, data) {
1005
- // Find history events by matching the entity ID in the first event's attributes
1006
- let entityEvents = [];
1007
- for (const [, events] of data.history) {
1008
- if (events.length > 0 && events[0].initialAttributes) {
1009
- const eventId = events[0].initialAttributes.id ?? events[0].initialAttributes.name;
1010
- if (eventId === entityId) {
1011
- entityEvents = events;
1012
- break;
1013
- }
1014
- }
1015
- }
1016
- // Skip entities with only a single commit (creation only) — no meaningful history
1017
- if (entityEvents.length <= 1)
1018
- return null;
1019
- // Build timeline (newest-first), limit to 20 events
1020
- const limited = [...entityEvents].reverse().slice(0, 20);
1021
- const items = limited.map(e => buildEventTag(e, data.repositoryUrl));
1022
- const list = new Tag('ol', { class: 'rf-plan-history__events' }, items);
1023
- return new Tag('section', {
1024
- class: 'rf-plan-history',
1025
- 'data-name': 'history',
1026
- }, [
1027
- new Tag('h2', { class: 'rf-plan-history__heading' }, ['History']),
1028
- list,
1029
- ]);
1030
- }
1031
869
  //# sourceMappingURL=pipeline.js.map