@meltemi/diagrams-model 1.9.0 → 1.10.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.
Files changed (50) hide show
  1. package/dist/c4.d.ts +5 -4
  2. package/dist/c4.d.ts.map +1 -1
  3. package/dist/c4.js +108 -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 +4 -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 +4 -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 +43 -1
  16. package/dist/flow.d.ts.map +1 -1
  17. package/dist/flow.js +230 -20
  18. package/dist/flow.js.map +1 -1
  19. package/dist/index.d.ts +2 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/json.d.ts +19 -0
  24. package/dist/json.d.ts.map +1 -1
  25. package/dist/json.js +9 -0
  26. package/dist/json.js.map +1 -1
  27. package/dist/messages/changes.d.ts +1 -0
  28. package/dist/messages/changes.d.ts.map +1 -1
  29. package/dist/messages/changes.js +2 -0
  30. package/dist/messages/changes.js.map +1 -1
  31. package/dist/messages/en.d.ts +16 -6
  32. package/dist/messages/en.d.ts.map +1 -1
  33. package/dist/messages/en.js +21 -11
  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 +13 -9
  37. package/dist/messages/es.js.map +1 -1
  38. package/dist/messages/index.d.ts +8 -4
  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 +21 -0
  43. package/dist/outline.js.map +1 -0
  44. package/dist/schema.d.ts +24 -4
  45. package/dist/schema.d.ts.map +1 -1
  46. package/dist/schema.js +66 -10
  47. package/dist/schema.js.map +1 -1
  48. package/dist/types.d.ts +31 -5
  49. package/dist/types.d.ts.map +1 -1
  50. 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, MAIN_LAYER, PORTS, PORT_SIDES, RELATION_ARROWS, RELATION_ONLY_PROPERTIES, diagramStatements, profileTakesShape, takesShape, profileTakesRotation, takesRotation, } from "./schema.js";
3
3
  import { relationAllowed, } from "./diagram-types.js";
4
4
  import { messages } from "./messages/index.js";
5
5
  import { StyleDefinitions, checkArg, describeArg, headerHasError, readClasses, readPropertyBlock, readStatementValue, refuseFreeProperties, singleIdent, spanOf, 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 = [];
@@ -198,6 +203,8 @@ export class FlowAnalyzer {
198
203
  this.declare(decl.stmt, decl.groupPath);
199
204
  for (const rel of classified.relations)
200
205
  this.relation(rel.stmt, rel.groupPath);
206
+ // After the relations, which may declare elements that only they mention.
207
+ this.resolveAttachments();
201
208
  this.assignKeys();
202
209
  this.afterRelations(classified);
203
210
  for (const stmt of classified.properties)
@@ -216,6 +223,7 @@ export class FlowAnalyzer {
216
223
  this.styleOrAnimate(stmt, keyword);
217
224
  }
218
225
  this.checkLayers();
226
+ this.checkOutlinedDashes();
219
227
  this.finish();
220
228
  if (this.declared)
221
229
  this.checkDeclared(this.declared);
@@ -254,6 +262,26 @@ export class FlowAnalyzer {
254
262
  hasErrors: this.node.hasError || this.errors > 0,
255
263
  };
256
264
  }
265
+ /**
266
+ * 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
+ */
269
+ checkOutlinedDashes() {
270
+ const source = {
271
+ styles: this.styles,
272
+ classes: this.definitions.classes,
273
+ rules: this.definitions.rules,
274
+ };
275
+ for (const { name, token, stmt } of this.pendingDashes) {
276
+ const element = this.elementsById.get(name);
277
+ if (!element || outlined(source, element))
278
+ continue;
279
+ this.error("animate.invalid-target", messages().diagnostics.flow.dashNeedsOutline(name, element.kind), token, { hints: [messages().hints.flow.dashOutline] });
280
+ const at = this.animations.findIndex((a) => a.node === stmt && a.target === name && a.effect === "dash");
281
+ if (at >= 0)
282
+ this.animations.splice(at, 1);
283
+ }
284
+ }
257
285
  /** A declared type's rules: required properties, and the kinds relations may join. */
258
286
  checkDeclared(definition) {
259
287
  for (const element of this.elements) {
@@ -420,6 +448,9 @@ export class FlowAnalyzer {
420
448
  technology: null,
421
449
  external: false,
422
450
  shape: data ? "cylinder" : null,
451
+ line: null,
452
+ attachedTo: null,
453
+ attachedToRange: null,
423
454
  typeKind: null,
424
455
  data: {},
425
456
  groupPath,
@@ -435,19 +466,25 @@ export class FlowAnalyzer {
435
466
  const typeKind = declaredKind ? stmt.keyword.text : null;
436
467
  const kind = (declaredKind ? "box" : stmt.keyword.text);
437
468
  const sourced = kind === "text" || kind === "icon" || kind === "image";
469
+ const note = kind === "note" && !declaredKind;
438
470
  const second = { text: '"Text"', icon: '"<pack>:<name>"', image: '"<path>"' }[kind];
439
- const form = sourced
440
- ? `\`${kind} <id> ${second}\``
441
- : `\`${kind} <id> ["Label"]${kind === "group" ? " { ... }" : ""}\``;
471
+ const form = note
472
+ ? '`note <id> "Text" [on <element>]`'
473
+ : sourced
474
+ ? `\`${kind} <id> ${second}\``
475
+ : `\`${kind} <id> ["Label"]${kind === "group" ? " { ... }" : ""}\``;
442
476
  const example = {
443
477
  text: 'text heading "Architecture"',
444
478
  icon: 'icon server "tabler:server"',
445
479
  image: 'image logo "logo.png"',
480
+ note: 'note limits "500 rps" on api',
446
481
  }[kind];
447
482
  if (stmt.chain) {
448
483
  this.invalid(messages().diagnostics.common.singleBlock(kind), stmt.chain.clauses[1].keyword);
449
484
  return;
450
485
  }
486
+ if (kind === "line" && !declaredKind)
487
+ return this.declareLine(stmt, groupPath);
451
488
  const [idArg, labelArg, ...extra] = stmt.args;
452
489
  const idToken = singleIdent(idArg);
453
490
  if (!idToken) {
@@ -491,15 +528,22 @@ export class FlowAnalyzer {
491
528
  sourceRange = this.range(labelArg);
492
529
  }
493
530
  }
494
- else if (sourced) {
531
+ else if (sourced || (note && this.notesNeedText)) {
495
532
  const m = messages();
496
533
  this.invalid(m.diagnostics.flow.needsSource(kind, idToken.text, form), stmt, [
497
534
  m.hints.example(example),
498
535
  ]);
499
536
  }
537
+ // A note may end with `on <element>`; nothing else follows a label.
538
+ let attachment;
500
539
  if (extra.length > 0) {
501
- this.invalid(messages().diagnostics.flow.valueAfter(sourced ? kind : "label", form), extra[0]);
502
- return;
540
+ attachment = note ? this.attachmentOf(extra, form) : undefined;
541
+ if (!attachment) {
542
+ if (!note || !isWord(extra[0], "on")) {
543
+ this.invalid(messages().diagnostics.flow.valueAfter(note ? "text" : sourced ? kind : "label", form), extra[0]);
544
+ }
545
+ return;
546
+ }
503
547
  }
504
548
  const block = kind === "group"
505
549
  ? { ...this.groupBlock(stmt.block, idToken), ports: [], values: {} }
@@ -520,6 +564,9 @@ export class FlowAnalyzer {
520
564
  technology: null,
521
565
  external: false,
522
566
  shape: null,
567
+ line: null,
568
+ attachedTo: attachment?.target ?? null,
569
+ attachedToRange: attachment ? this.range(attachment.arg) : null,
523
570
  typeKind,
524
571
  data: declaredKind ? dataOf(blockValues, declaredKind.properties) : {},
525
572
  groupPath,
@@ -528,6 +575,8 @@ export class FlowAnalyzer {
528
575
  idToken,
529
576
  node: stmt,
530
577
  });
578
+ if (added && attachment)
579
+ this.attachments.push({ note: idToken.text, ...attachment, stmt });
531
580
  if (!added || source === null || !sourceRange)
532
581
  return;
533
582
  if (kind === "icon")
@@ -535,6 +584,123 @@ export class FlowAnalyzer {
535
584
  else
536
585
  this.images.push({ element: idToken.text, path: source, range: sourceRange });
537
586
  }
587
+ /**
588
+ * `line <id> ["Label"] from (x, y) to (x, y) [via [(x, y), ...]] [{ ... }]`: a free line that
589
+ * joins no element, its points in the interior coordinates of its innermost group. The label is
590
+ * empty unless written: a line has no name to show.
591
+ */
592
+ declareLine(stmt, groupPath) {
593
+ const form = '`line <id> ["Label"] from (x, y) to (x, y) [via [(x, y), ...]]`';
594
+ const m = messages();
595
+ const [idArg, ...rest] = stmt.args;
596
+ const idToken = singleIdent(idArg);
597
+ if (!idToken) {
598
+ this.invalid(m.diagnostics.common.expected(form), idArg ?? stmt.keyword, [
599
+ m.hints.example("line divider from (40, 200) to (520, 200)"),
600
+ ]);
601
+ return;
602
+ }
603
+ const refuse = (at) => this.invalid(m.diagnostics.flow.linePoints, at ?? stmt, [m.hints.flow.form(form)]);
604
+ const labelArg = rest[0]?.kind === "StringLiteral" ? rest[0] : undefined;
605
+ const [fromWord, fromArg, toWord, toArg, viaWord, viaList, extra] = labelArg
606
+ ? rest.slice(1)
607
+ : rest;
608
+ if (!isWord(fromWord, "from"))
609
+ return refuse(fromWord);
610
+ const from = readPoint(fromArg);
611
+ if (!from)
612
+ return refuse(fromArg);
613
+ if (!isWord(toWord, "to"))
614
+ return refuse(toWord);
615
+ const to = readPoint(toArg);
616
+ if (!to)
617
+ return refuse(toArg);
618
+ const via = [];
619
+ if (viaWord) {
620
+ if (!isWord(viaWord, "via") || viaList?.kind !== "List")
621
+ return refuse(viaList ?? viaWord);
622
+ for (const item of viaList.items) {
623
+ const point = readPoint(item);
624
+ if (!point)
625
+ return refuse(item);
626
+ via.push(point);
627
+ }
628
+ if (extra)
629
+ return refuse(extra);
630
+ }
631
+ const { values: _values, ...blockFields } = this.elementBlock(stmt.block, this.profile.elementProperties, "Element", idToken);
632
+ this.addElement({
633
+ id: idToken.text,
634
+ kind: "line",
635
+ label: labelArg?.kind === "StringLiteral" ? labelArg.value : "",
636
+ implicit: false,
637
+ ...blockFields,
638
+ source: null,
639
+ sourceRange: null,
640
+ labelWritten: labelArg !== undefined,
641
+ entity: null,
642
+ technology: null,
643
+ external: false,
644
+ shape: null,
645
+ line: { from, to, via },
646
+ attachedTo: null,
647
+ attachedToRange: null,
648
+ typeKind: null,
649
+ data: {},
650
+ groupPath,
651
+ range: this.range(stmt),
652
+ idRange: this.range(idToken),
653
+ idToken,
654
+ node: stmt,
655
+ });
656
+ }
657
+ /** Whether a note must write its text: free compositions and views, where a note is no node. */
658
+ get notesNeedText() {
659
+ return this.free;
660
+ }
661
+ /**
662
+ * The target of a note's `on <element>`: `extra` holds the word `on` and one identifier (C4
663
+ * views read an entity path), nothing else. Undefined when it does not; a lone `on` is reported
664
+ * here, as the form is wanted.
665
+ */
666
+ attachmentOf(extra, form) {
667
+ if (!isWord(extra[0], "on"))
668
+ return undefined;
669
+ const arg = extra[1];
670
+ const token = singleIdent(arg);
671
+ if (!token || extra.length > 2) {
672
+ this.invalid(messages().diagnostics.common.expected(form), extra[1] ?? extra[0]);
673
+ return undefined;
674
+ }
675
+ return { arg: arg, target: token.text };
676
+ }
677
+ /**
678
+ * Resolves the targets of the notes' `on <element>`: an element of the diagram other than the
679
+ * note itself, of any kind. Renames follow the reference this records.
680
+ */
681
+ resolveAttachments() {
682
+ const m = messages();
683
+ for (const { note, arg, target, stmt } of this.attachments) {
684
+ const token = singleIdent(arg);
685
+ if (target === note) {
686
+ this.invalid(m.diagnostics.flow.attachSelf(note), token);
687
+ }
688
+ else if (this.elementsById.has(target)) {
689
+ this.reference(token, target, "element", "attachment", stmt);
690
+ }
691
+ else {
692
+ this.error("ref.unknown-element", m.diagnostics.common.unknownElement(target), token, {
693
+ hints: [m.hints.flow.attachTarget(note)],
694
+ data: {
695
+ ids: [target],
696
+ candidates: this.elements
697
+ .filter((e) => e.id !== note && e.kind !== "line")
698
+ .map((e) => e.id),
699
+ },
700
+ });
701
+ }
702
+ }
703
+ }
538
704
  addElement(element) {
539
705
  const existing = this.elementsById.get(element.id);
540
706
  if (existing) {
@@ -782,7 +948,8 @@ export class FlowAnalyzer {
782
948
  from,
783
949
  to,
784
950
  line: this.profile.arrows[stmt.arrow.text] ?? "solid",
785
- twoWay: stmt.arrow.text.startsWith("<"),
951
+ heads: RELATION_ARROWS[stmt.arrow.text]?.heads ?? "end",
952
+ twoWay: RELATION_ARROWS[stmt.arrow.text]?.heads === "both",
786
953
  async: values.kind === "async",
787
954
  technology: null,
788
955
  sources: [],
@@ -841,6 +1008,9 @@ export class FlowAnalyzer {
841
1008
  technology: null,
842
1009
  external: false,
843
1010
  shape: null,
1011
+ line: null,
1012
+ attachedTo: null,
1013
+ attachedToRange: null,
844
1014
  typeKind: null,
845
1015
  data: {},
846
1016
  groupPath,
@@ -1075,7 +1245,9 @@ export class FlowAnalyzer {
1075
1245
  if (extra)
1076
1246
  return this.shapeError(stmt, form, extra);
1077
1247
  const element = this.resolveElement(targetToken, "place", stmt);
1078
- if (!element || this.duplicatePlacement(stmt, element))
1248
+ if (!element || this.lineRefused(element, "place", targetToken))
1249
+ return;
1250
+ if (this.duplicatePlacement(stmt, element))
1079
1251
  return;
1080
1252
  this.placement.push({
1081
1253
  statement: "place",
@@ -1102,7 +1274,9 @@ export class FlowAnalyzer {
1102
1274
  if (extra)
1103
1275
  return this.shapeError(stmt, form, extra);
1104
1276
  const element = this.resolveElement(targetToken, "size", stmt);
1105
- if (!element || this.duplicatePlacement(stmt, element))
1277
+ if (!element || this.lineRefused(element, "size", targetToken))
1278
+ return;
1279
+ if (this.duplicatePlacement(stmt, element))
1106
1280
  return;
1107
1281
  this.placement.push({
1108
1282
  statement: "size",
@@ -1340,12 +1514,17 @@ export class FlowAnalyzer {
1340
1514
  if (extra)
1341
1515
  return this.shapeError(stmt, form, extra);
1342
1516
  }
1343
- const relation = this.resolveRelation(targetToken, "label", stmt);
1344
- if (!relation || this.duplicatePlacement(stmt, relation.key))
1517
+ // A free line's label pill moves as a relation's does.
1518
+ const line = this.elementsById.get(targetToken.text);
1519
+ if (line?.kind === "line")
1520
+ this.reference(targetToken, line.id, "element", "label", stmt);
1521
+ const relation = line?.kind === "line" ? undefined : this.resolveRelation(targetToken, "label", stmt);
1522
+ const labelled = line?.kind === "line" ? line.id : relation?.key;
1523
+ if (labelled === undefined || this.duplicatePlacement(stmt, labelled))
1345
1524
  return;
1346
1525
  this.placement.push({
1347
1526
  statement: "label",
1348
- target: relation.key,
1527
+ target: labelled,
1349
1528
  at: at.value,
1350
1529
  offset: point,
1351
1530
  range: this.range(stmt),
@@ -1353,6 +1532,16 @@ export class FlowAnalyzer {
1353
1532
  node: stmt,
1354
1533
  });
1355
1534
  }
1535
+ /** A free line takes no `place` or `size`: its points are in its statement. Reports it. */
1536
+ lineRefused(id, statement, token) {
1537
+ if (this.elementsById.get(id)?.kind !== "line")
1538
+ return false;
1539
+ const m = messages();
1540
+ this.invalid(m.diagnostics.flow.lineHasPoints(id, statement), token, [
1541
+ m.hints.flow.linePointsWritten,
1542
+ ]);
1543
+ return true;
1544
+ }
1356
1545
  resolveElement(token, statement, stmt) {
1357
1546
  const name = token.text;
1358
1547
  if (this.elementsById.has(name)) {
@@ -1438,8 +1627,10 @@ export class FlowAnalyzer {
1438
1627
  const resolved = this.resolveStyleTarget(targetToken, kind, stmt);
1439
1628
  if (!resolved)
1440
1629
  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";
1441
1632
  const misfit = kind === "style"
1442
- ? resolved.kind === "relation"
1633
+ ? resolved.kind === "relation" || line
1443
1634
  ? ELEMENT_ONLY_PROPERTIES
1444
1635
  : RELATION_ONLY_PROPERTIES
1445
1636
  : [];
@@ -1449,7 +1640,9 @@ export class FlowAnalyzer {
1449
1640
  if (!token || name === "shape" || misplaced.has(name))
1450
1641
  continue;
1451
1642
  misplaced.add(name);
1452
- this.invalid(messages().diagnostics.flow.notForTargets(name, resolved.kind === "relation", resolved.name), token, [targetHint(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)]);
1453
1646
  }
1454
1647
  // `shape` is refused per target: a block may list a node and a decision.
1455
1648
  const shapeless = kind === "style" && tokens.shape ? this.shapeRefusal(resolved) : null;
@@ -1473,7 +1666,7 @@ export class FlowAnalyzer {
1473
1666
  if (kind === "animate") {
1474
1667
  // One effect per channel and target; different channels may share a target.
1475
1668
  const effect = values.effect;
1476
- if (effect && !this.animationTargetFits(effect, resolved, targetToken))
1669
+ if (effect && !this.animationTargetFits(effect, resolved, targetToken, stmt))
1477
1670
  continue;
1478
1671
  if (effect) {
1479
1672
  const key = `${resolved.name}:${effect}`;
@@ -1597,19 +1790,27 @@ export class FlowAnalyzer {
1597
1790
  return { profile: false, kind: element.kind };
1598
1791
  }
1599
1792
  /** Whether an effect can run on a target; reports `animate.invalid-target` when not. */
1600
- animationTargetFits(effect, resolved, token) {
1793
+ animationTargetFits(effect, resolved, token, stmt) {
1601
1794
  const element = resolved.kind === "element" ? this.elementsById.get(resolved.name) : undefined;
1602
1795
  const m = messages().diagnostics.flow;
1603
1796
  let reason = null;
1604
- if (effect === "flow" && resolved.kind !== "relation") {
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";
1799
+ if (effect === "flow" && !connector) {
1605
1800
  reason = m.flowTarget;
1606
1801
  }
1607
- else if (effect === "trace" && resolved.kind !== "relation") {
1802
+ else if (effect === "trace" && !connector) {
1608
1803
  reason = m.traceTarget;
1609
1804
  }
1610
1805
  else if (effect === "dash") {
1611
1806
  if (resolved.kind === "event")
1612
1807
  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`).
1811
+ if (stmt)
1812
+ this.pendingDashes.push({ name: resolved.name, token, stmt });
1813
+ }
1613
1814
  else if (element && NO_OUTLINE_KINDS.has(element.kind)) {
1614
1815
  reason = m.dashWithoutOutline(resolved.name, element.kind);
1615
1816
  }
@@ -1648,7 +1849,15 @@ export class FlowAnalyzer {
1648
1849
  // -------------------------------------------------------------------- free helpers
1649
1850
  /** The declarations and statements of flow and free diagrams, as their hints list them. */
1650
1851
  const FLOW_DECLARATIONS = ["node", "decision", "data", "group", "note", "tag"];
1651
- const FREE_DECLARATIONS = ["box", "text", "icon", "image", "group"];
1852
+ const FREE_DECLARATIONS = [
1853
+ "box",
1854
+ "text",
1855
+ "icon",
1856
+ "image",
1857
+ "note",
1858
+ "line",
1859
+ "group",
1860
+ ];
1652
1861
  const FLOW_STATEMENTS = [
1653
1862
  "direction",
1654
1863
  "layout",
@@ -1675,6 +1884,7 @@ const FREE_PROFILE_ONLY = {
1675
1884
  fontSize: true,
1676
1885
  fontWeight: true,
1677
1886
  textAlign: true,
1887
+ verticalAlign: true,
1678
1888
  };
1679
1889
  function isWord(arg, word) {
1680
1890
  return singleIdent(arg)?.text === word;