ssml-builder-js 2.12.0 → 2.14.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,13 +188,37 @@ 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
+ /** Best-effort path to the first source element represented by this chunk. */
208
+ sourceNodePath?: string[];
209
+ }
210
+ interface SplitSsmlOptions {
211
+ maxLength?: number;
212
+ /** Keeps `<mstts:backgroundaudio>` in every chunk instead of the first chunk only. */
213
+ replicateBackgroundAudio?: boolean;
214
+ }
191
215
  /**
192
216
  * Splits SSML into independently synthesizable documents without breaking XML
193
217
  * elements. Parent elements such as `<voice>` and `<prosody>` are copied into
194
218
  * every block. Paragraph and sentence elements therefore remain the natural
195
219
  * split points, while oversized text is split at word/character boundaries.
196
220
  */
197
- declare function splitSsmlDocument(ssml: string, maxLength?: number): string[];
221
+ declare function splitSsmlDocument(ssml: string, maxLength?: number | SplitSsmlOptions, options?: SplitSsmlOptions): SsmlChunk[];
198
222
 
199
223
  declare function parseSsml(xmlString: string): SsmlDocument;
200
224
 
@@ -292,6 +316,17 @@ interface AzureValidationOptions {
292
316
  allowHttpAudio?: boolean;
293
317
  customVoiceStyleMap?: Record<string, readonly string[]>;
294
318
  customVoiceDefinitions?: readonly AzureVoiceDefinition[];
319
+ /** Host-side validation hook for URL-bearing SSML attributes. */
320
+ urlValidator?: AzureUrlValidator;
321
+ /** Alias for urlValidator retained for applications that use the longer name. */
322
+ customUrlValidator?: AzureUrlValidator;
323
+ /** Controls deduplication, caching, cancellation, and concurrency for URL checks. */
324
+ urlValidation?: AzureUrlValidationRunnerOptions;
325
+ /** Flat aliases retained for callers that prefer not to nest URL runner options. */
326
+ urlValidatorConcurrency?: number;
327
+ urlValidatorTimeoutMs?: number;
328
+ urlValidatorSignal?: AbortSignal;
329
+ urlValidatorCache?: Map<string, AzureUrlValidationResult>;
295
330
  languageAliases?: Record<string, string | readonly string[]>;
296
331
  maxLength?: number;
297
332
  /** Maximum XML element nesting depth, counting `<speak>` as depth 1. */
@@ -311,6 +346,22 @@ interface AzureValidationOptions {
311
346
  voiceCatalog?: readonly AzureVoiceDefinition[];
312
347
  voiceDefinitions?: readonly AzureVoiceDefinition[];
313
348
  }
349
+ type AzureUrlValidationResult = boolean | {
350
+ valid: boolean;
351
+ reason?: string;
352
+ };
353
+ type AzureUrlValidator = (url: string, context: {
354
+ tag: string;
355
+ attribute: string;
356
+ }) => AzureUrlValidationResult | Promise<AzureUrlValidationResult>;
357
+ interface AzureUrlValidationRunnerOptions {
358
+ concurrency?: number;
359
+ cache?: Map<string, AzureUrlValidationResult>;
360
+ signal?: AbortSignal;
361
+ timeoutMs?: number;
362
+ }
363
+ /** Wraps a URL validator with URL deduplication, bounded concurrency, caching, and cancellation. */
364
+ declare function createAzureUrlValidatorRunner(validator: AzureUrlValidator, options?: AzureUrlValidationRunnerOptions): AzureUrlValidator;
314
365
  type AzureLanguageNormalizationOptions = Pick<AzureValidationOptions, "languageAliases" | "normalizeLanguage">;
315
366
  /** @deprecated Use AzureValidationOptions instead. */
316
367
  type AzureSsmlValidationOptions = AzureValidationOptions;
@@ -320,7 +371,15 @@ declare function isValidAzureAudioDuration(value: string): boolean;
320
371
  declare function normalizeAzureLanguage(language: string, options?: AzureLanguageNormalizationOptions): string;
321
372
  /** Compares two Azure language tags after BCP 47 and alias normalization. */
322
373
  declare function areAzureLanguagesEquivalent(first: string, second: string, options?: AzureLanguageNormalizationOptions): boolean;
323
- declare function validateAzureSsml(ssml: string, options?: AzureValidationOptions): SsmlDiagnostic[];
374
+ /**
375
+ * Validates Azure SSML synchronously unless a URL validator is supplied. URL
376
+ * validation is asynchronous-capable so hosts can perform DNS/private-network checks.
377
+ */
378
+ declare function validateAzureSsml(ssml: string, options?: Omit<AzureValidationOptions, "urlValidator" | "customUrlValidator">): SsmlDiagnostic[];
379
+ declare function validateAzureSsml(ssml: string, options: AzureValidationOptions & {
380
+ urlValidator?: AzureUrlValidator;
381
+ customUrlValidator?: AzureUrlValidator;
382
+ }): SsmlDiagnostic[] | Promise<SsmlDiagnostic[]>;
324
383
 
325
384
  interface AzureVoiceCatalogMetadata {
326
385
  apiVersion: string;
@@ -334,4 +393,4 @@ declare function getAzureVoiceCatalogMetadata(): AzureVoiceCatalogMetadata;
334
393
  /** Alias for consumers that refer to the bundled catalog as the built-in catalog. */
335
394
  declare const getBuiltInVoiceCatalogMetadata: typeof getAzureVoiceCatalogMetadata;
336
395
 
337
- 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, splitSsmlDocument, validateAzureSsml, validateSsml, validateSsmlStructureIntegrity };
396
+ export { type AudioElement, type AzureDiagnosticCode, type AzureLanguageNormalizationOptions, type AzureSsmlValidationOptions, type AzureUrlValidationResult, type AzureUrlValidationRunnerOptions, 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, createAzureUrlValidatorRunner, extractSsmlText, extractSsmlTranslatableText, fromPlainTextToSsml, getAzureVoiceCatalogMetadata, getBuiltInVoiceCatalogMetadata, isValidAzureAudioDuration, mapSsmlTextNodes, normalizeAzureLanguage, parseSsml, splitSsmlDocument, validateAzureSsml, validateSsml, validateSsmlStructureIntegrity };
package/dist/core.js CHANGED
@@ -30,6 +30,7 @@ __export(core_exports, {
30
30
  areAzureLanguagesEquivalent: () => areAzureLanguagesEquivalent,
31
31
  buildPartialSsml: () => buildPartialSsml,
32
32
  buildSsml: () => buildSsml,
33
+ createAzureUrlValidatorRunner: () => createAzureUrlValidatorRunner,
33
34
  extractSsmlText: () => extractSsmlText,
34
35
  extractSsmlTranslatableText: () => extractSsmlTranslatableText,
35
36
  fromPlainTextToSsml: () => fromPlainTextToSsml,
@@ -1045,30 +1046,138 @@ function splitNode(document, node, maxLength, context = []) {
1045
1046
  flush();
1046
1047
  return parts;
1047
1048
  }
1048
- function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH) {
1049
- if (!Number.isInteger(maxLength) || maxLength <= 0) {
1049
+ function textFromNode(node) {
1050
+ if (typeof node === "string") return node;
1051
+ if (node.type === "text") return node.value;
1052
+ return (node.children ?? []).map(textFromNode).join("");
1053
+ }
1054
+ function collectMarks(node, marks) {
1055
+ if (typeof node === "string" || node.type === "text") return;
1056
+ if (node.type === "mark" && node.name) marks.push(node.name);
1057
+ if (node.type === "bookmark" && node.mark) marks.push(node.mark);
1058
+ for (const child of node.children ?? []) collectMarks(child, marks);
1059
+ }
1060
+ function collectInheritedContext(nodes) {
1061
+ const context = {};
1062
+ const visit = (node) => {
1063
+ if (typeof node === "string" || node.type === "text") return;
1064
+ if (context.voice === void 0 && node.type === "voice" && node.name) context.voice = node.name;
1065
+ if (context.lang === void 0 && node.type === "lang" && node.lang) context.lang = node.lang;
1066
+ if (context.prosody === void 0 && node.type === "prosody") {
1067
+ const prosody = {};
1068
+ for (const [key, value] of Object.entries(node.attributes ?? {})) prosody[key] = String(value);
1069
+ for (const key of ["rate", "pitch", "volume", "contour", "range"]) {
1070
+ const value = node[key];
1071
+ if (value !== void 0) prosody[key] = String(value);
1072
+ }
1073
+ if (Object.keys(prosody).length > 0) context.prosody = prosody;
1074
+ }
1075
+ for (const child of node.children ?? []) visit(child);
1076
+ };
1077
+ nodes.forEach(visit);
1078
+ return context;
1079
+ }
1080
+ function elementName(node) {
1081
+ return node.type === "custom" || node.type === "element" ? node.name : node.type;
1082
+ }
1083
+ function findSourceNodePath(nodes, targetOffset) {
1084
+ let textOffset = 0;
1085
+ let firstPath;
1086
+ let foundPath;
1087
+ const visit = (node, path) => {
1088
+ if (typeof node === "string" || node.type === "text") {
1089
+ const text = typeof node === "string" ? node : node.value;
1090
+ if (text && firstPath === void 0) firstPath = [...path];
1091
+ if (text && foundPath === void 0 && targetOffset < textOffset + text.length) foundPath = [...path];
1092
+ textOffset += text.length;
1093
+ return;
1094
+ }
1095
+ node.children?.forEach((child, index) => {
1096
+ const childPath = typeof child === "string" || child.type === "text" ? path : [...path, `${elementName(child)}[${index}]`];
1097
+ visit(child, childPath);
1098
+ });
1099
+ };
1100
+ nodes.forEach((node, index) => {
1101
+ if (!foundPath) {
1102
+ if (typeof node === "string" || node.type === "text") visit(node, ["speak"]);
1103
+ else visit(node, ["speak", `${elementName(node)}[${index}]`]);
1104
+ }
1105
+ });
1106
+ return foundPath ?? firstPath;
1107
+ }
1108
+ function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio) {
1109
+ const chunkNodes = backgroundAudio && (replicateBackgroundAudio || chunkIndex === 0) ? [backgroundAudio, ...nodes] : nodes;
1110
+ const text = nodes.map(textFromNode).join("");
1111
+ const marks = [];
1112
+ for (const node of nodes) collectMarks(node, marks);
1113
+ const inheritedContext = collectInheritedContext(nodes);
1114
+ if (inheritedContext.lang === void 0 && document.lang) inheritedContext.lang = document.lang;
1115
+ return {
1116
+ chunkIndex,
1117
+ ssml: documentWithChildren(document, chunkNodes),
1118
+ originalTextRange: { start: textStart, end: textStart + text.length },
1119
+ inheritedContext,
1120
+ containedMarks: marks,
1121
+ hasBackgroundAudio: chunkNodes.some(
1122
+ (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1123
+ ),
1124
+ sourceNodePath: findSourceNodePath(document.children ?? [], textStart)
1125
+ };
1126
+ }
1127
+ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1128
+ const resolvedMaxLength = typeof maxLength === "number" ? maxLength : maxLength.maxLength ?? DEFAULT_MAX_LENGTH;
1129
+ const resolvedOptions = typeof maxLength === "number" ? options : maxLength;
1130
+ if (!Number.isInteger(resolvedMaxLength) || resolvedMaxLength <= 0) {
1050
1131
  throw new RangeError("maxLength must be a positive integer");
1051
1132
  }
1052
1133
  const document = parseSsml(ssml);
1053
- if (ssml.length <= maxLength) return [ssml];
1054
- const children = document.children ?? [];
1055
- const splitChildren = children.flatMap((child) => splitNode(document, child, maxLength));
1134
+ const backgroundAudio = (document.children ?? []).find(
1135
+ (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1136
+ );
1137
+ if (ssml.length <= resolvedMaxLength) {
1138
+ return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true)];
1139
+ }
1140
+ const contentChildren = (document.children ?? []).filter((node) => node !== backgroundAudio);
1141
+ const plainDocumentLength = documentWithChildren(document, []).length;
1142
+ const backgroundDocumentLength = backgroundAudio ? documentWithChildren(document, [backgroundAudio]).length : plainDocumentLength;
1143
+ const backgroundOverhead = Math.max(0, backgroundDocumentLength - plainDocumentLength);
1144
+ const contentMaxLength = Math.max(1, resolvedMaxLength - backgroundOverhead);
1145
+ const splitChildren = contentChildren.flatMap((child) => splitNode(document, child, contentMaxLength));
1056
1146
  const chunks = [];
1057
1147
  let group = [];
1058
1148
  for (const child of splitChildren) {
1059
1149
  const candidate = [...group, child];
1060
- if (documentWithChildren(document, candidate).length <= maxLength) {
1150
+ if (documentWithChildren(document, candidate).length <= contentMaxLength) {
1061
1151
  group = candidate;
1062
1152
  continue;
1063
1153
  }
1064
1154
  if (group.length > 0) chunks.push(group);
1065
1155
  group = [child];
1066
- if (documentWithChildren(document, group).length > maxLength) {
1156
+ if (documentWithChildren(document, group).length > contentMaxLength) {
1067
1157
  throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1068
1158
  }
1069
1159
  }
1070
1160
  if (group.length > 0) chunks.push(group);
1071
- return chunks.map((chunk) => documentWithChildren(document, chunk));
1161
+ if (chunks.length === 0) {
1162
+ const result = createChunk(document, [], 0, 0, backgroundAudio, resolvedOptions.replicateBackgroundAudio ?? false);
1163
+ if (result.ssml.length > resolvedMaxLength) {
1164
+ throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1165
+ }
1166
+ return [result];
1167
+ }
1168
+ let textStart = 0;
1169
+ return chunks.map((chunk, chunkIndex) => {
1170
+ const result = createChunk(
1171
+ document,
1172
+ chunk,
1173
+ chunkIndex,
1174
+ textStart,
1175
+ backgroundAudio,
1176
+ resolvedOptions.replicateBackgroundAudio ?? false
1177
+ );
1178
+ textStart = result.originalTextRange.end;
1179
+ return result;
1180
+ });
1072
1181
  }
1073
1182
 
1074
1183
  // packages/ssml-core/src/validation.ts
@@ -1238,7 +1347,7 @@ async function mapSsmlTextNodes(ssml, transform, options = {}) {
1238
1347
 
1239
1348
  // packages/ssml-core/src/migration.ts
1240
1349
  var DEFAULT_TRANSLATION_SKIP_TAGS = ["phoneme", "say-as", "sayAs", "sub"];
1241
- function elementName(element) {
1350
+ function elementName2(element) {
1242
1351
  switch (element.type) {
1243
1352
  case "custom":
1244
1353
  case "element":
@@ -1391,7 +1500,7 @@ function extractSsmlTranslatableText(ssml, options = {}) {
1391
1500
  }
1392
1501
  return;
1393
1502
  }
1394
- const tag = elementName(node);
1503
+ const tag = elementName2(node);
1395
1504
  if (skipTags.has(tag.toLowerCase())) return;
1396
1505
  visit(childrenOf(node), [...ancestors, tag], [...path, String(index)]);
1397
1506
  });
@@ -1437,7 +1546,7 @@ function serializeDocument2(document) {
1437
1546
  const serialize = (node) => {
1438
1547
  if (typeof node === "string") return node.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1439
1548
  if (node.type === "text") return serialize(node.value);
1440
- const tag = elementName(node);
1549
+ const tag = elementName2(node);
1441
1550
  const nodeAttributes = elementAttributes(node);
1442
1551
  const serializedAttributes = Object.entries(nodeAttributes).map(([name, value]) => ` ${name}="${serialize(value).replace(/"/g, "&quot;")}"`).join("");
1443
1552
  const children = childrenOf(node).map(serialize).join("");
@@ -1451,7 +1560,7 @@ function flatten(document) {
1451
1560
  nodes.forEach((node, index) => {
1452
1561
  if (typeof node === "string" || node.type === "text") return;
1453
1562
  const currentPath = `${path}/${index}`;
1454
- result.push({ name: elementName(node), attributes: elementAttributes(node), path: currentPath });
1563
+ result.push({ name: elementName2(node), attributes: elementAttributes(node), path: currentPath });
1455
1564
  visit(childrenOf(node), currentPath);
1456
1565
  });
1457
1566
  };
@@ -1649,6 +1758,69 @@ var AZURE_VOICE_DEFINITIONS = [
1649
1758
  ];
1650
1759
 
1651
1760
  // packages/ssml-core/src/azureValidation.ts
1761
+ function createAzureUrlValidatorRunner(validator, options = {}) {
1762
+ if (typeof validator !== "function") throw new TypeError("A URL validator function is required.");
1763
+ const concurrency = options.concurrency === void 0 ? Infinity : Number.isFinite(options.concurrency) ? Math.max(1, Math.floor(options.concurrency)) : Infinity;
1764
+ const cache = options.cache ?? /* @__PURE__ */ new Map();
1765
+ const inFlight = /* @__PURE__ */ new Map();
1766
+ const waiters = [];
1767
+ let active = 0;
1768
+ const acquire = async () => {
1769
+ if (active < concurrency) {
1770
+ active += 1;
1771
+ return;
1772
+ }
1773
+ await new Promise((resolve) => waiters.push(resolve));
1774
+ active += 1;
1775
+ };
1776
+ const release = () => {
1777
+ active -= 1;
1778
+ waiters.shift()?.();
1779
+ };
1780
+ const check = async (url, context) => {
1781
+ if (options.signal?.aborted) throw new Error("URL validation was aborted.");
1782
+ const cached = cache.get(url);
1783
+ if (cached !== void 0) return cached;
1784
+ const existing = inFlight.get(url);
1785
+ if (existing) return existing;
1786
+ const promise = (async () => {
1787
+ await acquire();
1788
+ try {
1789
+ if (options.signal?.aborted) throw new Error("URL validation was aborted.");
1790
+ const validation = Promise.resolve(validator(url, context));
1791
+ let timer;
1792
+ let abortHandler;
1793
+ const cancellation = new Promise((_resolve, reject) => {
1794
+ abortHandler = () => reject(new Error("URL validation was aborted."));
1795
+ options.signal?.addEventListener("abort", abortHandler, { once: true });
1796
+ if (options.timeoutMs !== void 0 && options.timeoutMs > 0) {
1797
+ timer = setTimeout(
1798
+ () => reject(new Error(`URL validation timed out after ${options.timeoutMs} ms.`)),
1799
+ options.timeoutMs
1800
+ );
1801
+ }
1802
+ });
1803
+ try {
1804
+ const result = await (timer || abortHandler ? Promise.race([validation, cancellation]) : validation);
1805
+ cache.set(url, result);
1806
+ return result;
1807
+ } finally {
1808
+ if (timer) clearTimeout(timer);
1809
+ if (abortHandler) options.signal?.removeEventListener("abort", abortHandler);
1810
+ }
1811
+ } finally {
1812
+ release();
1813
+ }
1814
+ })();
1815
+ inFlight.set(url, promise);
1816
+ try {
1817
+ return await promise;
1818
+ } finally {
1819
+ inFlight.delete(url);
1820
+ }
1821
+ };
1822
+ return (url, context) => check(url, context);
1823
+ }
1652
1824
  var ALLOWED_BREAK_STRENGTHS = /* @__PURE__ */ new Set(["none", "x-weak", "weak", "medium", "strong", "x-strong"]);
1653
1825
  var ALLOWED_SAY_AS = /* @__PURE__ */ new Set([
1654
1826
  "characters",
@@ -1943,23 +2115,23 @@ function validateVoiceFeatureMatrix(token, source, diagnostics, voiceName, defin
1943
2115
  );
1944
2116
  }
1945
2117
  }
1946
- function validateAudioSource(token, source, diagnostics, options, elementName2) {
2118
+ function validateAudioSource(token, source, diagnostics, options, elementName3) {
1947
2119
  const src = attr(token, "src");
1948
2120
  if (!src) {
1949
- addDiagnostic(diagnostics, source, token.start, `<${elementName2}> requires a "src" attribute.`);
2121
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3}> requires a "src" attribute.`);
1950
2122
  return;
1951
2123
  }
1952
2124
  let parsed;
1953
2125
  try {
1954
2126
  parsed = new URL(src);
1955
2127
  } catch {
1956
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must be an absolute HTTP(S) URL.`);
2128
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must be an absolute HTTP(S) URL.`);
1957
2129
  return;
1958
2130
  }
1959
2131
  if (parsed.username || parsed.password)
1960
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must not contain URL credentials.`);
2132
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must not contain URL credentials.`);
1961
2133
  if (parsed.protocol !== "https:" && !(options.allowHttpAudio && parsed.protocol === "http:"))
1962
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must use HTTPS.`);
2134
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must use HTTPS.`);
1963
2135
  const isAllowedOrigin = options.allowedAudioOrigins?.some((allowedOrigin) => {
1964
2136
  try {
1965
2137
  const configured = new URL(allowedOrigin);
@@ -1971,13 +2143,13 @@ function validateAudioSource(token, source, diagnostics, options, elementName2)
1971
2143
  }
1972
2144
  }) ?? false;
1973
2145
  if (options.allowedAudioOrigins && !isAllowedOrigin)
1974
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> origin "${parsed.origin}" is not allowed.`);
2146
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> origin "${parsed.origin}" is not allowed.`);
1975
2147
  else if (!isAllowedOrigin && !options.allowExternalAudio)
1976
2148
  addDiagnostic(
1977
2149
  diagnostics,
1978
2150
  source,
1979
2151
  token.start,
1980
- `<${elementName2} src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`
2152
+ `<${elementName3} src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`
1981
2153
  );
1982
2154
  }
1983
2155
  function validateElement(token, source, diagnostics, voiceName, options, voiceCatalog) {
@@ -2159,7 +2331,7 @@ function validateElement(token, source, diagnostics, voiceName, options, voiceCa
2159
2331
  addDiagnostic(diagnostics, source, token.start, "<mstts:backgroundaudio> must be self-closing.");
2160
2332
  }
2161
2333
  }
2162
- function validateAzureSsml(ssml, options = {}) {
2334
+ function validateAzureSsmlStatic(ssml, options = {}) {
2163
2335
  const diagnostics = [];
2164
2336
  if (typeof ssml !== "string") {
2165
2337
  return [
@@ -2283,6 +2455,59 @@ function validateAzureSsml(ssml, options = {}) {
2283
2455
  }
2284
2456
  return diagnostics;
2285
2457
  }
2458
+ function urlAttributes(token) {
2459
+ const tag = canonicalTagName(token.name);
2460
+ const attributes = tag === "audio" || tag === "mstts:backgroundaudio" ? ["src"] : tag === "lexicon" ? ["uri"] : tag === "mstts:voiceconversion" ? ["url"] : [];
2461
+ return attributes.flatMap((attribute) => {
2462
+ const value = attr(token, attribute);
2463
+ return value === void 0 ? [] : [{ attribute, value }];
2464
+ });
2465
+ }
2466
+ function validateAzureSsml(ssml, options = {}) {
2467
+ const diagnostics = validateAzureSsmlStatic(ssml, options);
2468
+ const validator = options.urlValidator ?? options.customUrlValidator;
2469
+ if (!validator || typeof ssml !== "string") return diagnostics;
2470
+ const runnerOptions = options.urlValidation ?? {};
2471
+ const boundedValidator = createAzureUrlValidatorRunner(validator, {
2472
+ ...runnerOptions,
2473
+ ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
2474
+ ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
2475
+ ...options.urlValidatorSignal ? { signal: options.urlValidatorSignal } : {},
2476
+ ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
2477
+ });
2478
+ let tokens;
2479
+ try {
2480
+ tokens = tokenizeElements(ssml);
2481
+ } catch {
2482
+ return diagnostics;
2483
+ }
2484
+ const checks = tokens.flatMap(
2485
+ (token) => urlAttributes(token).map(async ({ attribute, value }) => {
2486
+ try {
2487
+ const result = await boundedValidator(value, { tag: token.name, attribute });
2488
+ const valid = typeof result === "boolean" ? result : result.valid;
2489
+ if (!valid) {
2490
+ const reason = typeof result === "boolean" ? void 0 : result.reason;
2491
+ addDiagnostic(
2492
+ diagnostics,
2493
+ ssml,
2494
+ token.start,
2495
+ `<${token.name} ${attribute}> was rejected by the custom URL validator${reason ? `: ${reason}` : "."}`
2496
+ );
2497
+ }
2498
+ } catch (error) {
2499
+ const reason = error instanceof Error ? error.message : String(error);
2500
+ addDiagnostic(
2501
+ diagnostics,
2502
+ ssml,
2503
+ token.start,
2504
+ `<${token.name} ${attribute}> could not be validated by the custom URL validator: ${reason}`
2505
+ );
2506
+ }
2507
+ })
2508
+ );
2509
+ return Promise.all(checks).then(() => diagnostics);
2510
+ }
2286
2511
 
2287
2512
  // packages/ssml-core/src/generated/azureVoiceCatalog.ts
2288
2513
  var AZURE_VOICE_CATALOG_METADATA = {
@@ -2305,6 +2530,7 @@ var getBuiltInVoiceCatalogMetadata = getAzureVoiceCatalogMetadata;
2305
2530
  areAzureLanguagesEquivalent,
2306
2531
  buildPartialSsml,
2307
2532
  buildSsml,
2533
+ createAzureUrlValidatorRunner,
2308
2534
  extractSsmlText,
2309
2535
  extractSsmlTranslatableText,
2310
2536
  fromPlainTextToSsml,