@nowline/core 0.5.1 → 0.7.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.
@@ -373,15 +373,15 @@ function applyRoadmapMode(
373
373
  sourcePath: childPath,
374
374
  });
375
375
 
376
- mergeMap(target.persons, child.persons, (name) => warn(name, 'Person'));
377
- mergeMap(target.teams, child.teams, (name) => warn(name, 'Team'));
378
- mergeMap(target.anchors, child.anchors, (name) => warn(name, 'Anchor'));
379
- mergeMap(target.labels, child.labels, (name) => warn(name, 'Label'));
380
- mergeMap(target.sizes, child.sizes, (name) => warn(name, 'Size'));
381
- mergeMap(target.statuses, child.statuses, (name) => warn(name, 'Status'));
382
- mergeMap(target.swimlanes, child.swimlanes, (name) => warn(name, 'Swimlane'));
383
- mergeMap(target.milestones, child.milestones, (name) => warn(name, 'Milestone'));
384
- mergeMap(target.footnotes, child.footnotes, (name) => warn(name, 'Footnote'));
376
+ mergeContentMap(target.persons, child.persons, (name) => warn(name, 'Person'));
377
+ mergeContentMap(target.teams, child.teams, (name) => warn(name, 'Team'));
378
+ mergeContentMap(target.anchors, child.anchors, (name) => warn(name, 'Anchor'));
379
+ mergeContentMap(target.labels, child.labels, (name) => warn(name, 'Label'));
380
+ mergeContentMap(target.sizes, child.sizes, (name) => warn(name, 'Size'));
381
+ mergeContentMap(target.statuses, child.statuses, (name) => warn(name, 'Status'));
382
+ mergeContentMap(target.swimlanes, child.swimlanes, (name) => warn(name, 'Swimlane'));
383
+ mergeContentMap(target.milestones, child.milestones, (name) => warn(name, 'Milestone'));
384
+ mergeContentMap(target.footnotes, child.footnotes, (name) => warn(name, 'Footnote'));
385
385
  if (child.roadmap && !target.roadmap) {
386
386
  target.roadmap = child.roadmap;
387
387
  }
@@ -401,6 +401,33 @@ function mergeMap<V>(
401
401
  }
402
402
  }
403
403
 
404
+ /**
405
+ * Merge a child content map into the parent. Explicit-id entries keep the
406
+ * parent-wins-on-collision behavior (and warn). Title-only (auto-slugged)
407
+ * entries are internal and non-referenceable, so they never shadow and never
408
+ * warn — each is re-keyed around the parent's entries and kept.
409
+ */
410
+ function mergeContentMap<V extends { name?: string; title?: string }>(
411
+ target: Map<string, V>,
412
+ source: Map<string, V>,
413
+ onConflict: (name: string) => void,
414
+ ): void {
415
+ for (const [name, value] of source) {
416
+ if (!value.name && value.title) {
417
+ target.set(
418
+ uniqueMapKey(target as Map<string, unknown>, slugifyTitle(value.title)),
419
+ value,
420
+ );
421
+ continue;
422
+ }
423
+ if (target.has(name)) {
424
+ onConflict(name);
425
+ continue;
426
+ }
427
+ target.set(name, value);
428
+ }
429
+ }
430
+
404
431
  function mergeLocalConfig(config: ResolvedConfig, file: NowlineFile): void {
405
432
  for (const entry of file.configEntries) {
406
433
  addConfigEntry(config, entry);
@@ -427,51 +454,128 @@ function addConfigEntry(config: ResolvedConfig, entry: ConfigEntry): void {
427
454
  }
428
455
  }
429
456
 
457
+ /** Explicit ids declared in a file, grouped by the content map they target. */
458
+ interface ReservedRoadmapIds {
459
+ swimlanes: Set<string>;
460
+ persons: Set<string>;
461
+ teams: Set<string>;
462
+ anchors: Set<string>;
463
+ labels: Set<string>;
464
+ sizes: Set<string>;
465
+ statuses: Set<string>;
466
+ milestones: Set<string>;
467
+ footnotes: Set<string>;
468
+ }
469
+
470
+ function collectExplicitRoadmapIds(entries: RoadmapEntry[]): ReservedRoadmapIds {
471
+ const reserved: ReservedRoadmapIds = {
472
+ swimlanes: new Set(),
473
+ persons: new Set(),
474
+ teams: new Set(),
475
+ anchors: new Set(),
476
+ labels: new Set(),
477
+ sizes: new Set(),
478
+ statuses: new Set(),
479
+ milestones: new Set(),
480
+ footnotes: new Set(),
481
+ };
482
+ for (const entry of entries) {
483
+ const name = (entry as { name?: string }).name;
484
+ if (!name) continue;
485
+ if (isSwimlaneDeclaration(entry)) reserved.swimlanes.add(name);
486
+ else if (isPersonDeclaration(entry)) reserved.persons.add(name);
487
+ else if (isTeamDeclaration(entry)) reserved.teams.add(name);
488
+ else if (isAnchorDeclaration(entry)) reserved.anchors.add(name);
489
+ else if (isLabelDeclaration(entry)) reserved.labels.add(name);
490
+ else if (isSizeDeclaration(entry)) reserved.sizes.add(name);
491
+ else if (isStatusDeclaration(entry)) reserved.statuses.add(name);
492
+ else if (isMilestoneDeclaration(entry)) reserved.milestones.add(name);
493
+ else if (isFootnoteDeclaration(entry)) reserved.footnotes.add(name);
494
+ }
495
+ return reserved;
496
+ }
497
+
430
498
  function mergeLocalContent(content: ResolvedContent, file: NowlineFile): void {
431
499
  if (file.roadmapDecl && !content.roadmap) {
432
500
  content.roadmap = file.roadmapDecl;
433
501
  }
502
+ // Pre-scan explicit ids so a title-only slug can never displace one an
503
+ // author spelled out, even when the title-only entry comes first in source.
504
+ const reserved = collectExplicitRoadmapIds(file.roadmapEntries);
434
505
  for (const entry of file.roadmapEntries) {
435
- addRoadmapEntry(content, entry);
506
+ addRoadmapEntry(content, entry, reserved);
436
507
  }
437
508
  }
438
509
 
439
- function addRoadmapEntry(content: ResolvedContent, entry: RoadmapEntry): void {
440
- if (isSwimlaneDeclaration(entry)) {
441
- if (entry.name && !content.swimlanes.has(entry.name)) {
442
- content.swimlanes.set(entry.name, entry);
510
+ /** Kebab-case slug for title-only entities (specs/dsl.md § Identifiers). */
511
+ function slugifyTitle(title: string): string {
512
+ return (
513
+ title
514
+ .toLowerCase()
515
+ .replace(/[^a-z0-9]+/g, '-')
516
+ .replace(/^-+|-+$/g, '') || 'entity'
517
+ );
518
+ }
519
+
520
+ /**
521
+ * Pick a map key that does not collide with an existing entry, nor with any
522
+ * key in `reserved` (explicit ids that will be inserted later in the same
523
+ * pass). Reserving keeps a title-only slug from claiming a key an author
524
+ * spelled out explicitly, regardless of source order.
525
+ */
526
+ function uniqueMapKey(map: Map<string, unknown>, base: string, reserved?: Set<string>): string {
527
+ const taken = (key: string): boolean => map.has(key) || (reserved?.has(key) ?? false);
528
+ if (!taken(base)) return base;
529
+ let n = 2;
530
+ while (taken(`${base}-${n}`)) n++;
531
+ return `${base}-${n}`;
532
+ }
533
+
534
+ /**
535
+ * Insert a roadmap entity into a resolved-content map. Explicit ids always
536
+ * win their key (and keep today's parent-wins-on-collision behavior); title-only
537
+ * entries land under a slug derived from the title (internal key — not written
538
+ * to AST) that avoids both occupied and `reserved` explicit-id keys.
539
+ */
540
+ function addByKey<V extends { name?: string; title?: string }>(
541
+ map: Map<string, V>,
542
+ entry: V,
543
+ reserved?: Set<string>,
544
+ ): void {
545
+ if (entry.name) {
546
+ if (!map.has(entry.name)) {
547
+ map.set(entry.name, entry);
443
548
  }
549
+ } else if (entry.title) {
550
+ map.set(
551
+ uniqueMapKey(map as Map<string, unknown>, slugifyTitle(entry.title), reserved),
552
+ entry,
553
+ );
554
+ }
555
+ }
556
+
557
+ function addRoadmapEntry(
558
+ content: ResolvedContent,
559
+ entry: RoadmapEntry,
560
+ reserved: ReservedRoadmapIds,
561
+ ): void {
562
+ if (isSwimlaneDeclaration(entry)) {
563
+ addByKey(content.swimlanes, entry, reserved.swimlanes);
444
564
  } else if (isPersonDeclaration(entry)) {
445
- if (entry.name && !content.persons.has(entry.name)) {
446
- content.persons.set(entry.name, entry);
447
- }
565
+ addByKey(content.persons, entry, reserved.persons);
448
566
  } else if (isTeamDeclaration(entry)) {
449
- if (entry.name && !content.teams.has(entry.name)) {
450
- content.teams.set(entry.name, entry);
451
- }
567
+ addByKey(content.teams, entry, reserved.teams);
452
568
  } else if (isAnchorDeclaration(entry)) {
453
- if (entry.name && !content.anchors.has(entry.name)) {
454
- content.anchors.set(entry.name, entry);
455
- }
569
+ addByKey(content.anchors, entry, reserved.anchors);
456
570
  } else if (isLabelDeclaration(entry)) {
457
- if (entry.name && !content.labels.has(entry.name)) {
458
- content.labels.set(entry.name, entry);
459
- }
571
+ addByKey(content.labels, entry, reserved.labels);
460
572
  } else if (isSizeDeclaration(entry)) {
461
- if (entry.name && !content.sizes.has(entry.name)) {
462
- content.sizes.set(entry.name, entry);
463
- }
573
+ addByKey(content.sizes, entry, reserved.sizes);
464
574
  } else if (isStatusDeclaration(entry)) {
465
- if (entry.name && !content.statuses.has(entry.name)) {
466
- content.statuses.set(entry.name, entry);
467
- }
575
+ addByKey(content.statuses, entry, reserved.statuses);
468
576
  } else if (isMilestoneDeclaration(entry)) {
469
- if (entry.name && !content.milestones.has(entry.name)) {
470
- content.milestones.set(entry.name, entry);
471
- }
577
+ addByKey(content.milestones, entry, reserved.milestones);
472
578
  } else if (isFootnoteDeclaration(entry)) {
473
- if (entry.name && !content.footnotes.has(entry.name)) {
474
- content.footnotes.set(entry.name, entry);
475
- }
579
+ addByKey(content.footnotes, entry, reserved.footnotes);
476
580
  }
477
581
  }
@@ -0,0 +1,3 @@
1
+ export type TemplateName = 'minimal' | 'teams' | 'product' | 'showcase';
2
+
3
+ export const TEMPLATE_NAMES: readonly TemplateName[] = ['minimal', 'teams', 'product', 'showcase'];