@dxos/diagram 0.11.1 → 0.13.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.
- package/dist/lib/chunk-parse.mjs +9396 -0
- package/dist/lib/chunk-parse.mjs.map +1 -0
- package/dist/lib/extension.mjs +204 -0
- package/dist/lib/extension.mjs.map +1 -0
- package/dist/lib/index.mjs +497 -2846
- package/dist/lib/index.mjs.map +1 -1
- package/dist/types/src/aesthetics.d.ts +12 -0
- package/dist/types/src/aesthetics.d.ts.map +1 -0
- package/dist/types/src/aesthetics.test.d.ts +2 -0
- package/dist/types/src/aesthetics.test.d.ts.map +1 -0
- package/dist/types/src/appeal.d.ts +55 -0
- package/dist/types/src/appeal.d.ts.map +1 -0
- package/dist/types/src/appeal.test.d.ts +2 -0
- package/dist/types/src/appeal.test.d.ts.map +1 -0
- package/dist/types/src/architecture.d.ts +86 -0
- package/dist/types/src/architecture.d.ts.map +1 -0
- package/dist/types/src/architecture.test.d.ts +2 -0
- package/dist/types/src/architecture.test.d.ts.map +1 -0
- package/dist/types/src/compact.d.ts +39 -0
- package/dist/types/src/compact.d.ts.map +1 -0
- package/dist/types/src/compact.test.d.ts +2 -0
- package/dist/types/src/compact.test.d.ts.map +1 -0
- package/dist/types/src/diagnostics.d.ts +70 -3
- package/dist/types/src/diagnostics.d.ts.map +1 -1
- package/dist/types/src/dsl/cli.d.ts +2 -0
- package/dist/types/src/dsl/cli.d.ts.map +1 -0
- package/dist/types/src/dsl/compile.d.ts +12 -0
- package/dist/types/src/dsl/compile.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.d.ts +45 -0
- package/dist/types/src/dsl/convert.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.test.d.ts +2 -0
- package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
- package/dist/types/src/dsl/dsl.test.d.ts +2 -0
- package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
- package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
- package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
- package/dist/types/src/dsl/index.d.ts +7 -0
- package/dist/types/src/dsl/index.d.ts.map +1 -0
- package/dist/types/src/dsl/parse.d.ts +49 -0
- package/dist/types/src/dsl/parse.d.ts.map +1 -0
- package/dist/types/src/dsl/print.d.ts +19 -0
- package/dist/types/src/dsl/print.d.ts.map +1 -0
- package/dist/types/src/dsl/semantic.test.d.ts +2 -0
- package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
- package/dist/types/src/dsl/vocabulary.d.ts +61 -0
- package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
- package/dist/types/src/dx-svg.d.ts +12 -0
- package/dist/types/src/dx-svg.d.ts.map +1 -0
- package/dist/types/src/dx-svg.test.d.ts +2 -0
- package/dist/types/src/dx-svg.test.d.ts.map +1 -0
- package/dist/types/src/extension/complete.d.ts +3 -0
- package/dist/types/src/extension/complete.d.ts.map +1 -0
- package/dist/types/src/extension/extension.test.d.ts +2 -0
- package/dist/types/src/extension/extension.test.d.ts.map +1 -0
- package/dist/types/src/extension/highlight.d.ts +14 -0
- package/dist/types/src/extension/highlight.d.ts.map +1 -0
- package/dist/types/src/extension/index.d.ts +6 -0
- package/dist/types/src/extension/index.d.ts.map +1 -0
- package/dist/types/src/extension/language.d.ts +13 -0
- package/dist/types/src/extension/language.d.ts.map +1 -0
- package/dist/types/src/extension/lint.d.ts +21 -0
- package/dist/types/src/extension/lint.d.ts.map +1 -0
- package/dist/types/src/extension/syntax.d.ts +3 -0
- package/dist/types/src/extension/syntax.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +14 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/mermaid-engine.d.ts +40 -1
- package/dist/types/src/mermaid-engine.d.ts.map +1 -1
- package/dist/types/src/mermaid.d.ts +3 -3
- package/dist/types/src/mermaid.d.ts.map +1 -1
- package/dist/types/src/nudge.d.ts +30 -0
- package/dist/types/src/nudge.d.ts.map +1 -0
- package/dist/types/src/nudge.test.d.ts +2 -0
- package/dist/types/src/nudge.test.d.ts.map +1 -0
- package/dist/types/src/objective.d.ts +4 -0
- package/dist/types/src/objective.d.ts.map +1 -1
- package/dist/types/src/ortho-router.d.ts +13 -1
- package/dist/types/src/ortho-router.d.ts.map +1 -1
- package/dist/types/src/ports.d.ts +40 -0
- package/dist/types/src/ports.d.ts.map +1 -0
- package/dist/types/src/ports.test.d.ts +2 -0
- package/dist/types/src/ports.test.d.ts.map +1 -0
- package/dist/types/src/relations.test.d.ts +2 -0
- package/dist/types/src/relations.test.d.ts.map +1 -0
- package/dist/types/src/rules.d.ts +30 -0
- package/dist/types/src/rules.d.ts.map +1 -0
- package/dist/types/src/scene.d.ts +99 -65
- package/dist/types/src/scene.d.ts.map +1 -1
- package/dist/types/src/score.d.ts +54 -0
- package/dist/types/src/score.d.ts.map +1 -0
- package/dist/types/src/score.test.d.ts +2 -0
- package/dist/types/src/score.test.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.d.ts +41 -0
- package/dist/types/src/semantic-engine.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.test.d.ts +2 -0
- package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
- package/dist/types/src/semantic-place.d.ts +81 -0
- package/dist/types/src/semantic-place.d.ts.map +1 -0
- package/dist/types/src/semantic-route.d.ts +133 -0
- package/dist/types/src/semantic-route.d.ts.map +1 -0
- package/dist/types/src/semantic.d.ts +164 -0
- package/dist/types/src/semantic.d.ts.map +1 -0
- package/dist/types/src/uml-grid.d.ts.map +1 -1
- package/dist/types/src/uml.d.ts +3 -5
- package/dist/types/src/uml.d.ts.map +1 -1
- package/dist/types/src/view.d.ts +53 -0
- package/dist/types/src/view.d.ts.map +1 -0
- package/dist/types/src/view.test.d.ts +2 -0
- package/dist/types/src/view.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -5
- package/rules/DIAGRAM.mdl +388 -0
- package/src/aesthetics.test.ts +89 -0
- package/src/aesthetics.ts +148 -0
- package/src/appeal.test.ts +144 -0
- package/src/appeal.ts +586 -0
- package/src/architecture.test.ts +94 -0
- package/src/architecture.ts +215 -0
- package/src/compact.test.ts +67 -0
- package/src/compact.ts +219 -0
- package/src/diagnostics.test.ts +207 -1
- package/src/diagnostics.ts +481 -37
- package/src/dsl/cli.ts +46 -0
- package/src/dsl/compile.ts +28 -0
- package/src/dsl/convert.test.ts +51 -0
- package/src/dsl/convert.ts +98 -0
- package/src/dsl/diagram.grammar +195 -0
- package/src/dsl/dsl.test.ts +286 -0
- package/src/dsl/gen/diagram.ts +22 -0
- package/src/dsl/index.ts +44 -0
- package/src/dsl/parse.ts +1054 -0
- package/src/dsl/print.ts +198 -0
- package/src/dsl/semantic.test.ts +200 -0
- package/src/dsl/vocabulary.ts +150 -0
- package/src/dx-svg.test.ts +44 -0
- package/src/dx-svg.ts +77 -0
- package/src/extension/complete.ts +105 -0
- package/src/extension/extension.test.ts +135 -0
- package/src/extension/highlight.ts +23 -0
- package/src/extension/index.ts +9 -0
- package/src/extension/language.ts +31 -0
- package/src/extension/lint.ts +60 -0
- package/src/extension/syntax.ts +62 -0
- package/src/index.ts +14 -0
- package/src/mermaid-engine.test.ts +6 -6
- package/src/mermaid-engine.ts +569 -119
- package/src/mermaid.test.ts +17 -1
- package/src/mermaid.ts +30 -18
- package/src/nudge.test.ts +155 -0
- package/src/nudge.ts +385 -0
- package/src/objective.test.ts +2 -0
- package/src/objective.ts +17 -1
- package/src/ortho-router.ts +240 -80
- package/src/ports.test.ts +157 -0
- package/src/ports.ts +186 -0
- package/src/relations.test.ts +98 -0
- package/src/rules.ts +117 -0
- package/src/scene.ts +64 -3
- package/src/score.test.ts +64 -0
- package/src/score.ts +130 -0
- package/src/semantic-engine.test.ts +635 -0
- package/src/semantic-engine.ts +888 -0
- package/src/semantic-place.ts +1508 -0
- package/src/semantic-route.ts +1136 -0
- package/src/semantic.ts +278 -0
- package/src/typings.d.ts +8 -0
- package/src/uml-grid.ts +6 -1
- package/src/uml.test.ts +5 -1
- package/src/uml.ts +24 -11
- package/src/view.test.ts +139 -0
- package/src/view.ts +376 -0
package/src/dsl/print.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// Canonical text for a scene. The form is fixed — two-space indent, one element per line, a blank
|
|
7
|
+
// line between statements, attributes in the order `vocabulary.ts` lists them — because
|
|
8
|
+
// `parse(print(x))` round-tripping is only worth anything if the text side is normalized too.
|
|
9
|
+
//
|
|
10
|
+
|
|
11
|
+
import type * as Scene from '../scene.ts';
|
|
12
|
+
import { BARE_ID, BARE_REF, ELEMENT_ATTRS, OBJECT_ATTRS, RESERVED } from './vocabulary.ts';
|
|
13
|
+
|
|
14
|
+
const ESCAPES: Record<string, string> = { '\\': '\\\\', '"': '\\"', '\n': '\\n', '\t': '\\t', '\r': '\\r' };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Shortest form that reads back: integers keep no trailing zeros, negatives keep their sign, and
|
|
18
|
+
* the exponent notation `String` switches to outside ~1e-7..1e21 is spelled as-is, which the
|
|
19
|
+
* grammar accepts. `NaN` and `Infinity` have no readable form at all, so they are refused here
|
|
20
|
+
* rather than silently producing a document that cannot be parsed.
|
|
21
|
+
*/
|
|
22
|
+
export const formatNumber = (value: number): string => {
|
|
23
|
+
if (!Number.isFinite(value)) {
|
|
24
|
+
throw new TypeError(`A scene coordinate must be finite; got ${value}.`);
|
|
25
|
+
}
|
|
26
|
+
return String(value);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const formatString = (value: string): string =>
|
|
30
|
+
`"${value.replace(/[\\"\n\t\r]/g, (character) => ESCAPES[character] ?? character)}"`;
|
|
31
|
+
|
|
32
|
+
/** Bare where the grammar allows it, quoted where a word would be read as a keyword or fail to lex. */
|
|
33
|
+
export const formatId = (id: string): string => (BARE_ID.test(id) && !RESERVED.has(id) ? id : formatString(id));
|
|
34
|
+
|
|
35
|
+
const formatPoint = ({ x, y }: Scene.Point): string => `${formatNumber(x)},${formatNumber(y)}`;
|
|
36
|
+
|
|
37
|
+
const formatSize = (w: number, h: number): string => `${formatNumber(w)}x${formatNumber(h)}`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Quoted unless every id in it is a bare word: a dialect may build a ref from a node id the
|
|
41
|
+
* grammar cannot lex, such as mermaid's `1st` becoming `1st/box`, and an unquoted one would make
|
|
42
|
+
* the printed scene fail to parse.
|
|
43
|
+
*/
|
|
44
|
+
const formatRef = (ref: string): string => (BARE_REF.test(ref) && !RESERVED.has(ref) ? ref : formatString(ref));
|
|
45
|
+
|
|
46
|
+
/** `_` is the end the schema leaves unset; a bound end prints as its ref, a free one as a point. */
|
|
47
|
+
const formatEndpoint = (ref: string | undefined, point: Scene.Point | undefined): string =>
|
|
48
|
+
ref !== undefined ? formatRef(ref) : point !== undefined ? formatPoint(point) : '_';
|
|
49
|
+
|
|
50
|
+
type AttrValue = string | number | boolean;
|
|
51
|
+
|
|
52
|
+
/** Prints `name=value` for the attributes of `kind` that are set, in the table's order. */
|
|
53
|
+
const formatAttrs = (kind: keyof typeof ELEMENT_ATTRS, values: Partial<Record<string, AttrValue>>): string[] =>
|
|
54
|
+
ELEMENT_ATTRS[kind].flatMap(({ name, type }) => {
|
|
55
|
+
const value = values[name];
|
|
56
|
+
if (value === undefined) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
return [`${name}=${type === 'string' && typeof value === 'string' ? formatString(value) : String(value)}`];
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const style = ({ color, fill, stroke, weight }: Scene.Element): Partial<Record<string, AttrValue>> => ({
|
|
63
|
+
color,
|
|
64
|
+
fill,
|
|
65
|
+
stroke,
|
|
66
|
+
weight,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const formatElement = (element: Scene.Element): string => {
|
|
70
|
+
switch (element.kind) {
|
|
71
|
+
case 'rect':
|
|
72
|
+
case 'ellipse':
|
|
73
|
+
case 'diamond':
|
|
74
|
+
case 'triangle':
|
|
75
|
+
return [
|
|
76
|
+
element.kind,
|
|
77
|
+
formatId(element.id),
|
|
78
|
+
formatPoint(element),
|
|
79
|
+
formatSize(element.w, element.h),
|
|
80
|
+
...(element.text === undefined ? [] : [formatString(element.text)]),
|
|
81
|
+
...formatAttrs(element.kind, { rotation: element.rotation, corners: element.corners, ...style(element) }),
|
|
82
|
+
].join(' ');
|
|
83
|
+
|
|
84
|
+
case 'circle':
|
|
85
|
+
return [
|
|
86
|
+
'circle',
|
|
87
|
+
formatId(element.id),
|
|
88
|
+
formatPoint({ x: element.cx, y: element.cy }),
|
|
89
|
+
formatNumber(element.r),
|
|
90
|
+
...(element.text === undefined ? [] : [formatString(element.text)]),
|
|
91
|
+
...formatAttrs('circle', style(element)),
|
|
92
|
+
].join(' ');
|
|
93
|
+
|
|
94
|
+
case 'line':
|
|
95
|
+
return [
|
|
96
|
+
'line',
|
|
97
|
+
formatId(element.id),
|
|
98
|
+
...element.points.map(formatPoint),
|
|
99
|
+
...formatAttrs('line', { closed: element.closed, ...style(element) }),
|
|
100
|
+
].join(' ');
|
|
101
|
+
|
|
102
|
+
case 'curve':
|
|
103
|
+
return [
|
|
104
|
+
'curve',
|
|
105
|
+
formatId(element.id),
|
|
106
|
+
...element.points.map(formatPoint),
|
|
107
|
+
...formatAttrs('curve', style(element)),
|
|
108
|
+
].join(' ');
|
|
109
|
+
|
|
110
|
+
case 'arc':
|
|
111
|
+
return [
|
|
112
|
+
'arc',
|
|
113
|
+
formatId(element.id),
|
|
114
|
+
formatPoint({ x: element.cx, y: element.cy }),
|
|
115
|
+
formatNumber(element.r),
|
|
116
|
+
`${formatNumber(element.startAngle)}..${formatNumber(element.endAngle)}`,
|
|
117
|
+
...formatAttrs('arc', style(element)),
|
|
118
|
+
].join(' ');
|
|
119
|
+
|
|
120
|
+
case 'text':
|
|
121
|
+
return [
|
|
122
|
+
'text',
|
|
123
|
+
formatId(element.id),
|
|
124
|
+
formatPoint(element),
|
|
125
|
+
formatString(element.text),
|
|
126
|
+
...formatAttrs('text', { w: element.w, ...style(element) }),
|
|
127
|
+
].join(' ');
|
|
128
|
+
|
|
129
|
+
case 'arrow':
|
|
130
|
+
return [
|
|
131
|
+
'arrow',
|
|
132
|
+
formatId(element.id),
|
|
133
|
+
formatEndpoint(element.from, element.start),
|
|
134
|
+
'->',
|
|
135
|
+
formatEndpoint(element.to, element.end),
|
|
136
|
+
...(element.text === undefined ? [] : [formatString(element.text)]),
|
|
137
|
+
...formatAttrs('arrow', {
|
|
138
|
+
head: element.head,
|
|
139
|
+
tail: element.tail,
|
|
140
|
+
relation: element.relation,
|
|
141
|
+
...style(element),
|
|
142
|
+
}),
|
|
143
|
+
].join(' ');
|
|
144
|
+
|
|
145
|
+
case 'portal':
|
|
146
|
+
return [
|
|
147
|
+
'portal',
|
|
148
|
+
formatId(element.id),
|
|
149
|
+
formatPoint(element),
|
|
150
|
+
formatSize(element.w, element.h),
|
|
151
|
+
...(element.text === undefined ? [] : [formatString(element.text)]),
|
|
152
|
+
...formatAttrs('portal', { ref: element.ref, ...style(element) }),
|
|
153
|
+
].join(' ');
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const block = (header: string, elements: readonly Scene.Element[]): string =>
|
|
158
|
+
[`${header} {`, ...elements.map((element) => ` ${formatElement(element)}`), '}'].join('\n');
|
|
159
|
+
|
|
160
|
+
export const formatObject = (object: Scene.WorldObject): string =>
|
|
161
|
+
block(
|
|
162
|
+
[
|
|
163
|
+
'object',
|
|
164
|
+
formatId(object.id),
|
|
165
|
+
...(object.origin === undefined ? [] : ['@', formatPoint(object.origin)]),
|
|
166
|
+
...OBJECT_ATTRS.flatMap(({ name, type }) => {
|
|
167
|
+
const value = { scale: object.scale, index: object.index, ref: object.ref }[name];
|
|
168
|
+
if (value === undefined) {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
return [`${name}=${type === 'string' && typeof value === 'string' ? formatString(value) : String(value)}`];
|
|
172
|
+
}),
|
|
173
|
+
].join(' '),
|
|
174
|
+
object.elements,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
export const formatCommand = (command: Scene.Command): string => {
|
|
178
|
+
switch (command.op) {
|
|
179
|
+
case 'upsert-object':
|
|
180
|
+
return formatObject(command.object);
|
|
181
|
+
case 'upsert-elements':
|
|
182
|
+
return block(`elements ${formatId(command.objectId)}`, command.elements);
|
|
183
|
+
case 'move-object':
|
|
184
|
+
return `move ${formatId(command.objectId)} @ ${formatPoint(command.origin)}`;
|
|
185
|
+
case 'remove-object':
|
|
186
|
+
return `remove object ${formatId(command.objectId)}`;
|
|
187
|
+
case 'remove-elements':
|
|
188
|
+
return `remove elements ${formatId(command.objectId)} ${command.elementIds.map(formatId).join(' ')}`;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** A whole document: statements separated by a blank line, newline-terminated. */
|
|
193
|
+
export const printCommands = (commands: readonly Scene.Command[]): string =>
|
|
194
|
+
commands.length === 0 ? '' : `${commands.map(formatCommand).join('\n\n')}\n`;
|
|
195
|
+
|
|
196
|
+
/** A scene document — every statement an `object`, which is what a drawing serializes to. */
|
|
197
|
+
export const print = (scene: Scene.Scene): string =>
|
|
198
|
+
printCommands(scene.objects.map((object) => ({ op: 'upsert-object', object }) as const));
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { parse, read } from './parse.ts';
|
|
8
|
+
import { formatId } from './print.ts';
|
|
9
|
+
|
|
10
|
+
/** The problems of a document as `severity: text-at-range`, so a test names what each one underlines. */
|
|
11
|
+
const problemsOf = (text: string) =>
|
|
12
|
+
parse(text).problems.map(({ severity, from, to }) => `${severity}: ${text.slice(from, to)}`);
|
|
13
|
+
|
|
14
|
+
const messagesOf = (text: string) =>
|
|
15
|
+
parse(text)
|
|
16
|
+
.problems.map(({ message }) => message)
|
|
17
|
+
.join('\n');
|
|
18
|
+
|
|
19
|
+
describe('semantic statements', () => {
|
|
20
|
+
test('diagram attributes', ({ expect }) => {
|
|
21
|
+
const { diagram, problems } = read('diagram @ 10,20 flow=right grid=384x224 box=160x64\nnode A');
|
|
22
|
+
expect(problems).toEqual([]);
|
|
23
|
+
expect(diagram?.flow).toBe('right');
|
|
24
|
+
expect(diagram?.origin).toEqual({ x: 10, y: 20 });
|
|
25
|
+
expect(diagram?.grid).toEqual({ w: 384, h: 224 });
|
|
26
|
+
expect(diagram?.box).toEqual({ w: 160, h: 64 });
|
|
27
|
+
expect(diagram?.hinted).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('nodes: label, ref, style, pins and relations', ({ expect }) => {
|
|
31
|
+
const { diagram, problems } = read(`
|
|
32
|
+
node A "Alpha" ref="packages/a" color=blue shape=ellipse @cell(1,2)
|
|
33
|
+
node B right-of A ~below A
|
|
34
|
+
node C @ 384,0
|
|
35
|
+
`);
|
|
36
|
+
expect(problems).toEqual([]);
|
|
37
|
+
const [a, b, c] = diagram?.nodes ?? [];
|
|
38
|
+
expect(a).toMatchObject({
|
|
39
|
+
id: 'A',
|
|
40
|
+
label: 'Alpha',
|
|
41
|
+
ref: 'packages/a',
|
|
42
|
+
color: 'blue',
|
|
43
|
+
shape: 'ellipse',
|
|
44
|
+
pin: { kind: 'cell', col: 1, row: 2 },
|
|
45
|
+
});
|
|
46
|
+
expect(b.label).toBe('B');
|
|
47
|
+
expect(b.relations.map(({ kind, target, soft }) => ({ kind, target, soft }))).toEqual([
|
|
48
|
+
{ kind: 'right-of', target: 'A', soft: false },
|
|
49
|
+
{ kind: 'below', target: 'A', soft: true },
|
|
50
|
+
]);
|
|
51
|
+
expect(c.pin).toMatchObject({ kind: 'point', x: 384, y: 0 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('groups hold nodes and edges and relate to each other', ({ expect }) => {
|
|
55
|
+
const { diagram, problems } = read(`
|
|
56
|
+
group left "Left" { node A node B edge A -> B }
|
|
57
|
+
group right "Right" right-of left gap=64 color=yellow { node C }
|
|
58
|
+
`);
|
|
59
|
+
expect(problems).toEqual([]);
|
|
60
|
+
expect(diagram?.nodes.map(({ id, group }) => `${id}:${group}`)).toEqual(['A:left', 'B:left', 'C:right']);
|
|
61
|
+
expect(diagram?.groups[1]).toMatchObject({ id: 'right', label: 'Right', gap: 64, color: 'yellow' });
|
|
62
|
+
expect(diagram?.groups[1].relations[0]).toMatchObject({ kind: 'right-of', target: 'left', soft: false });
|
|
63
|
+
expect(diagram?.edges).toHaveLength(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('edges: sides, two-way, no head, fans, waypoints and style', ({ expect }) => {
|
|
67
|
+
const { diagram, problems } = read(`
|
|
68
|
+
node A node B node C
|
|
69
|
+
edge A:right -> B:top|left "calls" stroke=dashed head=triangle
|
|
70
|
+
edge A <-> B "talks"
|
|
71
|
+
edge B -- C
|
|
72
|
+
edge A -> B, C "both"
|
|
73
|
+
edge A -> C via 600,_ cell(1.5,_) _,40
|
|
74
|
+
`);
|
|
75
|
+
expect(problems).toEqual([]);
|
|
76
|
+
const edges = diagram?.edges ?? [];
|
|
77
|
+
expect(edges[0]).toMatchObject({
|
|
78
|
+
from: { node: 'A', sides: ['right'] },
|
|
79
|
+
to: { node: 'B', sides: ['top', 'left'] },
|
|
80
|
+
label: 'calls',
|
|
81
|
+
stroke: 'dashed',
|
|
82
|
+
head: 'triangle',
|
|
83
|
+
});
|
|
84
|
+
// A two-way edge is two connectors; only the first carries the label.
|
|
85
|
+
expect(edges.slice(1, 3).map(({ from, to, label }) => `${from.node}->${to.node}:${label ?? ''}`)).toEqual([
|
|
86
|
+
'A->B:talks',
|
|
87
|
+
'B->A:',
|
|
88
|
+
]);
|
|
89
|
+
expect(edges[3].head).toBe('none');
|
|
90
|
+
// Without `bus`, a fan is one labelled edge per target.
|
|
91
|
+
expect(edges.slice(4, 6).map(({ to, label }) => `${to.node}:${label}`)).toEqual(['B:both', 'C:both']);
|
|
92
|
+
expect(edges[6].via.map(({ x, y, unit }) => ({ x, y, unit }))).toEqual([
|
|
93
|
+
{ x: 600, y: undefined, unit: 'scene' },
|
|
94
|
+
{ x: 1.5, y: undefined, unit: 'cell' },
|
|
95
|
+
{ x: undefined, y: 40, unit: 'scene' },
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('buses: a fan with `bus`, and named buses across statements', ({ expect }) => {
|
|
100
|
+
const { diagram, problems } = read(`
|
|
101
|
+
node H node A node B node C
|
|
102
|
+
edge H -> A, B "trunk" bus
|
|
103
|
+
edge A -> C "one" bus=into
|
|
104
|
+
edge B -> C "two" bus=into
|
|
105
|
+
`);
|
|
106
|
+
expect(problems).toEqual([]);
|
|
107
|
+
const edges = diagram?.edges ?? [];
|
|
108
|
+
expect(edges[0].bus).toBe(edges[1].bus);
|
|
109
|
+
expect(edges[0].label).toBeUndefined();
|
|
110
|
+
expect([...(diagram?.busLabels.values() ?? [])]).toEqual(['trunk']);
|
|
111
|
+
expect(edges[2].bus).toMatch(/\|in$/);
|
|
112
|
+
expect(edges[2].label).toBe('one');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('soft sides, group shape and aspect', ({ expect }) => {
|
|
116
|
+
const { diagram, problems } = read(`
|
|
117
|
+
diagram aspect=16:9
|
|
118
|
+
group g "G" compact max-width=3 { node A }
|
|
119
|
+
node B
|
|
120
|
+
edge A:~left|top -> B:bottom
|
|
121
|
+
`);
|
|
122
|
+
expect(problems).toEqual([]);
|
|
123
|
+
expect(diagram?.aspect).toBeCloseTo(16 / 9);
|
|
124
|
+
expect(diagram?.groups[0]).toMatchObject({ id: 'g', compact: true, maxWidth: 3 });
|
|
125
|
+
expect(diagram?.edges[0].from).toMatchObject({ sides: ['left', 'top'], soft: true });
|
|
126
|
+
expect(diagram?.edges[0].to.soft).toBeUndefined();
|
|
127
|
+
expect(diagram?.hinted).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a fan-in bus gathers into its target', ({ expect }) => {
|
|
131
|
+
const { diagram, problems } = read('node A node B node C node D\nedge A, B, C -> D "joins" bus');
|
|
132
|
+
expect(problems).toEqual([]);
|
|
133
|
+
const keys = new Set(diagram?.edges.map((edge) => edge.bus));
|
|
134
|
+
expect([...keys]).toHaveLength(1);
|
|
135
|
+
expect([...keys][0]).toMatch(/^D\|.*\|in$/);
|
|
136
|
+
expect([...(diagram?.busLabels.values() ?? [])]).toEqual(['joins']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('a bus the engine cannot honour is reported', ({ expect }) => {
|
|
140
|
+
expect(problemsOf('node A\nnode B\nedge A -> B bus')).toEqual(['warning: bus']);
|
|
141
|
+
expect(problemsOf('node A\nnode B\nedge A -> B bus=solo')).toEqual(['warning: bus=solo']);
|
|
142
|
+
expect(problemsOf('node A\nnode B\nnode C\nedge A -> B, C bus via 10,_')).toEqual(['warning: 10,_']);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('every problem names its source', ({ expect }) => {
|
|
146
|
+
expect(problemsOf('node A\nedge A -> Missing')).toEqual(['error: Missing']);
|
|
147
|
+
expect(problemsOf('node A\nnode B sideways-of A')).toEqual(['error: sideways-of']);
|
|
148
|
+
expect(problemsOf('node A\nnode B\nedge A:middle -> B')).toEqual(['error: middle']);
|
|
149
|
+
expect(problemsOf('node A below Nobody')).toEqual(['error: below Nobody']);
|
|
150
|
+
expect(problemsOf('node A below A')).toEqual(['error: below A']);
|
|
151
|
+
expect(problemsOf('node A\nnode A')).toEqual(['error: A']);
|
|
152
|
+
expect(problemsOf('group g { group h { node A } }')).toEqual(['error: h']);
|
|
153
|
+
expect(problemsOf('node A @cell(1.5,2)')).toEqual(['error: cell(1.5,2)']);
|
|
154
|
+
expect(problemsOf('node A\nnode B\nedge A -> B via _,_')).toEqual(['error: _,_']);
|
|
155
|
+
expect(problemsOf('node A\nnode B\nnode C\nnode D\nedge A, B -> C, D bus')).toEqual(['error: bus']);
|
|
156
|
+
expect(problemsOf('node A colour=red')).toEqual(['error: colour=red']);
|
|
157
|
+
expect(problemsOf('diagram grid=big\nnode A')).toEqual(['error: grid=big']);
|
|
158
|
+
expect(problemsOf('diagram aspect=wide\nnode A')).toEqual(['error: aspect=wide']);
|
|
159
|
+
expect(problemsOf('group g max-width=1.5 { node A }')).toEqual(['error: max-width=1.5']);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('contradictions are relaxed with a warning, and the diagram still lays out', ({ expect }) => {
|
|
163
|
+
// Two pins on one cell: the second pin is dropped.
|
|
164
|
+
expect(problemsOf('node A @cell(0,0)\nnode B @cell(0,0)')).toEqual(['warning: @cell(0,0)']);
|
|
165
|
+
// A relation the pins contradict.
|
|
166
|
+
expect(problemsOf('node A @cell(0,0)\nnode B @cell(0,1) right-of A')).toEqual(['warning: right-of A']);
|
|
167
|
+
// Relations that cannot all hold.
|
|
168
|
+
const cycle = parse('node A right-of B\nnode B right-of A');
|
|
169
|
+
expect(cycle.problems.map(({ severity }) => severity)).toEqual(['warning']);
|
|
170
|
+
expect(cycle.commands.length).toBeGreaterThan(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('a scene statement claiming a laid-out id is flagged', ({ expect }) => {
|
|
174
|
+
expect(problemsOf('node A\nobject A @ 0,0 { rect box 0,0 10x10 }')[0]).toMatch(/^warning: object A/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('the new keywords are reserved, so the printer quotes them as ids', ({ expect }) => {
|
|
178
|
+
for (const word of ['diagram', 'group', 'node', 'edge', 'cell', 'via', 'bus', 'compact']) {
|
|
179
|
+
expect(formatId(word)).toBe(`"${word}"`);
|
|
180
|
+
}
|
|
181
|
+
// Relation words and sides stay usable as ids.
|
|
182
|
+
expect(formatId('left')).toBe('left');
|
|
183
|
+
expect(formatId('below')).toBe('below');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('an inherited property name is not a relationship', ({ expect }) => {
|
|
187
|
+
for (const word of ['constructor', 'toString', '__proto__']) {
|
|
188
|
+
expect(messagesOf(`node A\nnode B\nedge A ${word} B`)).toMatch(/Unknown relationship/);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("no node or group may take the connectors object's id", ({ expect }) => {
|
|
193
|
+
expect(messagesOf('node edges\nnode B')).toMatch(/"edges" names the diagram's connectors object/);
|
|
194
|
+
expect(messagesOf('group edges { node A }')).toMatch(/"edges" names the diagram's connectors object/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('a document of scene statements alone has no diagram', ({ expect }) => {
|
|
198
|
+
expect(read('object A @ 0,0 { rect box 0,0 10x10 }').diagram).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// The words the DSL knows, shared by the parser, the printer, the linter and completion so they
|
|
7
|
+
// cannot drift apart — the round trip holds only because one table decides both which attributes
|
|
8
|
+
// an element accepts and the order they print in. Every literal set is read off `scene.ts` rather
|
|
9
|
+
// than restated, so a schema change shows up here as a type error or a new value, never as a
|
|
10
|
+
// silently stale list.
|
|
11
|
+
//
|
|
12
|
+
|
|
13
|
+
import * as Scene from '../scene.ts';
|
|
14
|
+
import * as Semantic from '../semantic.ts';
|
|
15
|
+
|
|
16
|
+
/** Element kinds as the DSL spells them; each is also the scene element's `kind`. */
|
|
17
|
+
export const ELEMENT_KINDS = [
|
|
18
|
+
...Scene.BoxKind.literals,
|
|
19
|
+
'circle',
|
|
20
|
+
'line',
|
|
21
|
+
'curve',
|
|
22
|
+
'arc',
|
|
23
|
+
'text',
|
|
24
|
+
'arrow',
|
|
25
|
+
'portal',
|
|
26
|
+
] as const;
|
|
27
|
+
export type ElementKind = (typeof ELEMENT_KINDS)[number];
|
|
28
|
+
|
|
29
|
+
/** Statements that state intent and leave layout to the engine. */
|
|
30
|
+
export const SEMANTIC_KEYWORDS = ['diagram', 'group', 'node', 'edge'] as const;
|
|
31
|
+
|
|
32
|
+
/** Statement keywords. */
|
|
33
|
+
export const STATEMENT_KEYWORDS = ['object', 'elements', 'move', 'remove', ...SEMANTIC_KEYWORDS] as const;
|
|
34
|
+
|
|
35
|
+
/** Keywords inside semantic statements: `@cell(c,r)`, `via …`, `bus`, `compact`. */
|
|
36
|
+
export const CLAUSE_KEYWORDS = ['cell', 'via', 'bus', 'compact'] as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Words the grammar reserves, so the printer knows to quote an id that collides with one.
|
|
40
|
+
* `_` is reserved too: it is the unbound arrow end.
|
|
41
|
+
*/
|
|
42
|
+
export const RESERVED: ReadonlySet<string> = new Set<string>([
|
|
43
|
+
...STATEMENT_KEYWORDS,
|
|
44
|
+
...ELEMENT_KINDS,
|
|
45
|
+
...CLAUSE_KEYWORDS,
|
|
46
|
+
'_',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** Attribute value shapes; `enum` carries the literals the schema allows, `size` is `WxH`, `ratio` is `W:H`. */
|
|
50
|
+
export type AttrType =
|
|
51
|
+
| { type: 'number' }
|
|
52
|
+
| { type: 'string' }
|
|
53
|
+
| { type: 'boolean' }
|
|
54
|
+
| { type: 'size' }
|
|
55
|
+
| { type: 'ratio' }
|
|
56
|
+
| { type: 'enum'; values: readonly string[] };
|
|
57
|
+
|
|
58
|
+
export type AttrSpec = AttrType & { name: string };
|
|
59
|
+
|
|
60
|
+
const STYLE: readonly AttrSpec[] = [
|
|
61
|
+
{ name: 'color', type: 'enum', values: Scene.Color.literals },
|
|
62
|
+
{ name: 'fill', type: 'enum', values: Scene.Fill.literals },
|
|
63
|
+
{ name: 'stroke', type: 'enum', values: Scene.Stroke.literals },
|
|
64
|
+
{ name: 'weight', type: 'enum', values: Scene.Weight.literals },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const BOX_ATTRS: readonly AttrSpec[] = [
|
|
68
|
+
{ name: 'rotation', type: 'number' },
|
|
69
|
+
{ name: 'corners', type: 'enum', values: Scene.Corners.literals },
|
|
70
|
+
...STYLE,
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Attributes each element kind accepts, in the order they print. Geometry and the label are
|
|
75
|
+
* positional, so the schema's `x`/`y`/`w`/`h`/`points`/`text` do not appear here — except
|
|
76
|
+
* `text`'s wrap width, which the schema also calls `w`.
|
|
77
|
+
*/
|
|
78
|
+
export const ELEMENT_ATTRS: Record<ElementKind, readonly AttrSpec[]> = {
|
|
79
|
+
rect: BOX_ATTRS,
|
|
80
|
+
ellipse: BOX_ATTRS,
|
|
81
|
+
diamond: BOX_ATTRS,
|
|
82
|
+
triangle: BOX_ATTRS,
|
|
83
|
+
circle: STYLE,
|
|
84
|
+
line: [{ name: 'closed', type: 'boolean' }, ...STYLE],
|
|
85
|
+
curve: STYLE,
|
|
86
|
+
arc: STYLE,
|
|
87
|
+
text: [{ name: 'w', type: 'number' }, ...STYLE],
|
|
88
|
+
arrow: [
|
|
89
|
+
{ name: 'head', type: 'enum', values: Scene.ArrowHead.literals },
|
|
90
|
+
{ name: 'tail', type: 'enum', values: Scene.ArrowTail.literals },
|
|
91
|
+
{ name: 'relation', type: 'enum', values: Scene.Relation.literals },
|
|
92
|
+
...STYLE,
|
|
93
|
+
],
|
|
94
|
+
portal: [{ name: 'ref', type: 'string' }, ...STYLE],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Object attributes other than `@ <origin>`, in the order they print. */
|
|
98
|
+
export const OBJECT_ATTRS: readonly AttrSpec[] = [
|
|
99
|
+
{ name: 'scale', type: 'number' },
|
|
100
|
+
{ name: 'index', type: 'string' },
|
|
101
|
+
{ name: 'ref', type: 'string' },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/** `diagram` attributes. */
|
|
105
|
+
export const DIAGRAM_ATTRS: readonly AttrSpec[] = [
|
|
106
|
+
{ name: 'flow', type: 'enum', values: Semantic.FLOWS },
|
|
107
|
+
{ name: 'grid', type: 'size' },
|
|
108
|
+
{ name: 'box', type: 'size' },
|
|
109
|
+
{ name: 'aspect', type: 'ratio' },
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/** `group` attributes. */
|
|
113
|
+
export const GROUP_ATTRS: readonly AttrSpec[] = [
|
|
114
|
+
{ name: 'gap', type: 'number' },
|
|
115
|
+
{ name: 'max-width', type: 'number' },
|
|
116
|
+
{ name: 'color', type: 'enum', values: Scene.Color.literals },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/** `node` attributes. */
|
|
120
|
+
export const NODE_ATTRS: readonly AttrSpec[] = [
|
|
121
|
+
{ name: 'ref', type: 'string' },
|
|
122
|
+
{ name: 'shape', type: 'enum', values: Scene.BoxKind.literals },
|
|
123
|
+
{ name: 'color', type: 'enum', values: Scene.Color.literals },
|
|
124
|
+
{ name: 'fill', type: 'enum', values: Scene.Fill.literals },
|
|
125
|
+
{ name: 'stroke', type: 'enum', values: Scene.Stroke.literals },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** `edge` attributes. */
|
|
129
|
+
export const EDGE_ATTRS: readonly AttrSpec[] = [
|
|
130
|
+
{ name: 'head', type: 'enum', values: Scene.ArrowHead.literals },
|
|
131
|
+
{ name: 'tail', type: 'enum', values: Scene.ArrowTail.literals },
|
|
132
|
+
{ name: 'stroke', type: 'enum', values: Scene.Stroke.literals },
|
|
133
|
+
{ name: 'color', type: 'enum', values: Scene.Color.literals },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
/** Placement relations a `node` or `group` may state; `~` before one makes it a preference. */
|
|
137
|
+
export const RELATION_WORDS: readonly string[] = Semantic.RELATION_KINDS;
|
|
138
|
+
|
|
139
|
+
/** An id that needs no quoting: a bare word that is not reserved. */
|
|
140
|
+
export const BARE_ID = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
141
|
+
|
|
142
|
+
/** A ref that needs no quoting: every segment is a bare word. */
|
|
143
|
+
export const BARE_REF = /^[A-Za-z_][A-Za-z0-9_-]*(\/[A-Za-z_][A-Za-z0-9_-]*)?(#[A-Za-z_][A-Za-z0-9_-]*)?$/;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A structurally valid ref — `[object/]element[#port]`, as `Scene.parseRef` reads it, whose ids may
|
|
147
|
+
* not contain `/` or `#`. Separate from {@link BARE_REF} because quoting exists to carry a ref
|
|
148
|
+
* whose ids cannot lex bare (`1st/box` from a numeric node id), which is well-formed all the same.
|
|
149
|
+
*/
|
|
150
|
+
export const REF_SHAPE = /^[^/#]+(\/[^/#]+)?(#[^/#]+)?$/;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { FORMAT, embed, extract, isDxSvg } from './dx-svg.ts';
|
|
8
|
+
|
|
9
|
+
const SVG =
|
|
10
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>';
|
|
11
|
+
|
|
12
|
+
describe('dx-svg', () => {
|
|
13
|
+
test('a payload round-trips through the SVG, which still renders as before', ({ expect }) => {
|
|
14
|
+
const payload = {
|
|
15
|
+
format: FORMAT,
|
|
16
|
+
version: 1,
|
|
17
|
+
objects: [
|
|
18
|
+
{ id: 'a', text: '<b> & </metadata> "quoted"' },
|
|
19
|
+
{ id: 'b', ref: { '/': 'echo:/a' } },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
const svg = embed(SVG, payload);
|
|
23
|
+
|
|
24
|
+
expect(isDxSvg(svg)).toBe(true);
|
|
25
|
+
expect(extract(svg)).toEqual(payload);
|
|
26
|
+
expect(svg).toContain('<rect width="10" height="10"/>');
|
|
27
|
+
expect(svg).toContain(`dx:payload="${FORMAT}"`);
|
|
28
|
+
// Escaped as JSON, so the payload's text cannot close the element or need entity decoding.
|
|
29
|
+
expect(svg.match(/<\/metadata>/g)).toHaveLength(1);
|
|
30
|
+
// One object per line, so a changed drawing diffs by object.
|
|
31
|
+
expect(svg).toMatch(/\n\{"id":"a".*\},\n\{"id":"b"/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a plain SVG has no payload, and a second embed is refused', ({ expect }) => {
|
|
35
|
+
expect(isDxSvg(SVG)).toBe(false);
|
|
36
|
+
expect(extract(SVG)).toBeUndefined();
|
|
37
|
+
expect(() => embed(embed(SVG, {}), {})).toThrow(/already/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('an unknown encoding is an error, not a missing payload', ({ expect }) => {
|
|
41
|
+
const svg = embed(SVG, { objects: [] }).replace('data-encoding="json"', 'data-encoding="deflate-raw+base64"');
|
|
42
|
+
expect(() => extract(svg)).toThrow(/Unsupported/);
|
|
43
|
+
});
|
|
44
|
+
});
|
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
|
+
};
|