@ssml-builder-js/ssml-editor-react 2.4.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/CHANGELOG.md +91 -0
- package/dist/index.d.mts +288 -0
- package/dist/index.d.ts +288 -0
- package/dist/index.js +5423 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +5385 -0
- package/dist/index.mjs.map +1 -0
- package/e2e/monaco-editor.spec.ts +126 -0
- package/package.json +57 -0
- package/src/SsmlEditor.tsx +1302 -0
- package/src/buttonVisibility.ts +36 -0
- package/src/clearSsmlDocument.ts +57 -0
- package/src/components/popovers/InsertionPopover.tsx +136 -0
- package/src/components/popovers/InsertionPopovers.tsx +47 -0
- package/src/components/popovers/ProsodyPopovers.tsx +78 -0
- package/src/components/popovers/TextPopovers.tsx +8 -0
- package/src/components/popovers/TimingPopovers.tsx +8 -0
- package/src/constants/ssmlPresets.ts +660 -0
- package/src/constants/ui.ts +11 -0
- package/src/editableSsml.ts +251 -0
- package/src/formatXml.ts +600 -0
- package/src/hooks/useSsmlEditorState.ts +704 -0
- package/src/hooks/useSsmlMonaco.ts +393 -0
- package/src/index.tsx +50 -0
- package/src/locales.ts +435 -0
- package/src/ssmlCodeAction.ts +144 -0
- package/src/ssmlCodeLens.ts +226 -0
- package/src/ssmlCompletion.ts +129 -0
- package/src/ssmlContext.ts +196 -0
- package/src/ssmlDiagnostics.ts +191 -0
- package/src/ssmlHover.ts +703 -0
- package/src/ssmlInsertion.ts +75 -0
- package/src/ssmlInsertions.ts +471 -0
- package/src/styles/editorStyles.ts +282 -0
- package/test/format-xml-edge-cases.test.ts +107 -0
- package/test/index.test.ts +597 -0
- package/test/randomized-editor-invariants.test.ts +520 -0
- package/test/ui-components.test.tsx +854 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.mjs +10 -0
package/src/formatXml.ts
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
const INDENT = " ";
|
|
2
|
+
const FRAGMENT_ROOT = "ssml-builder-fragment";
|
|
3
|
+
const XML_ENTITY_NAMES = new Set(["amp", "apos", "gt", "lt", "quot"]);
|
|
4
|
+
export const INTRINSICALLY_EMPTY_ELEMENTS = new Set([
|
|
5
|
+
"break",
|
|
6
|
+
"bookmark",
|
|
7
|
+
"lexicon",
|
|
8
|
+
"mark",
|
|
9
|
+
"mstts:silence",
|
|
10
|
+
"mstts:viseme",
|
|
11
|
+
"silence",
|
|
12
|
+
"viseme",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
type XmlNode = XmlElementNode | XmlTextNode | XmlMarkupNode;
|
|
16
|
+
|
|
17
|
+
interface XmlElementNode {
|
|
18
|
+
kind: "element";
|
|
19
|
+
name: string;
|
|
20
|
+
open: string;
|
|
21
|
+
close?: string;
|
|
22
|
+
selfClosing: boolean;
|
|
23
|
+
children: XmlNode[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface XmlTextNode {
|
|
27
|
+
kind: "text";
|
|
28
|
+
value: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface XmlMarkupNode {
|
|
32
|
+
kind: "comment" | "cdata" | "declaration" | "processing-instruction";
|
|
33
|
+
raw: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface XmlDocument {
|
|
37
|
+
children: XmlNode[];
|
|
38
|
+
root: XmlElementNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isXmlNameStart(value: string | undefined): boolean {
|
|
42
|
+
return value !== undefined && /[A-Za-z_]/.test(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isXmlNameCharacter(value: string | undefined): boolean {
|
|
46
|
+
return value !== undefined && /[A-Za-z0-9_.:-]/.test(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isXmlWhitespace(value: string | undefined): boolean {
|
|
50
|
+
return value === " " || value === "\t" || value === "\r" || value === "\n";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fail(message: string): never {
|
|
54
|
+
throw new Error(message);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function skipWhitespace(source: string, index: number): number {
|
|
58
|
+
while (isXmlWhitespace(source[index])) {
|
|
59
|
+
index += 1;
|
|
60
|
+
}
|
|
61
|
+
return index;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readName(source: string, start: number, end: number): { name: string; index: number } {
|
|
65
|
+
if (!isXmlNameStart(source[start])) {
|
|
66
|
+
fail("Invalid XML name");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let index = start + 1;
|
|
70
|
+
while (index < end && isXmlNameCharacter(source[index])) {
|
|
71
|
+
index += 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { name: source.slice(start, index), index };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validateCharacterReference(entity: string): void {
|
|
78
|
+
const isHexadecimal = entity.startsWith("#x") || entity.startsWith("#X");
|
|
79
|
+
const isDecimal = entity.startsWith("#");
|
|
80
|
+
if (!isHexadecimal && !isDecimal) {
|
|
81
|
+
if (!XML_ENTITY_NAMES.has(entity)) {
|
|
82
|
+
fail(`Unknown XML entity: &${entity};`);
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const digits = entity.slice(isHexadecimal ? 2 : 1);
|
|
88
|
+
const validDigits = isHexadecimal ? /^[0-9A-Fa-f]+$/.test(digits) : /^[0-9]+$/.test(digits);
|
|
89
|
+
const codePoint = Number.parseInt(digits, isHexadecimal ? 16 : 10);
|
|
90
|
+
if (
|
|
91
|
+
!validDigits ||
|
|
92
|
+
!Number.isInteger(codePoint) ||
|
|
93
|
+
codePoint < 0 ||
|
|
94
|
+
codePoint > 0x10ffff ||
|
|
95
|
+
(codePoint >= 0xd800 && codePoint <= 0xdfff) ||
|
|
96
|
+
(codePoint < 0x20 && ![9, 10, 13].includes(codePoint))
|
|
97
|
+
) {
|
|
98
|
+
fail(`Invalid XML character reference: &${entity};`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateEntityReferences(value: string): void {
|
|
103
|
+
let index = 0;
|
|
104
|
+
|
|
105
|
+
while (true) {
|
|
106
|
+
const ampersand = value.indexOf("&", index);
|
|
107
|
+
if (ampersand === -1) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const semicolon = value.indexOf(";", ampersand + 1);
|
|
112
|
+
if (semicolon === -1) {
|
|
113
|
+
fail("Unterminated XML entity reference");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
validateCharacterReference(value.slice(ampersand + 1, semicolon));
|
|
117
|
+
index = semicolon + 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function findTagEnd(source: string, start: number, hasInternalSubset = false): number {
|
|
122
|
+
let quote: string | undefined;
|
|
123
|
+
let subsetDepth = 0;
|
|
124
|
+
|
|
125
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
126
|
+
const character = source[index];
|
|
127
|
+
|
|
128
|
+
if (quote !== undefined) {
|
|
129
|
+
if (character === quote) {
|
|
130
|
+
quote = undefined;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (character === '"' || character === "'") {
|
|
136
|
+
quote = character;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (hasInternalSubset) {
|
|
141
|
+
if (character === "[") {
|
|
142
|
+
subsetDepth += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (character === "]" && subsetDepth > 0) {
|
|
146
|
+
subsetDepth -= 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (character === ">" && subsetDepth === 0) {
|
|
152
|
+
return index;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fail("Unclosed XML markup");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseStartTag(
|
|
160
|
+
source: string,
|
|
161
|
+
start: number,
|
|
162
|
+
end: number,
|
|
163
|
+
): {
|
|
164
|
+
name: string;
|
|
165
|
+
selfClosing: boolean;
|
|
166
|
+
} {
|
|
167
|
+
const nameResult = readName(source, start + 1, end);
|
|
168
|
+
let index = nameResult.index;
|
|
169
|
+
let hasAttribute = false;
|
|
170
|
+
const attributeNames = new Set<string>();
|
|
171
|
+
|
|
172
|
+
while (index < end) {
|
|
173
|
+
const beforeWhitespace = index;
|
|
174
|
+
index = skipWhitespace(source, index);
|
|
175
|
+
if (index === end) {
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (source[index] === "/") {
|
|
180
|
+
if (index + 1 !== end) {
|
|
181
|
+
fail("Invalid XML self-closing tag");
|
|
182
|
+
}
|
|
183
|
+
return { name: nameResult.name, selfClosing: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (hasAttribute && beforeWhitespace === index) {
|
|
187
|
+
fail("XML attributes must be separated by whitespace");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const attribute = readName(source, index, end);
|
|
191
|
+
index = skipWhitespace(source, attribute.index);
|
|
192
|
+
if (source[index] !== "=") {
|
|
193
|
+
fail(`XML attribute ${attribute.name} must have a value`);
|
|
194
|
+
}
|
|
195
|
+
index = skipWhitespace(source, index + 1);
|
|
196
|
+
|
|
197
|
+
const quote = source[index];
|
|
198
|
+
if (quote !== '"' && quote !== "'") {
|
|
199
|
+
fail(`XML attribute ${attribute.name} must use a quoted value`);
|
|
200
|
+
}
|
|
201
|
+
index += 1;
|
|
202
|
+
|
|
203
|
+
const valueStart = index;
|
|
204
|
+
while (index < end && source[index] !== quote) {
|
|
205
|
+
if (source[index] === "<") {
|
|
206
|
+
fail(`Invalid "<" in XML attribute ${attribute.name}`);
|
|
207
|
+
}
|
|
208
|
+
index += 1;
|
|
209
|
+
}
|
|
210
|
+
if (index === end) {
|
|
211
|
+
fail(`Unclosed XML attribute ${attribute.name}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (attributeNames.has(attribute.name)) {
|
|
215
|
+
fail(`Duplicate XML attribute: ${attribute.name}`);
|
|
216
|
+
}
|
|
217
|
+
attributeNames.add(attribute.name);
|
|
218
|
+
validateEntityReferences(source.slice(valueStart, index));
|
|
219
|
+
index += 1;
|
|
220
|
+
hasAttribute = true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { name: nameResult.name, selfClosing: false };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseClosingTag(source: string, start: number, end: number): string {
|
|
227
|
+
const nameResult = readName(source, start + 2, end);
|
|
228
|
+
if (skipWhitespace(source, nameResult.index) !== end) {
|
|
229
|
+
fail("Invalid XML closing tag");
|
|
230
|
+
}
|
|
231
|
+
return nameResult.name;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseProcessingInstruction(source: string, start: number, end: number): void {
|
|
235
|
+
readName(source, start + 2, end);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function parseDeclaration(source: string, start: number, end: number): void {
|
|
239
|
+
readName(source, start + 2, end);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function appendNode(nodes: XmlNode[], node: XmlNode): void {
|
|
243
|
+
const previous = nodes[nodes.length - 1];
|
|
244
|
+
if (node.kind === "text" && previous?.kind === "text") {
|
|
245
|
+
previous.value += node.value;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
nodes.push(node);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function currentElement(stack: XmlElementNode[]): XmlElementNode | undefined {
|
|
252
|
+
return stack[stack.length - 1];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseXml(source: string): XmlDocument {
|
|
256
|
+
const children: XmlNode[] = [];
|
|
257
|
+
const stack: XmlElementNode[] = [];
|
|
258
|
+
let root: XmlElementNode | undefined;
|
|
259
|
+
let index = 0;
|
|
260
|
+
|
|
261
|
+
while (index < source.length) {
|
|
262
|
+
if (source[index] !== "<") {
|
|
263
|
+
const textStart = index;
|
|
264
|
+
while (index < source.length && source[index] !== "<") {
|
|
265
|
+
index += 1;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const value = source.slice(textStart, index);
|
|
269
|
+
if (value.includes("]]>")) {
|
|
270
|
+
fail("CDATA termination is not valid in ordinary XML text");
|
|
271
|
+
}
|
|
272
|
+
validateEntityReferences(value);
|
|
273
|
+
if (stack.length === 0 && value.trim() !== "") {
|
|
274
|
+
fail("Unexpected text outside the root XML element");
|
|
275
|
+
}
|
|
276
|
+
appendNode(currentElement(stack)?.children ?? children, { kind: "text", value });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (source.startsWith("<!--", index)) {
|
|
281
|
+
const end = source.indexOf("-->", index + 4);
|
|
282
|
+
if (end === -1) {
|
|
283
|
+
fail("Unclosed XML comment");
|
|
284
|
+
}
|
|
285
|
+
const content = source.slice(index + 4, end);
|
|
286
|
+
if (content.includes("--") || content.endsWith("-")) {
|
|
287
|
+
fail("Invalid XML comment");
|
|
288
|
+
}
|
|
289
|
+
appendNode(currentElement(stack)?.children ?? children, {
|
|
290
|
+
kind: "comment",
|
|
291
|
+
raw: source.slice(index, end + 3),
|
|
292
|
+
});
|
|
293
|
+
index = end + 3;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (source.startsWith("<![CDATA[", index)) {
|
|
298
|
+
const end = source.indexOf("]]>", index + "<![CDATA[".length);
|
|
299
|
+
if (end === -1) {
|
|
300
|
+
fail("Unclosed XML CDATA section");
|
|
301
|
+
}
|
|
302
|
+
if (stack.length === 0) {
|
|
303
|
+
fail("CDATA is not allowed outside the root XML element");
|
|
304
|
+
}
|
|
305
|
+
appendNode(currentElement(stack)?.children ?? children, {
|
|
306
|
+
kind: "cdata",
|
|
307
|
+
raw: source.slice(index, end + 3),
|
|
308
|
+
});
|
|
309
|
+
index = end + 3;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (source.startsWith("<?", index)) {
|
|
314
|
+
const end = source.indexOf("?>", index + 2);
|
|
315
|
+
if (end === -1) {
|
|
316
|
+
fail("Unclosed XML processing instruction");
|
|
317
|
+
}
|
|
318
|
+
parseProcessingInstruction(source, index, end);
|
|
319
|
+
appendNode(currentElement(stack)?.children ?? children, {
|
|
320
|
+
kind: "processing-instruction",
|
|
321
|
+
raw: source.slice(index, end + 2),
|
|
322
|
+
});
|
|
323
|
+
index = end + 2;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (source.startsWith("</", index)) {
|
|
328
|
+
const end = findTagEnd(source, index);
|
|
329
|
+
const name = parseClosingTag(source, index, end);
|
|
330
|
+
const element = currentElement(stack);
|
|
331
|
+
if (element === undefined || element.name !== name) {
|
|
332
|
+
fail(`Mismatched closing XML element: </${name}>`);
|
|
333
|
+
}
|
|
334
|
+
element.close = source.slice(index, end + 1);
|
|
335
|
+
stack.pop();
|
|
336
|
+
index = end + 1;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (source.startsWith("<!", index)) {
|
|
341
|
+
const end = findTagEnd(source, index, true);
|
|
342
|
+
parseDeclaration(source, index, end);
|
|
343
|
+
appendNode(currentElement(stack)?.children ?? children, {
|
|
344
|
+
kind: "declaration",
|
|
345
|
+
raw: source.slice(index, end + 1),
|
|
346
|
+
});
|
|
347
|
+
index = end + 1;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const end = findTagEnd(source, index);
|
|
352
|
+
const { name, selfClosing } = parseStartTag(source, index, end);
|
|
353
|
+
if (stack.length === 0) {
|
|
354
|
+
if (root !== undefined) {
|
|
355
|
+
fail("Multiple root XML elements are not allowed");
|
|
356
|
+
}
|
|
357
|
+
root = {
|
|
358
|
+
kind: "element",
|
|
359
|
+
name,
|
|
360
|
+
open: source.slice(index, end + 1),
|
|
361
|
+
selfClosing,
|
|
362
|
+
children: [],
|
|
363
|
+
};
|
|
364
|
+
children.push(root);
|
|
365
|
+
} else {
|
|
366
|
+
const element: XmlElementNode = {
|
|
367
|
+
kind: "element",
|
|
368
|
+
name,
|
|
369
|
+
open: source.slice(index, end + 1),
|
|
370
|
+
selfClosing,
|
|
371
|
+
children: [],
|
|
372
|
+
};
|
|
373
|
+
currentElement(stack)?.children.push(element);
|
|
374
|
+
if (!selfClosing) {
|
|
375
|
+
stack.push(element);
|
|
376
|
+
}
|
|
377
|
+
index = end + 1;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!selfClosing) {
|
|
382
|
+
stack.push(root);
|
|
383
|
+
}
|
|
384
|
+
index = end + 1;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (stack.length > 0) {
|
|
388
|
+
fail(`Unclosed XML element: <${currentElement(stack)?.name}>`);
|
|
389
|
+
}
|
|
390
|
+
if (root === undefined) {
|
|
391
|
+
fail("XML input does not contain a root element");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return { children, root };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function indentation(depth: number): string {
|
|
398
|
+
return INDENT.repeat(depth);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function renderInline(node: XmlElementNode): string {
|
|
402
|
+
const content = node.children
|
|
403
|
+
.map((child) => {
|
|
404
|
+
if (child.kind === "element") {
|
|
405
|
+
return renderInline(child);
|
|
406
|
+
}
|
|
407
|
+
return child.kind === "text" ? child.value : child.raw;
|
|
408
|
+
})
|
|
409
|
+
.join("");
|
|
410
|
+
|
|
411
|
+
return `${node.open}${content}${node.close ?? ""}`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function hasOnlyTextChildren(node: XmlElementNode): boolean {
|
|
415
|
+
return node.children.every((child) => child.kind === "text");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function hasSignificantText(node: XmlElementNode): boolean {
|
|
419
|
+
return node.children.some((child) => child.kind === "text" && child.value.trim() !== "");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function hasOnlyWhitespaceText(node: XmlElementNode): boolean {
|
|
423
|
+
return node.children.every((child) => child.kind === "text" && child.value.trim() === "");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function toSelfClosingTag(open: string): string {
|
|
427
|
+
return `${open.slice(0, -1).trimEnd()}/>`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function formatElement(node: XmlElementNode, depth: number, isRoot: boolean): string[] {
|
|
431
|
+
const prefix = indentation(depth);
|
|
432
|
+
|
|
433
|
+
if (node.selfClosing) {
|
|
434
|
+
return [`${prefix}${node.open}`];
|
|
435
|
+
}
|
|
436
|
+
if (node.close === undefined) {
|
|
437
|
+
fail(`Unclosed XML element: <${node.name}>`);
|
|
438
|
+
}
|
|
439
|
+
if (INTRINSICALLY_EMPTY_ELEMENTS.has(node.name) && hasOnlyWhitespaceText(node)) {
|
|
440
|
+
return [`${prefix}${toSelfClosingTag(node.open)}`];
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (isRoot && hasOnlyTextChildren(node)) {
|
|
444
|
+
const text = node.children
|
|
445
|
+
.filter((child): child is XmlTextNode => child.kind === "text")
|
|
446
|
+
.map((child) => child.value)
|
|
447
|
+
.join("");
|
|
448
|
+
const trimmedText = text.trim();
|
|
449
|
+
|
|
450
|
+
if (trimmedText === "") {
|
|
451
|
+
return [`${prefix}${node.open}`, `${prefix}${node.close}`];
|
|
452
|
+
}
|
|
453
|
+
if (text.includes("\n") || text.includes("\r") || text !== trimmedText) {
|
|
454
|
+
return [`${prefix}${renderInline(node)}`];
|
|
455
|
+
}
|
|
456
|
+
return [`${prefix}${node.open}`, `${indentation(depth + 1)}${trimmedText}`, `${prefix}${node.close}`];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (hasSignificantText(node)) {
|
|
460
|
+
return [`${prefix}${renderInline(node)}`];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const lines = [`${prefix}${node.open}`];
|
|
464
|
+
for (const child of node.children) {
|
|
465
|
+
if (child.kind === "text") {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (child.kind === "element") {
|
|
469
|
+
lines.push(...formatElement(child, depth + 1, false));
|
|
470
|
+
} else {
|
|
471
|
+
lines.push(`${indentation(depth + 1)}${child.raw}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
lines.push(`${prefix}${node.close}`);
|
|
475
|
+
return lines;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function stripTrailingWhitespace(value: string): string {
|
|
479
|
+
return value.replace(/[ \t]+$/gm, "");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function getTrailingLineBreaks(value: string): string {
|
|
483
|
+
let start = value.length;
|
|
484
|
+
while (start > 0) {
|
|
485
|
+
if (value[start - 1] === "\n") {
|
|
486
|
+
start -= 1;
|
|
487
|
+
if (start > 0 && value[start - 1] === "\r") {
|
|
488
|
+
start -= 1;
|
|
489
|
+
}
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (value[start - 1] === "\r") {
|
|
493
|
+
start -= 1;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
return value.slice(start);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function stripTrailingLineBreaks(value: string): string {
|
|
502
|
+
let end = value.length;
|
|
503
|
+
while (end > 0) {
|
|
504
|
+
if (value[end - 1] === "\n") {
|
|
505
|
+
end -= 1;
|
|
506
|
+
if (end > 0 && value[end - 1] === "\r") {
|
|
507
|
+
end -= 1;
|
|
508
|
+
}
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (value[end - 1] === "\r") {
|
|
512
|
+
end -= 1;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
return value.slice(0, end);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function preserveTrailingLineBreak(formatted: string, trailingLineBreaks: string): string {
|
|
521
|
+
if (trailingLineBreaks === "") {
|
|
522
|
+
return formatted;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return `${stripTrailingLineBreaks(formatted)}${trailingLineBreaks}`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function renderDocument(document: XmlDocument): string {
|
|
529
|
+
const lines: string[] = [];
|
|
530
|
+
|
|
531
|
+
for (const child of document.children) {
|
|
532
|
+
if (child.kind === "text") {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (child.kind === "element") {
|
|
536
|
+
lines.push(...formatElement(child, 0, child === document.root));
|
|
537
|
+
} else {
|
|
538
|
+
lines.push(child.raw);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return stripTrailingWhitespace(lines.join("\n")).trim();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function formatXml(xml: string): string {
|
|
546
|
+
const trailingLineBreaks = getTrailingLineBreaks(xml);
|
|
547
|
+
const source = xml.trim();
|
|
548
|
+
if (source === "") {
|
|
549
|
+
return "";
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
return preserveTrailingLineBreak(renderDocument(parseXml(source)), trailingLineBreaks);
|
|
554
|
+
} catch {
|
|
555
|
+
return xml;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function unwrapFormattedFragment(formatted: string): string | undefined {
|
|
560
|
+
const opening = `<${FRAGMENT_ROOT}>`;
|
|
561
|
+
const closing = `</${FRAGMENT_ROOT}>`;
|
|
562
|
+
|
|
563
|
+
if (!formatted.startsWith(opening) || !formatted.endsWith(closing)) {
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (formatted === `${opening}${closing}`) {
|
|
568
|
+
return "";
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const multilineOpening = `${opening}\n`;
|
|
572
|
+
const multilineClosing = `\n${closing}`;
|
|
573
|
+
if (formatted.startsWith(multilineOpening) && formatted.endsWith(multilineClosing)) {
|
|
574
|
+
const content = formatted.slice(multilineOpening.length, -multilineClosing.length);
|
|
575
|
+
return content
|
|
576
|
+
.split("\n")
|
|
577
|
+
.map((line) => (line.startsWith(INDENT) ? line.slice(INDENT.length) : line))
|
|
578
|
+
.join("\n");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return formatted.slice(opening.length, -closing.length);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function formatXmlFragment(xml: string): string {
|
|
585
|
+
const trailingLineBreaks = getTrailingLineBreaks(xml);
|
|
586
|
+
const source = xml.trim();
|
|
587
|
+
if (source === "") {
|
|
588
|
+
return "";
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const wrapped = `<${FRAGMENT_ROOT}>${source}</${FRAGMENT_ROOT}>`;
|
|
592
|
+
let formatted: string;
|
|
593
|
+
try {
|
|
594
|
+
formatted = renderDocument(parseXml(wrapped));
|
|
595
|
+
} catch {
|
|
596
|
+
return xml;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return preserveTrailingLineBreak(unwrapFormattedFragment(formatted) ?? xml, trailingLineBreaks);
|
|
600
|
+
}
|