@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/ssmlHover.ts
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
// @ts-expect-error The Node strip-types test runner requires the explicit TypeScript extension.
|
|
2
|
+
import * as SSML_PRESETS from "./constants/ssmlPresets.ts";
|
|
3
|
+
// @ts-expect-error The Node strip-types test runner requires the explicit TypeScript extension.
|
|
4
|
+
import { SSML_HOVER_COPY, type SsmlEditorLocale } from "./locales.ts";
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
BREAK_STRENGTH_PRESETS,
|
|
8
|
+
EMPHASIS_LEVEL_PRESETS,
|
|
9
|
+
PHONEME_ALPHABET_PRESETS,
|
|
10
|
+
PROSODY_RATE_VALUES,
|
|
11
|
+
PROSODY_VOLUME_PRESETS,
|
|
12
|
+
SILENCE_TYPE_PRESETS,
|
|
13
|
+
SSML_PRESET_EXAMPLES,
|
|
14
|
+
VISEME_TYPE_PRESETS,
|
|
15
|
+
} = SSML_PRESETS;
|
|
16
|
+
|
|
17
|
+
export interface SsmlParameterDefinition {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
aliases?: readonly string[];
|
|
21
|
+
values?: readonly string[];
|
|
22
|
+
example?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SsmlTagDefinition {
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
aliases?: readonly string[];
|
|
29
|
+
parameters: readonly SsmlParameterDefinition[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SsmlHoverRange {
|
|
33
|
+
startLineNumber: number;
|
|
34
|
+
startColumn: number;
|
|
35
|
+
endLineNumber: number;
|
|
36
|
+
endColumn: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type SsmlHoverTargetKind = "tag" | "parameter" | "parameter-value";
|
|
40
|
+
|
|
41
|
+
export interface SsmlHoverTarget {
|
|
42
|
+
kind: SsmlHoverTargetKind;
|
|
43
|
+
tagName: string;
|
|
44
|
+
isClosingTag: boolean;
|
|
45
|
+
definition: SsmlTagDefinition;
|
|
46
|
+
parameter?: SsmlParameterDefinition;
|
|
47
|
+
range: SsmlHoverRange;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SSML_TAG_DEFINITIONS: readonly SsmlTagDefinition[] = [
|
|
51
|
+
{
|
|
52
|
+
name: "voice",
|
|
53
|
+
description: "Selects the voice and optional voice effect used to synthesize the enclosed text.",
|
|
54
|
+
parameters: [
|
|
55
|
+
{
|
|
56
|
+
name: "name",
|
|
57
|
+
description: "The voice name, such as `en-US-JennyNeural`.",
|
|
58
|
+
example: "en-US-JennyNeural",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "effect",
|
|
62
|
+
description: "An optional voice effect, such as `eq_car`.",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "prosody",
|
|
68
|
+
description: "Changes the speaking rate, pitch, volume, or pitch contour of the enclosed text.",
|
|
69
|
+
parameters: [
|
|
70
|
+
{
|
|
71
|
+
name: "rate",
|
|
72
|
+
description: "Controls speaking speed.",
|
|
73
|
+
values: PROSODY_RATE_VALUES,
|
|
74
|
+
example: SSML_PRESET_EXAMPLES.prosodyRate,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "pitch",
|
|
78
|
+
description: "Adjusts pitch using a named value, percentage, frequency, or semitone value.",
|
|
79
|
+
example: SSML_PRESET_EXAMPLES.prosodyPitch,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "volume",
|
|
83
|
+
description: "Controls loudness using a named value, percentage, or decibel value.",
|
|
84
|
+
values: PROSODY_VOLUME_PRESETS,
|
|
85
|
+
example: SSML_PRESET_EXAMPLES.prosodyVolume,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "contour",
|
|
89
|
+
description: "Defines a sequence of relative pitch changes at positions in the text.",
|
|
90
|
+
example: "(0%,+0st) (100%,+2st)",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "range",
|
|
94
|
+
description: "Adjusts the pitch range of the voice.",
|
|
95
|
+
example: "+2st",
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "break",
|
|
101
|
+
description: "Inserts a pause between words or other spoken content.",
|
|
102
|
+
parameters: [
|
|
103
|
+
{
|
|
104
|
+
name: "time",
|
|
105
|
+
description: "The pause duration, for example `500ms` or `1s`.",
|
|
106
|
+
example: SSML_PRESET_EXAMPLES.breakTime,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "strength",
|
|
110
|
+
description: "The relative pause strength.",
|
|
111
|
+
values: BREAK_STRENGTH_PRESETS,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "mstts:express-as",
|
|
117
|
+
aliases: ["express-as", "expressAs"],
|
|
118
|
+
description: "Applies a speaking style, style degree, or role to the enclosed text.",
|
|
119
|
+
parameters: [
|
|
120
|
+
{
|
|
121
|
+
name: "style",
|
|
122
|
+
description: "The speaking style supported by the selected voice, such as `cheerful`.",
|
|
123
|
+
example: SSML_PRESET_EXAMPLES.expressAsStyle,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "styledegree",
|
|
127
|
+
aliases: ["style-degree", "styleDegree"],
|
|
128
|
+
description: "Controls the intensity of the selected speaking style.",
|
|
129
|
+
example: "1.5",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "role",
|
|
133
|
+
description: "Changes the speaking role when supported by the selected voice.",
|
|
134
|
+
example: "YoungAdultFemale",
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "say-as",
|
|
140
|
+
aliases: ["sayAs"],
|
|
141
|
+
description: "Controls how the enclosed text is interpreted and spoken.",
|
|
142
|
+
parameters: [
|
|
143
|
+
{
|
|
144
|
+
name: "interpret-as",
|
|
145
|
+
description: "Specifies the interpretation, such as characters, digits, date, or time.",
|
|
146
|
+
example: "characters",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "format",
|
|
150
|
+
description: "Provides a format hint for the selected interpretation.",
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "detail",
|
|
154
|
+
description: "Provides an additional detail hint for the selected interpretation.",
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: "phoneme",
|
|
160
|
+
description: "Replaces normal pronunciation with the supplied phonetic pronunciation.",
|
|
161
|
+
parameters: [
|
|
162
|
+
{
|
|
163
|
+
name: "alphabet",
|
|
164
|
+
description: "The phonetic alphabet used by the `ph` value.",
|
|
165
|
+
values: PHONEME_ALPHABET_PRESETS,
|
|
166
|
+
example: SSML_PRESET_EXAMPLES.phonemeAlphabet,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "ph",
|
|
170
|
+
description: "The phonetic pronunciation for the enclosed text.",
|
|
171
|
+
example: "həˈloʊ",
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "emphasis",
|
|
177
|
+
description: "Adds emphasis to the enclosed text.",
|
|
178
|
+
parameters: [
|
|
179
|
+
{
|
|
180
|
+
name: "level",
|
|
181
|
+
description: "Controls the amount of emphasis.",
|
|
182
|
+
values: EMPHASIS_LEVEL_PRESETS,
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "audio",
|
|
188
|
+
description: "Plays an audio file as part of the synthesized output.",
|
|
189
|
+
parameters: [
|
|
190
|
+
{
|
|
191
|
+
name: "src",
|
|
192
|
+
description: "The URI of the audio file.",
|
|
193
|
+
example: "https://example.com/intro.wav",
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "desc",
|
|
197
|
+
description: "Alternative text to use if the audio cannot be played.",
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "clipBegin",
|
|
201
|
+
description: "The starting offset within the audio file.",
|
|
202
|
+
example: "0s",
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: "clipEnd",
|
|
206
|
+
description: "The ending offset within the audio file.",
|
|
207
|
+
example: "5s",
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "speed",
|
|
211
|
+
description: "The playback speed of the audio file.",
|
|
212
|
+
example: "1.0",
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: "repeatCount",
|
|
216
|
+
description: "The number of times to repeat the audio.",
|
|
217
|
+
example: "2",
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "repeatDuration",
|
|
221
|
+
description: "The total duration for which the audio may repeat.",
|
|
222
|
+
example: "10s",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: "soundLevel",
|
|
226
|
+
description: "The audio volume adjustment in decibels.",
|
|
227
|
+
example: "-3dB",
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: "sub",
|
|
233
|
+
description: "Substitutes the alias text when speaking the enclosed text.",
|
|
234
|
+
parameters: [
|
|
235
|
+
{
|
|
236
|
+
name: "alias",
|
|
237
|
+
description: "The text to speak instead of the enclosed text.",
|
|
238
|
+
example: "World Wide Web",
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: "lang",
|
|
244
|
+
description: "Changes the language used for the enclosed text.",
|
|
245
|
+
parameters: [
|
|
246
|
+
{
|
|
247
|
+
name: "xml:lang",
|
|
248
|
+
aliases: ["lang"],
|
|
249
|
+
description: "The BCP-47 language tag.",
|
|
250
|
+
example: "ja-JP",
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: "mark",
|
|
256
|
+
description: "Inserts a custom marker into the synthesized audio stream.",
|
|
257
|
+
parameters: [
|
|
258
|
+
{
|
|
259
|
+
name: "name",
|
|
260
|
+
description: "The application-defined marker name.",
|
|
261
|
+
example: "chapter-1",
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: "bookmark",
|
|
267
|
+
description: "Inserts a bookmark marker into the synthesized audio stream.",
|
|
268
|
+
parameters: [
|
|
269
|
+
{
|
|
270
|
+
name: "mark",
|
|
271
|
+
description: "The application-defined bookmark name.",
|
|
272
|
+
example: "chapter-1",
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: "lexicon",
|
|
278
|
+
description: "Associates a pronunciation lexicon with the synthesized document.",
|
|
279
|
+
parameters: [
|
|
280
|
+
{
|
|
281
|
+
name: "uri",
|
|
282
|
+
description: "The URI of the pronunciation lexicon.",
|
|
283
|
+
example: "https://example.com/lexicon.pls",
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
name: "p",
|
|
289
|
+
description: "Groups text into a paragraph.",
|
|
290
|
+
parameters: [],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: "s",
|
|
294
|
+
description: "Groups text into a sentence.",
|
|
295
|
+
parameters: [],
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: "w",
|
|
299
|
+
description: "Groups text into a word.",
|
|
300
|
+
parameters: [],
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: "mstts:silence",
|
|
304
|
+
aliases: ["silence"],
|
|
305
|
+
description: "Adds a specified silence before or after text or at a punctuation boundary.",
|
|
306
|
+
parameters: [
|
|
307
|
+
{
|
|
308
|
+
name: "type",
|
|
309
|
+
description: "The silence position or punctuation boundary.",
|
|
310
|
+
values: SILENCE_TYPE_PRESETS,
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: "value",
|
|
314
|
+
description: "The silence duration, for example `300ms`.",
|
|
315
|
+
example: SSML_PRESET_EXAMPLES.silenceValue,
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: "mstts:viseme",
|
|
321
|
+
aliases: ["viseme"],
|
|
322
|
+
description: "Requests viseme events for the synthesized audio.",
|
|
323
|
+
parameters: [
|
|
324
|
+
{
|
|
325
|
+
name: "type",
|
|
326
|
+
description: "The viseme event format.",
|
|
327
|
+
values: VISEME_TYPE_PRESETS,
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
] as const;
|
|
332
|
+
|
|
333
|
+
export { SSML_TAG_DEFINITIONS };
|
|
334
|
+
|
|
335
|
+
const definitionsByName = new Map<string, SsmlTagDefinition>();
|
|
336
|
+
for (const definition of SSML_TAG_DEFINITIONS) {
|
|
337
|
+
definitionsByName.set(definition.name, definition);
|
|
338
|
+
for (const alias of definition.aliases ?? []) {
|
|
339
|
+
definitionsByName.set(alias, definition);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
interface TokenRange {
|
|
344
|
+
start: number;
|
|
345
|
+
end: number;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
interface AttributeToken {
|
|
349
|
+
name: TokenRange;
|
|
350
|
+
value?: TokenRange;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
interface TagToken {
|
|
354
|
+
name: string;
|
|
355
|
+
nameRange: TokenRange;
|
|
356
|
+
start: number;
|
|
357
|
+
end: number;
|
|
358
|
+
closing: boolean;
|
|
359
|
+
attributes: AttributeToken[];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isXmlNameStart(value: string | undefined): boolean {
|
|
363
|
+
return value !== undefined && /[A-Za-z_]/.test(value);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isXmlNameCharacter(value: string | undefined): boolean {
|
|
367
|
+
return value !== undefined && /[A-Za-z0-9_.:-]/.test(value);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function isXmlWhitespace(value: string | undefined): boolean {
|
|
371
|
+
return value === " " || value === "\t" || value === "\r" || value === "\n";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function positionToOffset(source: string, lineNumber: number, column: number): number | undefined {
|
|
375
|
+
if (!Number.isInteger(lineNumber) || !Number.isInteger(column) || lineNumber < 1 || column < 1) {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let lineStart = 0;
|
|
380
|
+
for (let line = 1; line < lineNumber; line += 1) {
|
|
381
|
+
const newline = source.indexOf("\n", lineStart);
|
|
382
|
+
if (newline === -1) {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
lineStart = newline + 1;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const lineEnd = source.indexOf("\n", lineStart);
|
|
389
|
+
const lineLength = lineEnd === -1 ? source.length - lineStart : lineEnd - lineStart;
|
|
390
|
+
if (column > lineLength + 1) {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return lineStart + column - 1;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function offsetToPosition(
|
|
398
|
+
source: string,
|
|
399
|
+
offset: number,
|
|
400
|
+
): {
|
|
401
|
+
lineNumber: number;
|
|
402
|
+
column: number;
|
|
403
|
+
} {
|
|
404
|
+
const lastNewline = source.lastIndexOf("\n", offset - 1);
|
|
405
|
+
const lineNumber = source.slice(0, offset).split("\n").length;
|
|
406
|
+
return {
|
|
407
|
+
lineNumber,
|
|
408
|
+
column: offset - lastNewline,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function toRange(source: string, token: TokenRange): SsmlHoverRange {
|
|
413
|
+
const start = offsetToPosition(source, token.start);
|
|
414
|
+
const end = offsetToPosition(source, token.end);
|
|
415
|
+
return {
|
|
416
|
+
startLineNumber: start.lineNumber,
|
|
417
|
+
startColumn: start.column,
|
|
418
|
+
endLineNumber: end.lineNumber,
|
|
419
|
+
endColumn: end.column,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function containsOffset(token: TokenRange, offset: number): boolean {
|
|
424
|
+
return offset >= token.start && offset < token.end;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function findTagEnd(source: string, start: number): number | undefined {
|
|
428
|
+
let quote: string | undefined;
|
|
429
|
+
for (let index = start; index < source.length; index += 1) {
|
|
430
|
+
const character = source[index];
|
|
431
|
+
if (quote !== undefined) {
|
|
432
|
+
if (character === quote) {
|
|
433
|
+
quote = undefined;
|
|
434
|
+
}
|
|
435
|
+
} else if (character === '"' || character === "'") {
|
|
436
|
+
quote = character;
|
|
437
|
+
} else if (character === ">") {
|
|
438
|
+
return index;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function parseTag(source: string, start: number, contentEnd: number, tokenEnd: number): TagToken | undefined {
|
|
445
|
+
let index = start + 1;
|
|
446
|
+
let closing = false;
|
|
447
|
+
if (source[index] === "/") {
|
|
448
|
+
closing = true;
|
|
449
|
+
index += 1;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
while (index < contentEnd && isXmlWhitespace(source[index])) {
|
|
453
|
+
index += 1;
|
|
454
|
+
}
|
|
455
|
+
if (!isXmlNameStart(source[index])) {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const nameStart = index;
|
|
460
|
+
index += 1;
|
|
461
|
+
while (index < contentEnd && isXmlNameCharacter(source[index])) {
|
|
462
|
+
index += 1;
|
|
463
|
+
}
|
|
464
|
+
const nameEnd = index;
|
|
465
|
+
const attributes: AttributeToken[] = [];
|
|
466
|
+
|
|
467
|
+
if (!closing) {
|
|
468
|
+
while (index < contentEnd) {
|
|
469
|
+
while (index < contentEnd && isXmlWhitespace(source[index])) {
|
|
470
|
+
index += 1;
|
|
471
|
+
}
|
|
472
|
+
if (index >= contentEnd || source[index] === "/") {
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
if (!isXmlNameStart(source[index])) {
|
|
476
|
+
index += 1;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const attributeStart = index;
|
|
481
|
+
index += 1;
|
|
482
|
+
while (index < contentEnd && isXmlNameCharacter(source[index])) {
|
|
483
|
+
index += 1;
|
|
484
|
+
}
|
|
485
|
+
const attributeEnd = index;
|
|
486
|
+
while (index < contentEnd && isXmlWhitespace(source[index])) {
|
|
487
|
+
index += 1;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
let value: TokenRange | undefined;
|
|
491
|
+
if (source[index] === "=") {
|
|
492
|
+
index += 1;
|
|
493
|
+
while (index < contentEnd && isXmlWhitespace(source[index])) {
|
|
494
|
+
index += 1;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const quote = source[index];
|
|
498
|
+
if (quote === '"' || quote === "'") {
|
|
499
|
+
index += 1;
|
|
500
|
+
const valueStart = index;
|
|
501
|
+
while (index < contentEnd && source[index] !== quote) {
|
|
502
|
+
index += 1;
|
|
503
|
+
}
|
|
504
|
+
value = { start: valueStart, end: index };
|
|
505
|
+
if (index < contentEnd) {
|
|
506
|
+
index += 1;
|
|
507
|
+
}
|
|
508
|
+
} else {
|
|
509
|
+
const valueStart = index;
|
|
510
|
+
while (index < contentEnd && !isXmlWhitespace(source[index]) && source[index] !== "/") {
|
|
511
|
+
index += 1;
|
|
512
|
+
}
|
|
513
|
+
value = { start: valueStart, end: index };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
attributes.push({
|
|
518
|
+
name: { start: attributeStart, end: attributeEnd },
|
|
519
|
+
value,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
name: source.slice(nameStart, nameEnd),
|
|
526
|
+
nameRange: { start: nameStart, end: nameEnd },
|
|
527
|
+
start,
|
|
528
|
+
end: tokenEnd,
|
|
529
|
+
closing,
|
|
530
|
+
attributes,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function findTagAtOffset(source: string, offset: number): TagToken | undefined {
|
|
535
|
+
let searchStart = 0;
|
|
536
|
+
while (searchStart < source.length) {
|
|
537
|
+
const start = source.indexOf("<", searchStart);
|
|
538
|
+
if (start === -1 || start > offset) {
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (source.startsWith("<!--", start)) {
|
|
543
|
+
const commentEnd = source.indexOf("-->", start + 4);
|
|
544
|
+
const tokenEnd = commentEnd === -1 ? source.length : commentEnd + 3;
|
|
545
|
+
if (offset < tokenEnd) {
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
searchStart = tokenEnd;
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (source.startsWith("<![CDATA[", start)) {
|
|
553
|
+
const cdataEnd = source.indexOf("]]>", start + 9);
|
|
554
|
+
const tokenEnd = cdataEnd === -1 ? source.length : cdataEnd + 3;
|
|
555
|
+
if (offset < tokenEnd) {
|
|
556
|
+
return undefined;
|
|
557
|
+
}
|
|
558
|
+
searchStart = tokenEnd;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (source.startsWith("<?", start)) {
|
|
563
|
+
const processingEnd = source.indexOf("?>", start + 2);
|
|
564
|
+
const tokenEnd = processingEnd === -1 ? source.length : processingEnd + 2;
|
|
565
|
+
if (offset < tokenEnd) {
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
searchStart = tokenEnd;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const tagEnd = findTagEnd(source, start + 1);
|
|
573
|
+
const contentEnd = tagEnd ?? source.length;
|
|
574
|
+
const tokenEnd = tagEnd === undefined ? source.length : tagEnd + 1;
|
|
575
|
+
if (offset < tokenEnd) {
|
|
576
|
+
return parseTag(source, start, contentEnd, tokenEnd);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (tagEnd === undefined) {
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
searchStart = tokenEnd;
|
|
583
|
+
}
|
|
584
|
+
return undefined;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function findParameter(definition: SsmlTagDefinition, name: string): SsmlParameterDefinition | undefined {
|
|
588
|
+
return definition.parameters.find(
|
|
589
|
+
(parameter) => parameter.name === name || parameter.aliases?.includes(name) === true,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function getSsmlTagDefinition(name: string): SsmlTagDefinition | undefined {
|
|
594
|
+
return definitionsByName.get(name);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export function findSsmlHoverTarget(source: string, lineNumber: number, column: number): SsmlHoverTarget | undefined {
|
|
598
|
+
const offset = positionToOffset(source, lineNumber, column);
|
|
599
|
+
if (offset === undefined) {
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const tag = findTagAtOffset(source, offset);
|
|
604
|
+
if (!tag) {
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const definition = getSsmlTagDefinition(tag.name);
|
|
609
|
+
if (!definition) {
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (containsOffset(tag.nameRange, offset)) {
|
|
614
|
+
return {
|
|
615
|
+
kind: "tag",
|
|
616
|
+
tagName: tag.name,
|
|
617
|
+
isClosingTag: tag.closing,
|
|
618
|
+
definition,
|
|
619
|
+
range: toRange(source, tag.nameRange),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
for (const attribute of tag.attributes) {
|
|
624
|
+
if (containsOffset(attribute.name, offset)) {
|
|
625
|
+
const parameter = findParameter(definition, source.slice(attribute.name.start, attribute.name.end));
|
|
626
|
+
if (!parameter) {
|
|
627
|
+
return undefined;
|
|
628
|
+
}
|
|
629
|
+
return {
|
|
630
|
+
kind: "parameter",
|
|
631
|
+
tagName: tag.name,
|
|
632
|
+
isClosingTag: tag.closing,
|
|
633
|
+
definition,
|
|
634
|
+
parameter,
|
|
635
|
+
range: toRange(source, attribute.name),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (attribute.value && containsOffset(attribute.value, offset)) {
|
|
640
|
+
const parameter = findParameter(definition, source.slice(attribute.name.start, attribute.name.end));
|
|
641
|
+
if (!parameter) {
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
kind: "parameter-value",
|
|
646
|
+
tagName: tag.name,
|
|
647
|
+
isClosingTag: tag.closing,
|
|
648
|
+
definition,
|
|
649
|
+
parameter,
|
|
650
|
+
range: toRange(source, attribute.value),
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function code(value: string): string {
|
|
659
|
+
return `\`${value.replace(/\\/g, "\\\\").replace(/`/g, "\\`")}\``;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function formatParameter(parameter: SsmlParameterDefinition, tagName: string, locale: SsmlEditorLocale): string {
|
|
663
|
+
const localizedParameter = SSML_HOVER_COPY[locale].tags[tagName]?.parameters[parameter.name];
|
|
664
|
+
const description = localizedParameter?.description ?? parameter.description;
|
|
665
|
+
const values =
|
|
666
|
+
parameter.values && parameter.values.length > 0
|
|
667
|
+
? ` ${SSML_HOVER_COPY[locale].allowedValues}: ${parameter.values.map(code).join(", ")}.`
|
|
668
|
+
: "";
|
|
669
|
+
const example = parameter.example ? ` ${SSML_HOVER_COPY[locale].example}: ${code(parameter.example)}.` : "";
|
|
670
|
+
return `- ${code(parameter.name)}: ${description}${values}${example}`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export function formatSsmlHover(target: SsmlHoverTarget, locale: SsmlEditorLocale = "en"): string {
|
|
674
|
+
const localizedTag = SSML_HOVER_COPY[locale].tags[target.definition.name];
|
|
675
|
+
const tagTitle = localizedTag?.title ?? target.definition.name;
|
|
676
|
+
const tagDescription = localizedTag?.description ?? target.definition.description;
|
|
677
|
+
const tagSyntax = target.isClosingTag ? `</${target.tagName}>` : `<${target.tagName}>`;
|
|
678
|
+
const lines = [`### ${code(tagSyntax)}`, "", `**${tagTitle}**`, "", tagDescription];
|
|
679
|
+
|
|
680
|
+
if (target.parameter) {
|
|
681
|
+
const localizedParameter = localizedTag?.parameters[target.parameter.name];
|
|
682
|
+
const parameterTitle = localizedParameter?.title ?? target.parameter.name;
|
|
683
|
+
const parameterDescription = localizedParameter?.description ?? target.parameter.description;
|
|
684
|
+
lines.push("", `**${SSML_HOVER_COPY[locale].parameterHeading} ${code(parameterTitle)}**`, "", parameterDescription);
|
|
685
|
+
if (target.parameter.values && target.parameter.values.length > 0) {
|
|
686
|
+
lines.push("", `${SSML_HOVER_COPY[locale].allowedValues}: ${target.parameter.values.map(code).join(", ")}.`);
|
|
687
|
+
}
|
|
688
|
+
if (target.parameter.example) {
|
|
689
|
+
lines.push("", `${SSML_HOVER_COPY[locale].example}: ${code(target.parameter.example)}.`);
|
|
690
|
+
}
|
|
691
|
+
} else if (target.definition.parameters.length > 0) {
|
|
692
|
+
lines.push(
|
|
693
|
+
"",
|
|
694
|
+
`**${SSML_HOVER_COPY[locale].parametersHeading}**`,
|
|
695
|
+
"",
|
|
696
|
+
...target.definition.parameters.map((parameter) => formatParameter(parameter, target.definition.name, locale)),
|
|
697
|
+
);
|
|
698
|
+
} else {
|
|
699
|
+
lines.push("", SSML_HOVER_COPY[locale].noParameters);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return lines.join("\n");
|
|
703
|
+
}
|