@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.
- 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 +40 -9
- 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 +14 -7
- package/dist/index.js.map +1 -1
- package/dist/pipeline.d.ts +10 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +279 -441
- 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 -28
- 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,14 +1,12 @@
|
|
|
1
1
|
import Markdoc from '@markdoc/markdoc';
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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 {
|
|
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
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|