sketchmark 1.0.3 → 1.1.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.

Potentially problematic release.


This version of sketchmark might be problematic. Click here for more details.

package/dist/index.cjs CHANGED
@@ -227,7 +227,7 @@ function tokenize$1(src) {
227
227
  }
228
228
 
229
229
  // ============================================================
230
- // sketchmark Parser (Tokens DiagramAST)
230
+ // sketchmark - Parser (Tokens -> DiagramAST)
231
231
  // ============================================================
232
232
  let _uid = 0;
233
233
  function uid(prefix) {
@@ -286,15 +286,16 @@ function propsToStyle(p) {
286
286
  s.textAlign = p["text-align"];
287
287
  if (p.padding)
288
288
  s.padding = parseFloat(p.padding);
289
- if (p["vertical-align"])
289
+ if (p["vertical-align"]) {
290
290
  s.verticalAlign = p["vertical-align"];
291
+ }
291
292
  if (p["line-height"])
292
293
  s.lineHeight = parseFloat(p["line-height"]);
293
294
  if (p["letter-spacing"])
294
295
  s.letterSpacing = parseFloat(p["letter-spacing"]);
295
296
  if (p.font)
296
297
  s.font = p.font;
297
- const dashVal = p["dash"] || p["stroke-dash"];
298
+ const dashVal = p.dash || p["stroke-dash"];
298
299
  if (dashVal) {
299
300
  const parts = dashVal
300
301
  .split(",")
@@ -305,10 +306,15 @@ function propsToStyle(p) {
305
306
  }
306
307
  return s;
307
308
  }
309
+ function isValueToken(t) {
310
+ return !!t && (t.type === "IDENT" || t.type === "STRING" || t.type === "KEYWORD");
311
+ }
312
+ function isPropKeyToken(t) {
313
+ return !!t && (t.type === "IDENT" || t.type === "KEYWORD");
314
+ }
308
315
  function parse(src) {
309
316
  resetUid();
310
317
  const tokens = tokenize$1(src).filter((t) => t.type !== "NEWLINE" || t.value === "\n");
311
- // Collapse multiple consecutive NEWLINEs into one
312
318
  const flat = [];
313
319
  let lastNL = false;
314
320
  for (const t of tokens) {
@@ -337,11 +343,9 @@ function parse(src) {
337
343
  config: {},
338
344
  rootOrder: [],
339
345
  };
340
- const nodeIds = new Set();
341
- const tableIds = new Set();
342
- const chartIds = new Set();
343
- const groupIds = new Set();
344
- const markdownIds = new Set();
346
+ const authoredEntityKinds = new Map();
347
+ const unresolvedGroupItems = new Map();
348
+ const groupTokens = new Map();
345
349
  let i = 0;
346
350
  const cur = () => flat[i] ?? flat[flat.length - 1];
347
351
  const peek1 = () => flat[i + 1] ?? flat[flat.length - 1];
@@ -350,7 +354,6 @@ function parse(src) {
350
354
  while (cur().type === "NEWLINE")
351
355
  skip();
352
356
  };
353
- // Consume until EOL, return all tokens
354
357
  function lineTokens() {
355
358
  const acc = [];
356
359
  while (cur().type !== "NEWLINE" && cur().type !== "EOF") {
@@ -361,24 +364,104 @@ function parse(src) {
361
364
  skip();
362
365
  return acc;
363
366
  }
367
+ function requireExplicitId(keywordTok, toks) {
368
+ const first = toks[0];
369
+ if (!isValueToken(first) || toks[1]?.type === "EQUALS") {
370
+ throw new ParseError(`${keywordTok.value} requires an explicit id before properties`, keywordTok.line, keywordTok.col);
371
+ }
372
+ return first.value;
373
+ }
374
+ function parseSimpleProps(toks, startIndex) {
375
+ const props = {};
376
+ let j = startIndex;
377
+ while (j < toks.length - 1) {
378
+ const key = toks[j];
379
+ const eq = toks[j + 1];
380
+ if (isPropKeyToken(key) && eq?.type === "EQUALS" && j + 2 < toks.length) {
381
+ props[key.value] = toks[j + 2].value;
382
+ j += 3;
383
+ }
384
+ else {
385
+ j++;
386
+ }
387
+ }
388
+ return props;
389
+ }
390
+ function parseGroupProps(toks, startIndex) {
391
+ const props = {};
392
+ const itemIds = [];
393
+ let j = startIndex;
394
+ while (j < toks.length) {
395
+ const key = toks[j];
396
+ const eq = toks[j + 1];
397
+ if (!isPropKeyToken(key) || eq?.type !== "EQUALS") {
398
+ j++;
399
+ continue;
400
+ }
401
+ if (key.value === "items") {
402
+ const open = toks[j + 2];
403
+ if (open?.type !== "LBRACKET") {
404
+ throw new ParseError(`items must use bracket syntax like items=[a,b]`, key.line, key.col);
405
+ }
406
+ j += 3;
407
+ while (j < toks.length && toks[j].type !== "RBRACKET") {
408
+ const tok = toks[j];
409
+ if (tok.type === "COMMA") {
410
+ j++;
411
+ continue;
412
+ }
413
+ if (!isValueToken(tok)) {
414
+ const invalidTok = toks[j];
415
+ throw new ParseError(`items can only contain ids like items=[a,b]`, invalidTok.line, invalidTok.col);
416
+ }
417
+ itemIds.push(tok.value);
418
+ j++;
419
+ if (toks[j]?.type === "COMMA") {
420
+ j++;
421
+ }
422
+ else if (toks[j] && toks[j].type !== "RBRACKET") {
423
+ throw new ParseError(`Expected ',' or ']' in items list`, toks[j].line, toks[j].col);
424
+ }
425
+ }
426
+ if (toks[j]?.type !== "RBRACKET") {
427
+ throw new ParseError(`Unterminated items list; expected ']'`, key.line, key.col);
428
+ }
429
+ j++;
430
+ continue;
431
+ }
432
+ if (j + 2 < toks.length) {
433
+ props[key.value] = toks[j + 2].value;
434
+ j += 3;
435
+ }
436
+ else {
437
+ j++;
438
+ }
439
+ }
440
+ return { props, itemIds };
441
+ }
442
+ function registerAuthoredId(id, kind, tok) {
443
+ const existing = authoredEntityKinds.get(id);
444
+ if (existing) {
445
+ throw new ParseError(`Duplicate id "${id}" already declared as a ${existing}`, tok.line, tok.col);
446
+ }
447
+ authoredEntityKinds.set(id, kind);
448
+ }
364
449
  function parseDataArray() {
365
450
  const rows = [];
366
451
  while (cur().type !== "LBRACKET" && cur().type !== "EOF")
367
452
  skip();
368
- skip(); // outer [
453
+ skip();
369
454
  skipNL();
370
455
  while (cur().type !== "RBRACKET" && cur().type !== "EOF") {
371
456
  skipNL();
372
457
  if (cur().type === "RBRACKET" || cur().type === "EOF")
373
- break; // ← ADD THIS LINE
458
+ break;
374
459
  if (cur().type === "LBRACKET") {
375
460
  skip();
376
461
  const row = [];
377
462
  while (cur().type !== "RBRACKET" && cur().type !== "EOF") {
378
463
  const v = cur();
379
- if (v.type === "STRING" ||
380
- v.type === "IDENT" ||
381
- v.type === "KEYWORD") {
464
+ if (v.type === "STRING" || v.type === "IDENT" || v.type === "KEYWORD") {
382
465
  row.push(v.value);
383
466
  skip();
384
467
  }
@@ -389,8 +472,9 @@ function parse(src) {
389
472
  else if (v.type === "COMMA" || v.type === "NEWLINE") {
390
473
  skip();
391
474
  }
392
- else
475
+ else {
393
476
  break;
477
+ }
394
478
  }
395
479
  if (cur().type === "RBRACKET")
396
480
  skip();
@@ -399,46 +483,25 @@ function parse(src) {
399
483
  else if (cur().type === "COMMA" || cur().type === "NEWLINE") {
400
484
  skip();
401
485
  }
402
- else
486
+ else {
403
487
  skip();
488
+ }
404
489
  }
405
490
  if (cur().type === "RBRACKET")
406
491
  skip();
407
492
  return rows;
408
493
  }
409
- function parseNode(shape, groupId) {
410
- skip(); // shape keyword
494
+ function parseNode(shape) {
495
+ const keywordTok = cur();
496
+ skip();
411
497
  const toks = lineTokens();
412
- let id = groupId ? groupId + "_" + uid(shape) : uid(shape);
413
- const props = {};
414
- let j = 0;
415
- // First token may be the node id
416
- if (j < toks.length &&
417
- (toks[j].type === "IDENT" || toks[j].type === "STRING")) {
418
- id = toks[j++].value;
419
- }
420
- // Remaining tokens are key=value pairs
421
- while (j < toks.length) {
422
- const t = toks[j];
423
- if ((t.type === "IDENT" || t.type === "KEYWORD") &&
424
- j + 1 < toks.length &&
425
- toks[j + 1].type === "EQUALS") {
426
- const key = t.value;
427
- j += 2;
428
- if (j < toks.length) {
429
- props[key] = toks[j].value;
430
- j++;
431
- }
432
- }
433
- else
434
- j++;
435
- }
498
+ const id = requireExplicitId(keywordTok, toks);
499
+ const props = parseSimpleProps(toks, 1);
436
500
  const node = {
437
501
  kind: "node",
438
502
  id,
439
503
  shape,
440
504
  label: props.label || "",
441
- ...(groupId ? { groupId } : {}),
442
505
  ...(props.width ? { width: parseFloat(props.width) } : {}),
443
506
  ...(props.height ? { height: parseFloat(props.height) } : {}),
444
507
  ...(props.deg ? { deg: parseFloat(props.deg) } : {}),
@@ -456,113 +519,56 @@ function parse(src) {
456
519
  node.pathData = props.value;
457
520
  return node;
458
521
  }
459
- function parseEdge(fromId, connector, rest) {
460
- const toTok = rest.shift();
461
- if (!toTok)
462
- throw new ParseError("Expected edge target", 0, 0);
463
- const toId = toTok.value;
464
- const props = {};
465
- let j = 0;
466
- while (j < rest.length) {
467
- const t = rest[j];
468
- if ((t.type === "IDENT" || t.type === "KEYWORD") &&
469
- j + 1 < rest.length &&
470
- rest[j + 1].type === "EQUALS") {
471
- const key = t.value;
472
- j += 2;
473
- if (j < rest.length) {
474
- props[key] = rest[j].value;
475
- j++;
476
- }
477
- }
478
- else
479
- j++;
480
- }
481
- const dashed = connector.includes("--") ||
482
- connector.includes(".-") ||
483
- connector.includes("-.");
484
- const bidir = connector.includes("<") && connector.includes(">");
485
- return {
486
- kind: "edge",
487
- id: uid("edge"),
488
- from: fromId,
489
- to: toId,
490
- connector: connector,
491
- label: props.label,
492
- dashed,
493
- bidirectional: bidir,
494
- style: propsToStyle(props),
495
- };
496
- }
497
- // ── parseNote → returns ASTNode with shape='note' ────────
498
- function parseNote(groupId) {
499
- skip(); // 'note'
522
+ function parseNote() {
523
+ const keywordTok = cur();
524
+ skip();
500
525
  const toks = lineTokens();
501
- let id = groupId ? groupId + "_" + uid("note") : uid("note");
502
- if (toks[0])
503
- id = toks[0].value;
526
+ const id = requireExplicitId(keywordTok, toks);
504
527
  const props = {};
505
528
  let j = 1;
506
- // Backward compat: second token is a bare/quoted string → label
507
529
  if (toks[1] &&
508
530
  (toks[1].type === "STRING" ||
509
531
  (toks[1].type === "IDENT" && toks[2]?.type !== "EQUALS"))) {
510
532
  props.label = toks[1].value;
511
533
  j = 2;
512
534
  }
513
- // Parse remaining key=value props
514
- while (j < toks.length - 1) {
515
- const k = toks[j];
516
- const eq = toks[j + 1];
517
- if (eq && eq.type === "EQUALS" && j + 2 < toks.length) {
518
- props[k.value] = toks[j + 2].value;
519
- j += 3;
520
- }
521
- else
522
- j++;
523
- }
524
- // Support multiline via literal \n in label string
525
- const rawLabel = props.label ?? "";
535
+ Object.assign(props, parseSimpleProps(toks, j));
526
536
  return {
527
537
  kind: "node",
528
538
  id,
529
539
  shape: "note",
530
- label: rawLabel.replace(/\\n/g, "\n"),
531
- groupId,
540
+ label: (props.label ?? "").replace(/\\n/g, "\n"),
532
541
  theme: props.theme,
533
542
  style: propsToStyle(props),
534
543
  ...(props.width ? { width: parseFloat(props.width) } : {}),
535
544
  ...(props.height ? { height: parseFloat(props.height) } : {}),
536
545
  };
537
546
  }
538
- // ── parseGroup ───────────────────────────────────────────
539
- function parseGroup(parentGroupId) {
540
- skip(); // 'group'
547
+ function parseGroup() {
548
+ const keywordTok = cur();
549
+ skip();
541
550
  const toks = lineTokens();
542
- let id = uid("group");
543
- if (toks[0])
544
- id = toks[0].value;
551
+ if (toks.some((t) => t.type === "LBRACE" || t.type === "RBRACE")) {
552
+ throw new ParseError(`Nested group blocks were removed. Use ${keywordTok.value} <id> items=[...] instead.`, keywordTok.line, keywordTok.col);
553
+ }
554
+ const id = requireExplicitId(keywordTok, toks);
545
555
  const props = {};
546
556
  let j = 1;
547
- // Backward compat: second token is a quoted/bare string (old label syntax)
548
557
  if (toks[1] &&
549
558
  (toks[1].type === "STRING" ||
550
559
  (toks[1].type === "IDENT" && toks[2]?.type !== "EQUALS"))) {
551
560
  props.label = toks[1].value;
552
561
  j = 2;
553
562
  }
554
- // Parse remaining key=value props
555
- while (j < toks.length - 1) {
556
- const k = toks[j];
557
- const eq = toks[j + 1];
558
- if (eq && eq.type === "EQUALS" && j + 2 < toks.length) {
559
- props[k.value] = toks[j + 2].value;
560
- j += 3;
561
- }
562
- else
563
- j++;
563
+ const parsed = parseGroupProps(toks, j);
564
+ Object.assign(props, parsed.props);
565
+ skipNL();
566
+ if (cur().type === "LBRACE") {
567
+ throw new ParseError(`Nested group blocks were removed. Use ${keywordTok.value} ${id} items=[...] instead.`, cur().line, cur().col);
564
568
  }
565
- const group = {
569
+ unresolvedGroupItems.set(id, parsed.itemIds);
570
+ groupTokens.set(id, keywordTok);
571
+ return {
566
572
  kind: "group",
567
573
  id,
568
574
  label: props.label ?? "",
@@ -575,109 +581,43 @@ function parse(src) {
575
581
  justify: props.justify,
576
582
  theme: props.theme,
577
583
  style: propsToStyle(props),
578
- width: props.width !== undefined ? parseFloat(props.width) : undefined, // ← add
584
+ width: props.width !== undefined ? parseFloat(props.width) : undefined,
579
585
  height: props.height !== undefined ? parseFloat(props.height) : undefined,
580
586
  };
581
- skipNL();
582
- if (cur().type === "LBRACE") {
583
- skip();
584
- skipNL();
585
- }
586
- while (cur().type !== "RBRACE" &&
587
- cur().value !== "end" &&
588
- cur().type !== "EOF") {
589
- skipNL();
590
- if (cur().type === "RBRACE")
591
- break;
592
- const v = cur().value;
593
- // ── Nested group ──────────────────────────────────
594
- if (v === "group" || v === "bare") {
595
- const isBare = v === "bare";
596
- const nested = parseGroup();
597
- if (isBare) {
598
- nested.label = "";
599
- nested.style = {
600
- ...nested.style,
601
- fill: nested.style?.fill ?? "none",
602
- stroke: nested.style?.stroke ?? "none",
603
- strokeWidth: nested.style?.strokeWidth ?? 0,
604
- };
605
- }
606
- ast.groups.push(nested);
607
- groupIds.add(nested.id);
608
- group.children.push({ kind: "group", id: nested.id });
609
- continue;
610
- }
611
- // ── Table ─────────────────────────────────────────
612
- if (v === "table") {
613
- const tbl = parseTable();
614
- ast.tables.push(tbl);
615
- tableIds.add(tbl.id);
616
- group.children.push({ kind: "table", id: tbl.id });
617
- continue;
618
- }
619
- // ── Note (parsed as node with shape='note') ──────
620
- if (v === "note") {
621
- const note = parseNote(id);
622
- ast.nodes.push(note);
623
- nodeIds.add(note.id);
624
- group.children.push({ kind: "node", id: note.id });
625
- continue;
626
- }
627
- // ── Markdown ───────────────────────────────────────
628
- if (v === "markdown") {
629
- const md = parseMarkdown(id);
630
- ast.markdowns.push(md);
631
- markdownIds.add(md.id);
632
- group.children.push({ kind: "markdown", id: md.id });
633
- continue;
634
- }
635
- if (v === "bare") {
636
- // treat exactly like 'group' but inject defaults
637
- const grp = parseGroup(); // reuse parseGroup
638
- grp.label = "";
639
- grp.style = { ...grp.style, stroke: "none", fill: "none" };
640
- // rest is identical to group handling
641
- }
642
- // ── Chart ──────────────────────────────────────────
643
- if (CHART_TYPES.includes(v)) {
644
- const chart = parseChart(v);
645
- ast.charts.push(chart);
646
- chartIds.add(chart.id);
647
- group.children.push({ kind: "chart", id: chart.id });
648
- continue;
649
- }
650
- // ── Node shape ────────────────────────────────────
651
- if (SHAPES$1.includes(v)) {
652
- const node = parseNode(v, id);
653
- if (!nodeIds.has(node.id)) {
654
- nodeIds.add(node.id);
655
- ast.nodes.push(node);
656
- }
657
- group.children.push({ kind: "node", id: node.id });
658
- continue;
587
+ }
588
+ function parseEdge(fromId, connector, rest) {
589
+ const toTok = rest.shift();
590
+ if (!toTok)
591
+ throw new ParseError("Expected edge target", 0, 0);
592
+ const props = {};
593
+ let j = 0;
594
+ while (j < rest.length) {
595
+ const t = rest[j];
596
+ if ((t.type === "IDENT" || t.type === "KEYWORD") &&
597
+ j + 1 < rest.length &&
598
+ rest[j + 1].type === "EQUALS") {
599
+ props[t.value] = rest[j + 2]?.value ?? "";
600
+ j += 3;
659
601
  }
660
- // ── Edge inside group ─────────────────────────────
661
- if (cur().type === "IDENT" ||
662
- cur().type === "STRING" ||
663
- cur().type === "KEYWORD") {
664
- const nextTok = flat[i + 1];
665
- if (nextTok && nextTok.type === "ARROW") {
666
- const lineToks = lineTokens();
667
- if (lineToks.length >= 3 && lineToks[1].type === "ARROW") {
668
- const fromId = lineToks[0].value;
669
- const conn = lineToks[1].value;
670
- const edge = parseEdge(fromId, conn, lineToks.slice(2));
671
- ast.edges.push(edge);
672
- }
673
- continue;
674
- }
602
+ else {
603
+ j++;
675
604
  }
676
- skip();
677
605
  }
678
- if (cur().type === "RBRACE")
679
- skip();
680
- return group;
606
+ const dashed = connector.includes("--") ||
607
+ connector.includes(".-") ||
608
+ connector.includes("-.");
609
+ const bidirectional = connector.includes("<") && connector.includes(">");
610
+ return {
611
+ kind: "edge",
612
+ id: uid("edge"),
613
+ from: fromId,
614
+ to: toTok.value,
615
+ connector: connector,
616
+ label: props.label,
617
+ dashed,
618
+ bidirectional,
619
+ style: propsToStyle(props),
620
+ };
681
621
  }
682
622
  function parseStep() {
683
623
  skip();
@@ -688,12 +628,10 @@ function parse(src) {
688
628
  target = `${toks[1].value}${toks[2].value}${toks[3].value}`;
689
629
  }
690
630
  const step = { kind: "step", action, target };
691
- // narrate: text is the value, not a target
692
631
  if (action === "narrate") {
693
632
  step.target = "";
694
633
  step.value = toks[1]?.value ?? "";
695
634
  }
696
- // bracket: needs two targets
697
635
  if (action === "bracket" && toks.length >= 3) {
698
636
  step.target = toks[1]?.value ?? "";
699
637
  step.target2 = toks[2]?.value ?? "";
@@ -703,7 +641,6 @@ function parse(src) {
703
641
  const k = toks[j]?.value;
704
642
  const eq = toks[j + 1];
705
643
  const vt = toks[j + 2];
706
- // key=value form
707
644
  if (eq?.type === "EQUALS" && vt) {
708
645
  if (k === "dx") {
709
646
  step.dx = parseFloat(vt.value);
@@ -735,12 +672,7 @@ function parse(src) {
735
672
  j += 2;
736
673
  continue;
737
674
  }
738
- if (k === "fill") {
739
- step.value = vt.value;
740
- j += 2;
741
- continue;
742
- }
743
- if (k === "color") {
675
+ if (k === "fill" || k === "color") {
744
676
  step.value = vt.value;
745
677
  j += 2;
746
678
  continue;
@@ -751,7 +683,6 @@ function parse(src) {
751
683
  continue;
752
684
  }
753
685
  }
754
- // bare key value (legacy)
755
686
  if (k === "delay" && eq?.type === "NUMBER") {
756
687
  step.delay = parseFloat(eq.value);
757
688
  j++;
@@ -765,89 +696,16 @@ function parse(src) {
765
696
  if (k === "trigger") {
766
697
  step.trigger = eq?.value;
767
698
  j++;
768
- continue;
769
699
  }
770
700
  }
771
701
  return step;
772
702
  }
773
- // function parseStep(): ASTStep {
774
- // skip(); // 'step'
775
- // const toks = lineTokens();
776
- // const action = (toks[0]?.value ?? "highlight") as AnimationAction;
777
- // let target = toks[1]?.value ?? "";
778
- // if (toks[2]?.type === "ARROW" && toks[3]) {
779
- // target = `${toks[1].value}${toks[2].value}${toks[3].value}`;
780
- // }
781
- // const step: ASTStep = { kind: "step", action, target };
782
- // for (let j = 2; j < toks.length - 1; j++) {
783
- // const k = toks[j].value;
784
- // const eq = toks[j + 1];
785
- // const vt = toks[j + 2];
786
- // // key=value form (dx=50, dy=-80, duration=600)
787
- // if (eq?.type === "EQUALS" && vt) {
788
- // if (k === "dx") {
789
- // step.dx = parseFloat(vt.value);
790
- // j += 2;
791
- // continue;
792
- // }
793
- // if (k === "dy") {
794
- // step.dy = parseFloat(vt.value);
795
- // j += 2;
796
- // continue;
797
- // }
798
- // if (k === "duration") {
799
- // step.duration = parseFloat(vt.value);
800
- // j += 2;
801
- // continue;
802
- // }
803
- // if (k === "delay") {
804
- // step.delay = parseFloat(vt.value);
805
- // j += 2;
806
- // continue;
807
- // }
808
- // }
809
- // // bare key value form (legacy: delay 500, duration 400)
810
- // if (k === "delay" && eq?.type === "NUMBER") {
811
- // step.delay = parseFloat(eq.value);
812
- // j++;
813
- // }
814
- // if (k === "duration" && eq?.type === "NUMBER") {
815
- // step.duration = parseFloat(eq.value);
816
- // j++;
817
- // }
818
- // if (k === "trigger") {
819
- // step.trigger = eq?.value as AnimationTrigger;
820
- // j++;
821
- // }
822
- // if (k === "factor") {
823
- // step.factor = parseFloat(vt.value);
824
- // j += 2;
825
- // continue;
826
- // }
827
- // if (k === "deg") {
828
- // step.deg = parseFloat(vt.value);
829
- // j += 2;
830
- // continue;
831
- // }
832
- // }
833
- // return step;
834
- // }
835
703
  function parseChart(chartType) {
704
+ const keywordTok = cur();
836
705
  skip();
837
706
  const toks = lineTokens();
838
- const id = toks[0]?.value ?? uid("chart");
839
- const props = {};
840
- let j = 1;
841
- while (j < toks.length - 1) {
842
- const k = toks[j];
843
- const eq = toks[j + 1];
844
- if (eq?.type === "EQUALS" && j + 2 < toks.length) {
845
- props[k.value] = toks[j + 2].value;
846
- j += 3;
847
- }
848
- else
849
- j++;
850
- }
707
+ const id = requireExplicitId(keywordTok, toks);
708
+ const props = parseSimpleProps(toks, 1);
851
709
  let dataRows = [];
852
710
  skipNL();
853
711
  while (cur().type !== "EOF" && cur().value !== "end") {
@@ -860,30 +718,31 @@ function parse(src) {
860
718
  }
861
719
  else if ((cur().type === "IDENT" || cur().type === "KEYWORD") &&
862
720
  peek1().type === "EQUALS") {
863
- const k = cur().value;
721
+ const key = cur().value;
864
722
  skip();
865
723
  skip();
866
- props[k] = cur().value;
724
+ props[key] = cur().value;
867
725
  skip();
868
726
  }
869
727
  else if (SHAPES$1.includes(v) ||
870
728
  v === "step" ||
871
729
  v === "group" ||
872
- v === "note" || // ← ADD
730
+ v === "bare" ||
731
+ v === "note" ||
873
732
  v === "table" ||
874
- v === "config" || // ← ADD
875
- v === "theme" || // ← ADD
733
+ v === "config" ||
734
+ v === "theme" ||
876
735
  v === "style" ||
877
736
  v === "markdown" ||
878
737
  CHART_TYPES.includes(v)) {
879
738
  break;
880
739
  }
881
740
  else if (peek1().type === "ARROW") {
882
- // ← ADD THIS WHOLE BLOCK
883
741
  break;
884
742
  }
885
- else
743
+ else {
886
744
  skip();
745
+ }
887
746
  }
888
747
  const headers = dataRows[0]?.map(String) ?? [];
889
748
  const rows = dataRows.slice(1);
@@ -900,29 +759,19 @@ function parse(src) {
900
759
  };
901
760
  }
902
761
  function parseTable() {
903
- skip(); // 'table'
762
+ const keywordTok = cur();
763
+ skip();
904
764
  const toks = lineTokens();
905
- let id = uid("table");
906
- if (toks[0])
907
- id = toks[0].value;
765
+ const id = requireExplicitId(keywordTok, toks);
908
766
  const props = {};
909
767
  let j = 1;
910
- // label="..." or bare second token
911
768
  if (toks[1] &&
912
769
  (toks[1].type === "STRING" ||
913
770
  (toks[1].type === "IDENT" && toks[2]?.type !== "EQUALS"))) {
914
771
  props.label = toks[1].value;
915
772
  j = 2;
916
773
  }
917
- while (j < toks.length - 1) {
918
- const k = toks[j], eq = toks[j + 1];
919
- if (eq?.type === "EQUALS" && j + 2 < toks.length) {
920
- props[k.value] = toks[j + 2].value;
921
- j += 3;
922
- }
923
- else
924
- j++;
925
- }
774
+ Object.assign(props, parseSimpleProps(toks, j));
926
775
  const table = {
927
776
  kind: "table",
928
777
  id,
@@ -949,7 +798,8 @@ function parse(src) {
949
798
  while (cur().type !== "NEWLINE" && cur().type !== "EOF") {
950
799
  if (cur().type === "STRING" ||
951
800
  cur().type === "IDENT" ||
952
- cur().type === "NUMBER") {
801
+ cur().type === "NUMBER" ||
802
+ cur().type === "KEYWORD") {
953
803
  cells.push(cur().value);
954
804
  }
955
805
  skip();
@@ -958,31 +808,20 @@ function parse(src) {
958
808
  skip();
959
809
  table.rows.push({ kind: v === "header" ? "header" : "data", cells });
960
810
  }
961
- else
811
+ else {
962
812
  skip();
813
+ }
963
814
  }
964
815
  if (cur().type === "RBRACE")
965
816
  skip();
966
817
  return table;
967
818
  }
968
- // ── parseMarkdown ─────────────────────────────────────────
969
- function parseMarkdown(groupId) {
970
- skip(); // 'markdown'
819
+ function parseMarkdown() {
820
+ const keywordTok = cur();
821
+ skip();
971
822
  const toks = lineTokens();
972
- let id = groupId ? groupId + "_" + uid("md") : uid("md");
973
- if (toks[0])
974
- id = toks[0].value;
975
- const props = {};
976
- let j = 1;
977
- while (j < toks.length - 1) {
978
- const k = toks[j], eq = toks[j + 1];
979
- if (eq?.type === "EQUALS" && j + 2 < toks.length) {
980
- props[k.value] = toks[j + 2].value;
981
- j += 3;
982
- }
983
- else
984
- j++;
985
- }
823
+ const id = requireExplicitId(keywordTok, toks);
824
+ const props = parseSimpleProps(toks, 1);
986
825
  skipNL();
987
826
  let content = "";
988
827
  if (cur().type === "STRING_BLOCK") {
@@ -999,7 +838,6 @@ function parse(src) {
999
838
  style: propsToStyle(props),
1000
839
  };
1001
840
  }
1002
- // ── Main parse loop ─────────────────────────────────────
1003
841
  skipNL();
1004
842
  if (cur().value === "diagram")
1005
843
  skip();
@@ -1018,19 +856,16 @@ function parse(src) {
1018
856
  }
1019
857
  if (v === "end")
1020
858
  break;
1021
- // direction — silently ignored (removed from engine)
1022
859
  if (v === "direction") {
1023
860
  lineTokens();
1024
861
  continue;
1025
862
  }
1026
- // layout
1027
863
  if (v === "layout") {
1028
864
  skip();
1029
865
  ast.layout = cur().value ?? "column";
1030
866
  skip();
1031
867
  continue;
1032
868
  }
1033
- // title
1034
869
  if (v === "title") {
1035
870
  skip();
1036
871
  const toks = lineTokens();
@@ -1040,14 +875,10 @@ function parse(src) {
1040
875
  ast.title = toks[idx + 2]?.value ?? "";
1041
876
  }
1042
877
  else {
1043
- ast.title = toks
1044
- .map((t2) => t2.value)
1045
- .join(" ")
1046
- .replace(/"/g, "");
878
+ ast.title = toks.map((t2) => t2.value).join(" ").replace(/"/g, "");
1047
879
  }
1048
880
  continue;
1049
881
  }
1050
- // description
1051
882
  if (v === "description") {
1052
883
  skip();
1053
884
  ast.description = lineTokens()
@@ -1056,65 +887,38 @@ function parse(src) {
1056
887
  .replace(/"/g, "");
1057
888
  continue;
1058
889
  }
1059
- // config
1060
890
  if (v === "config") {
1061
891
  skip();
1062
- const k = cur().value;
892
+ const key = cur().value;
1063
893
  skip();
1064
894
  if (cur().type === "EQUALS")
1065
895
  skip();
1066
- const cv = cur().value;
896
+ const value = cur().value;
1067
897
  skip();
1068
- ast.config[k] = cv;
898
+ ast.config[key] = value;
1069
899
  continue;
1070
900
  }
1071
- // style
1072
901
  if (v === "style") {
1073
902
  skip();
1074
903
  const targetId = cur().value;
1075
904
  skip();
1076
- const lineToks = lineTokens();
1077
- const p = {};
1078
- let j = 0;
1079
- while (j < lineToks.length - 1) {
1080
- const k2 = lineToks[j];
1081
- const eq = lineToks[j + 1];
1082
- if (eq.type === "EQUALS") {
1083
- p[k2.value] = lineToks[j + 2]?.value ?? "";
1084
- j += 3;
1085
- }
1086
- else
1087
- j++;
1088
- }
1089
- ast.styles[targetId] = { ...ast.styles[targetId], ...propsToStyle(p) };
905
+ const props = parseSimpleProps(lineTokens(), 0);
906
+ ast.styles[targetId] = { ...ast.styles[targetId], ...propsToStyle(props) };
1090
907
  continue;
1091
908
  }
1092
- // theme
1093
909
  if (v === "theme") {
1094
910
  skip();
1095
911
  const toks = lineTokens();
1096
912
  const themeId = toks[0]?.value;
1097
913
  if (!themeId)
1098
914
  continue;
1099
- const props = {};
1100
- let j = 1;
1101
- while (j < toks.length - 1) {
1102
- const k2 = toks[j];
1103
- const eq = toks[j + 1];
1104
- if (eq && eq.type === "EQUALS" && j + 2 < toks.length) {
1105
- props[k2.value] = toks[j + 2].value;
1106
- j += 3;
1107
- }
1108
- else
1109
- j++;
1110
- }
1111
- ast.themes[themeId] = propsToStyle(props);
915
+ ast.themes[themeId] = propsToStyle(parseSimpleProps(toks, 1));
1112
916
  continue;
1113
917
  }
1114
- // group
1115
918
  if (v === "group" || v === "bare") {
1116
919
  const isBare = v === "bare";
1117
920
  const grp = parseGroup();
921
+ registerAuthoredId(grp.id, "group", t);
1118
922
  if (isBare) {
1119
923
  grp.label = "";
1120
924
  grp.style = {
@@ -1125,36 +929,34 @@ function parse(src) {
1125
929
  };
1126
930
  }
1127
931
  ast.groups.push(grp);
1128
- groupIds.add(grp.id);
1129
932
  ast.rootOrder.push({ kind: "group", id: grp.id });
1130
933
  continue;
1131
934
  }
1132
- // table
1133
935
  if (v === "table") {
1134
936
  const tbl = parseTable();
937
+ registerAuthoredId(tbl.id, "table", t);
1135
938
  ast.tables.push(tbl);
1136
- tableIds.add(tbl.id);
1137
939
  ast.rootOrder.push({ kind: "table", id: tbl.id });
1138
940
  continue;
1139
941
  }
1140
- // note (parsed as node with shape='note')
1141
942
  if (v === "note") {
1142
943
  const note = parseNote();
944
+ registerAuthoredId(note.id, "node", t);
1143
945
  ast.nodes.push(note);
1144
- nodeIds.add(note.id);
1145
946
  ast.rootOrder.push({ kind: "node", id: note.id });
1146
947
  continue;
1147
948
  }
1148
- // beat { ... } — parallel steps
1149
949
  if (v === "beat") {
1150
- skip(); // 'beat'
950
+ skip();
1151
951
  skipNL();
1152
952
  if (cur().type === "LBRACE") {
1153
953
  skip();
1154
954
  skipNL();
1155
955
  }
1156
956
  const children = [];
1157
- while (cur().type !== "RBRACE" && cur().value !== "end" && cur().type !== "EOF") {
957
+ while (cur().type !== "RBRACE" &&
958
+ cur().value !== "end" &&
959
+ cur().type !== "EOF") {
1158
960
  skipNL();
1159
961
  if (cur().type === "RBRACE")
1160
962
  break;
@@ -1170,69 +972,120 @@ function parse(src) {
1170
972
  ast.steps.push({ kind: "beat", children });
1171
973
  continue;
1172
974
  }
1173
- // step
1174
975
  if (v === "step") {
1175
976
  ast.steps.push(parseStep());
1176
977
  continue;
1177
978
  }
1178
- // charts
1179
979
  if (CHART_TYPES.includes(v)) {
1180
980
  const chart = parseChart(v);
981
+ registerAuthoredId(chart.id, "chart", t);
1181
982
  ast.charts.push(chart);
1182
- chartIds.add(chart.id);
1183
- ast.rootOrder.push({ kind: "chart", id: chart.id }); // ← ADD
983
+ ast.rootOrder.push({ kind: "chart", id: chart.id });
1184
984
  continue;
1185
985
  }
1186
986
  if (v === "markdown") {
1187
987
  const md = parseMarkdown();
988
+ registerAuthoredId(md.id, "markdown", t);
1188
989
  ast.markdowns.push(md);
1189
- markdownIds.add(md.id);
1190
990
  ast.rootOrder.push({ kind: "markdown", id: md.id });
1191
991
  continue;
1192
992
  }
1193
- // edge: A -> B (MUST come before shape check)
1194
993
  if (t.type === "IDENT" || t.type === "STRING" || t.type === "KEYWORD") {
1195
994
  const nextTok = flat[i + 1];
1196
995
  if (nextTok && nextTok.type === "ARROW") {
1197
996
  const lineToks = lineTokens();
1198
997
  if (lineToks.length >= 3 && lineToks[1].type === "ARROW") {
1199
998
  const fromId = lineToks[0].value;
1200
- const conn = lineToks[1].value;
1201
- const edge = parseEdge(fromId, conn, lineToks.slice(2));
1202
- ast.edges.push(edge);
1203
- // Auto-create implied nodes if they don't exist yet
1204
- for (const nid of [fromId, edge.to]) {
1205
- if (!nodeIds.has(nid) &&
1206
- !tableIds.has(nid) &&
1207
- !chartIds.has(nid) &&
1208
- !groupIds.has(nid)) {
1209
- nodeIds.add(nid);
1210
- ast.nodes.push({
1211
- kind: "node",
1212
- id: nid,
1213
- shape: "box",
1214
- label: nid,
1215
- style: {},
1216
- });
1217
- }
1218
- }
999
+ const connector = lineToks[1].value;
1000
+ ast.edges.push(parseEdge(fromId, connector, lineToks.slice(2)));
1219
1001
  continue;
1220
1002
  }
1221
1003
  }
1222
1004
  }
1223
- // node shapes — only reached if NOT followed by an arrow
1224
1005
  if (SHAPES$1.includes(v)) {
1225
1006
  const node = parseNode(v);
1226
- if (!nodeIds.has(node.id)) {
1227
- nodeIds.add(node.id);
1228
- ast.nodes.push(node);
1229
- ast.rootOrder.push({ kind: "node", id: node.id });
1230
- }
1007
+ registerAuthoredId(node.id, "node", t);
1008
+ ast.nodes.push(node);
1009
+ ast.rootOrder.push({ kind: "node", id: node.id });
1231
1010
  continue;
1232
1011
  }
1233
1012
  skip();
1234
1013
  }
1235
- // Merge global styles into node styles
1014
+ const allKnownIds = new Set(authoredEntityKinds.keys());
1015
+ for (const edge of ast.edges) {
1016
+ for (const id of [edge.from, edge.to]) {
1017
+ if (allKnownIds.has(id))
1018
+ continue;
1019
+ allKnownIds.add(id);
1020
+ ast.nodes.push({
1021
+ kind: "node",
1022
+ id,
1023
+ shape: "box",
1024
+ label: id,
1025
+ style: {},
1026
+ });
1027
+ }
1028
+ }
1029
+ const entityKindById = new Map();
1030
+ ast.nodes.forEach((node) => entityKindById.set(node.id, "node"));
1031
+ ast.groups.forEach((group) => entityKindById.set(group.id, "group"));
1032
+ ast.tables.forEach((table) => entityKindById.set(table.id, "table"));
1033
+ ast.charts.forEach((chart) => entityKindById.set(chart.id, "chart"));
1034
+ ast.markdowns.forEach((md) => entityKindById.set(md.id, "markdown"));
1035
+ for (const group of ast.groups) {
1036
+ const itemIds = unresolvedGroupItems.get(group.id) ?? [];
1037
+ group.children = itemIds.map((itemId) => {
1038
+ if (itemId === group.id) {
1039
+ const tok = groupTokens.get(group.id) ?? cur();
1040
+ throw new ParseError(`Group "${group.id}" cannot include itself in items=[...]`, tok.line, tok.col);
1041
+ }
1042
+ const kind = entityKindById.get(itemId);
1043
+ if (!kind) {
1044
+ const tok = groupTokens.get(group.id) ?? cur();
1045
+ throw new ParseError(`Group "${group.id}" references unknown item "${itemId}" in items=[...]`, tok.line, tok.col);
1046
+ }
1047
+ return { kind, id: itemId };
1048
+ });
1049
+ }
1050
+ const parentByItemId = new Map();
1051
+ for (const group of ast.groups) {
1052
+ for (const child of group.children) {
1053
+ const existingParent = parentByItemId.get(child.id);
1054
+ if (existingParent) {
1055
+ const tok = groupTokens.get(group.id) ?? cur();
1056
+ throw new ParseError(`Item "${child.id}" cannot belong to both "${existingParent}" and "${group.id}"`, tok.line, tok.col);
1057
+ }
1058
+ parentByItemId.set(child.id, group.id);
1059
+ }
1060
+ }
1061
+ const groupsById = new Map(ast.groups.map((group) => [group.id, group]));
1062
+ const visiting = new Set();
1063
+ const visited = new Set();
1064
+ const stack = [];
1065
+ function visitGroup(groupId) {
1066
+ if (visiting.has(groupId)) {
1067
+ const start = stack.indexOf(groupId);
1068
+ const cycle = (start >= 0 ? stack.slice(start) : stack).concat(groupId);
1069
+ const tok = groupTokens.get(groupId) ?? cur();
1070
+ throw new ParseError(`Group cycle detected: ${cycle.join(" -> ")}`, tok.line, tok.col);
1071
+ }
1072
+ if (visited.has(groupId))
1073
+ return;
1074
+ visiting.add(groupId);
1075
+ stack.push(groupId);
1076
+ const group = groupsById.get(groupId);
1077
+ if (group) {
1078
+ for (const child of group.children) {
1079
+ if (child.kind === "group")
1080
+ visitGroup(child.id);
1081
+ }
1082
+ }
1083
+ stack.pop();
1084
+ visiting.delete(groupId);
1085
+ visited.add(groupId);
1086
+ }
1087
+ for (const group of ast.groups)
1088
+ visitGroup(group.id);
1236
1089
  for (const node of ast.nodes) {
1237
1090
  if (ast.styles[node.id]) {
1238
1091
  node.style = { ...ast.styles[node.id], ...node.style };
@@ -3567,6 +3420,16 @@ function calcMarkdownWidth(lines, fontFamily = DEFAULT_FONT, pad = MARKDOWN.defa
3567
3420
  // ============================================================
3568
3421
  // ── Build scene graph from AST ────────────────────────────
3569
3422
  function buildSceneGraph(ast) {
3423
+ const nodeParentById = new Map();
3424
+ const groupParentById = new Map();
3425
+ for (const g of ast.groups) {
3426
+ for (const child of g.children) {
3427
+ if (child.kind === "node")
3428
+ nodeParentById.set(child.id, g.id);
3429
+ if (child.kind === "group")
3430
+ groupParentById.set(child.id, g.id);
3431
+ }
3432
+ }
3570
3433
  const nodes = ast.nodes.map((n) => {
3571
3434
  const themeStyle = n.theme ? (ast.themes[n.theme] ?? {}) : {};
3572
3435
  return {
@@ -3574,7 +3437,7 @@ function buildSceneGraph(ast) {
3574
3437
  shape: n.shape,
3575
3438
  label: n.label,
3576
3439
  style: { ...ast.styles[n.id], ...themeStyle, ...n.style },
3577
- groupId: n.groupId,
3440
+ groupId: nodeParentById.get(n.id),
3578
3441
  width: n.width,
3579
3442
  height: n.height,
3580
3443
  deg: n.deg,
@@ -3596,7 +3459,7 @@ function buildSceneGraph(ast) {
3596
3459
  return {
3597
3460
  id: g.id,
3598
3461
  label: g.label,
3599
- parentId: undefined, // set below
3462
+ parentId: groupParentById.get(g.id),
3600
3463
  children: g.children,
3601
3464
  layout: (g.layout ?? "column"),
3602
3465
  columns: g.columns ?? 1,
@@ -3644,28 +3507,21 @@ function buildSceneGraph(ast) {
3644
3507
  h: c.height ?? CHART.defaultH,
3645
3508
  };
3646
3509
  });
3647
- const markdowns = (ast.markdowns ?? []).map(m => {
3510
+ const markdowns = (ast.markdowns ?? []).map((m) => {
3648
3511
  const themeStyle = m.theme ? (ast.themes[m.theme] ?? {}) : {};
3649
3512
  return {
3650
3513
  id: m.id,
3651
3514
  content: m.content,
3652
3515
  lines: parseMarkdownContent(m.content),
3653
- style: { ...themeStyle, ...m.style },
3516
+ style: { ...ast.styles[m.id], ...themeStyle, ...m.style },
3654
3517
  width: m.width,
3655
3518
  height: m.height,
3656
- x: 0, y: 0, w: 0, h: 0,
3519
+ x: 0,
3520
+ y: 0,
3521
+ w: 0,
3522
+ h: 0,
3657
3523
  };
3658
3524
  });
3659
- // Set parentId for nested groups
3660
- for (const g of groups) {
3661
- for (const child of g.children) {
3662
- if (child.kind === "group") {
3663
- const nested = groups.find((gg) => gg.id === child.id);
3664
- if (nested)
3665
- nested.parentId = g.id;
3666
- }
3667
- }
3668
- }
3669
3525
  const edges = ast.edges.map((e) => ({
3670
3526
  id: e.id,
3671
3527
  from: e.from,
@@ -4792,15 +4648,30 @@ function layout(sg) {
4792
4648
  // 4. Build root order
4793
4649
  // sg.rootOrder preserves DSL declaration order.
4794
4650
  // Fall back: groups, then nodes, then tables.
4795
- const rootOrder = sg.rootOrder?.length
4796
- ? sg.rootOrder
4797
- : [
4798
- ...rootGroups.map((g) => ({ kind: "group", id: g.id })),
4799
- ...rootNodes.map((n) => ({ kind: "node", id: n.id })),
4800
- ...rootTables.map((t) => ({ kind: "table", id: t.id })),
4801
- ...rootCharts.map((c) => ({ kind: "chart", id: c.id })),
4802
- ...rootMarkdowns.map((m) => ({ kind: "markdown", id: m.id })),
4803
- ];
4651
+ const defaultRootOrder = [
4652
+ ...rootGroups.map((g) => ({ kind: "group", id: g.id })),
4653
+ ...rootNodes.map((n) => ({ kind: "node", id: n.id })),
4654
+ ...rootTables.map((t) => ({ kind: "table", id: t.id })),
4655
+ ...rootCharts.map((c) => ({ kind: "chart", id: c.id })),
4656
+ ...rootMarkdowns.map((m) => ({ kind: "markdown", id: m.id })),
4657
+ ];
4658
+ const rootOrderSource = sg.rootOrder?.length ? sg.rootOrder : defaultRootOrder;
4659
+ const rootOrder = rootOrderSource.filter((ref) => {
4660
+ switch (ref.kind) {
4661
+ case "group":
4662
+ return !nestedGroupIds.has(ref.id);
4663
+ case "node":
4664
+ return !groupedNodeIds.has(ref.id);
4665
+ case "table":
4666
+ return !groupedTableIds.has(ref.id);
4667
+ case "chart":
4668
+ return !groupedChartIds.has(ref.id);
4669
+ case "markdown":
4670
+ return !groupedMarkdownIds.has(ref.id);
4671
+ default:
4672
+ return true;
4673
+ }
4674
+ });
4804
4675
  // 5. Root-level layout
4805
4676
  // sg.layout:
4806
4677
  // 'row' → items flow left to right (default)