ssml-builder-js 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core.d.ts CHANGED
@@ -188,6 +188,36 @@ interface BuildPartialSsmlOptions extends SsmlPartialContext {
188
188
  declare function buildPartialSsml(text: string, context?: SsmlPartialContext): string;
189
189
  declare function buildPartialSsml(options: BuildPartialSsmlOptions): string;
190
190
 
191
+ interface SsmlTextRange {
192
+ start: number;
193
+ end: number;
194
+ }
195
+ interface SsmlChunkContext {
196
+ voice?: string;
197
+ lang?: string;
198
+ prosody?: Record<string, string>;
199
+ }
200
+ interface SsmlChunk {
201
+ chunkIndex: number;
202
+ ssml: string;
203
+ originalTextRange: SsmlTextRange;
204
+ inheritedContext: SsmlChunkContext;
205
+ containedMarks: string[];
206
+ hasBackgroundAudio: boolean;
207
+ }
208
+ interface SplitSsmlOptions {
209
+ maxLength?: number;
210
+ /** Keeps `<mstts:backgroundaudio>` in every chunk instead of the first chunk only. */
211
+ replicateBackgroundAudio?: boolean;
212
+ }
213
+ /**
214
+ * Splits SSML into independently synthesizable documents without breaking XML
215
+ * elements. Parent elements such as `<voice>` and `<prosody>` are copied into
216
+ * every block. Paragraph and sentence elements therefore remain the natural
217
+ * split points, while oversized text is split at word/character boundaries.
218
+ */
219
+ declare function splitSsmlDocument(ssml: string, maxLength?: number | SplitSsmlOptions, options?: SplitSsmlOptions): SsmlChunk[];
220
+
191
221
  declare function parseSsml(xmlString: string): SsmlDocument;
192
222
 
193
223
  interface SsmlValidationError {
@@ -284,8 +314,14 @@ interface AzureValidationOptions {
284
314
  allowHttpAudio?: boolean;
285
315
  customVoiceStyleMap?: Record<string, readonly string[]>;
286
316
  customVoiceDefinitions?: readonly AzureVoiceDefinition[];
317
+ /** Host-side validation hook for URL-bearing SSML attributes. */
318
+ urlValidator?: AzureUrlValidator;
319
+ /** Alias for urlValidator retained for applications that use the longer name. */
320
+ customUrlValidator?: AzureUrlValidator;
287
321
  languageAliases?: Record<string, string | readonly string[]>;
288
322
  maxLength?: number;
323
+ /** Maximum XML element nesting depth, counting `<speak>` as depth 1. */
324
+ maxXmlDepth?: number;
289
325
  /** Optional model identifier used to validate a voice's model compatibility. */
290
326
  model?: string;
291
327
  normalizeLanguage?: (lang: string) => string;
@@ -301,6 +337,14 @@ interface AzureValidationOptions {
301
337
  voiceCatalog?: readonly AzureVoiceDefinition[];
302
338
  voiceDefinitions?: readonly AzureVoiceDefinition[];
303
339
  }
340
+ type AzureUrlValidationResult = boolean | {
341
+ valid: boolean;
342
+ reason?: string;
343
+ };
344
+ type AzureUrlValidator = (url: string, context: {
345
+ tag: string;
346
+ attribute: string;
347
+ }) => AzureUrlValidationResult | Promise<AzureUrlValidationResult>;
304
348
  type AzureLanguageNormalizationOptions = Pick<AzureValidationOptions, "languageAliases" | "normalizeLanguage">;
305
349
  /** @deprecated Use AzureValidationOptions instead. */
306
350
  type AzureSsmlValidationOptions = AzureValidationOptions;
@@ -310,7 +354,15 @@ declare function isValidAzureAudioDuration(value: string): boolean;
310
354
  declare function normalizeAzureLanguage(language: string, options?: AzureLanguageNormalizationOptions): string;
311
355
  /** Compares two Azure language tags after BCP 47 and alias normalization. */
312
356
  declare function areAzureLanguagesEquivalent(first: string, second: string, options?: AzureLanguageNormalizationOptions): boolean;
313
- declare function validateAzureSsml(ssml: string, options?: AzureValidationOptions): SsmlDiagnostic[];
357
+ /**
358
+ * Validates Azure SSML synchronously unless a URL validator is supplied. URL
359
+ * validation is asynchronous-capable so hosts can perform DNS/private-network checks.
360
+ */
361
+ declare function validateAzureSsml(ssml: string, options?: Omit<AzureValidationOptions, "urlValidator" | "customUrlValidator">): SsmlDiagnostic[];
362
+ declare function validateAzureSsml(ssml: string, options: AzureValidationOptions & {
363
+ urlValidator?: AzureUrlValidator;
364
+ customUrlValidator?: AzureUrlValidator;
365
+ }): SsmlDiagnostic[] | Promise<SsmlDiagnostic[]>;
314
366
 
315
367
  interface AzureVoiceCatalogMetadata {
316
368
  apiVersion: string;
@@ -324,4 +376,4 @@ declare function getAzureVoiceCatalogMetadata(): AzureVoiceCatalogMetadata;
324
376
  /** Alias for consumers that refer to the bundled catalog as the built-in catalog. */
325
377
  declare const getBuiltInVoiceCatalogMetadata: typeof getAzureVoiceCatalogMetadata;
326
378
 
327
- export { type AudioElement, type AzureDiagnosticCode, type AzureLanguageNormalizationOptions, type AzureSsmlValidationOptions, type AzureValidationOptions, type AzureVoiceCatalogMetadata, type AzureVoiceDefinition, type AzureVoiceMetadata, type BookmarkElement, type BreakElement, type BuildPartialSsmlOptions, type CustomElement, type EmphasisElement, type ExpressAsElement, type ExtractSsmlTranslatableTextOptions, type FromPlainTextToSsmlOptions, type LangElement, type LexiconElement, type MapSsmlTextNodesOptions, type MarkElement, type MsttsAudioDurationElement, type MsttsEmbeddingElement, type MsttsSilenceElement, type MsttsTtsEmbeddingElement, type MsttsVisemeElement, type MsttsVoiceConversionElement, type NamedElement, type ParagraphElement, type PhonemeElement, type ProsodyElement, type SayAsElement, type SentenceElement, type SsmlAttributeValue, type SsmlAttributes, type SsmlBackgroundAudioNode, type SsmlBreakElement, type SsmlDiagnostic, type SsmlDiagnosticSeverity, type SsmlDiagnosticSource, type SsmlDialogNode, type SsmlDocument, type SsmlElement, type SsmlElementBase, type SsmlEmbeddingNode, type SsmlExpressAsElement, type SsmlNode, type SsmlPartialContext, type SsmlPartialProsody, type SsmlPartialVoice, type SsmlPhonemeElement, type SsmlProsodyElement, type SsmlSayAsElement, type SsmlStructureIntegrityResult, type SsmlStructureMismatch, type SsmlText, type SsmlTextNodeContext, type SsmlTtsEmbeddingNode, type SsmlTurnNode, type SsmlValidationError, type SsmlVoiceConversionNode, type SsmlVoiceElement, type SubElement, type VoiceElement, type WordElement, areAzureLanguagesEquivalent, buildPartialSsml, buildSsml, extractSsmlText, extractSsmlTranslatableText, fromPlainTextToSsml, getAzureVoiceCatalogMetadata, getBuiltInVoiceCatalogMetadata, isValidAzureAudioDuration, mapSsmlTextNodes, normalizeAzureLanguage, parseSsml, validateAzureSsml, validateSsml, validateSsmlStructureIntegrity };
379
+ export { type AudioElement, type AzureDiagnosticCode, type AzureLanguageNormalizationOptions, type AzureSsmlValidationOptions, type AzureUrlValidationResult, type AzureUrlValidator, type AzureValidationOptions, type AzureVoiceCatalogMetadata, type AzureVoiceDefinition, type AzureVoiceMetadata, type BookmarkElement, type BreakElement, type BuildPartialSsmlOptions, type CustomElement, type EmphasisElement, type ExpressAsElement, type ExtractSsmlTranslatableTextOptions, type FromPlainTextToSsmlOptions, type LangElement, type LexiconElement, type MapSsmlTextNodesOptions, type MarkElement, type MsttsAudioDurationElement, type MsttsEmbeddingElement, type MsttsSilenceElement, type MsttsTtsEmbeddingElement, type MsttsVisemeElement, type MsttsVoiceConversionElement, type NamedElement, type ParagraphElement, type PhonemeElement, type ProsodyElement, type SayAsElement, type SentenceElement, type SplitSsmlOptions, type SsmlAttributeValue, type SsmlAttributes, type SsmlBackgroundAudioNode, type SsmlBreakElement, type SsmlChunk, type SsmlChunkContext, type SsmlDiagnostic, type SsmlDiagnosticSeverity, type SsmlDiagnosticSource, type SsmlDialogNode, type SsmlDocument, type SsmlElement, type SsmlElementBase, type SsmlEmbeddingNode, type SsmlExpressAsElement, type SsmlNode, type SsmlPartialContext, type SsmlPartialProsody, type SsmlPartialVoice, type SsmlPhonemeElement, type SsmlProsodyElement, type SsmlSayAsElement, type SsmlStructureIntegrityResult, type SsmlStructureMismatch, type SsmlText, type SsmlTextNodeContext, type SsmlTextRange, type SsmlTtsEmbeddingNode, type SsmlTurnNode, type SsmlValidationError, type SsmlVoiceConversionNode, type SsmlVoiceElement, type SubElement, type VoiceElement, type WordElement, areAzureLanguagesEquivalent, buildPartialSsml, buildSsml, extractSsmlText, extractSsmlTranslatableText, fromPlainTextToSsml, getAzureVoiceCatalogMetadata, getBuiltInVoiceCatalogMetadata, isValidAzureAudioDuration, mapSsmlTextNodes, normalizeAzureLanguage, parseSsml, splitSsmlDocument, validateAzureSsml, validateSsml, validateSsmlStructureIntegrity };
package/dist/core.js CHANGED
@@ -39,6 +39,7 @@ __export(core_exports, {
39
39
  mapSsmlTextNodes: () => mapSsmlTextNodes,
40
40
  normalizeAzureLanguage: () => normalizeAzureLanguage,
41
41
  parseSsml: () => parseSsml,
42
+ splitSsmlDocument: () => splitSsmlDocument,
42
43
  validateAzureSsml: () => validateAzureSsml,
43
44
  validateSsml: () => validateSsml,
44
45
  validateSsmlStructureIntegrity: () => validateSsmlStructureIntegrity
@@ -972,6 +973,183 @@ function buildPartialSsml(textOrOptions, context) {
972
973
  return serializePartialSsml(textOrOptions.text, textOrOptions);
973
974
  }
974
975
 
976
+ // packages/ssml-core/src/split.ts
977
+ var DEFAULT_MAX_LENGTH = 1e4;
978
+ function cloneElement(element, children) {
979
+ return { ...element, children };
980
+ }
981
+ function documentWithChildren(document, children) {
982
+ return buildSsml({ ...document, children });
983
+ }
984
+ function wrapWithContext(node, context) {
985
+ return context.reduceRight((current, parent) => cloneElement(parent, [current]), node);
986
+ }
987
+ function documentWithNode(document, node, context) {
988
+ return documentWithChildren(document, [wrapWithContext(node, context)]);
989
+ }
990
+ function splitTextNode(text, document, context, maxLength) {
991
+ if (!text) return [text];
992
+ const parts = [];
993
+ let start = 0;
994
+ while (start < text.length) {
995
+ let end = start + 1;
996
+ let bestEnd = end;
997
+ while (end <= text.length) {
998
+ if (documentWithNode(document, text.slice(start, end), context).length > maxLength) break;
999
+ bestEnd = end;
1000
+ end += 1;
1001
+ }
1002
+ if (bestEnd === start) {
1003
+ throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1004
+ }
1005
+ const segment = text.slice(start, bestEnd);
1006
+ const boundary = Math.max(segment.lastIndexOf(" "), segment.lastIndexOf("\n"), segment.lastIndexOf(" "));
1007
+ const splitEnd = boundary > 0 ? start + boundary + 1 : bestEnd;
1008
+ parts.push(text.slice(start, splitEnd));
1009
+ start = splitEnd;
1010
+ }
1011
+ return parts;
1012
+ }
1013
+ function splitNode(document, node, maxLength, context = []) {
1014
+ if (typeof node === "string" || node.type === "text") {
1015
+ const value = typeof node === "string" ? node : node.value;
1016
+ if (documentWithNode(document, node, context).length <= maxLength) return [node];
1017
+ return splitTextNode(value, document, context, maxLength);
1018
+ }
1019
+ if (documentWithNode(document, node, context).length <= maxLength) return [node];
1020
+ const children = node.children ?? [];
1021
+ if (children.length === 0) {
1022
+ throw new RangeError(`maxLength is too small to contain <${node.type}>`);
1023
+ }
1024
+ const splitChildren = children.flatMap((child) => splitNode(document, child, maxLength, [...context, node]));
1025
+ const parts = [];
1026
+ let group = [];
1027
+ const flush = () => {
1028
+ if (group.length > 0) {
1029
+ parts.push(cloneElement(node, group));
1030
+ group = [];
1031
+ }
1032
+ };
1033
+ for (const child of splitChildren) {
1034
+ const candidate = cloneElement(node, [...group, child]);
1035
+ if (documentWithNode(document, candidate, context).length <= maxLength) {
1036
+ group.push(child);
1037
+ continue;
1038
+ }
1039
+ flush();
1040
+ if (documentWithNode(document, cloneElement(node, [child]), context).length > maxLength) {
1041
+ throw new RangeError(`maxLength is too small to contain <${node.type}>`);
1042
+ }
1043
+ group.push(child);
1044
+ }
1045
+ flush();
1046
+ return parts;
1047
+ }
1048
+ function textFromNode(node) {
1049
+ if (typeof node === "string") return node;
1050
+ if (node.type === "text") return node.value;
1051
+ return (node.children ?? []).map(textFromNode).join("");
1052
+ }
1053
+ function collectMarks(node, marks) {
1054
+ if (typeof node === "string" || node.type === "text") return;
1055
+ if (node.type === "mark" && node.name) marks.push(node.name);
1056
+ if (node.type === "bookmark" && node.mark) marks.push(node.mark);
1057
+ for (const child of node.children ?? []) collectMarks(child, marks);
1058
+ }
1059
+ function collectInheritedContext(nodes) {
1060
+ const context = {};
1061
+ const visit = (node) => {
1062
+ if (typeof node === "string" || node.type === "text") return;
1063
+ if (context.voice === void 0 && node.type === "voice" && node.name) context.voice = node.name;
1064
+ if (context.lang === void 0 && node.type === "lang" && node.lang) context.lang = node.lang;
1065
+ if (context.prosody === void 0 && node.type === "prosody") {
1066
+ const prosody = {};
1067
+ for (const [key, value] of Object.entries(node.attributes ?? {})) prosody[key] = String(value);
1068
+ for (const key of ["rate", "pitch", "volume", "contour", "range"]) {
1069
+ const value = node[key];
1070
+ if (value !== void 0) prosody[key] = String(value);
1071
+ }
1072
+ if (Object.keys(prosody).length > 0) context.prosody = prosody;
1073
+ }
1074
+ for (const child of node.children ?? []) visit(child);
1075
+ };
1076
+ nodes.forEach(visit);
1077
+ return context;
1078
+ }
1079
+ function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio) {
1080
+ const chunkNodes = backgroundAudio && (replicateBackgroundAudio || chunkIndex === 0) ? [backgroundAudio, ...nodes] : nodes;
1081
+ const text = nodes.map(textFromNode).join("");
1082
+ const marks = [];
1083
+ for (const node of nodes) collectMarks(node, marks);
1084
+ const inheritedContext = collectInheritedContext(nodes);
1085
+ if (inheritedContext.lang === void 0 && document.lang) inheritedContext.lang = document.lang;
1086
+ return {
1087
+ chunkIndex,
1088
+ ssml: documentWithChildren(document, chunkNodes),
1089
+ originalTextRange: { start: textStart, end: textStart + text.length },
1090
+ inheritedContext,
1091
+ containedMarks: marks,
1092
+ hasBackgroundAudio: chunkNodes.some(
1093
+ (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1094
+ )
1095
+ };
1096
+ }
1097
+ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1098
+ const resolvedMaxLength = typeof maxLength === "number" ? maxLength : maxLength.maxLength ?? DEFAULT_MAX_LENGTH;
1099
+ const resolvedOptions = typeof maxLength === "number" ? options : maxLength;
1100
+ if (!Number.isInteger(resolvedMaxLength) || resolvedMaxLength <= 0) {
1101
+ throw new RangeError("maxLength must be a positive integer");
1102
+ }
1103
+ const document = parseSsml(ssml);
1104
+ const backgroundAudio = (document.children ?? []).find(
1105
+ (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1106
+ );
1107
+ if (ssml.length <= resolvedMaxLength) {
1108
+ return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true)];
1109
+ }
1110
+ const contentChildren = (document.children ?? []).filter((node) => node !== backgroundAudio);
1111
+ const plainDocumentLength = documentWithChildren(document, []).length;
1112
+ const backgroundDocumentLength = backgroundAudio ? documentWithChildren(document, [backgroundAudio]).length : plainDocumentLength;
1113
+ const backgroundOverhead = Math.max(0, backgroundDocumentLength - plainDocumentLength);
1114
+ const contentMaxLength = Math.max(1, resolvedMaxLength - backgroundOverhead);
1115
+ const splitChildren = contentChildren.flatMap((child) => splitNode(document, child, contentMaxLength));
1116
+ const chunks = [];
1117
+ let group = [];
1118
+ for (const child of splitChildren) {
1119
+ const candidate = [...group, child];
1120
+ if (documentWithChildren(document, candidate).length <= contentMaxLength) {
1121
+ group = candidate;
1122
+ continue;
1123
+ }
1124
+ if (group.length > 0) chunks.push(group);
1125
+ group = [child];
1126
+ if (documentWithChildren(document, group).length > contentMaxLength) {
1127
+ throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1128
+ }
1129
+ }
1130
+ if (group.length > 0) chunks.push(group);
1131
+ if (chunks.length === 0) {
1132
+ const result = createChunk(document, [], 0, 0, backgroundAudio, resolvedOptions.replicateBackgroundAudio ?? false);
1133
+ if (result.ssml.length > resolvedMaxLength) {
1134
+ throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1135
+ }
1136
+ return [result];
1137
+ }
1138
+ let textStart = 0;
1139
+ return chunks.map((chunk, chunkIndex) => {
1140
+ const result = createChunk(
1141
+ document,
1142
+ chunk,
1143
+ chunkIndex,
1144
+ textStart,
1145
+ backgroundAudio,
1146
+ resolvedOptions.replicateBackgroundAudio ?? false
1147
+ );
1148
+ textStart = result.originalTextRange.end;
1149
+ return result;
1150
+ });
1151
+ }
1152
+
975
1153
  // packages/ssml-core/src/validation.ts
976
1154
  var PARSER_POSITION_SUFFIX = / at position (\d+)$/;
977
1155
  function validateSsml(xmlString) {
@@ -1666,6 +1844,7 @@ function tokenizeElements(source) {
1666
1844
  attributes,
1667
1845
  childElementIndex,
1668
1846
  end,
1847
+ depth: openElements.length + 1,
1669
1848
  name: tokenName,
1670
1849
  parentName: parent?.name,
1671
1850
  parentVoiceName,
@@ -1856,13 +2035,18 @@ function validateAudioSource(token, source, diagnostics, options, elementName2)
1856
2035
  addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must be an absolute HTTP(S) URL.`);
1857
2036
  return;
1858
2037
  }
2038
+ if (parsed.username || parsed.password)
2039
+ addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must not contain URL credentials.`);
1859
2040
  if (parsed.protocol !== "https:" && !(options.allowHttpAudio && parsed.protocol === "http:"))
1860
2041
  addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must use HTTPS.`);
1861
2042
  const isAllowedOrigin = options.allowedAudioOrigins?.some((allowedOrigin) => {
1862
2043
  try {
1863
- return new URL(allowedOrigin).origin === parsed.origin;
2044
+ const configured = new URL(allowedOrigin);
2045
+ if (configured.protocol !== "https:" && configured.protocol !== "http:" || configured.username || configured.password || configured.pathname !== "/" || configured.search || configured.hash)
2046
+ return false;
2047
+ return configured.origin === parsed.origin;
1864
2048
  } catch {
1865
- return allowedOrigin === parsed.origin;
2049
+ return false;
1866
2050
  }
1867
2051
  }) ?? false;
1868
2052
  if (options.allowedAudioOrigins && !isAllowedOrigin)
@@ -2054,7 +2238,7 @@ function validateElement(token, source, diagnostics, voiceName, options, voiceCa
2054
2238
  addDiagnostic(diagnostics, source, token.start, "<mstts:backgroundaudio> must be self-closing.");
2055
2239
  }
2056
2240
  }
2057
- function validateAzureSsml(ssml, options = {}) {
2241
+ function validateAzureSsmlStatic(ssml, options = {}) {
2058
2242
  const diagnostics = [];
2059
2243
  if (typeof ssml !== "string") {
2060
2244
  return [
@@ -2070,6 +2254,9 @@ function validateAzureSsml(ssml, options = {}) {
2070
2254
  const maxLength = options.maxLength ?? 1e4;
2071
2255
  if (ssml.length > maxLength)
2072
2256
  addDiagnostic(diagnostics, ssml, maxLength, `SSML exceeds the maximum length of ${maxLength} characters.`);
2257
+ if (options.maxXmlDepth !== void 0 && (!Number.isInteger(options.maxXmlDepth) || options.maxXmlDepth <= 0)) {
2258
+ addDiagnostic(diagnostics, ssml, 0, "maxXmlDepth must be a positive integer.");
2259
+ }
2073
2260
  try {
2074
2261
  parseSsml(ssml);
2075
2262
  } catch (error) {
@@ -2079,6 +2266,18 @@ function validateAzureSsml(ssml, options = {}) {
2079
2266
  return diagnostics;
2080
2267
  }
2081
2268
  const tokens = tokenizeElements(ssml);
2269
+ if (options.maxXmlDepth !== void 0) {
2270
+ for (const token of tokens) {
2271
+ if (token.depth > options.maxXmlDepth) {
2272
+ addDiagnostic(
2273
+ diagnostics,
2274
+ ssml,
2275
+ token.start,
2276
+ `XML nesting depth ${token.depth} exceeds the configured maximum of ${options.maxXmlDepth}.`
2277
+ );
2278
+ }
2279
+ }
2280
+ }
2082
2281
  const speak = tokens.find((token) => token.name.toLowerCase() === "speak");
2083
2282
  const voices = tokens.filter((token) => token.name.toLowerCase() === "voice");
2084
2283
  const backgroundAudioTokens = tokens.filter((token) => token.name.toLowerCase() === "mstts:backgroundaudio");
@@ -2163,6 +2362,51 @@ function validateAzureSsml(ssml, options = {}) {
2163
2362
  }
2164
2363
  return diagnostics;
2165
2364
  }
2365
+ function urlAttributes(token) {
2366
+ const tag = canonicalTagName(token.name);
2367
+ const attributes = tag === "audio" || tag === "mstts:backgroundaudio" ? ["src"] : tag === "lexicon" ? ["uri"] : tag === "mstts:voiceconversion" ? ["url"] : [];
2368
+ return attributes.flatMap((attribute) => {
2369
+ const value = attr(token, attribute);
2370
+ return value === void 0 ? [] : [{ attribute, value }];
2371
+ });
2372
+ }
2373
+ function validateAzureSsml(ssml, options = {}) {
2374
+ const diagnostics = validateAzureSsmlStatic(ssml, options);
2375
+ const validator = options.urlValidator ?? options.customUrlValidator;
2376
+ if (!validator || typeof ssml !== "string") return diagnostics;
2377
+ let tokens;
2378
+ try {
2379
+ tokens = tokenizeElements(ssml);
2380
+ } catch {
2381
+ return diagnostics;
2382
+ }
2383
+ const checks = tokens.flatMap(
2384
+ (token) => urlAttributes(token).map(async ({ attribute, value }) => {
2385
+ try {
2386
+ const result = await validator(value, { tag: token.name, attribute });
2387
+ const valid = typeof result === "boolean" ? result : result.valid;
2388
+ if (!valid) {
2389
+ const reason = typeof result === "boolean" ? void 0 : result.reason;
2390
+ addDiagnostic(
2391
+ diagnostics,
2392
+ ssml,
2393
+ token.start,
2394
+ `<${token.name} ${attribute}> was rejected by the custom URL validator${reason ? `: ${reason}` : "."}`
2395
+ );
2396
+ }
2397
+ } catch (error) {
2398
+ const reason = error instanceof Error ? error.message : String(error);
2399
+ addDiagnostic(
2400
+ diagnostics,
2401
+ ssml,
2402
+ token.start,
2403
+ `<${token.name} ${attribute}> could not be validated by the custom URL validator: ${reason}`
2404
+ );
2405
+ }
2406
+ })
2407
+ );
2408
+ return Promise.all(checks).then(() => diagnostics);
2409
+ }
2166
2410
 
2167
2411
  // packages/ssml-core/src/generated/azureVoiceCatalog.ts
2168
2412
  var AZURE_VOICE_CATALOG_METADATA = {
@@ -2194,6 +2438,7 @@ var getBuiltInVoiceCatalogMetadata = getAzureVoiceCatalogMetadata;
2194
2438
  mapSsmlTextNodes,
2195
2439
  normalizeAzureLanguage,
2196
2440
  parseSsml,
2441
+ splitSsmlDocument,
2197
2442
  validateAzureSsml,
2198
2443
  validateSsml,
2199
2444
  validateSsmlStructureIntegrity