@meltemi/diagrams-model 1.10.0 → 1.10.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/c4.d.ts.map +1 -1
  2. package/dist/c4.js +7 -0
  3. package/dist/c4.js.map +1 -1
  4. package/dist/diagram-types.d.ts.map +1 -1
  5. package/dist/diagram-types.js +6 -0
  6. package/dist/diagram-types.js.map +1 -1
  7. package/dist/diff.d.ts +1 -1
  8. package/dist/diff.d.ts.map +1 -1
  9. package/dist/diff.js +23 -0
  10. package/dist/diff.js.map +1 -1
  11. package/dist/flow.d.ts +60 -10
  12. package/dist/flow.d.ts.map +1 -1
  13. package/dist/flow.js +405 -67
  14. package/dist/flow.js.map +1 -1
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +3 -3
  18. package/dist/index.js.map +1 -1
  19. package/dist/json.d.ts +29 -0
  20. package/dist/json.d.ts.map +1 -1
  21. package/dist/json.js +19 -1
  22. package/dist/json.js.map +1 -1
  23. package/dist/messages/changes.d.ts +1 -0
  24. package/dist/messages/changes.d.ts.map +1 -1
  25. package/dist/messages/changes.js +2 -0
  26. package/dist/messages/changes.js.map +1 -1
  27. package/dist/messages/en.d.ts +67 -3
  28. package/dist/messages/en.d.ts.map +1 -1
  29. package/dist/messages/en.js +77 -9
  30. package/dist/messages/en.js.map +1 -1
  31. package/dist/messages/es.d.ts.map +1 -1
  32. package/dist/messages/es.js +45 -8
  33. package/dist/messages/es.js.map +1 -1
  34. package/dist/messages/index.d.ts +36 -2
  35. package/dist/messages/index.d.ts.map +1 -1
  36. package/dist/outline.d.ts +2 -2
  37. package/dist/outline.d.ts.map +1 -1
  38. package/dist/outline.js +9 -3
  39. package/dist/outline.js.map +1 -1
  40. package/dist/scenario.d.ts +15 -2
  41. package/dist/scenario.d.ts.map +1 -1
  42. package/dist/scenario.js +37 -1
  43. package/dist/scenario.js.map +1 -1
  44. package/dist/schema.d.ts +82 -11
  45. package/dist/schema.d.ts.map +1 -1
  46. package/dist/schema.js +106 -7
  47. package/dist/schema.js.map +1 -1
  48. package/dist/sequence.d.ts.map +1 -1
  49. package/dist/sequence.js +252 -12
  50. package/dist/sequence.js.map +1 -1
  51. package/dist/story.d.ts +8 -0
  52. package/dist/story.d.ts.map +1 -1
  53. package/dist/story.js +39 -12
  54. package/dist/story.js.map +1 -1
  55. package/dist/style-values.d.ts +9 -0
  56. package/dist/style-values.d.ts.map +1 -1
  57. package/dist/style-values.js +82 -27
  58. package/dist/style-values.js.map +1 -1
  59. package/dist/types.d.ts +94 -9
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +8 -3
package/dist/flow.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { isToken, } from "@meltemi/diagrams-language";
2
- import { DIRECTIONS, LAYOUT_MODES, ALIGN_EDGES, ANIMATION_EFFECT_PROPERTIES, AXES, ELEMENT_ONLY_PROPERTIES, FLOW_PROFILE, FREE_PROFILE, MAIN_LAYER, PORTS, PORT_SIDES, RELATION_ARROWS, RELATION_ONLY_PROPERTIES, diagramStatements, profileTakesShape, takesShape, profileTakesRotation, takesRotation, } from "./schema.js";
2
+ import { DIRECTIONS, LAYOUT_MODES, ALIGN_EDGES, ANIMATION_EFFECT_PROPERTIES, AXES, ELEMENT_ONLY_PROPERTIES, FLOW_PROFILE, FREE_PROFILE, LINE_REFUSED_PROPERTIES, LIST_LAYOUTS, LIST_PROPERTIES, MAIN_LAYER, PEN_PROPERTIES, PEN_REFUSED_PROPERTIES, PEN_WIDTH_RANGE, PORTS, PORT_SIDES, RELATION_ARROWS, RELATION_ONLY_PROPERTIES, STICKY_PROPERTIES, STICKY_REFUSED_PROPERTIES, TEXTLESS_KINDS, TEXT_PROPERTIES, diagramStatements, isFreeStyleProperty, profileTakesShape, takesShape, profileTakesRotation, takesRotation, } from "./schema.js";
3
3
  import { relationAllowed, } from "./diagram-types.js";
4
4
  import { messages } from "./messages/index.js";
5
- import { StyleDefinitions, checkArg, describeArg, headerHasError, readClasses, readPropertyBlock, readStatementValue, refuseFreeProperties, singleIdent, spanOf, targetHint, } from "./style-values.js";
5
+ import { StyleDefinitions, argumentOf, checkArg, describeArg, describeSpec, headerHasError, readClasses, readPropertyBlock, readStatementValue, refuseFreeProperties, singleIdent, spanOf, TAG_FORM, targetHint, } from "./style-values.js";
6
6
  import { outlined } from "./outline.js";
7
7
  /** Element kinds drawn without an outline, which a moving dash cannot follow. */
8
8
  const NO_OUTLINE_KINDS = new Set([
@@ -153,6 +153,7 @@ export class FlowAnalyzer {
153
153
  routing: "auto",
154
154
  layers: [MAIN_LAYER],
155
155
  autonumber: null,
156
+ spacing: null,
156
157
  };
157
158
  }
158
159
  /** Sequence diagrams: the ordered events, their index and the scenarios. */
@@ -264,7 +265,8 @@ export class FlowAnalyzer {
264
265
  }
265
266
  /**
266
267
  * A dash follows an outline: a note or a free text that no style statement, class or rule of the
267
- * diagram gives a `fill`, `stroke` or `pattern` has none, and the dash is refused and dropped.
268
+ * diagram gives a `fill`, `stroke` or `pattern` has none, nor a sticky without a `stroke`, and
269
+ * the dash is refused and dropped.
268
270
  */
269
271
  checkOutlinedDashes() {
270
272
  const source = {
@@ -276,7 +278,13 @@ export class FlowAnalyzer {
276
278
  const element = this.elementsById.get(name);
277
279
  if (!element || outlined(source, element))
278
280
  continue;
279
- this.error("animate.invalid-target", messages().diagnostics.flow.dashNeedsOutline(name, element.kind), token, { hints: [messages().hints.flow.dashOutline] });
281
+ this.error("animate.invalid-target", messages().diagnostics.flow.dashNeedsOutline(name, element.kind), token, {
282
+ hints: [
283
+ element.kind === "sticky"
284
+ ? messages().hints.flow.dashStroke
285
+ : messages().hints.flow.dashOutline,
286
+ ],
287
+ });
280
288
  const at = this.animations.findIndex((a) => a.node === stmt && a.target === name && a.effect === "dash");
281
289
  if (at >= 0)
282
290
  this.animations.splice(at, 1);
@@ -363,17 +371,32 @@ export class FlowAnalyzer {
363
371
  keyword in this.profile.groupProperties) {
364
372
  continue;
365
373
  }
374
+ // `layout`, `spacing` and `align` make a list of a free group; other profiles have none.
375
+ if (isListProperty(keyword)) {
376
+ if (!(keyword in this.profile.groupProperties)) {
377
+ const m = messages();
378
+ this.invalid(m.diagnostics.flow.listInProfile(keyword, this.profile.name), stmt.keyword, [m.hints.flow.listsInFree]);
379
+ }
380
+ continue;
381
+ }
366
382
  const m = messages();
367
- const hints = otherKind
368
- ? [otherKind]
369
- : diagramStatements(this.profile).includes(keyword)
370
- ? [m.hints.flow.diagramLevel(keyword)]
371
- : [m.hints.flow.groupContents];
383
+ const misplaced = this.misplacedHint(keyword);
384
+ const hints = misplaced
385
+ ? [misplaced]
386
+ : otherKind
387
+ ? [otherKind]
388
+ : diagramStatements(this.profile).includes(keyword)
389
+ ? [m.hints.flow.diagramLevel(keyword)]
390
+ : [m.hints.flow.groupContents];
372
391
  const candidates = otherKind
373
392
  ? this.declarationWords()
374
- : diagramStatements(this.profile).includes(keyword)
393
+ : misplaced || diagramStatements(this.profile).includes(keyword)
375
394
  ? []
376
- : ["description", "url"];
395
+ : [
396
+ "description",
397
+ "url",
398
+ ...LIST_PROPERTIES.filter((name) => name in this.profile.groupProperties),
399
+ ];
377
400
  this.error("prop.unknown", m.diagnostics.flow.unknownInGroup(keyword), stmt.keyword, {
378
401
  hints,
379
402
  data: { candidates },
@@ -392,6 +415,14 @@ export class FlowAnalyzer {
392
415
  this.error("prop.unknown", messages().diagnostics.flow.unknownInDiagram(keyword, this.profile.name), stmt.keyword, { hints, data: { candidates: this.statementCandidates(keyword) } });
393
416
  }
394
417
  }
418
+ /**
419
+ * The profile's own hint for a statement written outside the block it belongs in (a sequence
420
+ * `tag` outside a message or participant block), in the interface language; undefined for every
421
+ * other statement.
422
+ */
423
+ misplacedHint(_keyword) {
424
+ return undefined;
425
+ }
395
426
  /** Hints for an unknown statement at the diagram level, in the interface language. */
396
427
  statementHints(keyword, stmt) {
397
428
  const otherKind = this.otherProfileKind(keyword);
@@ -402,7 +433,7 @@ export class FlowAnalyzer {
402
433
  if (stmt.args.length === 0 && !stmt.block) {
403
434
  hints.push(m.declareElement(keyword, this.profile.shorthand.box));
404
435
  }
405
- hints.push(this.free ? m.freeStatements : m.flowStatements);
436
+ hints.push(this.free ? m.freeStatements(FREE_DECLARATIONS) : m.flowStatements);
406
437
  return hints;
407
438
  }
408
439
  /** The statements an unknown one at the diagram level may have meant, for fixes. */
@@ -427,7 +458,7 @@ export class FlowAnalyzer {
427
458
  return undefined;
428
459
  }
429
460
  const m = messages().hints.flow;
430
- return this.free ? m.freeDeclarations : m.flowDeclarations(keyword);
461
+ return this.free ? m.freeDeclarations(FREE_DECLARATIONS) : m.flowDeclarations(keyword);
431
462
  }
432
463
  // ------------------------------------------------------------------ elements
433
464
  declare(stmt, groupPath) {
@@ -449,6 +480,8 @@ export class FlowAnalyzer {
449
480
  external: false,
450
481
  shape: data ? "cylinder" : null,
451
482
  line: null,
483
+ pen: null,
484
+ list: null,
452
485
  attachedTo: null,
453
486
  attachedToRange: null,
454
487
  typeKind: null,
@@ -485,6 +518,10 @@ export class FlowAnalyzer {
485
518
  }
486
519
  if (kind === "line" && !declaredKind)
487
520
  return this.declareLine(stmt, groupPath);
521
+ if (kind === "pen" && !declaredKind)
522
+ return this.declarePen(stmt, groupPath);
523
+ if (kind === "sticky" && !declaredKind)
524
+ return this.declareSticky(stmt, groupPath);
488
525
  const [idArg, labelArg, ...extra] = stmt.args;
489
526
  const idToken = singleIdent(idArg);
490
527
  if (!idToken) {
@@ -546,11 +583,14 @@ export class FlowAnalyzer {
546
583
  }
547
584
  }
548
585
  const block = kind === "group"
549
- ? { ...this.groupBlock(stmt.block, idToken), ports: [], values: {} }
550
- : this.elementBlock(stmt.block, declaredKind
551
- ? { ...this.profile.elementProperties, ...tableOf(declaredKind.properties) }
552
- : this.profile.elementProperties, "Element", idToken);
553
- const { values: blockValues, ...blockFields } = block;
586
+ ? { ...this.groupBlock(stmt.block, idToken), ports: [], tags: [], values: {} }
587
+ : {
588
+ ...this.elementBlock(stmt.block, declaredKind
589
+ ? { ...this.profile.elementProperties, ...tableOf(declaredKind.properties) }
590
+ : this.profile.elementProperties, "Element", idToken),
591
+ list: null,
592
+ };
593
+ const { values: blockValues, list, ...blockFields } = block;
554
594
  const added = this.addElement({
555
595
  id: idToken.text,
556
596
  kind,
@@ -565,6 +605,8 @@ export class FlowAnalyzer {
565
605
  external: false,
566
606
  shape: null,
567
607
  line: null,
608
+ pen: null,
609
+ list,
568
610
  attachedTo: attachment?.target ?? null,
569
611
  attachedToRange: attachment ? this.range(attachment.arg) : null,
570
612
  typeKind,
@@ -643,6 +685,123 @@ export class FlowAnalyzer {
643
685
  external: false,
644
686
  shape: null,
645
687
  line: { from, to, via },
688
+ pen: null,
689
+ list: null,
690
+ attachedTo: null,
691
+ attachedToRange: null,
692
+ typeKind: null,
693
+ data: {},
694
+ groupPath,
695
+ range: this.range(stmt),
696
+ idRange: this.range(idToken),
697
+ idToken,
698
+ node: stmt,
699
+ });
700
+ }
701
+ /**
702
+ * `pen <id> [(x, y), (x, y), ...] [{ ... }]`: a freehand stroke through its points, in the
703
+ * interior coordinates of its innermost group as a line's are. One point is a dot; decimals are
704
+ * kept. A pen has no label.
705
+ */
706
+ declarePen(stmt, groupPath) {
707
+ const form = "`pen <id> [(x, y), (x, y), ...]`";
708
+ const m = messages();
709
+ const [idArg, list, extra] = stmt.args;
710
+ const idToken = singleIdent(idArg);
711
+ if (!idToken) {
712
+ this.invalid(m.diagnostics.common.expected(form), idArg ?? stmt.keyword, [
713
+ m.hints.example("pen arrow [(40, 60), (90.5, 58), (140, 70)]"),
714
+ ]);
715
+ return;
716
+ }
717
+ const hints = [m.hints.flow.form(form)];
718
+ if (list?.kind === "StringLiteral") {
719
+ return this.invalid(m.diagnostics.flow.penNoLabel(idToken.text), list, hints);
720
+ }
721
+ if (list?.kind !== "List" || list.items.length === 0) {
722
+ return this.invalid(m.diagnostics.flow.penPoints, list ?? stmt, hints);
723
+ }
724
+ const points = [];
725
+ for (const item of list.items) {
726
+ const point = readPoint(item);
727
+ if (!point)
728
+ return this.invalid(m.diagnostics.flow.penPoints, item, hints);
729
+ points.push(point);
730
+ }
731
+ if (extra)
732
+ return this.invalid(m.diagnostics.flow.unexpectedValue(form), extra, hints);
733
+ // A stroke has no sides for ports: `port` in its block is unknown, as in any block without it.
734
+ const { values: _values, ...blockFields } = this.elementBlock(stmt.block, this.profile.elementProperties, "Element", idToken, false);
735
+ this.addElement({
736
+ id: idToken.text,
737
+ kind: "pen",
738
+ label: "",
739
+ implicit: false,
740
+ ...blockFields,
741
+ source: null,
742
+ sourceRange: null,
743
+ labelWritten: false,
744
+ entity: null,
745
+ technology: null,
746
+ external: false,
747
+ shape: null,
748
+ line: null,
749
+ pen: { points },
750
+ list: null,
751
+ attachedTo: null,
752
+ attachedToRange: null,
753
+ typeKind: null,
754
+ data: {},
755
+ groupPath,
756
+ range: this.range(stmt),
757
+ idRange: this.range(idToken),
758
+ idToken,
759
+ node: stmt,
760
+ });
761
+ }
762
+ /**
763
+ * `sticky <id> "Text" [{ ... }]`: a square card whose text wraps to fit it. The text is required,
764
+ * `\n` breaking its lines; the block takes a box's: `description`, `class`, `layer`, `link`, `url`
765
+ * and ports. A sticky without its text is still declared, so that its uses resolve.
766
+ */
767
+ declareSticky(stmt, groupPath) {
768
+ const form = '`sticky <id> "Text"`';
769
+ const m = messages();
770
+ const [idArg, textArg, extra] = stmt.args;
771
+ const idToken = singleIdent(idArg);
772
+ if (!idToken) {
773
+ this.invalid(m.diagnostics.common.expected(form), idArg ?? stmt.keyword, [
774
+ m.hints.example('sticky idea "Cache the catalogue"'),
775
+ ]);
776
+ return;
777
+ }
778
+ if (textArg && textArg.kind !== "StringLiteral") {
779
+ return this.invalid(m.diagnostics.flow.quotedSource("text", idToken.text), textArg);
780
+ }
781
+ if (extra)
782
+ return this.invalid(m.diagnostics.flow.valueAfter("text", form), extra);
783
+ if (!textArg) {
784
+ this.invalid(m.diagnostics.flow.needsSource("sticky", idToken.text, form), stmt, [
785
+ m.hints.flow.form(form),
786
+ ]);
787
+ }
788
+ const { values: _values, ...blockFields } = this.elementBlock(stmt.block, this.profile.elementProperties, "Element", idToken);
789
+ this.addElement({
790
+ id: idToken.text,
791
+ kind: "sticky",
792
+ label: textArg ? textArg.value : idToken.text,
793
+ implicit: false,
794
+ ...blockFields,
795
+ source: null,
796
+ sourceRange: null,
797
+ labelWritten: textArg !== undefined,
798
+ entity: null,
799
+ technology: null,
800
+ external: false,
801
+ shape: null,
802
+ line: null,
803
+ pen: null,
804
+ list: null,
646
805
  attachedTo: null,
647
806
  attachedToRange: null,
648
807
  typeKind: null,
@@ -694,7 +853,7 @@ export class FlowAnalyzer {
694
853
  data: {
695
854
  ids: [target],
696
855
  candidates: this.elements
697
- .filter((e) => e.id !== note && e.kind !== "line")
856
+ .filter((e) => e.id !== note && e.kind !== "line" && e.kind !== "pen")
698
857
  .map((e) => e.id),
699
858
  },
700
859
  });
@@ -721,21 +880,36 @@ export class FlowAnalyzer {
721
880
  return true;
722
881
  }
723
882
  /**
724
- * Reads an element or relation block: its description, applied classes, named ports, and the
725
- * `layer` and `link` statements the profile accepts.
883
+ * Reads an element or relation block: its description, applied classes, named ports, the
884
+ * `layer` and `link` statements the profile accepts, and its `tag` statements where the table
885
+ * takes them (sequence messages and participants).
726
886
  */
727
- elementBlock(block, table, owner, ownerToken) {
887
+ elementBlock(block, table, owner, ownerToken,
888
+ /** False for an element without ports (a pen): `port` is then unknown in its block. */
889
+ takesPorts = true) {
728
890
  const empty = { layer: null, layerRange: null, link: null };
729
891
  if (!block)
730
- return { description: null, url: null, classes: [], ports: [], values: {}, ...empty };
892
+ return {
893
+ description: null,
894
+ url: null,
895
+ classes: [],
896
+ ports: [],
897
+ tags: [],
898
+ values: {},
899
+ ...empty,
900
+ };
731
901
  const ports = [];
902
+ const tags = [];
732
903
  const extras = { ...empty };
733
904
  const seen = new Map();
905
+ const portable = owner === "Element" && takesPorts && this.profile.ports.length > 0;
906
+ // Tags repeat, each an annotation of the owner: read apart, never a `prop.duplicate` keyword.
907
+ const tagged = table.tag?.kind === "tag";
734
908
  const rest = block.statements.filter((stmt) => {
735
909
  if (stmt.kind !== "KeywordStatement")
736
910
  return true;
737
911
  const keyword = stmt.keyword.text;
738
- if (owner === "Element" && keyword === "port" && this.profile.ports.length > 0) {
912
+ if (portable && keyword === "port") {
739
913
  if (!headerHasError(stmt))
740
914
  this.port(stmt, ports);
741
915
  return false;
@@ -745,22 +919,69 @@ export class FlowAnalyzer {
745
919
  this.extra(stmt, extras, seen, ownerToken);
746
920
  return false;
747
921
  }
922
+ if (tagged && keyword === "tag") {
923
+ if (!headerHasError(stmt))
924
+ this.tag(stmt, tags);
925
+ return false;
926
+ }
748
927
  return true;
749
928
  });
750
- const words = Object.keys(table).concat(owner === "Element" && this.profile.ports.length > 0 ? ["port"] : []);
751
- const { values, classes } = this.readProperties({ ...block, statements: rest }, table, () => {
752
- const m = messages().hints.flow;
753
- return owner === "Element" ? m.elementBlock(words) : m.relationBlock(words);
754
- }, owner.toLowerCase(), words);
929
+ const words = Object.keys(table).concat(portable ? ["port"] : []);
930
+ const { values, classes } = this.readProperties({ ...block, statements: rest }, table, () => this.blockHint(owner, words), owner.toLowerCase(), words);
755
931
  return {
756
932
  description: typeof values.description === "string" ? values.description : null,
757
933
  url: typeof values.url === "string" ? values.url : null,
758
934
  classes,
759
935
  ports,
936
+ tags,
760
937
  values,
761
938
  ...extras,
762
939
  };
763
940
  }
941
+ /** The hint of an unknown statement in an element or relation block, in the interface words. */
942
+ blockHint(owner, words) {
943
+ const m = messages().hints.flow;
944
+ return owner === "Element" ? m.elementBlock(words) : m.relationBlock(words);
945
+ }
946
+ /**
947
+ * Reads `tag "Text"` into the tags of a block: one line of text, not empty, and not a text the
948
+ * owner already has.
949
+ */
950
+ tag(stmt, tags) {
951
+ const m = messages();
952
+ const form = [m.hints.example('tag "PKCE"')];
953
+ if (stmt.block || stmt.chain) {
954
+ this.invalid(m.diagnostics.common.expected(TAG_FORM), stmt.block ?? stmt.chain, form);
955
+ return;
956
+ }
957
+ const [arg, extra] = stmt.args;
958
+ // The first value that breaks the form: an id before the text, a number, a second string.
959
+ const wrong = !arg ? stmt.keyword : arg.kind !== "StringLiteral" ? arg : extra;
960
+ if (!arg || wrong) {
961
+ this.invalid(m.diagnostics.common.expected(TAG_FORM), wrong ?? stmt.keyword, form);
962
+ return;
963
+ }
964
+ const checked = checkArg(arg, { kind: "tag" }, "tag");
965
+ if (!checked.ok || typeof checked.value !== "string") {
966
+ if (!checked.ok)
967
+ this.invalid(checked.message, arg, checked.hints);
968
+ return;
969
+ }
970
+ const text = checked.value;
971
+ const first = tags.find((t) => t.text === text);
972
+ if (first) {
973
+ this.error("prop.duplicate", m.diagnostics.sequence.tagRepeated(text), arg, {
974
+ related: [
975
+ {
976
+ message: m.related.firstWritten,
977
+ at: { start: first.textRange.start.offset, end: first.textRange.end.offset },
978
+ },
979
+ ],
980
+ });
981
+ return;
982
+ }
983
+ tags.push({ text, range: this.range(stmt), textRange: this.range(arg) });
984
+ }
764
985
  /** Reads `layer <name>` or `link <diagram>.<element>` into the extras of a block. */
765
986
  extra(stmt, extras, seen, ownerToken) {
766
987
  const keyword = stmt.keyword.text;
@@ -859,8 +1080,8 @@ export class FlowAnalyzer {
859
1080
  });
860
1081
  }
861
1082
  /**
862
- * Reads the `description`, `class`, `layer` and `link` properties of a group block; children are
863
- * classified.
1083
+ * Reads the `description`, `class`, `layer` and `link` properties of a group block, and in free
1084
+ * diagrams the `layout`, `spacing` and `align` of a list; children are classified.
864
1085
  */
865
1086
  groupBlock(block, ownerToken) {
866
1087
  let description = null;
@@ -868,8 +1089,9 @@ export class FlowAnalyzer {
868
1089
  let classes = [];
869
1090
  const extras = { layer: null, layerRange: null, link: null };
870
1091
  if (!block)
871
- return { description, url, classes, ...extras };
1092
+ return { description, url, classes, list: null, ...extras };
872
1093
  const seen = new Map();
1094
+ const listed = {};
873
1095
  for (const stmt of block.statements) {
874
1096
  if (stmt.kind !== "KeywordStatement" || headerHasError(stmt))
875
1097
  continue;
@@ -878,8 +1100,14 @@ export class FlowAnalyzer {
878
1100
  this.extra(stmt, extras, seen, ownerToken);
879
1101
  continue;
880
1102
  }
881
- if (name !== "description" && name !== "url" && !(name === "class" && !stmt.block))
1103
+ // Other profiles refuse the list properties when the block is classified.
1104
+ const listProperty = isListProperty(name) && name in this.profile.groupProperties;
1105
+ if (!listProperty &&
1106
+ name !== "description" &&
1107
+ name !== "url" &&
1108
+ !(name === "class" && !stmt.block)) {
882
1109
  continue;
1110
+ }
883
1111
  const first = seen.get(name);
884
1112
  if (first) {
885
1113
  const m = messages();
@@ -889,6 +1117,18 @@ export class FlowAnalyzer {
889
1117
  continue;
890
1118
  }
891
1119
  seen.set(name, stmt.keyword);
1120
+ if (listProperty) {
1121
+ const value = this.listValue(stmt, name);
1122
+ if (value === undefined)
1123
+ continue;
1124
+ if (name === "layout")
1125
+ listed.layout = value;
1126
+ else if (name === "spacing")
1127
+ listed.spacing = value;
1128
+ else
1129
+ listed.align = value;
1130
+ continue;
1131
+ }
892
1132
  if (name === "class") {
893
1133
  classes = readClasses(stmt, this.lines, this.report) ?? [];
894
1134
  continue;
@@ -903,7 +1143,45 @@ export class FlowAnalyzer {
903
1143
  if (typeof value === "string")
904
1144
  description = value;
905
1145
  }
906
- return { description, url, classes, ...extras };
1146
+ return { description, url, classes, list: this.listOf(listed, seen), ...extras };
1147
+ }
1148
+ /**
1149
+ * Reads `layout column|row`, `spacing <n>` or `align start|center|end|stretch` of a free group
1150
+ * block. `layout auto`, `assisted` and `manual` are the diagram's statement, which the hint says.
1151
+ */
1152
+ listValue(stmt, name) {
1153
+ const [arg] = stmt.args;
1154
+ const word = singleIdent(arg);
1155
+ if (name === "layout" &&
1156
+ word &&
1157
+ stmt.args.length === 1 &&
1158
+ !stmt.block &&
1159
+ !stmt.chain &&
1160
+ LAYOUT_MODES.includes(word.text)) {
1161
+ const m = messages();
1162
+ this.invalid(m.diagnostics.values.invalidEnum(describeArg(arg), name, LIST_LAYOUTS), arg, [
1163
+ m.hints.flow.groupLayout(word.text),
1164
+ ]);
1165
+ return undefined;
1166
+ }
1167
+ return this.readValue(stmt, this.profile.groupProperties[name], name);
1168
+ }
1169
+ /**
1170
+ * The list a group block writes, or null without a valid `layout`. `spacing` and `align` without
1171
+ * a `layout` are reported on their keywords.
1172
+ */
1173
+ listOf(listed, seen) {
1174
+ if (!seen.has("layout")) {
1175
+ const m = messages();
1176
+ for (const name of ["spacing", "align"]) {
1177
+ const token = seen.get(name);
1178
+ if (token)
1179
+ this.invalid(m.diagnostics.flow.listNeedsLayout(name), token, [m.hints.flow.listLayout]);
1180
+ }
1181
+ }
1182
+ if (listed.layout === undefined)
1183
+ return null;
1184
+ return { layout: listed.layout, spacing: listed.spacing ?? null, align: listed.align ?? null };
907
1185
  }
908
1186
  // ------------------------------------------------------------------ relations
909
1187
  relation(stmt, groupPath) {
@@ -941,7 +1219,7 @@ export class FlowAnalyzer {
941
1219
  if (this.profile.arrows[stmt.arrow.text] === undefined) {
942
1220
  this.invalid(messages().diagnostics.flow.arrowUnavailable(stmt.arrow.text, Object.keys(this.profile.arrows)), stmt.arrow);
943
1221
  }
944
- const { description, classes, layer, layerRange, values } = this.elementBlock(stmt.block, this.profile.relationProperties, "Relation", undefined);
1222
+ const { description, classes, tags, layer, layerRange, values } = this.elementBlock(stmt.block, this.profile.relationProperties, "Relation", undefined);
945
1223
  const relation = {
946
1224
  key: "",
947
1225
  id,
@@ -951,6 +1229,7 @@ export class FlowAnalyzer {
951
1229
  heads: RELATION_ARROWS[stmt.arrow.text]?.heads ?? "end",
952
1230
  twoWay: RELATION_ARROWS[stmt.arrow.text]?.heads === "both",
953
1231
  async: values.kind === "async",
1232
+ returns: null,
954
1233
  technology: null,
955
1234
  sources: [],
956
1235
  derived: false,
@@ -959,6 +1238,7 @@ export class FlowAnalyzer {
959
1238
  assumption: typeof values.assumption === "string" ? values.assumption : null,
960
1239
  data: this.declared ? dataOf(values, this.declared.relations.properties) : {},
961
1240
  classes,
1241
+ tags,
962
1242
  layer,
963
1243
  layerRange,
964
1244
  groupPath,
@@ -997,6 +1277,7 @@ export class FlowAnalyzer {
997
1277
  description: null,
998
1278
  url: null,
999
1279
  classes: [],
1280
+ tags: [],
1000
1281
  ports: [],
1001
1282
  layer: null,
1002
1283
  layerRange: null,
@@ -1009,6 +1290,8 @@ export class FlowAnalyzer {
1009
1290
  external: false,
1010
1291
  shape: null,
1011
1292
  line: null,
1293
+ pen: null,
1294
+ list: null,
1012
1295
  attachedTo: null,
1013
1296
  attachedToRange: null,
1014
1297
  typeKind: null,
@@ -1092,6 +1375,9 @@ export class FlowAnalyzer {
1092
1375
  case "autonumber":
1093
1376
  this.properties.autonumber = Number(value);
1094
1377
  break;
1378
+ case "spacing":
1379
+ this.properties.spacing = Number(value);
1380
+ break;
1095
1381
  }
1096
1382
  }
1097
1383
  /** Reads `layers a, b, c`: back to front, `main` last unless listed. */
@@ -1218,8 +1504,9 @@ export class FlowAnalyzer {
1218
1504
  }
1219
1505
  return true;
1220
1506
  }
1221
- duplicatePlacement(stmt, target) {
1222
- const key = `${stmt.keyword.text}:${target}`;
1507
+ /** Whether a placement of the same kind already names the target (on the same side, if any). */
1508
+ duplicatePlacement(stmt, target, side = "") {
1509
+ const key = `${stmt.keyword.text}:${target}${side === "" ? "" : `:${side}`}`;
1223
1510
  const first = this.placementSeen.get(key);
1224
1511
  if (first) {
1225
1512
  const m = messages();
@@ -1532,14 +1819,25 @@ export class FlowAnalyzer {
1532
1819
  node: stmt,
1533
1820
  });
1534
1821
  }
1535
- /** A free line takes no `place` or `size`: its points are in its statement. Reports it. */
1822
+ /**
1823
+ * A free line or a pen takes no `place` or `size` (and a pen no `rotation`): its points are in its
1824
+ * statement. Reports it.
1825
+ */
1536
1826
  lineRefused(id, statement, token) {
1537
- if (this.elementsById.get(id)?.kind !== "line")
1827
+ const kind = this.elementsById.get(id)?.kind;
1828
+ if (kind !== "line" && kind !== "pen")
1538
1829
  return false;
1539
1830
  const m = messages();
1540
- this.invalid(m.diagnostics.flow.lineHasPoints(id, statement), token, [
1541
- m.hints.flow.linePointsWritten,
1542
- ]);
1831
+ if (kind === "pen") {
1832
+ this.invalid(m.diagnostics.flow.penHasPoints(id, statement), token, [
1833
+ m.hints.flow.penPointsWritten,
1834
+ ]);
1835
+ }
1836
+ else {
1837
+ this.invalid(m.diagnostics.flow.lineHasPoints(id, statement), token, [
1838
+ m.hints.flow.linePointsWritten,
1839
+ ]);
1840
+ }
1543
1841
  return true;
1544
1842
  }
1545
1843
  resolveElement(token, statement, stmt) {
@@ -1599,7 +1897,7 @@ export class FlowAnalyzer {
1599
1897
  const table = kind === "style" ? this.profile.styleProperties : this.profile.animateProperties;
1600
1898
  const shapes = profileTakesShape(this.profile);
1601
1899
  const words = kind === "style"
1602
- ? Object.keys(table).filter((p) => p === "shape" ? shapes : this.free || !(p in FREE_PROFILE_ONLY))
1900
+ ? Object.keys(table).filter((p) => p === "shape" ? shapes : this.free || !isFreeStyleProperty(p))
1603
1901
  : ["effect", "duration", "repeat"];
1604
1902
  const hint = () => {
1605
1903
  const m = messages().hints.flow;
@@ -1627,22 +1925,57 @@ export class FlowAnalyzer {
1627
1925
  const resolved = this.resolveStyleTarget(targetToken, kind, stmt);
1628
1926
  if (!resolved)
1629
1927
  continue;
1630
- // A free line takes a relation's properties, and none of a box's.
1631
- const line = resolved.kind === "element" && this.elementsById.get(resolved.name)?.kind === "line";
1632
- const misfit = kind === "style"
1633
- ? resolved.kind === "relation" || line
1928
+ const element = resolved.kind === "element" ? this.elementsById.get(resolved.name) : undefined;
1929
+ // A free line takes a relation's properties, and none of a box's but `opacity`; a pen takes
1930
+ // its stroke's (`PEN_PROPERTIES`) and refuses the rest; a sticky takes a card's
1931
+ // (`STICKY_PROPERTIES`).
1932
+ const line = element?.kind === "line";
1933
+ const pen = element?.kind === "pen";
1934
+ const sticky = element?.kind === "sticky";
1935
+ // Icons and images draw no text: the text properties do not apply to them.
1936
+ const textless = element !== undefined && TEXTLESS_KINDS.includes(element.kind);
1937
+ const misfit = kind !== "style"
1938
+ ? []
1939
+ : resolved.kind === "relation"
1634
1940
  ? ELEMENT_ONLY_PROPERTIES
1635
- : RELATION_ONLY_PROPERTIES
1636
- : [];
1941
+ : line
1942
+ ? LINE_REFUSED_PROPERTIES
1943
+ : pen
1944
+ ? [...PEN_REFUSED_PROPERTIES, "rotation"]
1945
+ : sticky
1946
+ ? STICKY_REFUSED_PROPERTIES
1947
+ : textless
1948
+ ? [...RELATION_ONLY_PROPERTIES, ...TEXT_PROPERTIES]
1949
+ : RELATION_ONLY_PROPERTIES;
1637
1950
  for (const name of misfit) {
1638
1951
  const token = tokens[name];
1639
- // `shape` is reported below, with the kinds that take it.
1640
- if (!token || name === "shape" || misplaced.has(name))
1952
+ // `shape` is reported below, with the kinds that take it; a pen names what it takes.
1953
+ if (!token || (name === "shape" && !pen) || misplaced.has(name))
1641
1954
  continue;
1642
1955
  misplaced.add(name);
1643
- this.invalid(line
1644
- ? messages().diagnostics.flow.notForLines(name, resolved.name)
1645
- : messages().diagnostics.flow.notForTargets(name, resolved.kind === "relation", resolved.name), token, line ? [] : [targetHint(name)]);
1956
+ if (pen && name === "rotation") {
1957
+ this.lineRefused(resolved.name, "rotation", token);
1958
+ continue;
1959
+ }
1960
+ const m = messages();
1961
+ this.invalid(pen
1962
+ ? m.diagnostics.flow.notForPens(name, resolved.name, PEN_PROPERTIES)
1963
+ : line
1964
+ ? m.diagnostics.flow.notForLines(name, resolved.name)
1965
+ : sticky
1966
+ ? m.diagnostics.flow.notForStickies(name, resolved.name)
1967
+ : m.diagnostics.flow.notForTargets(name, resolved.kind === "relation", resolved.name), token, line || pen
1968
+ ? []
1969
+ : sticky
1970
+ ? [m.hints.values.stickyProperties(STICKY_PROPERTIES)]
1971
+ : [targetHint(name)]);
1972
+ }
1973
+ // A pen is drawn one pixel wide at least: `width 0` is refused on it, not on a box.
1974
+ const thin = kind === "style" && pen && values.width === 0;
1975
+ if (thin && !misplaced.has("width")) {
1976
+ misplaced.add("width");
1977
+ const m = messages();
1978
+ this.invalid(m.diagnostics.values.expects("width", describeSpec({ kind: "number", ...PEN_WIDTH_RANGE })), argumentOf(stmt.block, tokens.width) ?? tokens.width, [m.hints.values.penWidth]);
1646
1979
  }
1647
1980
  // `shape` is refused per target: a block may list a node and a decision.
1648
1981
  const shapeless = kind === "style" && tokens.shape ? this.shapeRefusal(resolved) : null;
@@ -1661,6 +1994,7 @@ export class FlowAnalyzer {
1661
1994
  const refused = [
1662
1995
  ...(shapeless === null ? [] : ["shape"]),
1663
1996
  ...(unturned === null ? [] : ["rotation"]),
1997
+ ...(thin ? ["width"] : []),
1664
1998
  ];
1665
1999
  const excluded = refused.length === 0 ? misfit : [...misfit, ...refused];
1666
2000
  if (kind === "animate") {
@@ -1794,8 +2128,9 @@ export class FlowAnalyzer {
1794
2128
  const element = resolved.kind === "element" ? this.elementsById.get(resolved.name) : undefined;
1795
2129
  const m = messages().diagnostics.flow;
1796
2130
  let reason = null;
1797
- // A free line runs effects as a relation does: it is a connector without ends.
1798
- const connector = resolved.kind === "relation" || element?.kind === "line";
2131
+ // A free line or a pen runs effects as a relation does: it is a connector without ends, and its
2132
+ // stroke is the outline a dash follows.
2133
+ const connector = resolved.kind === "relation" || element?.kind === "line" || element?.kind === "pen";
1799
2134
  if (effect === "flow" && !connector) {
1800
2135
  reason = m.flowTarget;
1801
2136
  }
@@ -1805,9 +2140,10 @@ export class FlowAnalyzer {
1805
2140
  else if (effect === "dash") {
1806
2141
  if (resolved.kind === "event")
1807
2142
  reason = m.dashOnEvent;
1808
- else if (element && (element.kind === "note" || element.kind === "text")) {
1809
- // A note or a text draws an outline when its styles say so, which a later statement may
1810
- // write: checked once every block is read (`checkOutlinedDashes`).
2143
+ else if (element &&
2144
+ (element.kind === "note" || element.kind === "text" || element.kind === "sticky")) {
2145
+ // A note, a text or a sticky draws an outline when its styles say so, which a later
2146
+ // statement may write: checked once every block is read (`checkOutlinedDashes`).
1811
2147
  if (stmt)
1812
2148
  this.pendingDashes.push({ name: resolved.name, token, stmt });
1813
2149
  }
@@ -1847,7 +2183,10 @@ export class FlowAnalyzer {
1847
2183
  }
1848
2184
  }
1849
2185
  // -------------------------------------------------------------------- free helpers
1850
- /** The declarations and statements of flow and free diagrams, as their hints list them. */
2186
+ /**
2187
+ * The declarations and statements of flow and free diagrams, as their hints list them: the hints
2188
+ * of free diagrams are built from `FREE_DECLARATIONS`, so a new kind is one word here.
2189
+ */
1851
2190
  const FLOW_DECLARATIONS = ["node", "decision", "data", "group", "note", "tag"];
1852
2191
  const FREE_DECLARATIONS = [
1853
2192
  "box",
@@ -1855,7 +2194,9 @@ const FREE_DECLARATIONS = [
1855
2194
  "icon",
1856
2195
  "image",
1857
2196
  "note",
2197
+ "sticky",
1858
2198
  "line",
2199
+ "pen",
1859
2200
  "group",
1860
2201
  ];
1861
2202
  const FLOW_STATEMENTS = [
@@ -1879,16 +2220,13 @@ const FREE_STATEMENTS = [
1879
2220
  "layers",
1880
2221
  ...FLOW_STATEMENTS.slice(8),
1881
2222
  ];
1882
- /** Style properties that only free diagrams accept, for hints. */
1883
- const FREE_PROFILE_ONLY = {
1884
- fontSize: true,
1885
- fontWeight: true,
1886
- textAlign: true,
1887
- verticalAlign: true,
1888
- };
1889
2223
  function isWord(arg, word) {
1890
2224
  return singleIdent(arg)?.text === word;
1891
2225
  }
2226
+ /** Whether a statement of a group block is one of the properties of a list. */
2227
+ function isListProperty(keyword) {
2228
+ return LIST_PROPERTIES.includes(keyword);
2229
+ }
1892
2230
  /** A tuple of exactly two numbers without units. */
1893
2231
  export function readPoint(arg) {
1894
2232
  if (arg?.kind !== "Tuple" || arg.items.length !== 2)