@refrakt-md/plan 0.15.0 → 0.16.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.
- package/dist/cli-plugin.js +1 -1
- package/dist/cli-plugin.js.map +1 -1
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +5 -0
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/plan-behaviors.js +5 -4
- package/dist/commands/plan-behaviors.js.map +1 -1
- package/dist/commands/render-pipeline.d.ts.map +1 -1
- package/dist/commands/render-pipeline.js +24 -3
- package/dist/commands/render-pipeline.js.map +1 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +5 -0
- package/dist/commands/serve.js.map +1 -1
- package/dist/filter.d.ts +6 -11
- package/dist/filter.d.ts.map +1 -1
- package/dist/filter.js +7 -65
- package/dist/filter.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -7
- package/dist/index.js.map +1 -1
- package/dist/pipeline.d.ts +6 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +108 -458
- package/dist/pipeline.js.map +1 -1
- package/dist/scanner-core.d.ts.map +1 -1
- package/dist/scanner-core.js +10 -2
- package/dist/scanner-core.js.map +1 -1
- package/dist/tags/backlog.d.ts +0 -1
- package/dist/tags/backlog.d.ts.map +1 -1
- package/dist/tags/backlog.js +62 -39
- package/dist/tags/backlog.js.map +1 -1
- package/dist/tags/decision-log.d.ts +0 -1
- package/dist/tags/decision-log.d.ts.map +1 -1
- package/dist/tags/decision-log.js +54 -22
- package/dist/tags/decision-log.js.map +1 -1
- package/dist/tags/plan-activity.d.ts +0 -1
- package/dist/tags/plan-activity.d.ts.map +1 -1
- package/dist/tags/plan-activity.js +49 -18
- package/dist/tags/plan-activity.js.map +1 -1
- package/package.json +8 -8
- package/dist/cards.d.ts +0 -23
- package/dist/cards.d.ts.map +0 -1
- package/dist/cards.js +0 -150
- package/dist/cards.js.map +0 -1
- package/dist/entity-tabs-behavior.d.ts +0 -13
- package/dist/entity-tabs-behavior.d.ts.map +0 -1
- package/dist/entity-tabs-behavior.js +0 -94
- package/dist/entity-tabs-behavior.js.map +0 -1
package/dist/pipeline.js
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import Markdoc from '@markdoc/markdoc';
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
|
-
import { BACKLOG_SENTINEL } from './tags/backlog.js';
|
|
5
|
-
import { DECISION_LOG_SENTINEL } from './tags/decision-log.js';
|
|
6
4
|
import { PLAN_PROGRESS_SENTINEL } from './tags/plan-progress.js';
|
|
7
|
-
import { PLAN_ACTIVITY_SENTINEL } from './tags/plan-activity.js';
|
|
8
5
|
import { PLAN_HISTORY_SENTINEL } from './tags/plan-history.js';
|
|
9
|
-
import { parseFilter, matchesFilter, sortEntities, groupEntities } from './filter.js';
|
|
10
6
|
import { execSync } from 'node:child_process';
|
|
11
7
|
import { extractBatchHistory, readHistoryCache, writeHistoryCache, } from './history.js';
|
|
12
8
|
import { buildRelationships } from './relationships.js';
|
|
13
|
-
import { buildEntityCard, buildDecisionEntry } from './cards.js';
|
|
14
9
|
import { parseFileContent } from './scanner-core.js';
|
|
15
10
|
const { Tag } = Markdoc;
|
|
16
11
|
const PLAN_RUNE_TYPES = new Set(['spec', 'work', 'bug', 'decision', 'milestone']);
|
|
@@ -271,6 +266,31 @@ function performUnconditionalScan(planDir, projectRoot, registry, ctx) {
|
|
|
271
266
|
ctx.warn(`Plan scan: ${sourceFile} has a ${runeType} rune missing ${runeType === 'milestone' ? 'name' : 'id'} attribute`);
|
|
272
267
|
return;
|
|
273
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);
|
|
274
294
|
// Site-load registration wins if both paths produced the same entity.
|
|
275
295
|
const existing = registry.getById(runeType, id);
|
|
276
296
|
if (existing && existing.sourceUrl) {
|
|
@@ -278,7 +298,9 @@ function performUnconditionalScan(planDir, projectRoot, registry, ctx) {
|
|
|
278
298
|
return;
|
|
279
299
|
}
|
|
280
300
|
if (existing && existing.sourceFile === sourceFile) {
|
|
281
|
-
// Already registered via the unconditional scan (idempotency). Skip
|
|
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.
|
|
282
304
|
return;
|
|
283
305
|
}
|
|
284
306
|
if (existing && existing.sourceFile && existing.sourceFile !== sourceFile) {
|
|
@@ -291,6 +313,32 @@ function performUnconditionalScan(planDir, projectRoot, registry, ctx) {
|
|
|
291
313
|
for (const [key, value] of Object.entries(entity.attributes)) {
|
|
292
314
|
data[key] = value;
|
|
293
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
|
+
}
|
|
294
342
|
// Closure-captured extractor — returns the top-level plan rune AST
|
|
295
343
|
// node from a re-parsed source file, or null if the file's structure
|
|
296
344
|
// has been edited away from the expected shape. Used by expand
|
|
@@ -335,6 +383,10 @@ export const planPipelineHooks = {
|
|
|
335
383
|
register(pages, registry, ctx) {
|
|
336
384
|
_idReferences.clear();
|
|
337
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.
|
|
338
390
|
for (const page of pages) {
|
|
339
391
|
walkTags(page.renderable, (tag) => {
|
|
340
392
|
const runeType = tag.attributes['data-rune'];
|
|
@@ -393,6 +445,31 @@ export const planPipelineHooks = {
|
|
|
393
445
|
if (_planDir) {
|
|
394
446
|
performUnconditionalScan(_planDir, _projectRoot, registry, ctx);
|
|
395
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
|
+
}
|
|
396
473
|
},
|
|
397
474
|
aggregate(registry, ctx) {
|
|
398
475
|
// Build a lookup of all registered entities for relationship building
|
|
@@ -404,6 +481,17 @@ export const planPipelineHooks = {
|
|
|
404
481
|
}
|
|
405
482
|
// Build bidirectional relationship index using extracted module
|
|
406
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
|
+
}
|
|
407
495
|
// Extract git history for all entities
|
|
408
496
|
let history = new Map();
|
|
409
497
|
let repositoryUrl;
|
|
@@ -452,79 +540,30 @@ export const planPipelineHooks = {
|
|
|
452
540
|
return page;
|
|
453
541
|
let modified = false;
|
|
454
542
|
const newRenderable = mapTags(page.renderable, (tag) => {
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
// Handle decision-log sentinel
|
|
461
|
-
if (tag.attributes['data-rune'] === 'decision-log' && hasSentinel(tag, DECISION_LOG_SENTINEL)) {
|
|
462
|
-
modified = true;
|
|
463
|
-
return resolveDecisionLog(tag, planData);
|
|
464
|
-
}
|
|
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.
|
|
465
548
|
// Handle plan-progress sentinel
|
|
466
549
|
if (tag.attributes['data-rune'] === 'plan-progress' && hasSentinel(tag, PLAN_PROGRESS_SENTINEL)) {
|
|
467
550
|
modified = true;
|
|
468
551
|
return resolvePlanProgress(tag, planData);
|
|
469
552
|
}
|
|
470
|
-
// Handle plan-activity sentinel
|
|
471
|
-
if (tag.attributes['data-rune'] === 'plan-activity' && hasSentinel(tag, PLAN_ACTIVITY_SENTINEL)) {
|
|
472
|
-
modified = true;
|
|
473
|
-
return resolvePlanActivity(tag, planData);
|
|
474
|
-
}
|
|
475
553
|
// Handle plan-history sentinel
|
|
476
554
|
if (tag.attributes['data-rune'] === 'plan-history' && hasSentinel(tag, PLAN_HISTORY_SENTINEL)) {
|
|
477
555
|
modified = true;
|
|
478
556
|
return resolvePlanHistory(tag, planData);
|
|
479
557
|
}
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
// Wrap entity content in tab-group with Overview / Relationships / History panels
|
|
492
|
-
if (PLAN_RUNE_TYPES.has(tag.attributes['data-rune'])) {
|
|
493
|
-
const runeType = tag.attributes['data-rune'];
|
|
494
|
-
const entityId = runeType === 'milestone'
|
|
495
|
-
? readField(tag, 'name')
|
|
496
|
-
: readField(tag, 'id');
|
|
497
|
-
if (entityId) {
|
|
498
|
-
const rels = planData.relationships.get(entityId);
|
|
499
|
-
const relationshipsSection = (rels && rels.length > 0)
|
|
500
|
-
? buildRelationshipsSection(rels, planData)
|
|
501
|
-
: null;
|
|
502
|
-
const historySection = buildAutoHistorySection(entityId, planData);
|
|
503
|
-
// Only add tabs if there is content for at least one extra panel
|
|
504
|
-
if (relationshipsSection || historySection) {
|
|
505
|
-
modified = true;
|
|
506
|
-
// Partition children: structural (meta fields, title, blurb) stay at top;
|
|
507
|
-
// body content goes into the Overview tab panel.
|
|
508
|
-
// Note: postProcess runs BEFORE the identity transform, so children
|
|
509
|
-
// have data-name/data-field from createComponentRenderable but no
|
|
510
|
-
// data-section or BEM classes yet.
|
|
511
|
-
const PREAMBLE_NAMES = new Set(['title', 'blurb']);
|
|
512
|
-
const structural = [];
|
|
513
|
-
const bodyContent = [];
|
|
514
|
-
for (const child of tag.children) {
|
|
515
|
-
if (Markdoc.Tag.isTag(child) && (child.attributes['data-field'] != null ||
|
|
516
|
-
PREAMBLE_NAMES.has(child.attributes['data-name']))) {
|
|
517
|
-
structural.push(child);
|
|
518
|
-
}
|
|
519
|
-
else {
|
|
520
|
-
bodyContent.push(child);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
const tabWrapper = buildEntityTabGroup(bodyContent, relationshipsSection, historySection);
|
|
524
|
-
return new Tag(tag.name, tag.attributes, [...structural, tabWrapper]);
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
}
|
|
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.
|
|
528
567
|
return tag;
|
|
529
568
|
});
|
|
530
569
|
if (!modified)
|
|
@@ -532,168 +571,6 @@ export const planPipelineHooks = {
|
|
|
532
571
|
return { ...page, renderable: newRenderable };
|
|
533
572
|
},
|
|
534
573
|
};
|
|
535
|
-
function resolveBacklog(tag, data) {
|
|
536
|
-
const filterExpr = readField(tag, 'filter');
|
|
537
|
-
const sortField = readField(tag, 'sort') || 'priority';
|
|
538
|
-
const groupField = readField(tag, 'group');
|
|
539
|
-
const show = readField(tag, 'show') || 'all';
|
|
540
|
-
const limitRaw = readField(tag, 'limit');
|
|
541
|
-
// Parse `limit` defensively — Markdoc surfaces the meta as a string.
|
|
542
|
-
// Treat 0 / negative / NaN the same as "unset" so a malformed
|
|
543
|
-
// authoring value doesn't silently render an empty backlog.
|
|
544
|
-
const limit = (() => {
|
|
545
|
-
if (!limitRaw)
|
|
546
|
-
return undefined;
|
|
547
|
-
const n = Number(limitRaw);
|
|
548
|
-
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
|
|
549
|
-
})();
|
|
550
|
-
// Collect entities by type
|
|
551
|
-
// "all" defaults to work+bug for backward compatibility; other types must be explicit
|
|
552
|
-
let entities = [];
|
|
553
|
-
if (show === 'all' || show === 'work')
|
|
554
|
-
entities.push(...data.workEntities);
|
|
555
|
-
if (show === 'all' || show === 'bug')
|
|
556
|
-
entities.push(...data.bugEntities);
|
|
557
|
-
if (show === 'spec')
|
|
558
|
-
entities.push(...data.specEntities);
|
|
559
|
-
if (show === 'decision')
|
|
560
|
-
entities.push(...data.decisionEntities);
|
|
561
|
-
if (show === 'milestone')
|
|
562
|
-
entities.push(...data.milestoneEntities);
|
|
563
|
-
// Apply filter
|
|
564
|
-
const filter = parseFilter(filterExpr);
|
|
565
|
-
entities = entities.filter(e => matchesFilter(e, filter));
|
|
566
|
-
// Sort
|
|
567
|
-
entities = sortEntities(entities, sortField);
|
|
568
|
-
// Limit applies post-sort, pre-group so the rendered set is "top N
|
|
569
|
-
// by sort order". When grouped, the limit caps the total entity count
|
|
570
|
-
// across all groups — callers wanting a per-group cap would need a
|
|
571
|
-
// separate attribute; keep it simple until that ask surfaces.
|
|
572
|
-
if (limit !== undefined && entities.length > limit) {
|
|
573
|
-
entities = entities.slice(0, limit);
|
|
574
|
-
}
|
|
575
|
-
// Build output
|
|
576
|
-
let children;
|
|
577
|
-
if (groupField) {
|
|
578
|
-
const groups = groupEntities(entities, groupField);
|
|
579
|
-
children = [];
|
|
580
|
-
for (const [groupName, groupEntities_] of groups) {
|
|
581
|
-
const groupTitle = new Tag('h3', { class: 'rf-backlog__group-title' }, [groupName]);
|
|
582
|
-
const cards = groupEntities_.map(e => buildEntityCard(e));
|
|
583
|
-
const groupDiv = new Tag('div', { class: 'rf-backlog__group', 'data-group': groupName }, [groupTitle, ...cards]);
|
|
584
|
-
children.push(groupDiv);
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
else {
|
|
588
|
-
children = entities.map(e => buildEntityCard(e));
|
|
589
|
-
}
|
|
590
|
-
const itemsDiv = new Tag('div', { 'data-name': 'items' }, children);
|
|
591
|
-
// Rebuild tag, replacing the sentinel and placeholder with resolved content
|
|
592
|
-
const newChildren = tag.children.filter((c) => !(Markdoc.Tag.isTag(c) && (c.attributes['data-field'] === BACKLOG_SENTINEL ||
|
|
593
|
-
c.attributes['data-name'] === 'items')));
|
|
594
|
-
newChildren.push(itemsDiv);
|
|
595
|
-
return new Tag(tag.name, tag.attributes, newChildren);
|
|
596
|
-
}
|
|
597
|
-
/** Build auto-backlog section for a milestone, showing assigned work/bug items grouped by status */
|
|
598
|
-
function buildMilestoneBacklog(milestoneName, data) {
|
|
599
|
-
// Collect work and bug entities assigned to this milestone
|
|
600
|
-
let entities = [
|
|
601
|
-
...data.workEntities,
|
|
602
|
-
...data.bugEntities,
|
|
603
|
-
].filter(e => String(e.data.milestone ?? '') === milestoneName);
|
|
604
|
-
if (entities.length === 0)
|
|
605
|
-
return null;
|
|
606
|
-
// Sort by priority within each group
|
|
607
|
-
entities = sortEntities(entities, 'priority');
|
|
608
|
-
// Group by status
|
|
609
|
-
const groups = groupEntities(entities, 'status');
|
|
610
|
-
// Calculate aggregate progress from checklist counts
|
|
611
|
-
let totalChecked = 0;
|
|
612
|
-
let totalCheckboxes = 0;
|
|
613
|
-
for (const e of entities) {
|
|
614
|
-
const checked = Number(e.data.checkedCount ?? 0);
|
|
615
|
-
const total = Number(e.data.totalCount ?? 0);
|
|
616
|
-
if (total > 0) {
|
|
617
|
-
totalChecked += checked;
|
|
618
|
-
totalCheckboxes += total;
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
const children = [];
|
|
622
|
-
// Add aggregate progress if any items have checklists
|
|
623
|
-
if (totalCheckboxes > 0) {
|
|
624
|
-
const fraction = `${totalChecked}/${totalCheckboxes}`;
|
|
625
|
-
const pct = Math.round((totalChecked / totalCheckboxes) * 100);
|
|
626
|
-
children.push(new Tag('div', {
|
|
627
|
-
class: 'rf-milestone__progress',
|
|
628
|
-
'data-progress-checked': String(totalChecked),
|
|
629
|
-
'data-progress-total': String(totalCheckboxes),
|
|
630
|
-
'data-percent': String(pct),
|
|
631
|
-
}, [
|
|
632
|
-
new Tag('div', { class: 'rf-milestone__progress-header' }, [
|
|
633
|
-
new Tag('span', { class: 'rf-milestone__progress-label' }, ['Progress']),
|
|
634
|
-
new Tag('span', { class: 'rf-milestone__progress-count' }, [`${fraction} criteria`]),
|
|
635
|
-
]),
|
|
636
|
-
new Tag('span', { class: 'rf-milestone__progress-bar', style: `--rf-progress: ${pct}%` }, []),
|
|
637
|
-
]));
|
|
638
|
-
}
|
|
639
|
-
// Build status-grouped cards as tabs (or flat list for single group)
|
|
640
|
-
const groupEntries = [...groups.entries()];
|
|
641
|
-
if (groupEntries.length === 1) {
|
|
642
|
-
// Single status — no tabs needed, render flat
|
|
643
|
-
const [groupName, groupItems] = groupEntries[0];
|
|
644
|
-
const cards = groupItems.map(e => buildEntityCard(e));
|
|
645
|
-
children.push(new Tag('div', {
|
|
646
|
-
class: 'rf-milestone__backlog-group',
|
|
647
|
-
'data-status': groupName,
|
|
648
|
-
}, [new Tag('h3', { class: 'rf-milestone__backlog-group-label' }, [groupName]), ...cards]));
|
|
649
|
-
}
|
|
650
|
-
else {
|
|
651
|
-
// Multiple statuses — render as tabs
|
|
652
|
-
const tabButtons = [];
|
|
653
|
-
const tabPanels = [];
|
|
654
|
-
for (const [groupName, groupItems] of groupEntries) {
|
|
655
|
-
const label = groupName.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
656
|
-
tabButtons.push(new Tag('button', {
|
|
657
|
-
role: 'tab',
|
|
658
|
-
class: 'rf-milestone__tab',
|
|
659
|
-
'data-status': groupName,
|
|
660
|
-
}, [`${label} (${groupItems.length})`]));
|
|
661
|
-
const cards = groupItems.map(e => buildEntityCard(e));
|
|
662
|
-
tabPanels.push(new Tag('div', {
|
|
663
|
-
role: 'tabpanel',
|
|
664
|
-
class: 'rf-milestone__panel',
|
|
665
|
-
'data-status': groupName,
|
|
666
|
-
}, cards));
|
|
667
|
-
}
|
|
668
|
-
children.push(new Tag('div', {
|
|
669
|
-
'data-name': 'tabs',
|
|
670
|
-
role: 'tablist',
|
|
671
|
-
class: 'rf-milestone__tabs',
|
|
672
|
-
}, tabButtons));
|
|
673
|
-
children.push(new Tag('div', {
|
|
674
|
-
'data-name': 'panels',
|
|
675
|
-
class: 'rf-milestone__panels',
|
|
676
|
-
}, tabPanels));
|
|
677
|
-
}
|
|
678
|
-
return new Tag('div', { class: 'rf-milestone__backlog', 'data-name': 'backlog', 'data-rune': 'milestone-backlog' }, children);
|
|
679
|
-
}
|
|
680
|
-
function resolveDecisionLog(tag, data) {
|
|
681
|
-
const filterExpr = readField(tag, 'filter');
|
|
682
|
-
const sortField = readField(tag, 'sort') || 'date';
|
|
683
|
-
let entities = [...data.decisionEntities];
|
|
684
|
-
// Apply filter
|
|
685
|
-
const filter = parseFilter(filterExpr);
|
|
686
|
-
entities = entities.filter(e => matchesFilter(e, filter));
|
|
687
|
-
// Sort
|
|
688
|
-
entities = sortEntities(entities, sortField);
|
|
689
|
-
const entries = entities.map(e => buildDecisionEntry(e));
|
|
690
|
-
const list = new Tag('ol', { 'data-name': 'items', class: 'rf-decision-log__list' }, entries);
|
|
691
|
-
// Rebuild tag
|
|
692
|
-
const newChildren = tag.children.filter((c) => !(Markdoc.Tag.isTag(c) && (c.attributes['data-field'] === DECISION_LOG_SENTINEL ||
|
|
693
|
-
c.attributes['data-name'] === 'items')));
|
|
694
|
-
newChildren.push(list);
|
|
695
|
-
return new Tag(tag.name, tag.attributes, newChildren);
|
|
696
|
-
}
|
|
697
574
|
// --- Status labels for display ---
|
|
698
575
|
const STATUS_LABELS = {
|
|
699
576
|
work: ['done', 'in-progress', 'review', 'ready', 'blocked', 'draft', 'pending'],
|
|
@@ -766,60 +643,6 @@ function resolvePlanProgress(tag, data) {
|
|
|
766
643
|
newChildren.push(itemsDiv);
|
|
767
644
|
return new Tag(tag.name, tag.attributes, newChildren);
|
|
768
645
|
}
|
|
769
|
-
function resolvePlanActivity(tag, data) {
|
|
770
|
-
const limit = parseInt(readField(tag, 'limit') || '10', 10);
|
|
771
|
-
// Collect all entities with mtime from their source URL registration data
|
|
772
|
-
const allEntities = [
|
|
773
|
-
...data.workEntities,
|
|
774
|
-
...data.bugEntities,
|
|
775
|
-
...data.decisionEntities,
|
|
776
|
-
...data.specEntities,
|
|
777
|
-
...data.milestoneEntities,
|
|
778
|
-
];
|
|
779
|
-
// Sort by modified date descending (entities with modification data)
|
|
780
|
-
const withModified = allEntities
|
|
781
|
-
.filter(e => {
|
|
782
|
-
const mod = e.data.modified ?? e.data.mtime;
|
|
783
|
-
if (mod == null)
|
|
784
|
-
return false;
|
|
785
|
-
if (typeof mod === 'string')
|
|
786
|
-
return mod.length > 0;
|
|
787
|
-
return Number(mod) > 0;
|
|
788
|
-
})
|
|
789
|
-
.sort((a, b) => {
|
|
790
|
-
const aDate = parseModifiedDate(a.data.modified ?? a.data.mtime);
|
|
791
|
-
const bDate = parseModifiedDate(b.data.modified ?? b.data.mtime);
|
|
792
|
-
return bDate - aDate;
|
|
793
|
-
})
|
|
794
|
-
.slice(0, limit);
|
|
795
|
-
const entries = withModified.map(e => {
|
|
796
|
-
const id = String(e.data.id ?? e.id);
|
|
797
|
-
const title = String(e.data.title ?? '');
|
|
798
|
-
const status = String(e.data.status ?? '');
|
|
799
|
-
const type = e.type;
|
|
800
|
-
const dateStr = formatModifiedDate(e.data.modified ?? e.data.mtime);
|
|
801
|
-
const innerChildren = [
|
|
802
|
-
new Tag('time', { class: 'rf-plan-activity__date' }, [dateStr]),
|
|
803
|
-
new Tag('span', { class: 'rf-plan-activity__type' }, [type]),
|
|
804
|
-
new Tag('span', { class: 'rf-plan-activity__id' }, [id]),
|
|
805
|
-
new Tag('span', { class: 'rf-plan-activity__status', 'data-status': status }, [status]),
|
|
806
|
-
new Tag('span', { class: 'rf-plan-activity__title' }, [title]),
|
|
807
|
-
];
|
|
808
|
-
const children = e.sourceUrl
|
|
809
|
-
? [new Tag('a', { class: 'rf-plan-activity__link', href: e.sourceUrl }, innerChildren)]
|
|
810
|
-
: innerChildren;
|
|
811
|
-
return new Tag('li', {
|
|
812
|
-
class: 'rf-plan-activity__entry',
|
|
813
|
-
'data-type': type,
|
|
814
|
-
'data-status': status,
|
|
815
|
-
}, children);
|
|
816
|
-
});
|
|
817
|
-
const list = new Tag('ol', { 'data-name': 'items', class: 'rf-plan-activity__list' }, entries);
|
|
818
|
-
const newChildren = tag.children.filter((c) => !(Markdoc.Tag.isTag(c) && (c.attributes['data-field'] === PLAN_ACTIVITY_SENTINEL ||
|
|
819
|
-
c.attributes['data-name'] === 'items')));
|
|
820
|
-
newChildren.push(list);
|
|
821
|
-
return new Tag(tag.name, tag.attributes, newChildren);
|
|
822
|
-
}
|
|
823
646
|
// ─── Plan History Resolution ───
|
|
824
647
|
function formatHistoryDate(isoDate) {
|
|
825
648
|
const d = new Date(isoDate);
|
|
@@ -1043,177 +866,4 @@ function resolvePlanHistory(tag, data) {
|
|
|
1043
866
|
newChildren.push(listContent);
|
|
1044
867
|
return new Tag(tag.name, attrs, newChildren);
|
|
1045
868
|
}
|
|
1046
|
-
/**
|
|
1047
|
-
* Build a tab-group wrapper for entity pages with Overview, Relationships, and History panels.
|
|
1048
|
-
* Emits the same HTML contract that tabsBehavior expects.
|
|
1049
|
-
*/
|
|
1050
|
-
function buildEntityTabGroup(bodyContent, relationshipsSection, historySection) {
|
|
1051
|
-
const tabButtons = [];
|
|
1052
|
-
const tabPanels = [];
|
|
1053
|
-
// Overview tab (always present)
|
|
1054
|
-
tabButtons.push(new Tag('button', {
|
|
1055
|
-
role: 'tab',
|
|
1056
|
-
class: 'rf-plan-entity-tabs__tab',
|
|
1057
|
-
'data-tab': 'overview',
|
|
1058
|
-
}, ['Overview']));
|
|
1059
|
-
tabPanels.push(new Tag('div', {
|
|
1060
|
-
role: 'tabpanel',
|
|
1061
|
-
class: 'rf-plan-entity-tabs__panel',
|
|
1062
|
-
'data-tab': 'overview',
|
|
1063
|
-
}, bodyContent));
|
|
1064
|
-
// Relationships tab (only if there are relationships)
|
|
1065
|
-
if (relationshipsSection) {
|
|
1066
|
-
tabButtons.push(new Tag('button', {
|
|
1067
|
-
role: 'tab',
|
|
1068
|
-
class: 'rf-plan-entity-tabs__tab',
|
|
1069
|
-
'data-tab': 'relationships',
|
|
1070
|
-
}, ['Relationships']));
|
|
1071
|
-
tabPanels.push(new Tag('div', {
|
|
1072
|
-
role: 'tabpanel',
|
|
1073
|
-
class: 'rf-plan-entity-tabs__panel',
|
|
1074
|
-
'data-tab': 'relationships',
|
|
1075
|
-
}, [relationshipsSection]));
|
|
1076
|
-
}
|
|
1077
|
-
// History tab (only if there is history)
|
|
1078
|
-
if (historySection) {
|
|
1079
|
-
tabButtons.push(new Tag('button', {
|
|
1080
|
-
role: 'tab',
|
|
1081
|
-
class: 'rf-plan-entity-tabs__tab',
|
|
1082
|
-
'data-tab': 'history',
|
|
1083
|
-
}, ['History']));
|
|
1084
|
-
tabPanels.push(new Tag('div', {
|
|
1085
|
-
role: 'tabpanel',
|
|
1086
|
-
class: 'rf-plan-entity-tabs__panel',
|
|
1087
|
-
'data-tab': 'history',
|
|
1088
|
-
}, [historySection]));
|
|
1089
|
-
}
|
|
1090
|
-
const tabList = new Tag('div', {
|
|
1091
|
-
'data-name': 'tabs',
|
|
1092
|
-
role: 'tablist',
|
|
1093
|
-
class: 'rf-plan-entity-tabs__tabs',
|
|
1094
|
-
}, tabButtons);
|
|
1095
|
-
const panels = new Tag('div', {
|
|
1096
|
-
'data-name': 'panels',
|
|
1097
|
-
class: 'rf-plan-entity-tabs__panels',
|
|
1098
|
-
}, tabPanels);
|
|
1099
|
-
return new Tag('div', {
|
|
1100
|
-
class: 'rf-plan-entity-tabs',
|
|
1101
|
-
'data-rune': 'plan-entity-tabs',
|
|
1102
|
-
}, [tabList, panels]);
|
|
1103
|
-
}
|
|
1104
|
-
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 };
|
|
1105
|
-
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' };
|
|
1106
|
-
/** Look up an entity across all aggregated type arrays */
|
|
1107
|
-
function findEntity(id, data) {
|
|
1108
|
-
const allArrays = [data.workEntities, data.bugEntities, data.decisionEntities, data.specEntities, data.milestoneEntities];
|
|
1109
|
-
for (const arr of allArrays) {
|
|
1110
|
-
const found = arr.find(e => e.id === id);
|
|
1111
|
-
if (found)
|
|
1112
|
-
return found;
|
|
1113
|
-
}
|
|
1114
|
-
return undefined;
|
|
1115
|
-
}
|
|
1116
|
-
function buildRelationshipsSection(rels, data) {
|
|
1117
|
-
// Group by kind
|
|
1118
|
-
const byKind = new Map();
|
|
1119
|
-
for (const rel of rels) {
|
|
1120
|
-
const kind = rel.kind;
|
|
1121
|
-
if (!byKind.has(kind))
|
|
1122
|
-
byKind.set(kind, []);
|
|
1123
|
-
byKind.get(kind).push(rel);
|
|
1124
|
-
}
|
|
1125
|
-
// Deduplicate: same target ID within a kind group
|
|
1126
|
-
for (const [kind, kindRels] of byKind) {
|
|
1127
|
-
const seen = new Set();
|
|
1128
|
-
byKind.set(kind, kindRels.filter(r => {
|
|
1129
|
-
const key = r.toId;
|
|
1130
|
-
if (seen.has(key))
|
|
1131
|
-
return false;
|
|
1132
|
-
seen.add(key);
|
|
1133
|
-
return true;
|
|
1134
|
-
}));
|
|
1135
|
-
}
|
|
1136
|
-
const groups = [];
|
|
1137
|
-
const sortedKinds = [...byKind.keys()].sort((a, b) => (KIND_ORDER[a] ?? 9) - (KIND_ORDER[b] ?? 9));
|
|
1138
|
-
for (const kind of sortedKinds) {
|
|
1139
|
-
const kindRels = byKind.get(kind);
|
|
1140
|
-
const label = KIND_LABELS[kind] || kind;
|
|
1141
|
-
// "Informed by" renders decision entry cards
|
|
1142
|
-
if (kind === 'informed-by') {
|
|
1143
|
-
const entries = [];
|
|
1144
|
-
for (const rel of kindRels) {
|
|
1145
|
-
const target = findEntity(rel.toId, data);
|
|
1146
|
-
if (target) {
|
|
1147
|
-
entries.push(buildDecisionEntry(target));
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
if (entries.length > 0) {
|
|
1151
|
-
groups.push(new Tag('div', {
|
|
1152
|
-
class: 'rf-plan-relationships__group',
|
|
1153
|
-
'data-kind': kind,
|
|
1154
|
-
}, [
|
|
1155
|
-
new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
|
|
1156
|
-
new Tag('ol', { class: 'rf-plan-relationships__decisions' }, entries),
|
|
1157
|
-
]));
|
|
1158
|
-
}
|
|
1159
|
-
continue;
|
|
1160
|
-
}
|
|
1161
|
-
const cards = [];
|
|
1162
|
-
for (const rel of kindRels) {
|
|
1163
|
-
const target = findEntity(rel.toId, data);
|
|
1164
|
-
if (target) {
|
|
1165
|
-
cards.push(buildEntityCard(target));
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
if (cards.length > 0) {
|
|
1169
|
-
groups.push(new Tag('div', {
|
|
1170
|
-
class: 'rf-plan-relationships__group',
|
|
1171
|
-
'data-kind': kind,
|
|
1172
|
-
}, [
|
|
1173
|
-
new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
|
|
1174
|
-
new Tag('div', { class: 'rf-plan-relationships__cards' }, cards),
|
|
1175
|
-
]));
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
if (groups.length === 0)
|
|
1179
|
-
return null;
|
|
1180
|
-
return new Tag('section', {
|
|
1181
|
-
class: 'rf-plan-relationships',
|
|
1182
|
-
'data-name': 'relationships',
|
|
1183
|
-
}, [
|
|
1184
|
-
new Tag('h2', { class: 'rf-plan-relationships__heading' }, ['Relationships']),
|
|
1185
|
-
...groups,
|
|
1186
|
-
]);
|
|
1187
|
-
}
|
|
1188
|
-
/**
|
|
1189
|
-
* Build an auto-injected History section for an entity page.
|
|
1190
|
-
* Returns null for entities with only a single commit (created and never modified).
|
|
1191
|
-
*/
|
|
1192
|
-
function buildAutoHistorySection(entityId, data) {
|
|
1193
|
-
// Find history events by matching the entity ID in the first event's attributes
|
|
1194
|
-
let entityEvents = [];
|
|
1195
|
-
for (const [, events] of data.history) {
|
|
1196
|
-
if (events.length > 0 && events[0].initialAttributes) {
|
|
1197
|
-
const eventId = events[0].initialAttributes.id ?? events[0].initialAttributes.name;
|
|
1198
|
-
if (eventId === entityId) {
|
|
1199
|
-
entityEvents = events;
|
|
1200
|
-
break;
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
// Skip entities with only a single commit (creation only) — no meaningful history
|
|
1205
|
-
if (entityEvents.length <= 1)
|
|
1206
|
-
return null;
|
|
1207
|
-
// Build timeline (newest-first), limit to 20 events
|
|
1208
|
-
const limited = [...entityEvents].reverse().slice(0, 20);
|
|
1209
|
-
const items = limited.map(e => buildEventTag(e, data.repositoryUrl));
|
|
1210
|
-
const list = new Tag('ol', { class: 'rf-plan-history__events' }, items);
|
|
1211
|
-
return new Tag('section', {
|
|
1212
|
-
class: 'rf-plan-history',
|
|
1213
|
-
'data-name': 'history',
|
|
1214
|
-
}, [
|
|
1215
|
-
new Tag('h2', { class: 'rf-plan-history__heading' }, ['History']),
|
|
1216
|
-
list,
|
|
1217
|
-
]);
|
|
1218
|
-
}
|
|
1219
869
|
//# sourceMappingURL=pipeline.js.map
|