@formicoidea/labre-framework-uml 0.42.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/LICENSE +384 -0
- package/dist/actions.d.ts +430 -0
- package/dist/actions.js +724 -0
- package/dist/background.d.ts +153 -0
- package/dist/background.js +557 -0
- package/dist/board-hit.d.ts +137 -0
- package/dist/board-hit.js +191 -0
- package/dist/commands-manifest.d.ts +19 -0
- package/dist/commands-manifest.js +571 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +983 -0
- package/dist/component.d.ts +521 -0
- package/dist/component.js +708 -0
- package/dist/consts.d.ts +365 -0
- package/dist/consts.js +463 -0
- package/dist/descriptor.d.ts +12 -0
- package/dist/descriptor.js +10 -0
- package/dist/drawio-decode.d.ts +32 -0
- package/dist/drawio-decode.js +96 -0
- package/dist/drawio-import.d.ts +133 -0
- package/dist/drawio-import.js +885 -0
- package/dist/edge-morph.d.ts +154 -0
- package/dist/edge-morph.js +153 -0
- package/dist/edge-styles.d.ts +78 -0
- package/dist/edge-styles.js +210 -0
- package/dist/effects.d.ts +9 -0
- package/dist/effects.js +6 -0
- package/dist/element-renderer.d.ts +64 -0
- package/dist/element-renderer.js +114 -0
- package/dist/element-view.d.ts +234 -0
- package/dist/element-view.js +430 -0
- package/dist/export.d.ts +61 -0
- package/dist/export.js +41 -0
- package/dist/filename.d.ts +17 -0
- package/dist/filename.js +26 -0
- package/dist/frame-tag.d.ts +70 -0
- package/dist/frame-tag.js +78 -0
- package/dist/grammar.d.ts +695 -0
- package/dist/grammar.js +1436 -0
- package/dist/import.d.ts +306 -0
- package/dist/import.js +1270 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +67 -0
- package/dist/interchange.d.ts +125 -0
- package/dist/interchange.js +372 -0
- package/dist/keywords.d.ts +204 -0
- package/dist/keywords.js +361 -0
- package/dist/kinds.d.ts +73 -0
- package/dist/kinds.js +91 -0
- package/dist/legend.d.ts +30 -0
- package/dist/legend.js +255 -0
- package/dist/model.d.ts +862 -0
- package/dist/model.js +1316 -0
- package/dist/morph.d.ts +178 -0
- package/dist/morph.js +496 -0
- package/dist/node/act-glyphs.d.ts +30 -0
- package/dist/node/act-glyphs.js +142 -0
- package/dist/node/compartment-watcher.d.ts +107 -0
- package/dist/node/compartment-watcher.js +163 -0
- package/dist/node/fit.d.ts +85 -0
- package/dist/node/fit.js +129 -0
- package/dist/node/node-renderer.d.ts +49 -0
- package/dist/node/node-renderer.js +611 -0
- package/dist/node/node-view.d.ts +51 -0
- package/dist/node/node-view.js +89 -0
- package/dist/node/paint.d.ts +84 -0
- package/dist/node/paint.js +143 -0
- package/dist/node/sd-glyphs.d.ts +36 -0
- package/dist/node/sd-glyphs.js +79 -0
- package/dist/node/stm-glyphs.d.ts +22 -0
- package/dist/node/stm-glyphs.js +111 -0
- package/dist/node/tier-metrics.d.ts +38 -0
- package/dist/node/tier-metrics.js +45 -0
- package/dist/operators.d.ts +45 -0
- package/dist/operators.js +75 -0
- package/dist/plantuml-import.d.ts +113 -0
- package/dist/plantuml-import.js +1723 -0
- package/dist/plantuml.d.ts +94 -0
- package/dist/plantuml.js +985 -0
- package/dist/presets.d.ts +149 -0
- package/dist/presets.js +302 -0
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.js +358 -0
- package/dist/reading.d.ts +160 -0
- package/dist/reading.js +648 -0
- package/dist/roles.d.ts +257 -0
- package/dist/roles.js +997 -0
- package/dist/rules.d.ts +267 -0
- package/dist/rules.js +3090 -0
- package/dist/templates.d.ts +2 -0
- package/dist/templates.js +197 -0
- package/dist/toolbar/config.d.ts +228 -0
- package/dist/toolbar/config.js +833 -0
- package/dist/toolbar/icons.d.ts +440 -0
- package/dist/toolbar/icons.js +711 -0
- package/dist/toolbar/senior-tool.d.ts +11 -0
- package/dist/toolbar/senior-tool.js +21 -0
- package/dist/toolbar/uml-menu.d.ts +17 -0
- package/dist/toolbar/uml-menu.js +20 -0
- package/dist/toolbar/uml-senior-button.d.ts +18 -0
- package/dist/toolbar/uml-senior-button.js +22 -0
- package/dist/translations.d.ts +27 -0
- package/dist/translations.js +117 -0
- package/dist/view.d.ts +33 -0
- package/dist/view.js +236 -0
- package/dist/xmi-import.d.ts +174 -0
- package/dist/xmi-import.js +2268 -0
- package/dist/xmi.d.ts +61 -0
- package/dist/xmi.js +1860 -0
- package/dist/xml-reader.d.ts +157 -0
- package/dist/xml-reader.js +430 -0
- package/dist/xml.d.ts +72 -0
- package/dist/xml.js +96 -0
- package/package.json +38 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tolerant XML READER — the inverse of `xml.ts`, and the one thing an
|
|
3
|
+
* importer cannot do without.
|
|
4
|
+
*
|
|
5
|
+
* ## Why not `DOMParser`
|
|
6
|
+
*
|
|
7
|
+
* BPMN's reader calls it (`gfx/bpmn/src/import.ts`), and it is the right call
|
|
8
|
+
* there: that parser has always run in a browser. This one must not. An
|
|
9
|
+
* interchange parser is a PURE function of a string (`docs/adr/0012` P3) — the
|
|
10
|
+
* same function an editor command, a unit test and labre-mcp all call — and
|
|
11
|
+
* `DOMParser` is a DOM global that exists in exactly one of those three. A
|
|
12
|
+
* reader that needs `happy-dom` to be loaded is a reader with an environment in
|
|
13
|
+
* its signature.
|
|
14
|
+
*
|
|
15
|
+
* So: two hundred lines of scanner, and no global in sight.
|
|
16
|
+
*
|
|
17
|
+
* ## Tolerant, and what that costs
|
|
18
|
+
*
|
|
19
|
+
* **It never throws.** A file that stops mid-element, a close tag that names
|
|
20
|
+
* the wrong element, an attribute with no quotes, an entity nobody declared —
|
|
21
|
+
* every one of them produces a tree with a NOTE beside it rather than an
|
|
22
|
+
* exception. That is not sloppiness, it is the job: the files this reader is
|
|
23
|
+
* handed were written by Papyrus, StarUML, Enterprise Architect and a dozen
|
|
24
|
+
* scripts, and a user who exported a model from one of them is entitled to get
|
|
25
|
+
* back everything it said even when part of it is malformed. The alternative —
|
|
26
|
+
* one exception for one stray `&` — imports nothing at all.
|
|
27
|
+
*
|
|
28
|
+
* What it does NOT do is pretend to be a conformant parser. There is no DTD, no
|
|
29
|
+
* entity declaration, no namespace RESOLUTION (prefixes are kept exactly as the
|
|
30
|
+
* file spelled them — see {@link XmlNode.prefix}), no attribute-value
|
|
31
|
+
* normalization beyond the character references XML 1.0 §3.3.3 makes
|
|
32
|
+
* mandatory. Every one of those is a feature an XMI file in the wild does not
|
|
33
|
+
* use, and each would be a piece of surface with nothing behind it.
|
|
34
|
+
*
|
|
35
|
+
* ## Verbatim means the source's own bytes
|
|
36
|
+
*
|
|
37
|
+
* Every node records the byte RANGE it was read from ({@link XmlNode.range}),
|
|
38
|
+
* and {@link xmlFragmentOf} hands back that slice of the original string. So a
|
|
39
|
+
* quarantined fragment (ADR 0012 D5) is the file's own text — its comments, its
|
|
40
|
+
* whitespace, its attribute order, its prefixes — and not this reader's opinion
|
|
41
|
+
* of it. {@link serializeXmlNode} re-serializes from the tree instead, and is
|
|
42
|
+
* the fallback for a node assembled rather than read.
|
|
43
|
+
*
|
|
44
|
+
* Pure: a string in, a tree out. No `std`, no DOM, no clock.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* One element.
|
|
48
|
+
*
|
|
49
|
+
* `name` is the qualified name AS WRITTEN (`uml:Model`), with {@link prefix}
|
|
50
|
+
* and {@link local} split out beside it, because an importer asks both
|
|
51
|
+
* questions: the prefix is how a file says which vocabulary it means, and the
|
|
52
|
+
* local name is the only half two tools reliably agree on — Papyrus writes
|
|
53
|
+
* `uml:Class`, an older Enterprise Architect writes `UML:Class`, and a
|
|
54
|
+
* namespace-defaulted file writes `Class`.
|
|
55
|
+
*/
|
|
56
|
+
export interface XmlNode {
|
|
57
|
+
/** The qualified name, verbatim: `uml:Model`, `packagedElement`. */
|
|
58
|
+
name: string;
|
|
59
|
+
/** The prefix, `''` when the name carries none. */
|
|
60
|
+
prefix: string;
|
|
61
|
+
/** The local name — `Model`, `packagedElement`. */
|
|
62
|
+
local: string;
|
|
63
|
+
/** Attributes by qualified name, values entity-decoded, in document order. */
|
|
64
|
+
attrs: Record<string, string>;
|
|
65
|
+
children: XmlNode[];
|
|
66
|
+
/**
|
|
67
|
+
* The element's character data — text and CDATA, concatenated and decoded.
|
|
68
|
+
*
|
|
69
|
+
* Absent rather than `''` when the element holds nothing but whitespace and
|
|
70
|
+
* children, which is what every element of an indented XMI document holds.
|
|
71
|
+
* A `<body>` with prose in it has this; a `<packagedElement>` does not.
|
|
72
|
+
*/
|
|
73
|
+
text?: string;
|
|
74
|
+
/**
|
|
75
|
+
* `[start, end)` in the SOURCE string — what {@link xmlFragmentOf} slices.
|
|
76
|
+
*
|
|
77
|
+
* `end` is the offset just past this element's close tag (or past `/>`). For
|
|
78
|
+
* an element the file never closed it is the end of what was read, which is
|
|
79
|
+
* the honest answer and keeps the slice well-formed enough to re-parse.
|
|
80
|
+
*/
|
|
81
|
+
range: [number, number];
|
|
82
|
+
}
|
|
83
|
+
/** A document, and everything the reader had to complain about to read it. */
|
|
84
|
+
export interface XmlDocument {
|
|
85
|
+
/** The first top-level element, or `undefined` for a document with none. */
|
|
86
|
+
root?: XmlNode;
|
|
87
|
+
/**
|
|
88
|
+
* Every top-level element, in order.
|
|
89
|
+
*
|
|
90
|
+
* A well-formed document has exactly one. A file that has been concatenated,
|
|
91
|
+
* truncated or hand-edited has none or several, and the reader says what it
|
|
92
|
+
* found rather than deciding which one was meant.
|
|
93
|
+
*/
|
|
94
|
+
roots: XmlNode[];
|
|
95
|
+
/** One line each, in the user's words — see the header on tolerance. */
|
|
96
|
+
notes: string[];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Character data as its characters — the five named entities and any numeric
|
|
100
|
+
* reference.
|
|
101
|
+
*
|
|
102
|
+
* An entity this reader does not know (` `, a DTD-declared one) is left
|
|
103
|
+
* EXACTLY as it was written, unresolved and unannounced. Two reasons, and the
|
|
104
|
+
* second is the one that matters: resolving it would need the DTD the file
|
|
105
|
+
* does not ship, and dropping it would lose a character; leaving the reference
|
|
106
|
+
* standing means a later re-serialization writes back what the file said, which
|
|
107
|
+
* is the promise `xmlFragmentOf` makes and this keeps true for text as well.
|
|
108
|
+
*/
|
|
109
|
+
export declare function decodeXmlText(value: string): string;
|
|
110
|
+
/**
|
|
111
|
+
* Read a document. Never throws; see the header.
|
|
112
|
+
*
|
|
113
|
+
* The scan is a single left-to-right pass with an explicit stack, which is what
|
|
114
|
+
* makes the tolerance cheap: every recovery is "say what is wrong, and carry on
|
|
115
|
+
* from the next `<`", and there is no recursion to unwind.
|
|
116
|
+
*/
|
|
117
|
+
export declare function readXml(source: string): XmlDocument;
|
|
118
|
+
/**
|
|
119
|
+
* The element's own bytes, from the string it was read from.
|
|
120
|
+
*
|
|
121
|
+
* This is what a quarantined fragment is (ADR 0012 D5): the file's text, not a
|
|
122
|
+
* re-rendering of this reader's tree. Comments, attribute order, prefixes and
|
|
123
|
+
* whitespace all survive, because none of them was ever taken apart.
|
|
124
|
+
*/
|
|
125
|
+
export declare function xmlFragmentOf(source: string, node: XmlNode): string;
|
|
126
|
+
/**
|
|
127
|
+
* A node back as XML, from the TREE rather than from the source.
|
|
128
|
+
*
|
|
129
|
+
* The fallback for a fragment that has no range to slice — one assembled by a
|
|
130
|
+
* caller, or read out of a document this function's caller no longer holds. It
|
|
131
|
+
* is faithful to what the tree records and therefore drops what the tree does
|
|
132
|
+
* not: comments, processing instructions, and the file's own indentation.
|
|
133
|
+
* Prefer {@link xmlFragmentOf} whenever the source string is still in hand.
|
|
134
|
+
*/
|
|
135
|
+
export declare function serializeXmlNode(node: XmlNode): string;
|
|
136
|
+
/** Children with this local name, whatever prefix the file gave them. */
|
|
137
|
+
export declare function xmlChildren(node: XmlNode, local: string): XmlNode[];
|
|
138
|
+
/** The first child with this local name, if any. */
|
|
139
|
+
export declare function xmlChild(node: XmlNode, local: string): XmlNode | undefined;
|
|
140
|
+
/**
|
|
141
|
+
* An attribute by LOCAL name — `xmi:id`, `xmi:ID` and a bare `id` all answer.
|
|
142
|
+
*
|
|
143
|
+
* Prefix-insensitive on purpose, and it is the difference between reading three
|
|
144
|
+
* tools and reading one: the XMI prefix is `xmi` by convention and by nothing
|
|
145
|
+
* else, Enterprise Architect has shipped files under `XMI`, and an
|
|
146
|
+
* attribute-defaulted file drops it altogether. The QUALIFIED name is still in
|
|
147
|
+
* `attrs` for anything that needs it, and {@link xmlFragmentOf} still writes
|
|
148
|
+
* back what the file said.
|
|
149
|
+
*
|
|
150
|
+
* The first match in document order wins, which only matters for a file that
|
|
151
|
+
* spells the same attribute twice under two prefixes.
|
|
152
|
+
*/
|
|
153
|
+
export declare function xmlAttr(node: XmlNode, local: string): string | undefined;
|
|
154
|
+
/** Every `xmlns` / `xmlns:*` declaration on this element, by prefix (`''`). */
|
|
155
|
+
export declare function xmlNamespaces(node: XmlNode): Record<string, string>;
|
|
156
|
+
/** Every element under `node`, at any depth, in document order. */
|
|
157
|
+
export declare function xmlDescendants(node: XmlNode): XmlNode[];
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tolerant XML READER — the inverse of `xml.ts`, and the one thing an
|
|
3
|
+
* importer cannot do without.
|
|
4
|
+
*
|
|
5
|
+
* ## Why not `DOMParser`
|
|
6
|
+
*
|
|
7
|
+
* BPMN's reader calls it (`gfx/bpmn/src/import.ts`), and it is the right call
|
|
8
|
+
* there: that parser has always run in a browser. This one must not. An
|
|
9
|
+
* interchange parser is a PURE function of a string (`docs/adr/0012` P3) — the
|
|
10
|
+
* same function an editor command, a unit test and labre-mcp all call — and
|
|
11
|
+
* `DOMParser` is a DOM global that exists in exactly one of those three. A
|
|
12
|
+
* reader that needs `happy-dom` to be loaded is a reader with an environment in
|
|
13
|
+
* its signature.
|
|
14
|
+
*
|
|
15
|
+
* So: two hundred lines of scanner, and no global in sight.
|
|
16
|
+
*
|
|
17
|
+
* ## Tolerant, and what that costs
|
|
18
|
+
*
|
|
19
|
+
* **It never throws.** A file that stops mid-element, a close tag that names
|
|
20
|
+
* the wrong element, an attribute with no quotes, an entity nobody declared —
|
|
21
|
+
* every one of them produces a tree with a NOTE beside it rather than an
|
|
22
|
+
* exception. That is not sloppiness, it is the job: the files this reader is
|
|
23
|
+
* handed were written by Papyrus, StarUML, Enterprise Architect and a dozen
|
|
24
|
+
* scripts, and a user who exported a model from one of them is entitled to get
|
|
25
|
+
* back everything it said even when part of it is malformed. The alternative —
|
|
26
|
+
* one exception for one stray `&` — imports nothing at all.
|
|
27
|
+
*
|
|
28
|
+
* What it does NOT do is pretend to be a conformant parser. There is no DTD, no
|
|
29
|
+
* entity declaration, no namespace RESOLUTION (prefixes are kept exactly as the
|
|
30
|
+
* file spelled them — see {@link XmlNode.prefix}), no attribute-value
|
|
31
|
+
* normalization beyond the character references XML 1.0 §3.3.3 makes
|
|
32
|
+
* mandatory. Every one of those is a feature an XMI file in the wild does not
|
|
33
|
+
* use, and each would be a piece of surface with nothing behind it.
|
|
34
|
+
*
|
|
35
|
+
* ## Verbatim means the source's own bytes
|
|
36
|
+
*
|
|
37
|
+
* Every node records the byte RANGE it was read from ({@link XmlNode.range}),
|
|
38
|
+
* and {@link xmlFragmentOf} hands back that slice of the original string. So a
|
|
39
|
+
* quarantined fragment (ADR 0012 D5) is the file's own text — its comments, its
|
|
40
|
+
* whitespace, its attribute order, its prefixes — and not this reader's opinion
|
|
41
|
+
* of it. {@link serializeXmlNode} re-serializes from the tree instead, and is
|
|
42
|
+
* the fallback for a node assembled rather than read.
|
|
43
|
+
*
|
|
44
|
+
* Pure: a string in, a tree out. No `std`, no DOM, no clock.
|
|
45
|
+
*/
|
|
46
|
+
/* ── Entities ─────────────────────────────────────────────────────────── */
|
|
47
|
+
/** The five XML 1.0 §4.6 predefined entities, and nothing else. */
|
|
48
|
+
const NAMED_ENTITIES = {
|
|
49
|
+
amp: '&',
|
|
50
|
+
lt: '<',
|
|
51
|
+
gt: '>',
|
|
52
|
+
quot: '"',
|
|
53
|
+
apos: "'",
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Character data as its characters — the five named entities and any numeric
|
|
57
|
+
* reference.
|
|
58
|
+
*
|
|
59
|
+
* An entity this reader does not know (` `, a DTD-declared one) is left
|
|
60
|
+
* EXACTLY as it was written, unresolved and unannounced. Two reasons, and the
|
|
61
|
+
* second is the one that matters: resolving it would need the DTD the file
|
|
62
|
+
* does not ship, and dropping it would lose a character; leaving the reference
|
|
63
|
+
* standing means a later re-serialization writes back what the file said, which
|
|
64
|
+
* is the promise `xmlFragmentOf` makes and this keeps true for text as well.
|
|
65
|
+
*/
|
|
66
|
+
export function decodeXmlText(value) {
|
|
67
|
+
if (!value.includes('&'))
|
|
68
|
+
return value;
|
|
69
|
+
return value.replaceAll(/&(#x[0-9a-fA-F]+|#\d+|[A-Za-z][\w.-]*);/g, (match, reference) => {
|
|
70
|
+
if (reference.startsWith('#')) {
|
|
71
|
+
const hex = reference[1] === 'x' || reference[1] === 'X';
|
|
72
|
+
const code = Number.parseInt(hex ? reference.slice(2) : reference.slice(1), hex ? 16 : 10);
|
|
73
|
+
// Out of Unicode's range is a reference no character answers, and
|
|
74
|
+
// `String.fromCodePoint` THROWS on one — which is the single place this
|
|
75
|
+
// reader could have raised. It hands back the reference instead.
|
|
76
|
+
return Number.isInteger(code) && code >= 0 && code <= 0x10ffff
|
|
77
|
+
? String.fromCodePoint(code)
|
|
78
|
+
: match;
|
|
79
|
+
}
|
|
80
|
+
return NAMED_ENTITIES[reference] ?? match;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/* ── The scanner ──────────────────────────────────────────────────────── */
|
|
84
|
+
/** A name character, generously: XML's NameChar minus the exotic ranges. */
|
|
85
|
+
const NAME_CHAR = /[\w.:_\-À-]/;
|
|
86
|
+
/** The prefix and the local half of a qualified name. */
|
|
87
|
+
function splitName(name) {
|
|
88
|
+
const colon = name.indexOf(':');
|
|
89
|
+
return colon < 0
|
|
90
|
+
? { prefix: '', local: name }
|
|
91
|
+
: { prefix: name.slice(0, colon), local: name.slice(colon + 1) };
|
|
92
|
+
}
|
|
93
|
+
/** A node with no children and no text, ready to be filled. */
|
|
94
|
+
function nodeOf(name, start) {
|
|
95
|
+
const { prefix, local } = splitName(name);
|
|
96
|
+
return {
|
|
97
|
+
name,
|
|
98
|
+
prefix,
|
|
99
|
+
local,
|
|
100
|
+
attrs: {},
|
|
101
|
+
children: [],
|
|
102
|
+
range: [start, start],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Read a document. Never throws; see the header.
|
|
107
|
+
*
|
|
108
|
+
* The scan is a single left-to-right pass with an explicit stack, which is what
|
|
109
|
+
* makes the tolerance cheap: every recovery is "say what is wrong, and carry on
|
|
110
|
+
* from the next `<`", and there is no recursion to unwind.
|
|
111
|
+
*/
|
|
112
|
+
export function readXml(source) {
|
|
113
|
+
const notes = [];
|
|
114
|
+
const roots = [];
|
|
115
|
+
const stack = [];
|
|
116
|
+
/** Character data accumulated for the element currently open. */
|
|
117
|
+
const pending = [];
|
|
118
|
+
let cursor = 0;
|
|
119
|
+
/** Attach the text read since the last tag to whatever is open. */
|
|
120
|
+
const flushText = () => {
|
|
121
|
+
if (pending.length === 0)
|
|
122
|
+
return;
|
|
123
|
+
const text = pending.join('');
|
|
124
|
+
pending.length = 0;
|
|
125
|
+
const open = stack[stack.length - 1];
|
|
126
|
+
if (!open)
|
|
127
|
+
return;
|
|
128
|
+
// Whitespace between two indented children is layout, not content, and an
|
|
129
|
+
// element that held only that keeps no `text` at all — which is what lets a
|
|
130
|
+
// caller ask `if (node.text)` and mean "the file wrote something here".
|
|
131
|
+
if (!/\S/.test(text) && open.children.length > 0)
|
|
132
|
+
return;
|
|
133
|
+
if (!/\S/.test(text) && open.text === undefined)
|
|
134
|
+
return;
|
|
135
|
+
open.text = (open.text ?? '') + text;
|
|
136
|
+
};
|
|
137
|
+
const close = (node, end) => {
|
|
138
|
+
node.range[1] = end;
|
|
139
|
+
const parent = stack[stack.length - 1];
|
|
140
|
+
if (parent)
|
|
141
|
+
parent.children.push(node);
|
|
142
|
+
else
|
|
143
|
+
roots.push(node);
|
|
144
|
+
};
|
|
145
|
+
while (cursor < source.length) {
|
|
146
|
+
const open = source.indexOf('<', cursor);
|
|
147
|
+
if (open < 0) {
|
|
148
|
+
pending.push(decodeXmlText(source.slice(cursor)));
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
if (open > cursor)
|
|
152
|
+
pending.push(decodeXmlText(source.slice(cursor, open)));
|
|
153
|
+
/* ── The four things that are not an element ─────────────────────── */
|
|
154
|
+
if (source.startsWith('<!--', open)) {
|
|
155
|
+
const end = source.indexOf('-->', open + 4);
|
|
156
|
+
if (end < 0) {
|
|
157
|
+
notes.push('A comment in this file is never closed; the rest was read.');
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
cursor = end + 3;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (source.startsWith('<![CDATA[', open)) {
|
|
164
|
+
const end = source.indexOf(']]>', open + 9);
|
|
165
|
+
if (end < 0) {
|
|
166
|
+
notes.push('A CDATA section in this file is never closed.');
|
|
167
|
+
pending.push(source.slice(open + 9));
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
// CDATA is character data with the escaping turned OFF, so it is pushed
|
|
171
|
+
// raw: decoding it would resolve a `&` the author wrote literally.
|
|
172
|
+
pending.push(source.slice(open + 9, end));
|
|
173
|
+
cursor = end + 3;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (source.startsWith('<?', open)) {
|
|
177
|
+
const end = source.indexOf('?>', open + 2);
|
|
178
|
+
if (end < 0) {
|
|
179
|
+
notes.push('A processing instruction in this file is never closed.');
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
cursor = end + 2;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (source.startsWith('<!', open)) {
|
|
186
|
+
// A DOCTYPE, with or without an internal subset. The subset is skipped
|
|
187
|
+
// whole rather than parsed: this reader declares no entities, so what is
|
|
188
|
+
// in there could only change how a `&name;` it cannot resolve is read.
|
|
189
|
+
let scan = open + 2;
|
|
190
|
+
let depth = 0;
|
|
191
|
+
while (scan < source.length) {
|
|
192
|
+
const char = source[scan];
|
|
193
|
+
if (char === '[')
|
|
194
|
+
depth += 1;
|
|
195
|
+
else if (char === ']')
|
|
196
|
+
depth -= 1;
|
|
197
|
+
else if (char === '>' && depth <= 0)
|
|
198
|
+
break;
|
|
199
|
+
scan += 1;
|
|
200
|
+
}
|
|
201
|
+
cursor = Math.min(scan + 1, source.length);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
/* ── A close tag ─────────────────────────────────────────────────── */
|
|
205
|
+
if (source.startsWith('</', open)) {
|
|
206
|
+
const end = source.indexOf('>', open);
|
|
207
|
+
if (end < 0) {
|
|
208
|
+
notes.push('A close tag in this file is never finished.');
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
flushText();
|
|
212
|
+
const name = source.slice(open + 2, end).trim();
|
|
213
|
+
const depth = stack.findLastIndex(node => node.name === name);
|
|
214
|
+
if (depth < 0) {
|
|
215
|
+
notes.push(`</${name}> closes an element this file never opened; it was ignored.`);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
// Everything still open INSIDE the element being closed is closed with
|
|
219
|
+
// it, in order. `<a><b></a>` is a file that lost a tag, not a file with
|
|
220
|
+
// no `<b>` in it, and the tree keeps the `<b>`.
|
|
221
|
+
for (let index = stack.length - 1; index > depth; index -= 1) {
|
|
222
|
+
const orphan = stack.pop();
|
|
223
|
+
notes.push(`<${orphan.name}> is closed by </${name}> rather than by a tag of ` +
|
|
224
|
+
`its own; it was read as far as it goes.`);
|
|
225
|
+
close(orphan, open);
|
|
226
|
+
}
|
|
227
|
+
const node = stack.pop();
|
|
228
|
+
close(node, end + 1);
|
|
229
|
+
}
|
|
230
|
+
cursor = end + 1;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
/* ── An open tag ─────────────────────────────────────────────────── */
|
|
234
|
+
let scan = open + 1;
|
|
235
|
+
while (scan < source.length && NAME_CHAR.test(source[scan]))
|
|
236
|
+
scan += 1;
|
|
237
|
+
const name = source.slice(open + 1, scan);
|
|
238
|
+
if (name.length === 0) {
|
|
239
|
+
// A bare `<` in character data — illegal, and every second hand-written
|
|
240
|
+
// file has one. It is kept as the character it is.
|
|
241
|
+
notes.push('A "<" in this file starts no tag; it was read as a plain character.');
|
|
242
|
+
pending.push('<');
|
|
243
|
+
cursor = open + 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
flushText();
|
|
247
|
+
const node = nodeOf(name, open);
|
|
248
|
+
/* ── Its attributes ──────────────────────────────────────────────── */
|
|
249
|
+
let selfClosing = false;
|
|
250
|
+
let tagEnd = source.length;
|
|
251
|
+
while (scan < source.length) {
|
|
252
|
+
while (scan < source.length && /\s/.test(source[scan]))
|
|
253
|
+
scan += 1;
|
|
254
|
+
if (scan >= source.length)
|
|
255
|
+
break;
|
|
256
|
+
if (source.startsWith('/>', scan)) {
|
|
257
|
+
selfClosing = true;
|
|
258
|
+
tagEnd = scan + 2;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
if (source[scan] === '>') {
|
|
262
|
+
tagEnd = scan + 1;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
const attrStart = scan;
|
|
266
|
+
while (scan < source.length && NAME_CHAR.test(source[scan]))
|
|
267
|
+
scan += 1;
|
|
268
|
+
const attrName = source.slice(attrStart, scan);
|
|
269
|
+
if (attrName.length === 0) {
|
|
270
|
+
// Something that is neither a name, a `>` nor whitespace. Skipped one
|
|
271
|
+
// character at a time so the scan cannot stall on it.
|
|
272
|
+
scan += 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
while (scan < source.length && /\s/.test(source[scan]))
|
|
276
|
+
scan += 1;
|
|
277
|
+
if (source[scan] !== '=') {
|
|
278
|
+
// A valueless attribute — HTML's habit, not XML's. Recorded as the
|
|
279
|
+
// empty string, which is what every tolerant reader does with one.
|
|
280
|
+
notes.push(`The attribute "${attrName}" on <${name}> has no value; it was read ` +
|
|
281
|
+
`as empty.`);
|
|
282
|
+
node.attrs[attrName] = '';
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
scan += 1;
|
|
286
|
+
while (scan < source.length && /\s/.test(source[scan]))
|
|
287
|
+
scan += 1;
|
|
288
|
+
const quote = source[scan];
|
|
289
|
+
let value = '';
|
|
290
|
+
if (quote === '"' || quote === "'") {
|
|
291
|
+
const end = source.indexOf(quote, scan + 1);
|
|
292
|
+
if (end < 0) {
|
|
293
|
+
notes.push(`The value of "${attrName}" on <${name}> is never closed; the ` +
|
|
294
|
+
`rest of the file was read as its value.`);
|
|
295
|
+
value = source.slice(scan + 1);
|
|
296
|
+
scan = source.length;
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
value = source.slice(scan + 1, end);
|
|
300
|
+
scan = end + 1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
// Unquoted. Read to the next whitespace or tag end, which is the only
|
|
305
|
+
// reading available and the one every tolerant parser agrees on.
|
|
306
|
+
const start = scan;
|
|
307
|
+
while (scan < source.length && !/[\s>/]/.test(source[scan]))
|
|
308
|
+
scan += 1;
|
|
309
|
+
value = source.slice(start, scan);
|
|
310
|
+
notes.push(`The value of "${attrName}" on <${name}> is not quoted; it was read ` +
|
|
311
|
+
`up to the next space.`);
|
|
312
|
+
}
|
|
313
|
+
node.attrs[attrName] = decodeXmlText(value);
|
|
314
|
+
}
|
|
315
|
+
if (tagEnd === source.length && !selfClosing && source[scan] !== '>') {
|
|
316
|
+
notes.push(`<${name}> is never finished; the file stops inside its tag.`);
|
|
317
|
+
}
|
|
318
|
+
if (selfClosing) {
|
|
319
|
+
close(node, tagEnd);
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
stack.push(node);
|
|
323
|
+
}
|
|
324
|
+
cursor = tagEnd;
|
|
325
|
+
}
|
|
326
|
+
flushText();
|
|
327
|
+
// Whatever the file left open. Closed at the end of what there was, so the
|
|
328
|
+
// tree holds every element the file named even when it stops mid-document.
|
|
329
|
+
while (stack.length > 0) {
|
|
330
|
+
const orphan = stack.pop();
|
|
331
|
+
notes.push(`<${orphan.name}> is never closed; it was read to the end.`);
|
|
332
|
+
close(orphan, source.length);
|
|
333
|
+
}
|
|
334
|
+
return { root: roots[0], roots, notes };
|
|
335
|
+
}
|
|
336
|
+
/* ── Getting back out ─────────────────────────────────────────────────── */
|
|
337
|
+
/**
|
|
338
|
+
* The element's own bytes, from the string it was read from.
|
|
339
|
+
*
|
|
340
|
+
* This is what a quarantined fragment is (ADR 0012 D5): the file's text, not a
|
|
341
|
+
* re-rendering of this reader's tree. Comments, attribute order, prefixes and
|
|
342
|
+
* whitespace all survive, because none of them was ever taken apart.
|
|
343
|
+
*/
|
|
344
|
+
export function xmlFragmentOf(source, node) {
|
|
345
|
+
return source.slice(node.range[0], node.range[1]);
|
|
346
|
+
}
|
|
347
|
+
/** The three characters that would otherwise start markup. */
|
|
348
|
+
function escapeText(value) {
|
|
349
|
+
return value
|
|
350
|
+
.replaceAll('&', '&')
|
|
351
|
+
.replaceAll('<', '<')
|
|
352
|
+
.replaceAll('>', '>');
|
|
353
|
+
}
|
|
354
|
+
/** An attribute value — see `xml.ts`, whose escaping this mirrors exactly. */
|
|
355
|
+
function escapeAttr(value) {
|
|
356
|
+
return escapeText(value)
|
|
357
|
+
.replaceAll('"', '"')
|
|
358
|
+
.replaceAll("'", ''')
|
|
359
|
+
.replaceAll('\n', ' ')
|
|
360
|
+
.replaceAll('\r', ' ')
|
|
361
|
+
.replaceAll('\t', '	');
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* A node back as XML, from the TREE rather than from the source.
|
|
365
|
+
*
|
|
366
|
+
* The fallback for a fragment that has no range to slice — one assembled by a
|
|
367
|
+
* caller, or read out of a document this function's caller no longer holds. It
|
|
368
|
+
* is faithful to what the tree records and therefore drops what the tree does
|
|
369
|
+
* not: comments, processing instructions, and the file's own indentation.
|
|
370
|
+
* Prefer {@link xmlFragmentOf} whenever the source string is still in hand.
|
|
371
|
+
*/
|
|
372
|
+
export function serializeXmlNode(node) {
|
|
373
|
+
const attrs = Object.entries(node.attrs)
|
|
374
|
+
.map(([key, value]) => ` ${key}="${escapeAttr(value)}"`)
|
|
375
|
+
.join('');
|
|
376
|
+
const inner = [
|
|
377
|
+
node.text === undefined ? '' : escapeText(node.text),
|
|
378
|
+
...node.children.map(child => serializeXmlNode(child)),
|
|
379
|
+
].join('');
|
|
380
|
+
if (inner.length === 0)
|
|
381
|
+
return `<${node.name}${attrs}/>`;
|
|
382
|
+
return `<${node.name}${attrs}>${inner}</${node.name}>`;
|
|
383
|
+
}
|
|
384
|
+
/* ── Reading a tree ───────────────────────────────────────────────────── */
|
|
385
|
+
/** Children with this local name, whatever prefix the file gave them. */
|
|
386
|
+
export function xmlChildren(node, local) {
|
|
387
|
+
return node.children.filter(child => child.local === local);
|
|
388
|
+
}
|
|
389
|
+
/** The first child with this local name, if any. */
|
|
390
|
+
export function xmlChild(node, local) {
|
|
391
|
+
return node.children.find(child => child.local === local);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* An attribute by LOCAL name — `xmi:id`, `xmi:ID` and a bare `id` all answer.
|
|
395
|
+
*
|
|
396
|
+
* Prefix-insensitive on purpose, and it is the difference between reading three
|
|
397
|
+
* tools and reading one: the XMI prefix is `xmi` by convention and by nothing
|
|
398
|
+
* else, Enterprise Architect has shipped files under `XMI`, and an
|
|
399
|
+
* attribute-defaulted file drops it altogether. The QUALIFIED name is still in
|
|
400
|
+
* `attrs` for anything that needs it, and {@link xmlFragmentOf} still writes
|
|
401
|
+
* back what the file said.
|
|
402
|
+
*
|
|
403
|
+
* The first match in document order wins, which only matters for a file that
|
|
404
|
+
* spells the same attribute twice under two prefixes.
|
|
405
|
+
*/
|
|
406
|
+
export function xmlAttr(node, local) {
|
|
407
|
+
const direct = node.attrs[local];
|
|
408
|
+
if (direct !== undefined)
|
|
409
|
+
return direct;
|
|
410
|
+
for (const [key, value] of Object.entries(node.attrs)) {
|
|
411
|
+
if (splitName(key).local === local)
|
|
412
|
+
return value;
|
|
413
|
+
}
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
/** Every `xmlns` / `xmlns:*` declaration on this element, by prefix (`''`). */
|
|
417
|
+
export function xmlNamespaces(node) {
|
|
418
|
+
const declarations = {};
|
|
419
|
+
for (const [key, value] of Object.entries(node.attrs)) {
|
|
420
|
+
if (key === 'xmlns')
|
|
421
|
+
declarations[''] = value;
|
|
422
|
+
else if (key.startsWith('xmlns:'))
|
|
423
|
+
declarations[key.slice(6)] = value;
|
|
424
|
+
}
|
|
425
|
+
return declarations;
|
|
426
|
+
}
|
|
427
|
+
/** Every element under `node`, at any depth, in document order. */
|
|
428
|
+
export function xmlDescendants(node) {
|
|
429
|
+
return node.children.flatMap(child => [child, ...xmlDescendants(child)]);
|
|
430
|
+
}
|
package/dist/xml.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal XML tree, and the two escapes that make it safe to write.
|
|
3
|
+
*
|
|
4
|
+
* Sixty lines rather than a dependency, and rather than an import from
|
|
5
|
+
* `gfx/bpmn`: BPMN's writer carries verbatim FRAGMENTS an importer handed it
|
|
6
|
+
* (the foreign-matter contract of `docs/adr/0012` D1), and that unescaped node
|
|
7
|
+
* is the one thing in its tree that must never be constructible from a string
|
|
8
|
+
* nobody serialized. UML writes no fragments — it has no importer yet — so its
|
|
9
|
+
* tree has no such node, and sharing the module would mean sharing an escape
|
|
10
|
+
* hatch this writer has no use for. A copy of eight obvious functions is the
|
|
11
|
+
* cheaper half of that trade.
|
|
12
|
+
*
|
|
13
|
+
* Pure: strings in, strings out, no `std`, no DOM (`DOMParser` appears only in
|
|
14
|
+
* the SPECS, where it checks that what we wrote actually parses).
|
|
15
|
+
*/
|
|
16
|
+
/** Attribute values, `undefined` meaning "do not write this attribute". */
|
|
17
|
+
export type XmlAttrs = Record<string, string | number | boolean | undefined>;
|
|
18
|
+
export interface XmlElement {
|
|
19
|
+
name: string;
|
|
20
|
+
attrs: XmlAttrs;
|
|
21
|
+
children: XmlElement[];
|
|
22
|
+
/** Text content. Mutually exclusive with {@link children} in practice. */
|
|
23
|
+
text?: string;
|
|
24
|
+
}
|
|
25
|
+
/** An element with attributes and children. */
|
|
26
|
+
export declare function el(name: string, attrs?: XmlAttrs, children?: XmlElement[]): XmlElement;
|
|
27
|
+
/** An element whose content is character data. */
|
|
28
|
+
export declare function textEl(name: string, text: string, attrs?: XmlAttrs): XmlElement;
|
|
29
|
+
/**
|
|
30
|
+
* Character DATA — the three characters that would otherwise start markup.
|
|
31
|
+
*
|
|
32
|
+
* A newline, a tab and a carriage return are left exactly as they are, which is
|
|
33
|
+
* what makes a multi-line note body faithful: inside an element, whitespace is
|
|
34
|
+
* content.
|
|
35
|
+
*/
|
|
36
|
+
export declare function escapeText(value: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* An attribute VALUE, which needs strictly more than character data does.
|
|
39
|
+
*
|
|
40
|
+
* The quotes are the obvious half. The other half loses data silently: XML 1.0
|
|
41
|
+
* §3.3.3 makes every conformant parser replace a literal `#xA`, `#xD` or `#x9`
|
|
42
|
+
* in an attribute value with a SPACE before anyone sees it —
|
|
43
|
+
* attribute-value normalization, and it is not optional. Only a character
|
|
44
|
+
* reference survives it.
|
|
45
|
+
*
|
|
46
|
+
* That matters here because a multi-line label is ordinary on this canvas (it is
|
|
47
|
+
* how a class name fits in its box) and `name` is where nearly all of them go:
|
|
48
|
+
* every classifier, every operation, every comment `body`. Written raw, a
|
|
49
|
+
* two-line note comes back as one line, with no warning and no way for the
|
|
50
|
+
* author to tell. Written as ` ` it comes back as it went in.
|
|
51
|
+
*/
|
|
52
|
+
export declare function escapeAttr(value: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* One element and everything under it, indented.
|
|
55
|
+
*
|
|
56
|
+
* Attribute ORDER is insertion order, which is what makes a golden test a
|
|
57
|
+
* readable diff rather than a hash: the writer builds `xmi:type` then `xmi:id`
|
|
58
|
+
* then `name` on every element, so two runs of the same model are the same
|
|
59
|
+
* bytes.
|
|
60
|
+
*/
|
|
61
|
+
export declare function serializeElement(node: XmlElement, indent?: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* A whole document: the prolog, the root, and a trailing newline.
|
|
64
|
+
*
|
|
65
|
+
* The encoding is DECLARED rather than assumed. An XMI file is handed to tools
|
|
66
|
+
* that read bytes off a disk with no HTTP header to tell them anything, and the
|
|
67
|
+
* default for an undeclared document is UTF-8 only by the XML spec's good
|
|
68
|
+
* grace — several readers in this space guess the platform codepage instead,
|
|
69
|
+
* and a `«interface»` that arrives as mojibake is a keyword that has stopped
|
|
70
|
+
* meaning anything.
|
|
71
|
+
*/
|
|
72
|
+
export declare function serializeDocument(root: XmlElement): string;
|