@dxos/diagram 0.11.1 → 0.14.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 (171) hide show
  1. package/dist/lib/chunk-parse.mjs +9406 -0
  2. package/dist/lib/chunk-parse.mjs.map +1 -0
  3. package/dist/lib/extension.mjs +204 -0
  4. package/dist/lib/extension.mjs.map +1 -0
  5. package/dist/lib/index.mjs +497 -2846
  6. package/dist/lib/index.mjs.map +1 -1
  7. package/dist/types/src/aesthetics.d.ts +12 -0
  8. package/dist/types/src/aesthetics.d.ts.map +1 -0
  9. package/dist/types/src/aesthetics.test.d.ts +2 -0
  10. package/dist/types/src/aesthetics.test.d.ts.map +1 -0
  11. package/dist/types/src/appeal.d.ts +55 -0
  12. package/dist/types/src/appeal.d.ts.map +1 -0
  13. package/dist/types/src/appeal.test.d.ts +2 -0
  14. package/dist/types/src/appeal.test.d.ts.map +1 -0
  15. package/dist/types/src/architecture.d.ts +86 -0
  16. package/dist/types/src/architecture.d.ts.map +1 -0
  17. package/dist/types/src/architecture.test.d.ts +2 -0
  18. package/dist/types/src/architecture.test.d.ts.map +1 -0
  19. package/dist/types/src/compact.d.ts +39 -0
  20. package/dist/types/src/compact.d.ts.map +1 -0
  21. package/dist/types/src/compact.test.d.ts +2 -0
  22. package/dist/types/src/compact.test.d.ts.map +1 -0
  23. package/dist/types/src/diagnostics.d.ts +70 -3
  24. package/dist/types/src/diagnostics.d.ts.map +1 -1
  25. package/dist/types/src/dsl/cli.d.ts +2 -0
  26. package/dist/types/src/dsl/cli.d.ts.map +1 -0
  27. package/dist/types/src/dsl/compile.d.ts +12 -0
  28. package/dist/types/src/dsl/compile.d.ts.map +1 -0
  29. package/dist/types/src/dsl/convert.d.ts +45 -0
  30. package/dist/types/src/dsl/convert.d.ts.map +1 -0
  31. package/dist/types/src/dsl/convert.test.d.ts +2 -0
  32. package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
  33. package/dist/types/src/dsl/dsl.test.d.ts +2 -0
  34. package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
  35. package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
  36. package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
  37. package/dist/types/src/dsl/index.d.ts +7 -0
  38. package/dist/types/src/dsl/index.d.ts.map +1 -0
  39. package/dist/types/src/dsl/parse.d.ts +49 -0
  40. package/dist/types/src/dsl/parse.d.ts.map +1 -0
  41. package/dist/types/src/dsl/print.d.ts +19 -0
  42. package/dist/types/src/dsl/print.d.ts.map +1 -0
  43. package/dist/types/src/dsl/semantic.test.d.ts +2 -0
  44. package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
  45. package/dist/types/src/dsl/vocabulary.d.ts +61 -0
  46. package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
  47. package/dist/types/src/dx-svg.d.ts +12 -0
  48. package/dist/types/src/dx-svg.d.ts.map +1 -0
  49. package/dist/types/src/dx-svg.test.d.ts +2 -0
  50. package/dist/types/src/dx-svg.test.d.ts.map +1 -0
  51. package/dist/types/src/extension/complete.d.ts +3 -0
  52. package/dist/types/src/extension/complete.d.ts.map +1 -0
  53. package/dist/types/src/extension/extension.test.d.ts +2 -0
  54. package/dist/types/src/extension/extension.test.d.ts.map +1 -0
  55. package/dist/types/src/extension/highlight.d.ts +14 -0
  56. package/dist/types/src/extension/highlight.d.ts.map +1 -0
  57. package/dist/types/src/extension/index.d.ts +6 -0
  58. package/dist/types/src/extension/index.d.ts.map +1 -0
  59. package/dist/types/src/extension/language.d.ts +13 -0
  60. package/dist/types/src/extension/language.d.ts.map +1 -0
  61. package/dist/types/src/extension/lint.d.ts +21 -0
  62. package/dist/types/src/extension/lint.d.ts.map +1 -0
  63. package/dist/types/src/extension/syntax.d.ts +3 -0
  64. package/dist/types/src/extension/syntax.d.ts.map +1 -0
  65. package/dist/types/src/index.d.ts +14 -0
  66. package/dist/types/src/index.d.ts.map +1 -1
  67. package/dist/types/src/mermaid-engine.d.ts +40 -1
  68. package/dist/types/src/mermaid-engine.d.ts.map +1 -1
  69. package/dist/types/src/mermaid.d.ts +3 -3
  70. package/dist/types/src/mermaid.d.ts.map +1 -1
  71. package/dist/types/src/nudge.d.ts +30 -0
  72. package/dist/types/src/nudge.d.ts.map +1 -0
  73. package/dist/types/src/nudge.test.d.ts +2 -0
  74. package/dist/types/src/nudge.test.d.ts.map +1 -0
  75. package/dist/types/src/objective.d.ts +4 -0
  76. package/dist/types/src/objective.d.ts.map +1 -1
  77. package/dist/types/src/ortho-router.d.ts +13 -1
  78. package/dist/types/src/ortho-router.d.ts.map +1 -1
  79. package/dist/types/src/ports.d.ts +40 -0
  80. package/dist/types/src/ports.d.ts.map +1 -0
  81. package/dist/types/src/ports.test.d.ts +2 -0
  82. package/dist/types/src/ports.test.d.ts.map +1 -0
  83. package/dist/types/src/relations.test.d.ts +2 -0
  84. package/dist/types/src/relations.test.d.ts.map +1 -0
  85. package/dist/types/src/rules.d.ts +30 -0
  86. package/dist/types/src/rules.d.ts.map +1 -0
  87. package/dist/types/src/scene.d.ts +108 -74
  88. package/dist/types/src/scene.d.ts.map +1 -1
  89. package/dist/types/src/score.d.ts +54 -0
  90. package/dist/types/src/score.d.ts.map +1 -0
  91. package/dist/types/src/score.test.d.ts +2 -0
  92. package/dist/types/src/score.test.d.ts.map +1 -0
  93. package/dist/types/src/semantic-engine.d.ts +41 -0
  94. package/dist/types/src/semantic-engine.d.ts.map +1 -0
  95. package/dist/types/src/semantic-engine.test.d.ts +2 -0
  96. package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
  97. package/dist/types/src/semantic-place.d.ts +81 -0
  98. package/dist/types/src/semantic-place.d.ts.map +1 -0
  99. package/dist/types/src/semantic-route.d.ts +133 -0
  100. package/dist/types/src/semantic-route.d.ts.map +1 -0
  101. package/dist/types/src/semantic.d.ts +164 -0
  102. package/dist/types/src/semantic.d.ts.map +1 -0
  103. package/dist/types/src/uml-grid.d.ts.map +1 -1
  104. package/dist/types/src/uml.d.ts +3 -5
  105. package/dist/types/src/uml.d.ts.map +1 -1
  106. package/dist/types/src/view.d.ts +53 -0
  107. package/dist/types/src/view.d.ts.map +1 -0
  108. package/dist/types/src/view.test.d.ts +2 -0
  109. package/dist/types/src/view.test.d.ts.map +1 -0
  110. package/dist/types/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +23 -5
  112. package/rules/DIAGRAM.mdl +388 -0
  113. package/src/aesthetics.test.ts +89 -0
  114. package/src/aesthetics.ts +148 -0
  115. package/src/appeal.test.ts +144 -0
  116. package/src/appeal.ts +586 -0
  117. package/src/architecture.test.ts +94 -0
  118. package/src/architecture.ts +215 -0
  119. package/src/compact.test.ts +67 -0
  120. package/src/compact.ts +219 -0
  121. package/src/diagnostics.test.ts +207 -1
  122. package/src/diagnostics.ts +481 -37
  123. package/src/dsl/cli.ts +46 -0
  124. package/src/dsl/compile.ts +28 -0
  125. package/src/dsl/convert.test.ts +51 -0
  126. package/src/dsl/convert.ts +98 -0
  127. package/src/dsl/diagram.grammar +195 -0
  128. package/src/dsl/dsl.test.ts +286 -0
  129. package/src/dsl/gen/diagram.ts +22 -0
  130. package/src/dsl/index.ts +44 -0
  131. package/src/dsl/parse.ts +1054 -0
  132. package/src/dsl/print.ts +198 -0
  133. package/src/dsl/semantic.test.ts +216 -0
  134. package/src/dsl/vocabulary.ts +150 -0
  135. package/src/dx-svg.test.ts +44 -0
  136. package/src/dx-svg.ts +77 -0
  137. package/src/extension/complete.ts +105 -0
  138. package/src/extension/extension.test.ts +135 -0
  139. package/src/extension/highlight.ts +23 -0
  140. package/src/extension/index.ts +9 -0
  141. package/src/extension/language.ts +31 -0
  142. package/src/extension/lint.ts +60 -0
  143. package/src/extension/syntax.ts +62 -0
  144. package/src/index.ts +14 -0
  145. package/src/mermaid-engine.test.ts +6 -6
  146. package/src/mermaid-engine.ts +569 -119
  147. package/src/mermaid.test.ts +17 -1
  148. package/src/mermaid.ts +30 -18
  149. package/src/nudge.test.ts +186 -0
  150. package/src/nudge.ts +387 -0
  151. package/src/objective.test.ts +2 -0
  152. package/src/objective.ts +17 -1
  153. package/src/ortho-router.ts +240 -80
  154. package/src/ports.test.ts +157 -0
  155. package/src/ports.ts +186 -0
  156. package/src/relations.test.ts +98 -0
  157. package/src/rules.ts +117 -0
  158. package/src/scene.ts +66 -5
  159. package/src/score.test.ts +64 -0
  160. package/src/score.ts +130 -0
  161. package/src/semantic-engine.test.ts +635 -0
  162. package/src/semantic-engine.ts +891 -0
  163. package/src/semantic-place.ts +1508 -0
  164. package/src/semantic-route.ts +1136 -0
  165. package/src/semantic.ts +278 -0
  166. package/src/typings.d.ts +8 -0
  167. package/src/uml-grid.ts +6 -1
  168. package/src/uml.test.ts +5 -1
  169. package/src/uml.ts +24 -11
  170. package/src/view.test.ts +139 -0
  171. package/src/view.ts +376 -0
package/src/dx-svg.ts ADDED
@@ -0,0 +1,77 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // The `.dx.svg` container: an SVG that renders anywhere and carries the data it was drawn from, as
7
+ // draw.io's editable SVG does. The payload (for the illustrator, a `.dx.json` with the drawing's ECHO
8
+ // objects) is JSON text in a `<metadata id="dx-payload">` element: unlike an attribute or a comment,
9
+ // `<metadata>` survives browsers, GitHub and DOMPurify. Format-only and ECHO-agnostic; the illustrator
10
+ // builds and reads the payload.
11
+ //
12
+
13
+ /** The container format; versions the slot and encoding, not the payload, which versions itself. */
14
+ export const FORMAT = 'org.dxos.dx-svg@1';
15
+
16
+ const NAMESPACE = 'https://dxos.org/ns/dx-svg';
17
+
18
+ const MEDIA_TYPE = 'application/vnd.dxos.dx+json';
19
+
20
+ const NOTICE =
21
+ '<!-- Drawn in DXOS: the embedded data is the source; edits to the picture alone are not reflected in it. -->';
22
+
23
+ /**
24
+ * JSON with `<`, `>` and `&` as unicode escapes, so the text needs neither CDATA nor entity decoding
25
+ * and `JSON.parse` restores it exactly. A top-level `objects` array goes one object per line, so a
26
+ * changed drawing diffs by object.
27
+ */
28
+ const encode = (payload: unknown): string => {
29
+ const escape = (json: string) => json.replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
30
+ if (
31
+ payload &&
32
+ typeof payload === 'object' &&
33
+ !Array.isArray(payload) &&
34
+ 'objects' in payload &&
35
+ Array.isArray(payload.objects)
36
+ ) {
37
+ const { objects, ...rest } = payload;
38
+ const head = JSON.stringify(rest);
39
+ const body = objects.map((object: unknown) => escape(JSON.stringify(object))).join(',\n');
40
+ return `${escape(head.slice(0, -1))}${head.length > 2 ? ',' : ''}"objects":[\n${body}\n]}`;
41
+ }
42
+ return escape(JSON.stringify(payload));
43
+ };
44
+
45
+ /** Embeds a payload in a standalone SVG: a marker on the root and the payload as its first child. */
46
+ export const embed = (svg: string, payload: unknown): string => {
47
+ const root = /<svg\b[^>]*>/.exec(svg);
48
+ if (!root) {
49
+ throw new Error('Not an SVG document: no <svg> element.');
50
+ }
51
+ if (/\bdx:payload=/.test(root[0])) {
52
+ throw new Error('The SVG already carries a dx-svg payload.');
53
+ }
54
+ const open = root[0].replace(/<svg\b/, `<svg xmlns:dx="${NAMESPACE}" dx:payload="${FORMAT}"`);
55
+ const metadata = `<metadata id="dx-payload" data-type="${MEDIA_TYPE}" data-version="1" data-encoding="json">\n${encode(payload)}\n</metadata>`;
56
+ return `${svg.slice(0, root.index)}${NOTICE}\n${open}${metadata}${svg.slice(root.index + root[0].length)}`;
57
+ };
58
+
59
+ /** Whether an SVG carries a payload, without parsing it. */
60
+ export const isDxSvg = (svg: string): boolean => /<metadata\b[^>]*\bid="dx-payload"/.test(svg);
61
+
62
+ /**
63
+ * The payload of a `.dx.svg`, or undefined for a plain SVG. Works on the text alone, so it runs in node,
64
+ * workers and the browser. An encoding this version does not know is an error, not a missing payload.
65
+ */
66
+ export const extract = (svg: string): unknown => {
67
+ const match = /<metadata\b([^>]*)\bid="dx-payload"([^>]*)>([\s\S]*?)<\/metadata>/.exec(svg);
68
+ if (!match) {
69
+ return undefined;
70
+ }
71
+ const attributes = `${match[1]} ${match[2]}`;
72
+ const encoding = /\bdata-encoding="([^"]*)"/.exec(attributes)?.[1] ?? 'json';
73
+ if (encoding !== 'json') {
74
+ throw new Error(`Unsupported dx-svg payload encoding: ${encoding}.`);
75
+ }
76
+ return JSON.parse(match[3]);
77
+ };
@@ -0,0 +1,105 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type CompletionContext, type CompletionResult, autocompletion } from '@codemirror/autocomplete';
6
+ import { syntaxTree } from '@codemirror/language';
7
+ import { type Extension } from '@codemirror/state';
8
+ import { type SyntaxNode } from '@lezer/common';
9
+
10
+ import {
11
+ type AttrSpec,
12
+ DIAGRAM_ATTRS,
13
+ EDGE_ATTRS,
14
+ ELEMENT_ATTRS,
15
+ ELEMENT_KINDS,
16
+ type ElementKind,
17
+ GROUP_ATTRS,
18
+ NODE_ATTRS,
19
+ OBJECT_ATTRS,
20
+ RELATION_WORDS,
21
+ STATEMENT_KEYWORDS,
22
+ } from '../dsl/vocabulary.ts';
23
+
24
+ /** What a semantic statement accepts after its id: attributes, plus relations or edge clauses. */
25
+ const SEMANTIC: Record<string, { attrs: readonly AttrSpec[]; words: readonly string[] }> = {
26
+ DiagramDecl: { attrs: DIAGRAM_ATTRS, words: [] },
27
+ GroupDecl: { attrs: GROUP_ATTRS, words: [...RELATION_WORDS, 'compact'] },
28
+ NodeDecl: { attrs: NODE_ATTRS, words: RELATION_WORDS },
29
+ EdgeDecl: { attrs: EDGE_ATTRS, words: ['via', 'bus'] },
30
+ };
31
+
32
+ const attributeOptions = (attrs: readonly AttrSpec[]) =>
33
+ attrs.map(({ name, type }) => ({
34
+ label: name,
35
+ type: 'property',
36
+ detail: type === 'enum' ? undefined : type,
37
+ apply: `${name}=`,
38
+ }));
39
+
40
+ /** The element kind a completion is inside, read off the tree rather than re-lexing the line. */
41
+ const enclosingKind = (context: CompletionContext, pos: number): ElementKind | undefined => {
42
+ let node = syntaxTree(context.state).resolveInner(pos, -1);
43
+ while (node.parent) {
44
+ if (node.name === 'Element') {
45
+ const shape = node.firstChild;
46
+ const keyword = shape && (shape.getChild('BoxKind') ?? shape.getChild('PathKind'))?.firstChild;
47
+ const word = context.state.doc.sliceString(
48
+ (keyword ?? shape?.firstChild)?.from ?? 0,
49
+ (keyword ?? shape?.firstChild)?.to ?? 0,
50
+ );
51
+ return ELEMENT_KINDS.find((kind) => kind === word);
52
+ }
53
+ if (node.name === 'ObjectDecl' || node.name === 'ElementsDecl') {
54
+ // Inside the declaration but outside any element: the object's own attributes.
55
+ return undefined;
56
+ }
57
+ node = node.parent;
58
+ }
59
+ return undefined;
60
+ };
61
+
62
+ /** Node names from the cursor out to the document, so a position is classified by where it sits. */
63
+ const ancestorsOf = (context: CompletionContext, pos: number): string[] => {
64
+ const names: string[] = [];
65
+ for (let node: SyntaxNode | null = syntaxTree(context.state).resolveInner(pos, -1); node; node = node.parent) {
66
+ names.push(node.name);
67
+ }
68
+ return names;
69
+ };
70
+
71
+ const source = (context: CompletionContext): CompletionResult | null => {
72
+ const word = context.matchBefore(/[\w-]*/);
73
+ if (!word || (word.from === word.to && !context.explicit)) {
74
+ return null;
75
+ }
76
+
77
+ const ancestors = ancestorsOf(context, context.pos);
78
+ // Inside a semantic statement the innermost one decides; a group's body holds nodes and edges.
79
+ const innermost = ancestors.find((name) => name in SEMANTIC || name === 'GroupBody');
80
+ if (innermost === 'GroupBody') {
81
+ return { from: word.from, options: ['node', 'edge'].map((label) => ({ label, type: 'keyword' })) };
82
+ }
83
+ if (innermost) {
84
+ const { attrs, words } = SEMANTIC[innermost];
85
+ return {
86
+ from: word.from,
87
+ options: [...words.map((label) => ({ label, type: 'keyword' })), ...attributeOptions(attrs)],
88
+ };
89
+ }
90
+ // Outside any declaration only a statement can start.
91
+ if (!ancestors.includes('ObjectDecl') && !ancestors.includes('ElementsDecl')) {
92
+ return { from: word.from, options: STATEMENT_KEYWORDS.map((label) => ({ label, type: 'keyword' })) };
93
+ }
94
+
95
+ // A `Body` holds elements and nothing else, so an empty position in one wants a kind; inside an
96
+ // element it wants that kind's attributes, and in the header it wants the object's.
97
+ const kind = enclosingKind(context, context.pos);
98
+ if (!kind && ancestors.includes('Body')) {
99
+ return { from: word.from, options: ELEMENT_KINDS.map((label) => ({ label, type: 'keyword' })) };
100
+ }
101
+
102
+ return { from: word.from, options: attributeOptions(kind ? ELEMENT_ATTRS[kind] : OBJECT_ATTRS) };
103
+ };
104
+
105
+ export const diagramComplete: Extension = autocompletion({ override: [source] });
@@ -0,0 +1,135 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { HighlightStyle } from '@codemirror/language';
6
+ import { highlightTree } from '@lezer/highlight';
7
+ import { vscodeDarkStyle, vscodeLightStyle } from '@uiw/codemirror-theme-vscode';
8
+ import { describe, test } from 'vitest';
9
+
10
+ import { printCommands } from '../dsl/print.ts';
11
+ import { diagramHighlightStyle } from './highlight.ts';
12
+ import { diagram } from './language.ts';
13
+ import { diagramDiagnostics } from './lint.ts';
14
+ import { diagramLanguage } from './syntax.ts';
15
+
16
+ describe('diagram language', () => {
17
+ test('the mode composes without an editor', ({ expect }) => {
18
+ expect(diagram({ layout: true })).toBeDefined();
19
+ expect(diagramLanguage.name).toEqual('diagram');
20
+ });
21
+
22
+ test('highlighting comes off the tree, not a regex', ({ expect }) => {
23
+ const source = 'object A @ 0,0 {\n rect box 0,0 10x10 "hi" color=red\n}\n';
24
+ const tree = diagramLanguage.parser.parse(source);
25
+ const names: string[] = [];
26
+ const cursor = tree.cursor();
27
+ do {
28
+ names.push(cursor.name);
29
+ } while (cursor.next());
30
+ expect(names).toContain('ObjectDecl');
31
+ expect(names).toContain('BoxElement');
32
+ expect(names).toContain('AttrName');
33
+ expect(names).not.toContain('⚠');
34
+ });
35
+
36
+ //
37
+ // The tags are standard so the theme colours most of a document, but `vscode*Style` defines
38
+ // neither `attributeValue` nor `null` — enum values and the unbound arrow end rendered plain
39
+ // until `diagramHighlightStyle` filled them. Asserting against the real theme is what catches
40
+ // the next tag added to `styleTags` with nothing to colour it.
41
+ //
42
+ for (const [mode, themeStyle] of [
43
+ ['light', vscodeLightStyle],
44
+ ['dark', vscodeDarkStyle],
45
+ ] as const) {
46
+ test(`every token is coloured under the ${mode} theme`, ({ expect }) => {
47
+ expect(uncoloured(SAMPLE, themeStyle)).toEqual([]);
48
+ });
49
+
50
+ test(`every semantic token is coloured under the ${mode} theme`, ({ expect }) => {
51
+ expect(uncoloured(SEMANTIC_SAMPLE, themeStyle)).toEqual([]);
52
+ });
53
+ }
54
+ });
55
+
56
+ /** Every element kind, both arrow forms, a quoted id, a comment — one of each tag the grammar emits. */
57
+ const SAMPLE = `# comment
58
+ object pkgA @ -24,-24 scale=1 index="a1" {
59
+ rect frame 0,0 248x172 "Package A" color=grey stroke=dashed
60
+ line path 0,0 10,10 closed=true
61
+ arc smile 50,50 20 0..180
62
+ arrow e1 A/box#left -> B/box "extends" head=triangle
63
+ arrow e2 10,20 -> _
64
+ portal "1st" 0,0 10x10 ref="dxn:echo:@:01"
65
+ }
66
+ `;
67
+
68
+ /** The semantic statements, with soft sides, a compact group, an aspect ratio and a fan-in bus. */
69
+ const SEMANTIC_SAMPLE = `diagram flow=down aspect=4:3
70
+ group g "G" compact max-width=2 ~right-of h { node A node B below A }
71
+ group h { node C }
72
+ edge A:~left|top, B -> C "joins" bus
73
+ `;
74
+
75
+ /** Non-whitespace runs that no highlighter claimed, as `offset:"text"`. */
76
+ const uncoloured = (source: string, themeStyle: Parameters<typeof HighlightStyle.define>[0]): string[] => {
77
+ const covered = new Uint8Array(source.length);
78
+ for (const style of [HighlightStyle.define(themeStyle), diagramHighlightStyle()]) {
79
+ highlightTree(diagramLanguage.parser.parse(source), style, (from, to, classes) => {
80
+ if (classes) {
81
+ covered.fill(1, from, to);
82
+ }
83
+ });
84
+ }
85
+
86
+ const gaps: string[] = [];
87
+ let run = '';
88
+ let start = 0;
89
+ for (let index = 0; index <= source.length; index++) {
90
+ const bare = index < source.length && !covered[index] && !/\s/.test(source[index]);
91
+ if (bare) {
92
+ if (!run) {
93
+ start = index;
94
+ }
95
+ run += source[index];
96
+ } else if (run) {
97
+ gaps.push(`${start}:${JSON.stringify(run)}`);
98
+ run = '';
99
+ }
100
+ }
101
+ return gaps;
102
+ };
103
+
104
+ describe('diagram lint', () => {
105
+ test('a clean document reports nothing', ({ expect }) => {
106
+ expect(diagramDiagnostics('object A @ 0,0 {\n rect box 0,0 10x10\n}\n', { layout: true })).toEqual([]);
107
+ });
108
+
109
+ test('parse problems carry a range inside the document', ({ expect }) => {
110
+ const source = 'object A @ 0,0 {\n rect box 0,0 10x10 colour=red\n}\n';
111
+ const [diagnostic, ...rest] = diagramDiagnostics(source);
112
+ expect(rest).toEqual([]);
113
+ expect(diagnostic.message).toContain('colour');
114
+ expect(source.slice(diagnostic.from, diagnostic.to)).toEqual('colour=red');
115
+ });
116
+
117
+ test('a layout error points at the object that caused it, not the top of the file', ({ expect }) => {
118
+ // Two boxes on the same spot — `node-overlap`, which `Diagnostics` reports against both refs.
119
+ const source = printCommands([
120
+ { op: 'upsert-object', object: { id: 'A', origin: { x: 0, y: 0 }, elements: [box('a')] } },
121
+ { op: 'upsert-object', object: { id: 'B', origin: { x: 0, y: 0 }, elements: [box('b')] } },
122
+ ]);
123
+ const overlaps = diagramDiagnostics(source, { layout: true }).filter(({ source: code }) => code === 'node-overlap');
124
+ expect(overlaps.length).toBeGreaterThan(0);
125
+ expect(overlaps[0].from).toBeGreaterThan(0);
126
+ expect(source.slice(overlaps[0].from, overlaps[0].to)).toContain('rect');
127
+ });
128
+
129
+ test('layout is not analysed while the document has a syntax error', ({ expect }) => {
130
+ const diagnostics = diagramDiagnostics('object A @ 0,0 {\n rect box 0,0\n}\n', { layout: true });
131
+ expect(diagnostics.every(({ source }) => source === undefined)).toBe(true);
132
+ });
133
+ });
134
+
135
+ const box = (id: string) => ({ kind: 'rect', id, x: 0, y: 0, w: 100, h: 50 }) as const;
@@ -0,0 +1,23 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { HighlightStyle } from '@codemirror/language';
6
+ import { tags } from '@lezer/highlight';
7
+
8
+ /**
9
+ * Fills the gaps the editor's own highlight style leaves. The grammar emits standard
10
+ * `@lezer/highlight` tags, so most of a document is coloured by whatever style the theme installs
11
+ * and inherits its palette — but `vscodeLightStyle`/`vscodeDarkStyle` define neither
12
+ * `attributeValue` nor `null`, which in this language are the enum values (`grey`, `dashed`,
13
+ * `triangle`) and the unbound arrow end. Those are the most scannable part of a diagram, so
14
+ * leaving them uncoloured is the one thing worth overriding.
15
+ *
16
+ * Classes are semantic theme tokens rather than hues, as in `mermaidHighlightStyle`, so light and
17
+ * dark follow the palette.
18
+ */
19
+ export const diagramHighlightStyle = () =>
20
+ HighlightStyle.define([
21
+ { tag: tags.attributeValue, class: 'text-accent' },
22
+ { tag: tags.null, class: 'text-fg-subtle' },
23
+ ]);
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export { diagram, diagramBlockDescription } from './language.ts';
6
+ export { type LintOptions, diagramDiagnostics, diagramLint } from './lint.ts';
7
+ export { diagramComplete } from './complete.ts';
8
+ export { diagramHighlightStyle } from './highlight.ts';
9
+ export { diagramLanguage } from './syntax.ts';
@@ -0,0 +1,31 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { LanguageDescription, LanguageSupport, syntaxHighlighting } from '@codemirror/language';
6
+ import { type Extension } from '@codemirror/state';
7
+
8
+ import { diagramComplete } from './complete.ts';
9
+ import { diagramHighlightStyle } from './highlight.ts';
10
+ import { type LintOptions, diagramLint } from './lint.ts';
11
+ import { diagramLanguage } from './syntax.ts';
12
+
13
+ /**
14
+ * The fence dispatch point. Pass this alongside deus's `mdlBlockDescription` in
15
+ * `createMarkdownExtensions({ codeLanguages: [...] })` and a ```diagram block inside a `.mdl`
16
+ * document parses with the diagram grammar — a sibling of MDL's, not a nesting inside it. See
17
+ * `docs/DSL.md`.
18
+ */
19
+ export const diagramBlockDescription = LanguageDescription.of({
20
+ name: 'diagram',
21
+ alias: ['dxd'],
22
+ support: new LanguageSupport(diagramLanguage),
23
+ });
24
+
25
+ /** The whole editing surface for a standalone DSL buffer: mode, highlighting, completion, lint. */
26
+ export const diagram = (options: LintOptions = {}): Extension => [
27
+ new LanguageSupport(diagramLanguage),
28
+ syntaxHighlighting(diagramHighlightStyle()),
29
+ diagramComplete,
30
+ diagramLint(options),
31
+ ];
@@ -0,0 +1,60 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Diagnostic, linter } from '@codemirror/lint';
6
+ import { type Extension } from '@codemirror/state';
7
+ import { type EditorView } from '@codemirror/view';
8
+
9
+ import * as Diagnostics from '../diagnostics.ts';
10
+ import { parse, read, toScene } from '../dsl/parse.ts';
11
+
12
+ export type LintOptions = {
13
+ /**
14
+ * Also report the layout diagnostics — overlaps, a connector through a node, a label that does
15
+ * not fit. Off by default because a document being typed is briefly nonsense; the bench and the
16
+ * agent loop both want it on.
17
+ */
18
+ layout?: boolean;
19
+ };
20
+
21
+ /**
22
+ * What is wrong with a document: everything `parse` found, and optionally everything
23
+ * `Diagnostics.analyze` finds in the scene it produced, each carrying the range of the statement
24
+ * or element that produced it — a layout error is only actionable when it points at the box it is
25
+ * about. Separate from the CodeMirror wrapper so it needs no editor, which is also how the
26
+ * agent-facing path reports the same problems.
27
+ */
28
+ export const diagramDiagnostics = (text: string, { layout = false }: LintOptions = {}): Diagnostic[] => {
29
+ // Laying out semantic statements is a search; without the layout report only the reading is needed.
30
+ const { commands, problems, ranges } = layout ? parse(text) : read(text);
31
+ const clamp = (value: number) => Math.max(0, Math.min(value, text.length));
32
+
33
+ const diagnostics: Diagnostic[] = problems.map(({ severity, message, from, to }) => ({
34
+ severity,
35
+ message,
36
+ from: clamp(from),
37
+ to: clamp(to),
38
+ }));
39
+
40
+ // A document with a syntax error has a scene that is missing pieces, so its layout report would
41
+ // be noise on top of an error the author is already fixing.
42
+ if (layout && !problems.some(({ severity }) => severity === 'error')) {
43
+ for (const diagnostic of Diagnostics.analyze(toScene(commands).objects).diagnostics) {
44
+ const range = diagnostic.refs.flatMap((ref) => ranges.get(ref) ?? []).at(0);
45
+ diagnostics.push({
46
+ severity: diagnostic.severity,
47
+ source: diagnostic.code,
48
+ message: diagnostic.message,
49
+ from: clamp(range?.from ?? 0),
50
+ to: clamp(range?.to ?? Math.min(1, text.length)),
51
+ });
52
+ }
53
+ }
54
+
55
+ return diagnostics.sort((left, right) => left.from - right.from);
56
+ };
57
+
58
+ /** The CodeMirror linter over {@link diagramDiagnostics}. */
59
+ export const diagramLint = (options: LintOptions = {}): Extension =>
60
+ linter((view: EditorView): Diagnostic[] => diagramDiagnostics(view.state.doc.toString(), options));
@@ -0,0 +1,62 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { LRLanguage } from '@codemirror/language';
6
+ import { styleTags, tags } from '@lezer/highlight';
7
+
8
+ import { parser } from '../dsl/gen/diagram.ts';
9
+
10
+ //
11
+ // Unlike deus's MDL mode — which keeps highlighting on a regex ViewPlugin so prose inside a block
12
+ // cannot trip LR error recovery — the diagram grammar covers its whole input, so highlighting comes
13
+ // off the tree. The tags are the standard ones, so most of a document inherits the editor's own
14
+ // palette; `highlight.ts` covers only the two the theme leaves undefined.
15
+ //
16
+
17
+ export const diagramLanguage = LRLanguage.define({
18
+ name: 'diagram',
19
+ parser: parser.configure({
20
+ props: [
21
+ styleTags({
22
+ 'object elements move remove diagram group node edge': tags.definitionKeyword,
23
+ 'cell via bus compact': tags.keyword,
24
+ // Relation words and sides are plain names the reader checks, so they are tagged by position.
25
+ 'RelationKind Side': tags.keyword,
26
+ 'Soft': tags.modifier,
27
+ 'rect ellipse diamond triangle circle line curve arc text arrow portal': tags.keyword,
28
+ // A quoted id is still an id, not a string.
29
+ 'Id/String': tags.variableName,
30
+ 'Id': tags.variableName,
31
+ 'Ref': tags.propertyName,
32
+ 'Label': tags.string,
33
+ 'String': tags.string,
34
+ 'Number': tags.number,
35
+ 'Size': tags.number,
36
+ 'Ratio': tags.number,
37
+ 'AttrName': tags.attributeName,
38
+ // On the leaf, not on `AttrValue`/`Unbound`: a named child with no tag of its own does not
39
+ // inherit its parent's, so tagging the wrapper colours nothing. A numeric or quoted value
40
+ // keeps its own number/string tag; this is the bare enum word (`grey`, `dashed`).
41
+ 'Word': tags.attributeValue,
42
+ // `head=triangle`, `head=arrow`, `tail=circle`: schema literals that are also element-kind
43
+ // keywords, so without the path they would colour as keywords while every other enum value
44
+ // colours as a value. Listed in full, like the grammar's own `Word` alternation, so a
45
+ // literal that starts colliding later is already covered.
46
+ 'Word/object Word/elements Word/move Word/remove Word/rect Word/ellipse Word/diamond Word/triangle Word/circle Word/line Word/curve Word/arc Word/text Word/arrow Word/portal Word/diagram Word/group Word/node Word/edge Word/cell Word/via Word/bus Word/compact':
47
+ tags.attributeValue,
48
+ '_': tags.null,
49
+ 'Comment': tags.lineComment,
50
+ '-> <-> -- @ .. : | ~': tags.operator,
51
+ '( )': tags.paren,
52
+ '=': tags.definitionOperator,
53
+ ',': tags.separator,
54
+ '{ }': tags.brace,
55
+ }),
56
+ ],
57
+ }),
58
+ languageData: {
59
+ commentTokens: { line: '#' },
60
+ closeBrackets: { brackets: ['{', '"'] },
61
+ },
62
+ });
package/src/index.ts CHANGED
@@ -4,12 +4,25 @@
4
4
 
5
5
  export * from './content.ts';
6
6
  export * from './dialect.ts';
7
+ export * as Aesthetics from './aesthetics.ts';
8
+ export * as Appeal from './appeal.ts';
9
+ export * as Architecture from './architecture.ts';
7
10
  export * as Diagnostics from './diagnostics.ts';
11
+ export * as Dsl from './dsl/index.ts';
12
+ export * as DxSvg from './dx-svg.ts';
8
13
  export * as Layout from './layout.ts';
9
14
  export * as Mermaid from './mermaid.ts';
10
15
  export * as MermaidEngine from './mermaid-engine.ts';
16
+ export * as Nudge from './nudge.ts';
11
17
  export * as Objective from './objective.ts';
18
+ export * as Ports from './ports.ts';
19
+ export * as Rules from './rules.ts';
12
20
  export * as Scene from './scene.ts';
21
+ export * as Semantic from './semantic.ts';
22
+ export * as SemanticEngine from './semantic-engine.ts';
23
+ export * as SemanticPlace from './semantic-place.ts';
24
+ export * as SemanticRoute from './semantic-route.ts';
25
+ export * as Score from './score.ts';
13
26
  export * from './svg-handler.ts';
14
27
  export * as Ui from './ui.ts';
15
28
  export * as Uml from './uml.ts';
@@ -17,3 +30,4 @@ export * as UmlEngine from './uml-engine.ts';
17
30
  export * as UmlGrid from './uml-grid.ts';
18
31
  export * as UmlRules from './uml-rules.ts';
19
32
  export * as UmlSearch from './uml-search.ts';
33
+ export * as View from './view.ts';
@@ -119,7 +119,7 @@ describe('mermaid-engine', { timeout: 120_000 }, () => {
119
119
  expect(edges.filter(({ id }) => /-stub-\d$/.test(id))).toHaveLength(2);
120
120
  const labelled = edges.filter((element) => element.kind === 'text' && element.text === 'via mixin');
121
121
  expect(labelled).toHaveLength(1);
122
- expect(edges.filter((element) => element.kind === 'arrow' && element.head === 'triangle')).toHaveLength(2);
122
+ expect(edges.filter((element) => element.kind === 'arrow' && element.relation === 'inheritance')).toHaveLength(2);
123
123
  });
124
124
 
125
125
  test('toStandard rewrites the UML tokens into mermaid-legal labelled arrows', ({ expect }) => {
@@ -181,10 +181,10 @@ describe('mermaid-engine', { timeout: 120_000 }, () => {
181
181
  .elements.flatMap((element) => (element.kind === 'arrow' ? [element] : []));
182
182
  const byId = (prefix: string) => arrows.find(({ id }) => id.startsWith(prefix))!;
183
183
 
184
- expect(byId('B-A')).toMatchObject({ head: 'triangle' });
185
- expect(byId('X-Y')).toMatchObject({ head: 'crowsfoot' });
186
- expect(byId('P-Q')).toMatchObject({ tail: 'circle' });
187
- expect(byId('R-S').head).toBeUndefined();
184
+ expect(byId('B-A')).toMatchObject({ relation: 'inheritance' });
185
+ expect(byId('X-Y')).toMatchObject({ relation: 'one-to-many' });
186
+ expect(byId('P-Q')).toMatchObject({ relation: 'aggregation' });
187
+ expect(byId('R-S').relation).toBeUndefined();
188
188
  });
189
189
 
190
190
  test('basic: no hard defects, every connector straight or one bend', async ({ expect }) => {
@@ -204,7 +204,7 @@ describe('mermaid-engine', { timeout: 120_000 }, () => {
204
204
  const edges = objects.find(({ id }) => id === 'edges')!.elements;
205
205
  expect(edges.find(({ id }) => id === 'A-bus')?.kind).toBe('line');
206
206
  expect(edges.filter(({ id }) => id.endsWith('-stub-0') || id.endsWith('-stub-1'))).toHaveLength(2);
207
- expect(edges.filter((element) => element.kind === 'arrow' && element.head === 'triangle')).toHaveLength(1);
207
+ expect(edges.filter((element) => element.kind === 'arrow' && element.relation === 'inheritance')).toHaveLength(1);
208
208
  // Nothing else needs a second bend, and the three frames sit at equal gaps.
209
209
  expect(report.metrics.frameGapSpread).toBe(0);
210
210