@meltemi/diagrams-model 1.9.0 → 1.10.17

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 (65) hide show
  1. package/dist/c4.d.ts +5 -4
  2. package/dist/c4.d.ts.map +1 -1
  3. package/dist/c4.js +115 -36
  4. package/dist/c4.js.map +1 -1
  5. package/dist/diagram-types.d.ts.map +1 -1
  6. package/dist/diagram-types.js +10 -1
  7. package/dist/diagram-types.js.map +1 -1
  8. package/dist/diff.d.ts +1 -1
  9. package/dist/diff.d.ts.map +1 -1
  10. package/dist/diff.js +27 -0
  11. package/dist/diff.js.map +1 -1
  12. package/dist/export.d.ts.map +1 -1
  13. package/dist/export.js +1 -16
  14. package/dist/export.js.map +1 -1
  15. package/dist/flow.d.ts +100 -8
  16. package/dist/flow.d.ts.map +1 -1
  17. package/dist/flow.js +614 -66
  18. package/dist/flow.js.map +1 -1
  19. package/dist/index.d.ts +5 -4
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -4
  22. package/dist/index.js.map +1 -1
  23. package/dist/json.d.ts +48 -0
  24. package/dist/json.d.ts.map +1 -1
  25. package/dist/json.js +28 -1
  26. package/dist/json.js.map +1 -1
  27. package/dist/messages/changes.d.ts +2 -0
  28. package/dist/messages/changes.d.ts.map +1 -1
  29. package/dist/messages/changes.js +4 -0
  30. package/dist/messages/changes.js.map +1 -1
  31. package/dist/messages/en.d.ts +83 -9
  32. package/dist/messages/en.d.ts.map +1 -1
  33. package/dist/messages/en.js +92 -14
  34. package/dist/messages/en.js.map +1 -1
  35. package/dist/messages/es.d.ts.map +1 -1
  36. package/dist/messages/es.js +52 -11
  37. package/dist/messages/es.js.map +1 -1
  38. package/dist/messages/index.d.ts +44 -6
  39. package/dist/messages/index.d.ts.map +1 -1
  40. package/dist/outline.d.ts +14 -0
  41. package/dist/outline.d.ts.map +1 -0
  42. package/dist/outline.js +27 -0
  43. package/dist/outline.js.map +1 -0
  44. package/dist/scenario.d.ts +15 -2
  45. package/dist/scenario.d.ts.map +1 -1
  46. package/dist/scenario.js +37 -1
  47. package/dist/scenario.js.map +1 -1
  48. package/dist/schema.d.ts +102 -11
  49. package/dist/schema.d.ts.map +1 -1
  50. package/dist/schema.js +168 -13
  51. package/dist/schema.js.map +1 -1
  52. package/dist/sequence.d.ts.map +1 -1
  53. package/dist/sequence.js +252 -12
  54. package/dist/sequence.js.map +1 -1
  55. package/dist/story.d.ts +8 -0
  56. package/dist/story.d.ts.map +1 -1
  57. package/dist/story.js +39 -12
  58. package/dist/story.js.map +1 -1
  59. package/dist/style-values.d.ts +9 -0
  60. package/dist/style-values.d.ts.map +1 -1
  61. package/dist/style-values.js +82 -27
  62. package/dist/style-values.js.map +1 -1
  63. package/dist/types.d.ts +123 -12
  64. package/dist/types.d.ts.map +1 -1
  65. package/package.json +3 -3
package/dist/flow.js CHANGED
@@ -1,8 +1,9 @@
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_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
+ import { outlined } from "./outline.js";
6
7
  /** Element kinds drawn without an outline, which a moving dash cannot follow. */
7
8
  const NO_OUTLINE_KINDS = new Set([
8
9
  "note",
@@ -115,6 +116,10 @@ export class FlowAnalyzer {
115
116
  layerUses = [];
116
117
  layerRanges = new Map();
117
118
  animations = [];
119
+ /** Dashes on notes and texts, whose outline is checked once every style is read. */
120
+ pendingDashes = [];
121
+ /** The `on <element>` clauses of notes, resolved once every element is declared. */
122
+ attachments = [];
118
123
  references = [];
119
124
  entityReferences = [];
120
125
  placementBlocks = [];
@@ -148,6 +153,7 @@ export class FlowAnalyzer {
148
153
  routing: "auto",
149
154
  layers: [MAIN_LAYER],
150
155
  autonumber: null,
156
+ spacing: null,
151
157
  };
152
158
  }
153
159
  /** Sequence diagrams: the ordered events, their index and the scenarios. */
@@ -198,6 +204,8 @@ export class FlowAnalyzer {
198
204
  this.declare(decl.stmt, decl.groupPath);
199
205
  for (const rel of classified.relations)
200
206
  this.relation(rel.stmt, rel.groupPath);
207
+ // After the relations, which may declare elements that only they mention.
208
+ this.resolveAttachments();
201
209
  this.assignKeys();
202
210
  this.afterRelations(classified);
203
211
  for (const stmt of classified.properties)
@@ -216,6 +224,7 @@ export class FlowAnalyzer {
216
224
  this.styleOrAnimate(stmt, keyword);
217
225
  }
218
226
  this.checkLayers();
227
+ this.checkOutlinedDashes();
219
228
  this.finish();
220
229
  if (this.declared)
221
230
  this.checkDeclared(this.declared);
@@ -254,6 +263,33 @@ export class FlowAnalyzer {
254
263
  hasErrors: this.node.hasError || this.errors > 0,
255
264
  };
256
265
  }
266
+ /**
267
+ * A dash follows an outline: a note or a free text that no style statement, class or rule of the
268
+ * diagram gives a `fill`, `stroke` or `pattern` has none, nor a sticky without a `stroke`, and
269
+ * the dash is refused and dropped.
270
+ */
271
+ checkOutlinedDashes() {
272
+ const source = {
273
+ styles: this.styles,
274
+ classes: this.definitions.classes,
275
+ rules: this.definitions.rules,
276
+ };
277
+ for (const { name, token, stmt } of this.pendingDashes) {
278
+ const element = this.elementsById.get(name);
279
+ if (!element || outlined(source, element))
280
+ continue;
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
+ });
288
+ const at = this.animations.findIndex((a) => a.node === stmt && a.target === name && a.effect === "dash");
289
+ if (at >= 0)
290
+ this.animations.splice(at, 1);
291
+ }
292
+ }
257
293
  /** A declared type's rules: required properties, and the kinds relations may join. */
258
294
  checkDeclared(definition) {
259
295
  for (const element of this.elements) {
@@ -335,17 +371,32 @@ export class FlowAnalyzer {
335
371
  keyword in this.profile.groupProperties) {
336
372
  continue;
337
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
+ }
338
382
  const m = messages();
339
- const hints = otherKind
340
- ? [otherKind]
341
- : diagramStatements(this.profile).includes(keyword)
342
- ? [m.hints.flow.diagramLevel(keyword)]
343
- : [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];
344
391
  const candidates = otherKind
345
392
  ? this.declarationWords()
346
- : diagramStatements(this.profile).includes(keyword)
393
+ : misplaced || diagramStatements(this.profile).includes(keyword)
347
394
  ? []
348
- : ["description", "url"];
395
+ : [
396
+ "description",
397
+ "url",
398
+ ...LIST_PROPERTIES.filter((name) => name in this.profile.groupProperties),
399
+ ];
349
400
  this.error("prop.unknown", m.diagnostics.flow.unknownInGroup(keyword), stmt.keyword, {
350
401
  hints,
351
402
  data: { candidates },
@@ -364,6 +415,14 @@ export class FlowAnalyzer {
364
415
  this.error("prop.unknown", messages().diagnostics.flow.unknownInDiagram(keyword, this.profile.name), stmt.keyword, { hints, data: { candidates: this.statementCandidates(keyword) } });
365
416
  }
366
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
+ }
367
426
  /** Hints for an unknown statement at the diagram level, in the interface language. */
368
427
  statementHints(keyword, stmt) {
369
428
  const otherKind = this.otherProfileKind(keyword);
@@ -374,7 +433,7 @@ export class FlowAnalyzer {
374
433
  if (stmt.args.length === 0 && !stmt.block) {
375
434
  hints.push(m.declareElement(keyword, this.profile.shorthand.box));
376
435
  }
377
- hints.push(this.free ? m.freeStatements : m.flowStatements);
436
+ hints.push(this.free ? m.freeStatements(FREE_DECLARATIONS) : m.flowStatements);
378
437
  return hints;
379
438
  }
380
439
  /** The statements an unknown one at the diagram level may have meant, for fixes. */
@@ -399,7 +458,7 @@ export class FlowAnalyzer {
399
458
  return undefined;
400
459
  }
401
460
  const m = messages().hints.flow;
402
- return this.free ? m.freeDeclarations : m.flowDeclarations(keyword);
461
+ return this.free ? m.freeDeclarations(FREE_DECLARATIONS) : m.flowDeclarations(keyword);
403
462
  }
404
463
  // ------------------------------------------------------------------ elements
405
464
  declare(stmt, groupPath) {
@@ -420,6 +479,11 @@ export class FlowAnalyzer {
420
479
  technology: null,
421
480
  external: false,
422
481
  shape: data ? "cylinder" : null,
482
+ line: null,
483
+ pen: null,
484
+ list: null,
485
+ attachedTo: null,
486
+ attachedToRange: null,
423
487
  typeKind: null,
424
488
  data: {},
425
489
  groupPath,
@@ -435,19 +499,29 @@ export class FlowAnalyzer {
435
499
  const typeKind = declaredKind ? stmt.keyword.text : null;
436
500
  const kind = (declaredKind ? "box" : stmt.keyword.text);
437
501
  const sourced = kind === "text" || kind === "icon" || kind === "image";
502
+ const note = kind === "note" && !declaredKind;
438
503
  const second = { text: '"Text"', icon: '"<pack>:<name>"', image: '"<path>"' }[kind];
439
- const form = sourced
440
- ? `\`${kind} <id> ${second}\``
441
- : `\`${kind} <id> ["Label"]${kind === "group" ? " { ... }" : ""}\``;
504
+ const form = note
505
+ ? '`note <id> "Text" [on <element>]`'
506
+ : sourced
507
+ ? `\`${kind} <id> ${second}\``
508
+ : `\`${kind} <id> ["Label"]${kind === "group" ? " { ... }" : ""}\``;
442
509
  const example = {
443
510
  text: 'text heading "Architecture"',
444
511
  icon: 'icon server "tabler:server"',
445
512
  image: 'image logo "logo.png"',
513
+ note: 'note limits "500 rps" on api',
446
514
  }[kind];
447
515
  if (stmt.chain) {
448
516
  this.invalid(messages().diagnostics.common.singleBlock(kind), stmt.chain.clauses[1].keyword);
449
517
  return;
450
518
  }
519
+ if (kind === "line" && !declaredKind)
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);
451
525
  const [idArg, labelArg, ...extra] = stmt.args;
452
526
  const idToken = singleIdent(idArg);
453
527
  if (!idToken) {
@@ -491,22 +565,32 @@ export class FlowAnalyzer {
491
565
  sourceRange = this.range(labelArg);
492
566
  }
493
567
  }
494
- else if (sourced) {
568
+ else if (sourced || (note && this.notesNeedText)) {
495
569
  const m = messages();
496
570
  this.invalid(m.diagnostics.flow.needsSource(kind, idToken.text, form), stmt, [
497
571
  m.hints.example(example),
498
572
  ]);
499
573
  }
574
+ // A note may end with `on <element>`; nothing else follows a label.
575
+ let attachment;
500
576
  if (extra.length > 0) {
501
- this.invalid(messages().diagnostics.flow.valueAfter(sourced ? kind : "label", form), extra[0]);
502
- return;
577
+ attachment = note ? this.attachmentOf(extra, form) : undefined;
578
+ if (!attachment) {
579
+ if (!note || !isWord(extra[0], "on")) {
580
+ this.invalid(messages().diagnostics.flow.valueAfter(note ? "text" : sourced ? kind : "label", form), extra[0]);
581
+ }
582
+ return;
583
+ }
503
584
  }
504
585
  const block = kind === "group"
505
- ? { ...this.groupBlock(stmt.block, idToken), ports: [], values: {} }
506
- : this.elementBlock(stmt.block, declaredKind
507
- ? { ...this.profile.elementProperties, ...tableOf(declaredKind.properties) }
508
- : this.profile.elementProperties, "Element", idToken);
509
- 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;
510
594
  const added = this.addElement({
511
595
  id: idToken.text,
512
596
  kind,
@@ -520,6 +604,11 @@ export class FlowAnalyzer {
520
604
  technology: null,
521
605
  external: false,
522
606
  shape: null,
607
+ line: null,
608
+ pen: null,
609
+ list,
610
+ attachedTo: attachment?.target ?? null,
611
+ attachedToRange: attachment ? this.range(attachment.arg) : null,
523
612
  typeKind,
524
613
  data: declaredKind ? dataOf(blockValues, declaredKind.properties) : {},
525
614
  groupPath,
@@ -528,6 +617,8 @@ export class FlowAnalyzer {
528
617
  idToken,
529
618
  node: stmt,
530
619
  });
620
+ if (added && attachment)
621
+ this.attachments.push({ note: idToken.text, ...attachment, stmt });
531
622
  if (!added || source === null || !sourceRange)
532
623
  return;
533
624
  if (kind === "icon")
@@ -535,6 +626,240 @@ export class FlowAnalyzer {
535
626
  else
536
627
  this.images.push({ element: idToken.text, path: source, range: sourceRange });
537
628
  }
629
+ /**
630
+ * `line <id> ["Label"] from (x, y) to (x, y) [via [(x, y), ...]] [{ ... }]`: a free line that
631
+ * joins no element, its points in the interior coordinates of its innermost group. The label is
632
+ * empty unless written: a line has no name to show.
633
+ */
634
+ declareLine(stmt, groupPath) {
635
+ const form = '`line <id> ["Label"] from (x, y) to (x, y) [via [(x, y), ...]]`';
636
+ const m = messages();
637
+ const [idArg, ...rest] = stmt.args;
638
+ const idToken = singleIdent(idArg);
639
+ if (!idToken) {
640
+ this.invalid(m.diagnostics.common.expected(form), idArg ?? stmt.keyword, [
641
+ m.hints.example("line divider from (40, 200) to (520, 200)"),
642
+ ]);
643
+ return;
644
+ }
645
+ const refuse = (at) => this.invalid(m.diagnostics.flow.linePoints, at ?? stmt, [m.hints.flow.form(form)]);
646
+ const labelArg = rest[0]?.kind === "StringLiteral" ? rest[0] : undefined;
647
+ const [fromWord, fromArg, toWord, toArg, viaWord, viaList, extra] = labelArg
648
+ ? rest.slice(1)
649
+ : rest;
650
+ if (!isWord(fromWord, "from"))
651
+ return refuse(fromWord);
652
+ const from = readPoint(fromArg);
653
+ if (!from)
654
+ return refuse(fromArg);
655
+ if (!isWord(toWord, "to"))
656
+ return refuse(toWord);
657
+ const to = readPoint(toArg);
658
+ if (!to)
659
+ return refuse(toArg);
660
+ const via = [];
661
+ if (viaWord) {
662
+ if (!isWord(viaWord, "via") || viaList?.kind !== "List")
663
+ return refuse(viaList ?? viaWord);
664
+ for (const item of viaList.items) {
665
+ const point = readPoint(item);
666
+ if (!point)
667
+ return refuse(item);
668
+ via.push(point);
669
+ }
670
+ if (extra)
671
+ return refuse(extra);
672
+ }
673
+ const { values: _values, ...blockFields } = this.elementBlock(stmt.block, this.profile.elementProperties, "Element", idToken);
674
+ this.addElement({
675
+ id: idToken.text,
676
+ kind: "line",
677
+ label: labelArg?.kind === "StringLiteral" ? labelArg.value : "",
678
+ implicit: false,
679
+ ...blockFields,
680
+ source: null,
681
+ sourceRange: null,
682
+ labelWritten: labelArg !== undefined,
683
+ entity: null,
684
+ technology: null,
685
+ external: false,
686
+ shape: null,
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,
805
+ attachedTo: null,
806
+ attachedToRange: null,
807
+ typeKind: null,
808
+ data: {},
809
+ groupPath,
810
+ range: this.range(stmt),
811
+ idRange: this.range(idToken),
812
+ idToken,
813
+ node: stmt,
814
+ });
815
+ }
816
+ /** Whether a note must write its text: free compositions and views, where a note is no node. */
817
+ get notesNeedText() {
818
+ return this.free;
819
+ }
820
+ /**
821
+ * The target of a note's `on <element>`: `extra` holds the word `on` and one identifier (C4
822
+ * views read an entity path), nothing else. Undefined when it does not; a lone `on` is reported
823
+ * here, as the form is wanted.
824
+ */
825
+ attachmentOf(extra, form) {
826
+ if (!isWord(extra[0], "on"))
827
+ return undefined;
828
+ const arg = extra[1];
829
+ const token = singleIdent(arg);
830
+ if (!token || extra.length > 2) {
831
+ this.invalid(messages().diagnostics.common.expected(form), extra[1] ?? extra[0]);
832
+ return undefined;
833
+ }
834
+ return { arg: arg, target: token.text };
835
+ }
836
+ /**
837
+ * Resolves the targets of the notes' `on <element>`: an element of the diagram other than the
838
+ * note itself, of any kind. Renames follow the reference this records.
839
+ */
840
+ resolveAttachments() {
841
+ const m = messages();
842
+ for (const { note, arg, target, stmt } of this.attachments) {
843
+ const token = singleIdent(arg);
844
+ if (target === note) {
845
+ this.invalid(m.diagnostics.flow.attachSelf(note), token);
846
+ }
847
+ else if (this.elementsById.has(target)) {
848
+ this.reference(token, target, "element", "attachment", stmt);
849
+ }
850
+ else {
851
+ this.error("ref.unknown-element", m.diagnostics.common.unknownElement(target), token, {
852
+ hints: [m.hints.flow.attachTarget(note)],
853
+ data: {
854
+ ids: [target],
855
+ candidates: this.elements
856
+ .filter((e) => e.id !== note && e.kind !== "line" && e.kind !== "pen")
857
+ .map((e) => e.id),
858
+ },
859
+ });
860
+ }
861
+ }
862
+ }
538
863
  addElement(element) {
539
864
  const existing = this.elementsById.get(element.id);
540
865
  if (existing) {
@@ -555,21 +880,36 @@ export class FlowAnalyzer {
555
880
  return true;
556
881
  }
557
882
  /**
558
- * Reads an element or relation block: its description, applied classes, named ports, and the
559
- * `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).
560
886
  */
561
- 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) {
562
890
  const empty = { layer: null, layerRange: null, link: null };
563
891
  if (!block)
564
- 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
+ };
565
901
  const ports = [];
902
+ const tags = [];
566
903
  const extras = { ...empty };
567
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";
568
908
  const rest = block.statements.filter((stmt) => {
569
909
  if (stmt.kind !== "KeywordStatement")
570
910
  return true;
571
911
  const keyword = stmt.keyword.text;
572
- if (owner === "Element" && keyword === "port" && this.profile.ports.length > 0) {
912
+ if (portable && keyword === "port") {
573
913
  if (!headerHasError(stmt))
574
914
  this.port(stmt, ports);
575
915
  return false;
@@ -579,22 +919,69 @@ export class FlowAnalyzer {
579
919
  this.extra(stmt, extras, seen, ownerToken);
580
920
  return false;
581
921
  }
922
+ if (tagged && keyword === "tag") {
923
+ if (!headerHasError(stmt))
924
+ this.tag(stmt, tags);
925
+ return false;
926
+ }
582
927
  return true;
583
928
  });
584
- const words = Object.keys(table).concat(owner === "Element" && this.profile.ports.length > 0 ? ["port"] : []);
585
- const { values, classes } = this.readProperties({ ...block, statements: rest }, table, () => {
586
- const m = messages().hints.flow;
587
- return owner === "Element" ? m.elementBlock(words) : m.relationBlock(words);
588
- }, 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);
589
931
  return {
590
932
  description: typeof values.description === "string" ? values.description : null,
591
933
  url: typeof values.url === "string" ? values.url : null,
592
934
  classes,
593
935
  ports,
936
+ tags,
594
937
  values,
595
938
  ...extras,
596
939
  };
597
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
+ }
598
985
  /** Reads `layer <name>` or `link <diagram>.<element>` into the extras of a block. */
599
986
  extra(stmt, extras, seen, ownerToken) {
600
987
  const keyword = stmt.keyword.text;
@@ -693,8 +1080,8 @@ export class FlowAnalyzer {
693
1080
  });
694
1081
  }
695
1082
  /**
696
- * Reads the `description`, `class`, `layer` and `link` properties of a group block; children are
697
- * 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.
698
1085
  */
699
1086
  groupBlock(block, ownerToken) {
700
1087
  let description = null;
@@ -702,8 +1089,9 @@ export class FlowAnalyzer {
702
1089
  let classes = [];
703
1090
  const extras = { layer: null, layerRange: null, link: null };
704
1091
  if (!block)
705
- return { description, url, classes, ...extras };
1092
+ return { description, url, classes, list: null, ...extras };
706
1093
  const seen = new Map();
1094
+ const listed = {};
707
1095
  for (const stmt of block.statements) {
708
1096
  if (stmt.kind !== "KeywordStatement" || headerHasError(stmt))
709
1097
  continue;
@@ -712,8 +1100,14 @@ export class FlowAnalyzer {
712
1100
  this.extra(stmt, extras, seen, ownerToken);
713
1101
  continue;
714
1102
  }
715
- 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)) {
716
1109
  continue;
1110
+ }
717
1111
  const first = seen.get(name);
718
1112
  if (first) {
719
1113
  const m = messages();
@@ -723,6 +1117,18 @@ export class FlowAnalyzer {
723
1117
  continue;
724
1118
  }
725
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
+ }
726
1132
  if (name === "class") {
727
1133
  classes = readClasses(stmt, this.lines, this.report) ?? [];
728
1134
  continue;
@@ -737,7 +1143,45 @@ export class FlowAnalyzer {
737
1143
  if (typeof value === "string")
738
1144
  description = value;
739
1145
  }
740
- 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 };
741
1185
  }
742
1186
  // ------------------------------------------------------------------ relations
743
1187
  relation(stmt, groupPath) {
@@ -775,15 +1219,17 @@ export class FlowAnalyzer {
775
1219
  if (this.profile.arrows[stmt.arrow.text] === undefined) {
776
1220
  this.invalid(messages().diagnostics.flow.arrowUnavailable(stmt.arrow.text, Object.keys(this.profile.arrows)), stmt.arrow);
777
1221
  }
778
- 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);
779
1223
  const relation = {
780
1224
  key: "",
781
1225
  id,
782
1226
  from,
783
1227
  to,
784
1228
  line: this.profile.arrows[stmt.arrow.text] ?? "solid",
785
- twoWay: stmt.arrow.text.startsWith("<"),
1229
+ heads: RELATION_ARROWS[stmt.arrow.text]?.heads ?? "end",
1230
+ twoWay: RELATION_ARROWS[stmt.arrow.text]?.heads === "both",
786
1231
  async: values.kind === "async",
1232
+ returns: null,
787
1233
  technology: null,
788
1234
  sources: [],
789
1235
  derived: false,
@@ -792,6 +1238,7 @@ export class FlowAnalyzer {
792
1238
  assumption: typeof values.assumption === "string" ? values.assumption : null,
793
1239
  data: this.declared ? dataOf(values, this.declared.relations.properties) : {},
794
1240
  classes,
1241
+ tags,
795
1242
  layer,
796
1243
  layerRange,
797
1244
  groupPath,
@@ -830,6 +1277,7 @@ export class FlowAnalyzer {
830
1277
  description: null,
831
1278
  url: null,
832
1279
  classes: [],
1280
+ tags: [],
833
1281
  ports: [],
834
1282
  layer: null,
835
1283
  layerRange: null,
@@ -841,6 +1289,11 @@ export class FlowAnalyzer {
841
1289
  technology: null,
842
1290
  external: false,
843
1291
  shape: null,
1292
+ line: null,
1293
+ pen: null,
1294
+ list: null,
1295
+ attachedTo: null,
1296
+ attachedToRange: null,
844
1297
  typeKind: null,
845
1298
  data: {},
846
1299
  groupPath,
@@ -922,6 +1375,9 @@ export class FlowAnalyzer {
922
1375
  case "autonumber":
923
1376
  this.properties.autonumber = Number(value);
924
1377
  break;
1378
+ case "spacing":
1379
+ this.properties.spacing = Number(value);
1380
+ break;
925
1381
  }
926
1382
  }
927
1383
  /** Reads `layers a, b, c`: back to front, `main` last unless listed. */
@@ -1048,8 +1504,9 @@ export class FlowAnalyzer {
1048
1504
  }
1049
1505
  return true;
1050
1506
  }
1051
- duplicatePlacement(stmt, target) {
1052
- 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}`}`;
1053
1510
  const first = this.placementSeen.get(key);
1054
1511
  if (first) {
1055
1512
  const m = messages();
@@ -1075,7 +1532,9 @@ export class FlowAnalyzer {
1075
1532
  if (extra)
1076
1533
  return this.shapeError(stmt, form, extra);
1077
1534
  const element = this.resolveElement(targetToken, "place", stmt);
1078
- if (!element || this.duplicatePlacement(stmt, element))
1535
+ if (!element || this.lineRefused(element, "place", targetToken))
1536
+ return;
1537
+ if (this.duplicatePlacement(stmt, element))
1079
1538
  return;
1080
1539
  this.placement.push({
1081
1540
  statement: "place",
@@ -1102,7 +1561,9 @@ export class FlowAnalyzer {
1102
1561
  if (extra)
1103
1562
  return this.shapeError(stmt, form, extra);
1104
1563
  const element = this.resolveElement(targetToken, "size", stmt);
1105
- if (!element || this.duplicatePlacement(stmt, element))
1564
+ if (!element || this.lineRefused(element, "size", targetToken))
1565
+ return;
1566
+ if (this.duplicatePlacement(stmt, element))
1106
1567
  return;
1107
1568
  this.placement.push({
1108
1569
  statement: "size",
@@ -1340,12 +1801,17 @@ export class FlowAnalyzer {
1340
1801
  if (extra)
1341
1802
  return this.shapeError(stmt, form, extra);
1342
1803
  }
1343
- const relation = this.resolveRelation(targetToken, "label", stmt);
1344
- if (!relation || this.duplicatePlacement(stmt, relation.key))
1804
+ // A free line's label pill moves as a relation's does.
1805
+ const line = this.elementsById.get(targetToken.text);
1806
+ if (line?.kind === "line")
1807
+ this.reference(targetToken, line.id, "element", "label", stmt);
1808
+ const relation = line?.kind === "line" ? undefined : this.resolveRelation(targetToken, "label", stmt);
1809
+ const labelled = line?.kind === "line" ? line.id : relation?.key;
1810
+ if (labelled === undefined || this.duplicatePlacement(stmt, labelled))
1345
1811
  return;
1346
1812
  this.placement.push({
1347
1813
  statement: "label",
1348
- target: relation.key,
1814
+ target: labelled,
1349
1815
  at: at.value,
1350
1816
  offset: point,
1351
1817
  range: this.range(stmt),
@@ -1353,6 +1819,27 @@ export class FlowAnalyzer {
1353
1819
  node: stmt,
1354
1820
  });
1355
1821
  }
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
+ */
1826
+ lineRefused(id, statement, token) {
1827
+ const kind = this.elementsById.get(id)?.kind;
1828
+ if (kind !== "line" && kind !== "pen")
1829
+ return false;
1830
+ const m = messages();
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
+ }
1841
+ return true;
1842
+ }
1356
1843
  resolveElement(token, statement, stmt) {
1357
1844
  const name = token.text;
1358
1845
  if (this.elementsById.has(name)) {
@@ -1410,7 +1897,7 @@ export class FlowAnalyzer {
1410
1897
  const table = kind === "style" ? this.profile.styleProperties : this.profile.animateProperties;
1411
1898
  const shapes = profileTakesShape(this.profile);
1412
1899
  const words = kind === "style"
1413
- ? 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))
1414
1901
  : ["effect", "duration", "repeat"];
1415
1902
  const hint = () => {
1416
1903
  const m = messages().hints.flow;
@@ -1438,18 +1925,57 @@ export class FlowAnalyzer {
1438
1925
  const resolved = this.resolveStyleTarget(targetToken, kind, stmt);
1439
1926
  if (!resolved)
1440
1927
  continue;
1441
- const misfit = kind === "style"
1442
- ? resolved.kind === "relation"
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"
1443
1940
  ? ELEMENT_ONLY_PROPERTIES
1444
- : RELATION_ONLY_PROPERTIES
1445
- : [];
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;
1446
1950
  for (const name of misfit) {
1447
1951
  const token = tokens[name];
1448
- // `shape` is reported below, with the kinds that take it.
1449
- 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))
1450
1954
  continue;
1451
1955
  misplaced.add(name);
1452
- this.invalid(messages().diagnostics.flow.notForTargets(name, resolved.kind === "relation", resolved.name), token, [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]);
1453
1979
  }
1454
1980
  // `shape` is refused per target: a block may list a node and a decision.
1455
1981
  const shapeless = kind === "style" && tokens.shape ? this.shapeRefusal(resolved) : null;
@@ -1468,12 +1994,13 @@ export class FlowAnalyzer {
1468
1994
  const refused = [
1469
1995
  ...(shapeless === null ? [] : ["shape"]),
1470
1996
  ...(unturned === null ? [] : ["rotation"]),
1997
+ ...(thin ? ["width"] : []),
1471
1998
  ];
1472
1999
  const excluded = refused.length === 0 ? misfit : [...misfit, ...refused];
1473
2000
  if (kind === "animate") {
1474
2001
  // One effect per channel and target; different channels may share a target.
1475
2002
  const effect = values.effect;
1476
- if (effect && !this.animationTargetFits(effect, resolved, targetToken))
2003
+ if (effect && !this.animationTargetFits(effect, resolved, targetToken, stmt))
1477
2004
  continue;
1478
2005
  if (effect) {
1479
2006
  const key = `${resolved.name}:${effect}`;
@@ -1597,19 +2124,29 @@ export class FlowAnalyzer {
1597
2124
  return { profile: false, kind: element.kind };
1598
2125
  }
1599
2126
  /** Whether an effect can run on a target; reports `animate.invalid-target` when not. */
1600
- animationTargetFits(effect, resolved, token) {
2127
+ animationTargetFits(effect, resolved, token, stmt) {
1601
2128
  const element = resolved.kind === "element" ? this.elementsById.get(resolved.name) : undefined;
1602
2129
  const m = messages().diagnostics.flow;
1603
2130
  let reason = null;
1604
- if (effect === "flow" && resolved.kind !== "relation") {
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";
2134
+ if (effect === "flow" && !connector) {
1605
2135
  reason = m.flowTarget;
1606
2136
  }
1607
- else if (effect === "trace" && resolved.kind !== "relation") {
2137
+ else if (effect === "trace" && !connector) {
1608
2138
  reason = m.traceTarget;
1609
2139
  }
1610
2140
  else if (effect === "dash") {
1611
2141
  if (resolved.kind === "event")
1612
2142
  reason = m.dashOnEvent;
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`).
2147
+ if (stmt)
2148
+ this.pendingDashes.push({ name: resolved.name, token, stmt });
2149
+ }
1613
2150
  else if (element && NO_OUTLINE_KINDS.has(element.kind)) {
1614
2151
  reason = m.dashWithoutOutline(resolved.name, element.kind);
1615
2152
  }
@@ -1646,9 +2183,22 @@ export class FlowAnalyzer {
1646
2183
  }
1647
2184
  }
1648
2185
  // -------------------------------------------------------------------- free helpers
1649
- /** 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
+ */
1650
2190
  const FLOW_DECLARATIONS = ["node", "decision", "data", "group", "note", "tag"];
1651
- const FREE_DECLARATIONS = ["box", "text", "icon", "image", "group"];
2191
+ const FREE_DECLARATIONS = [
2192
+ "box",
2193
+ "text",
2194
+ "icon",
2195
+ "image",
2196
+ "note",
2197
+ "sticky",
2198
+ "line",
2199
+ "pen",
2200
+ "group",
2201
+ ];
1652
2202
  const FLOW_STATEMENTS = [
1653
2203
  "direction",
1654
2204
  "layout",
@@ -1670,15 +2220,13 @@ const FREE_STATEMENTS = [
1670
2220
  "layers",
1671
2221
  ...FLOW_STATEMENTS.slice(8),
1672
2222
  ];
1673
- /** Style properties that only free diagrams accept, for hints. */
1674
- const FREE_PROFILE_ONLY = {
1675
- fontSize: true,
1676
- fontWeight: true,
1677
- textAlign: true,
1678
- };
1679
2223
  function isWord(arg, word) {
1680
2224
  return singleIdent(arg)?.text === word;
1681
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
+ }
1682
2230
  /** A tuple of exactly two numbers without units. */
1683
2231
  export function readPoint(arg) {
1684
2232
  if (arg?.kind !== "Tuple" || arg.items.length !== 2)