@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/index.d.mts +288 -0
  3. package/dist/index.d.ts +288 -0
  4. package/dist/index.js +5423 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +5385 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/e2e/monaco-editor.spec.ts +126 -0
  9. package/package.json +57 -0
  10. package/src/SsmlEditor.tsx +1302 -0
  11. package/src/buttonVisibility.ts +36 -0
  12. package/src/clearSsmlDocument.ts +57 -0
  13. package/src/components/popovers/InsertionPopover.tsx +136 -0
  14. package/src/components/popovers/InsertionPopovers.tsx +47 -0
  15. package/src/components/popovers/ProsodyPopovers.tsx +78 -0
  16. package/src/components/popovers/TextPopovers.tsx +8 -0
  17. package/src/components/popovers/TimingPopovers.tsx +8 -0
  18. package/src/constants/ssmlPresets.ts +660 -0
  19. package/src/constants/ui.ts +11 -0
  20. package/src/editableSsml.ts +251 -0
  21. package/src/formatXml.ts +600 -0
  22. package/src/hooks/useSsmlEditorState.ts +704 -0
  23. package/src/hooks/useSsmlMonaco.ts +393 -0
  24. package/src/index.tsx +50 -0
  25. package/src/locales.ts +435 -0
  26. package/src/ssmlCodeAction.ts +144 -0
  27. package/src/ssmlCodeLens.ts +226 -0
  28. package/src/ssmlCompletion.ts +129 -0
  29. package/src/ssmlContext.ts +196 -0
  30. package/src/ssmlDiagnostics.ts +191 -0
  31. package/src/ssmlHover.ts +703 -0
  32. package/src/ssmlInsertion.ts +75 -0
  33. package/src/ssmlInsertions.ts +471 -0
  34. package/src/styles/editorStyles.ts +282 -0
  35. package/test/format-xml-edge-cases.test.ts +107 -0
  36. package/test/index.test.ts +597 -0
  37. package/test/randomized-editor-invariants.test.ts +520 -0
  38. package/test/ui-components.test.tsx +854 -0
  39. package/tsconfig.json +10 -0
  40. package/tsup.config.ts +17 -0
  41. package/vitest.config.mjs +10 -0
@@ -0,0 +1,226 @@
1
+ import type { Monaco } from "@monaco-editor/react";
2
+ import type { MonacoEditor } from "./ssmlDiagnostics";
3
+ import type { SsmlTagRange } from "./ssmlContext";
4
+
5
+ export type SsmlCodeLensAction =
6
+ | {
7
+ type: "attribute";
8
+ insertionId: "rate" | "pitch" | "break";
9
+ attributeName: "rate" | "pitch" | "time";
10
+ tagRange: SsmlTagRange;
11
+ }
12
+ | {
13
+ type: "unwrap" | "delete";
14
+ tagRange: SsmlTagRange;
15
+ elementRange: SsmlTagRange;
16
+ };
17
+
18
+ export type SsmlCodeLensCallback = (action: SsmlCodeLensAction) => void;
19
+
20
+ type MonacoCodeLensProvider = Parameters<Monaco["languages"]["registerCodeLensProvider"]>[1];
21
+ type MonacoCodeLensModel = Parameters<NonNullable<MonacoCodeLensProvider["provideCodeLenses"]>>[0];
22
+ type MonacoCodeLens = NonNullable<
23
+ Awaited<ReturnType<NonNullable<MonacoCodeLensProvider["provideCodeLenses"]>>>
24
+ >["lenses"][number];
25
+
26
+ const CODE_LENS_COMMAND = "ssml-editor.codeLens";
27
+
28
+ function findTagEnd(source: string, start: number): number {
29
+ let quote: string | undefined;
30
+ for (let index = start + 1; index < source.length; index += 1) {
31
+ const character = source[index];
32
+ if (quote !== undefined) {
33
+ if (character === quote) {
34
+ quote = undefined;
35
+ }
36
+ } else if (character === '"' || character === "'") {
37
+ quote = character;
38
+ } else if (character === ">") {
39
+ return index;
40
+ }
41
+ }
42
+ return -1;
43
+ }
44
+
45
+ function getAttributeValue(tag: string, attributeName: string): string | undefined {
46
+ const escapedName = attributeName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
+ return tag.match(new RegExp(`\\b${escapedName}\\s*=\\s*(["'])([\\s\\S]*?)\\1`, "i"))?.[2];
48
+ }
49
+
50
+ function getTagRange(start: number, end: number): SsmlTagRange {
51
+ return { start, end };
52
+ }
53
+
54
+ function getElementEnd(source: string, tagName: string, tagEnd: number): number {
55
+ const openingTag = source.slice(0, tagEnd + 1);
56
+ if (/\/\s*>$/.test(openingTag)) {
57
+ return tagEnd + 1;
58
+ }
59
+
60
+ let depth = 1;
61
+ let index = tagEnd + 1;
62
+ while (index < source.length) {
63
+ const nextStart = source.indexOf("<", index);
64
+ if (nextStart === -1) {
65
+ break;
66
+ }
67
+ if (source.startsWith("<!--", nextStart)) {
68
+ index = source.indexOf("-->", nextStart + 4);
69
+ index = index === -1 ? source.length : index + 3;
70
+ continue;
71
+ }
72
+ const nextEnd = findTagEnd(source, nextStart);
73
+ if (nextEnd === -1) {
74
+ break;
75
+ }
76
+ const nextTag = source.slice(nextStart, nextEnd + 1);
77
+ const closingMatch = nextTag.match(/^<\s*\/\s*([A-Za-z_][A-Za-z0-9_.:-]*)/);
78
+ const openingMatch = nextTag.match(/^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/);
79
+ if (closingMatch?.[1]?.toLowerCase() === tagName) {
80
+ depth -= 1;
81
+ if (depth === 0) {
82
+ return nextEnd + 1;
83
+ }
84
+ } else if (openingMatch?.[1]?.toLowerCase() === tagName && !/\/\s*>$/.test(nextTag)) {
85
+ depth += 1;
86
+ }
87
+ index = nextEnd + 1;
88
+ }
89
+ return tagEnd + 1;
90
+ }
91
+
92
+ function createLens(
93
+ model: MonacoCodeLensModel,
94
+ start: number,
95
+ end: number,
96
+ title: string,
97
+ action: SsmlCodeLensAction,
98
+ ): MonacoCodeLens {
99
+ const startPosition = model.getPositionAt(start);
100
+ const endPosition = model.getPositionAt(end);
101
+ return {
102
+ range: {
103
+ startLineNumber: startPosition.lineNumber,
104
+ startColumn: startPosition.column,
105
+ endLineNumber: endPosition.lineNumber,
106
+ endColumn: endPosition.column,
107
+ },
108
+ command: {
109
+ id: CODE_LENS_COMMAND,
110
+ title,
111
+ arguments: [action],
112
+ },
113
+ };
114
+ }
115
+
116
+ export function registerSsmlCodeLens(
117
+ monaco: Monaco,
118
+ editor: MonacoEditor,
119
+ onOpenPopover: SsmlCodeLensCallback,
120
+ ): ReturnType<Monaco["languages"]["registerCodeLensProvider"]> {
121
+ const provider: MonacoCodeLensProvider = {
122
+ provideCodeLenses(model: MonacoCodeLensModel) {
123
+ const source = model.getValue();
124
+ const lenses: MonacoCodeLens[] = [];
125
+ let index = 0;
126
+
127
+ while (index < source.length) {
128
+ const tagStart = source.indexOf("<", index);
129
+ if (tagStart === -1) {
130
+ break;
131
+ }
132
+ if (source.startsWith("<!--", tagStart)) {
133
+ index = source.indexOf("-->", tagStart + 4);
134
+ index = index === -1 ? source.length : index + 3;
135
+ continue;
136
+ }
137
+ const tagEnd = findTagEnd(source, tagStart);
138
+ if (tagEnd === -1) {
139
+ break;
140
+ }
141
+ const tag = source.slice(tagStart, tagEnd + 1);
142
+ const tagName = tag.match(/^<\s*(prosody|break)\b/i)?.[1]?.toLowerCase();
143
+ index = tagEnd + 1;
144
+ if (!tagName) {
145
+ continue;
146
+ }
147
+
148
+ const tagRange = getTagRange(tagStart, tagEnd + 1);
149
+ const elementEnd = getElementEnd(source, tagName, tagEnd);
150
+ const elementRange = { start: tagStart, end: elementEnd };
151
+
152
+ if (tagName === "prosody") {
153
+ lenses.push(
154
+ createLens(
155
+ model,
156
+ tagStart,
157
+ tagEnd + 1,
158
+ `⚡ Rate: ${getAttributeValue(tag, "rate") ?? "default"} (Click to edit)`,
159
+ { type: "attribute", insertionId: "rate", attributeName: "rate", tagRange },
160
+ ),
161
+ createLens(
162
+ model,
163
+ tagStart,
164
+ tagEnd + 1,
165
+ `⚡ Pitch: ${getAttributeValue(tag, "pitch") ?? "default"} (Click to edit)`,
166
+ { type: "attribute", insertionId: "pitch", attributeName: "pitch", tagRange },
167
+ ),
168
+ createLens(model, tagStart, tagEnd + 1, "Unwrap", {
169
+ type: "unwrap",
170
+ tagRange,
171
+ elementRange,
172
+ }),
173
+ );
174
+ } else if (/\/\s*>$/.test(tag)) {
175
+ lenses.push(
176
+ createLens(
177
+ model,
178
+ tagStart,
179
+ tagEnd + 1,
180
+ `⚡ Time: ${getAttributeValue(tag, "time") ?? "default"} (Click to edit)`,
181
+ { type: "attribute", insertionId: "break", attributeName: "time", tagRange },
182
+ ),
183
+ createLens(model, tagStart, tagEnd + 1, "Delete", {
184
+ type: "delete",
185
+ tagRange,
186
+ elementRange,
187
+ }),
188
+ );
189
+ }
190
+ }
191
+
192
+ return { lenses, dispose: () => undefined };
193
+ },
194
+ };
195
+
196
+ const disposable = monaco.languages.registerCodeLensProvider("xml", provider);
197
+ const commandHandler = (_accessor: unknown, ...args: unknown[]) => {
198
+ const action = args[0];
199
+ if (action && typeof action === "object" && "type" in action) {
200
+ onOpenPopover(action as SsmlCodeLensAction);
201
+ }
202
+ };
203
+
204
+ const commandDisposable =
205
+ typeof monaco.editor?.registerCommand === "function"
206
+ ? monaco.editor.registerCommand(CODE_LENS_COMMAND, commandHandler)
207
+ : typeof monaco.registerCommand === "function"
208
+ ? monaco.registerCommand(CODE_LENS_COMMAND, commandHandler)
209
+ : editor.addAction({
210
+ id: CODE_LENS_COMMAND,
211
+ label: "SSML CodeLens",
212
+ run: (_editor, ...args: unknown[]) => {
213
+ const action = args[0];
214
+ if (action && typeof action === "object" && "type" in action) {
215
+ onOpenPopover(action as SsmlCodeLensAction);
216
+ }
217
+ },
218
+ });
219
+
220
+ return {
221
+ dispose: () => {
222
+ commandDisposable.dispose();
223
+ disposable.dispose();
224
+ },
225
+ };
226
+ }
@@ -0,0 +1,129 @@
1
+ import type * as monaco from "monaco-editor";
2
+ // @ts-expect-error The Node strip-types test runner requires the explicit TypeScript extension.
3
+ import { resolveExpressAsStyles, SSML_ATTRIBUTE_PRESETS } from "./constants/ssmlPresets.ts";
4
+ // @ts-expect-error The Node strip-types test runner requires the explicit TypeScript extension.
5
+ import { findSsmlVoiceContext } from "./ssmlContext.ts";
6
+
7
+ type Monaco = typeof monaco;
8
+ type MonacoLanguages = Monaco["languages"];
9
+ type MonacoCompletionProvider = Parameters<MonacoLanguages["registerCompletionItemProvider"]>[1];
10
+ type MonacoCompletionMethod = NonNullable<MonacoCompletionProvider["provideCompletionItems"]>;
11
+ type MonacoCompletionModel = Parameters<MonacoCompletionMethod>[0];
12
+ type MonacoCompletionPosition = Parameters<MonacoCompletionMethod>[1];
13
+
14
+ const SSML_ATTRIBUTE_VALUE_PATTERN = /<([\w:-]+)\s+[^>]*?\b([\w:-]+)=["']([^"']*)$/i;
15
+ const EXPRESS_AS_TAG_NAMES = new Set(["mstts:express-as", "express-as", "expressas"]);
16
+
17
+ export interface SsmlCompletionProviderOptions {
18
+ getOuterVoiceName?: () => string | undefined;
19
+ model?: MonacoCompletionModel | null;
20
+ }
21
+
22
+ function findSsmlAttributePresets(tagName: string, attributeName: string): readonly string[] | undefined {
23
+ const tagPresets = Object.entries(SSML_ATTRIBUTE_PRESETS).find(
24
+ ([presetTagName]) => presetTagName.toLowerCase() === tagName.toLowerCase(),
25
+ )?.[1];
26
+
27
+ return Object.entries(tagPresets ?? {}).find(
28
+ ([presetAttributeName]) => presetAttributeName.toLowerCase() === attributeName.toLowerCase(),
29
+ )?.[1];
30
+ }
31
+
32
+ const SSML_COMPLETION_SNIPPETS = [
33
+ {
34
+ label: "break",
35
+ insertText: '<break time="500ms" />',
36
+ },
37
+ {
38
+ label: "prosody",
39
+ insertText: `<prosody rate="medium" pitch="medium">\${1:text}</prosody>`,
40
+ },
41
+ {
42
+ label: "mstts:express-as",
43
+ insertText: `<mstts:express-as style="cheerful">\${1:text}</mstts:express-as>`,
44
+ },
45
+ {
46
+ label: "sub",
47
+ insertText: `<sub alias="\${1:読み}">\${2:漢字}</sub>`,
48
+ },
49
+ ] as const;
50
+
51
+ export function registerSsmlCompletionProvider(
52
+ monaco: Monaco,
53
+ options: SsmlCompletionProviderOptions = {},
54
+ ): ReturnType<MonacoLanguages["registerCompletionItemProvider"]> {
55
+ const provider: MonacoCompletionProvider = {
56
+ provideCompletionItems(model: MonacoCompletionModel, position: MonacoCompletionPosition) {
57
+ if (options.model && options.model !== model) {
58
+ return { suggestions: [] };
59
+ }
60
+
61
+ const value = model.getValue();
62
+ const offset = model.getOffsetAt(position);
63
+ const textUntilPosition = value.slice(0, offset);
64
+ const isClosingTag = /<\/[a-zA-Z0-9:-]*$/.test(textUntilPosition);
65
+ if (isClosingTag) {
66
+ return { suggestions: [] };
67
+ }
68
+
69
+ const attributeMatch = SSML_ATTRIBUTE_VALUE_PATTERN.exec(textUntilPosition);
70
+ let attributeValues = attributeMatch ? findSsmlAttributePresets(attributeMatch[1], attributeMatch[2]) : undefined;
71
+ if (
72
+ attributeMatch &&
73
+ attributeValues &&
74
+ EXPRESS_AS_TAG_NAMES.has(attributeMatch[1].toLowerCase()) &&
75
+ attributeMatch[2].toLowerCase() === "style"
76
+ ) {
77
+ const voiceContext = findSsmlVoiceContext(value, offset);
78
+ const voiceName = voiceContext === undefined ? options.getOuterVoiceName?.() : voiceContext.voiceName;
79
+ attributeValues = resolveExpressAsStyles(voiceName, attributeValues);
80
+ }
81
+ const openTagMatch = textUntilPosition.match(/<(?!\/)[a-zA-Z0-9:-]*$/);
82
+ const openTagLength = openTagMatch?.[0].length ?? 0;
83
+ const isAfterBracket =
84
+ model.getValueInRange({
85
+ startLineNumber: position.lineNumber,
86
+ startColumn: position.column - 1,
87
+ endLineNumber: position.lineNumber,
88
+ endColumn: position.column,
89
+ }) === "<";
90
+ const hasClosingBracket =
91
+ isAfterBracket &&
92
+ model.getValueInRange({
93
+ startLineNumber: position.lineNumber,
94
+ startColumn: position.column,
95
+ endLineNumber: position.lineNumber,
96
+ endColumn: position.column + 1,
97
+ }) === ">";
98
+ const range = {
99
+ startLineNumber: position.lineNumber,
100
+ startColumn: openTagLength > 0 ? position.column - openTagLength : position.column,
101
+ endLineNumber: position.lineNumber,
102
+ endColumn: hasClosingBracket ? position.column + 1 : position.column,
103
+ };
104
+
105
+ return {
106
+ suggestions: [
107
+ ...(attributeMatch
108
+ ? []
109
+ : SSML_COMPLETION_SNIPPETS.map(({ label, insertText }) => ({
110
+ label,
111
+ kind: monaco.languages.CompletionItemKind.Snippet,
112
+ insertText,
113
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
114
+ range,
115
+ }))),
116
+ ...(attributeValues?.map((value) => ({
117
+ label: value,
118
+ kind: monaco.languages.CompletionItemKind.Value,
119
+ insertText: value,
120
+ range,
121
+ })) ?? []),
122
+ ],
123
+ };
124
+ },
125
+ triggerCharacters: ["<", '"', "'"],
126
+ };
127
+
128
+ return monaco.languages.registerCompletionItemProvider("xml", provider);
129
+ }
@@ -0,0 +1,196 @@
1
+ export interface SsmlVoiceContext {
2
+ voiceName?: string;
3
+ }
4
+
5
+ export interface SsmlTagRange {
6
+ start: number;
7
+ end: number;
8
+ }
9
+
10
+ interface OpenElement {
11
+ name: string;
12
+ voiceName?: string;
13
+ }
14
+
15
+ function findTagEnd(source: string, start: number, limit: number): number {
16
+ let quote: string | undefined;
17
+ for (let index = start + 1; index < limit; index += 1) {
18
+ const character = source[index];
19
+ if (quote !== undefined) {
20
+ if (character === quote) {
21
+ quote = undefined;
22
+ }
23
+ continue;
24
+ }
25
+ if (character === '"' || character === "'") {
26
+ quote = character;
27
+ continue;
28
+ }
29
+ if (character === ">") {
30
+ return index;
31
+ }
32
+ }
33
+ return -1;
34
+ }
35
+
36
+ function getVoiceName(tag: string): string | undefined {
37
+ return tag.match(/\bname\s*=\s*(["'])([\s\S]*?)\1/i)?.[2];
38
+ }
39
+
40
+ function escapeXmlAttribute(value: string): string {
41
+ return value
42
+ .replace(/&/g, "&amp;")
43
+ .replace(/"/g, "&quot;")
44
+ .replace(/</g, "&lt;")
45
+ .replace(/>/g, "&gt;")
46
+ .replace(/'/g, "&apos;");
47
+ }
48
+
49
+ export function updateTagAttribute(
50
+ text: string,
51
+ tagRange: SsmlTagRange,
52
+ attributeName: string,
53
+ newValue: string,
54
+ ): string {
55
+ const start = Math.max(0, Math.min(tagRange.start, text.length));
56
+ const end = Math.max(start, Math.min(tagRange.end, text.length));
57
+ const tag = text.slice(start, end);
58
+ const escapedAttributeName = attributeName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
59
+ const attributePattern = new RegExp(`(\\b${escapedAttributeName}\\s*=\\s*)(["'])([\\s\\S]*?)\\2`, "i");
60
+ const escapedValue = escapeXmlAttribute(newValue);
61
+ const match = tag.match(attributePattern);
62
+
63
+ if (match?.index !== undefined) {
64
+ const valueStart = match.index + match[0].indexOf(match[2]) + 1;
65
+ const valueEnd = valueStart + (match[3]?.length ?? 0);
66
+ return `${text.slice(0, start)}${tag.slice(0, valueStart)}${escapedValue}${tag.slice(valueEnd)}${text.slice(end)}`;
67
+ }
68
+
69
+ const insertionIndex = tag.endsWith("/>") ? tag.length - 2 : tag.length - 1;
70
+ const attribute = ` ${attributeName}="${escapedValue}"`;
71
+ return `${text.slice(0, start)}${tag.slice(0, insertionIndex)}${attribute}${tag.slice(insertionIndex)}${text.slice(end)}`;
72
+ }
73
+
74
+ function closeElement(stack: OpenElement[], name: string): void {
75
+ for (let index = stack.length - 1; index >= 0; index -= 1) {
76
+ if (stack[index]?.name === name) {
77
+ stack.splice(index);
78
+ return;
79
+ }
80
+ }
81
+ }
82
+
83
+ export function findActiveSsmlTags(source: string, offset: number): Set<string> {
84
+ const limit = Math.max(0, Math.min(offset, source.length));
85
+ const stack: OpenElement[] = [];
86
+ let index = 0;
87
+
88
+ while (index < source.length) {
89
+ const tagStart = source.indexOf("<", index);
90
+ if (tagStart === -1 || tagStart > limit) {
91
+ break;
92
+ }
93
+
94
+ const nonContentEnd = source.startsWith("<!--", tagStart)
95
+ ? source.indexOf("-->", tagStart + 4)
96
+ : source.startsWith("<![CDATA[", tagStart)
97
+ ? source.indexOf("]]>", tagStart + 9)
98
+ : source.startsWith("<?", tagStart)
99
+ ? source.indexOf("?>", tagStart + 2)
100
+ : undefined;
101
+ if (nonContentEnd !== undefined) {
102
+ const delimiterLength = source.startsWith("<?", tagStart) ? 2 : 3;
103
+ const end = nonContentEnd === -1 ? source.length : nonContentEnd + delimiterLength;
104
+ if (limit < end) {
105
+ break;
106
+ }
107
+ index = end;
108
+ continue;
109
+ }
110
+
111
+ const tagEnd = findTagEnd(source, tagStart, source.length);
112
+ const tag = source.slice(tagStart, tagEnd === -1 ? source.length : tagEnd + 1);
113
+ const closingMatch = tag.match(/^<\s*\/\s*([A-Za-z_][A-Za-z0-9_.:-]*)/);
114
+ const openingMatch = tag.match(/^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/);
115
+
116
+ if (tagEnd === -1 || limit <= tagEnd) {
117
+ if (openingMatch?.[1]) {
118
+ stack.push({ name: openingMatch[1].toLowerCase() });
119
+ }
120
+ break;
121
+ }
122
+
123
+ if (closingMatch?.[1]) {
124
+ closeElement(stack, closingMatch[1].toLowerCase());
125
+ } else if (openingMatch?.[1] && !/\/\s*>$/.test(tag)) {
126
+ stack.push({ name: openingMatch[1].toLowerCase() });
127
+ }
128
+ index = tagEnd + 1;
129
+ }
130
+
131
+ return new Set(stack.map(({ name }) => name));
132
+ }
133
+
134
+ export function findSsmlVoiceContext(source: string, offset: number): SsmlVoiceContext | undefined {
135
+ const limit = Math.max(0, Math.min(offset, source.length));
136
+ const stack: OpenElement[] = [];
137
+ let index = 0;
138
+
139
+ while (index < limit) {
140
+ const tagStart = source.indexOf("<", index);
141
+ if (tagStart === -1 || tagStart >= limit) {
142
+ break;
143
+ }
144
+
145
+ if (source.startsWith("<!--", tagStart)) {
146
+ const end = source.indexOf("-->", tagStart + 4);
147
+ index = end === -1 || end + 3 > limit ? limit : end + 3;
148
+ continue;
149
+ }
150
+ if (source.startsWith("<![CDATA[", tagStart)) {
151
+ const end = source.indexOf("]]>", tagStart + 9);
152
+ index = end === -1 || end + 3 > limit ? limit : end + 3;
153
+ continue;
154
+ }
155
+ if (source.startsWith("<?", tagStart)) {
156
+ const end = source.indexOf("?>", tagStart + 2);
157
+ index = end === -1 || end + 2 > limit ? limit : end + 2;
158
+ continue;
159
+ }
160
+
161
+ const tagEnd = findTagEnd(source, tagStart, limit);
162
+ if (tagEnd === -1) {
163
+ break;
164
+ }
165
+ const tag = source.slice(tagStart, tagEnd + 1);
166
+ if (tag.startsWith("<!")) {
167
+ index = tagEnd + 1;
168
+ continue;
169
+ }
170
+
171
+ const closingMatch = tag.match(/^<\s*\/\s*([A-Za-z_][A-Za-z0-9_.:-]*)/);
172
+ if (closingMatch?.[1]) {
173
+ closeElement(stack, closingMatch[1].toLowerCase());
174
+ index = tagEnd + 1;
175
+ continue;
176
+ }
177
+
178
+ const openingMatch = tag.match(/^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/);
179
+ if (openingMatch?.[1] && !/\/\s*>$/.test(tag)) {
180
+ const name = openingMatch[1].toLowerCase();
181
+ stack.push({
182
+ name,
183
+ ...(name === "voice" ? { voiceName: getVoiceName(tag) } : {}),
184
+ });
185
+ }
186
+ index = tagEnd + 1;
187
+ }
188
+
189
+ for (let stackIndex = stack.length - 1; stackIndex >= 0; stackIndex -= 1) {
190
+ const element = stack[stackIndex];
191
+ if (element?.name === "voice") {
192
+ return element.voiceName === undefined ? {} : { voiceName: element.voiceName };
193
+ }
194
+ }
195
+ return undefined;
196
+ }