ssml-builder-js 2.13.0 → 2.15.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.js CHANGED
@@ -30,11 +30,13 @@ __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,
36
37
  getAzureVoiceCatalogMetadata: () => getAzureVoiceCatalogMetadata,
37
38
  getBuiltInVoiceCatalogMetadata: () => getBuiltInVoiceCatalogMetadata,
39
+ getSsmlSourceMap: () => getSsmlSourceMap,
38
40
  isValidAzureAudioDuration: () => isValidAzureAudioDuration,
39
41
  mapSsmlTextNodes: () => mapSsmlTextNodes,
40
42
  normalizeAzureLanguage: () => normalizeAzureLanguage,
@@ -973,6 +975,236 @@ function buildPartialSsml(textOrOptions, context) {
973
975
  return serializePartialSsml(textOrOptions.text, textOrOptions);
974
976
  }
975
977
 
978
+ // packages/ssml-core/src/textNodes.ts
979
+ function decodeXmlText(value) {
980
+ return value.replace(/&(?:amp|apos|gt|lt|quot);|&#(?:x[\da-f]+|\d+);/gi, (entity) => {
981
+ if (entity === "&") return "&";
982
+ if (entity === "'") return "'";
983
+ if (entity === ">") return ">";
984
+ if (entity === "&lt;") return "<";
985
+ if (entity === "&quot;") return '"';
986
+ const hexadecimal = entity.toLowerCase().startsWith("&#x");
987
+ const digits = entity.slice(hexadecimal ? 3 : 2, -1);
988
+ return String.fromCodePoint(Number.parseInt(digits, hexadecimal ? 16 : 10));
989
+ });
990
+ }
991
+ function encodeXmlText(value) {
992
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
993
+ }
994
+ function decodeXmlAttribute(value) {
995
+ return decodeXmlText(value);
996
+ }
997
+ function findTagEnd(source, start) {
998
+ let quote = "";
999
+ for (let index = start; index < source.length; index += 1) {
1000
+ const character = source[index];
1001
+ if (quote) {
1002
+ if (character === quote) quote = "";
1003
+ } else if (character === '"' || character === "'") {
1004
+ quote = character;
1005
+ } else if (character === ">") {
1006
+ return index;
1007
+ }
1008
+ }
1009
+ return source.length - 1;
1010
+ }
1011
+ function readTagName(tag) {
1012
+ const match = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(tag);
1013
+ return match?.[1];
1014
+ }
1015
+ function readTagAttributes(tag, name) {
1016
+ const attributes = {};
1017
+ const nameStart = tag.indexOf(name);
1018
+ const attributeSource = tag.slice(nameStart + name.length, tag.length - 1).replace(/\/\s*$/, "");
1019
+ const attributePattern = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])([\s\S]*?)\2/g;
1020
+ for (const match of attributeSource.matchAll(attributePattern)) {
1021
+ attributes[match[1].toLowerCase()] = decodeXmlAttribute(match[3]);
1022
+ }
1023
+ return attributes;
1024
+ }
1025
+ function collectTextNodes(source) {
1026
+ const nodes = [];
1027
+ const elements = [];
1028
+ let index = 0;
1029
+ const addText = (start, end, rawText, sourceStart = start, sourceEnd = end) => {
1030
+ if (!rawText) return;
1031
+ const path = elements.map((element) => element.name);
1032
+ const parent = elements[elements.length - 1];
1033
+ nodes.push({
1034
+ context: {
1035
+ ancestorTags: path.slice(0, -1),
1036
+ parentAttributes: { ...parent?.attributes ?? {} },
1037
+ parentTag: parent?.name ?? "",
1038
+ path
1039
+ },
1040
+ decodedText: decodeXmlText(rawText),
1041
+ end,
1042
+ sourceEnd,
1043
+ sourceStart,
1044
+ start
1045
+ });
1046
+ };
1047
+ while (index < source.length) {
1048
+ if (source[index] !== "<") {
1049
+ const nextTag = source.indexOf("<", index);
1050
+ const end2 = nextTag === -1 ? source.length : nextTag;
1051
+ addText(index, end2, source.slice(index, end2));
1052
+ index = end2;
1053
+ continue;
1054
+ }
1055
+ if (source.startsWith("<!--", index)) {
1056
+ const end2 = source.indexOf("-->", index + 4);
1057
+ index = end2 === -1 ? source.length : end2 + 3;
1058
+ continue;
1059
+ }
1060
+ if (source.startsWith("<![CDATA[", index)) {
1061
+ const contentStart = index + 9;
1062
+ const end2 = source.indexOf("]]>", contentStart);
1063
+ const contentEnd = end2 === -1 ? source.length : end2;
1064
+ addText(
1065
+ contentStart,
1066
+ contentEnd,
1067
+ source.slice(contentStart, contentEnd),
1068
+ index,
1069
+ end2 === -1 ? source.length : end2 + 3
1070
+ );
1071
+ index = end2 === -1 ? source.length : end2 + 3;
1072
+ continue;
1073
+ }
1074
+ if (source.startsWith("<?", index)) {
1075
+ const end2 = source.indexOf("?>", index + 2);
1076
+ index = end2 === -1 ? source.length : end2 + 2;
1077
+ continue;
1078
+ }
1079
+ if (source.startsWith("</", index)) {
1080
+ const end2 = findTagEnd(source, index + 2);
1081
+ elements.pop();
1082
+ index = end2 + 1;
1083
+ continue;
1084
+ }
1085
+ const end = findTagEnd(source, index + 1);
1086
+ const tag = source.slice(index, end + 1);
1087
+ const name = readTagName(tag);
1088
+ if (name && !/\/\s*>$/.test(tag)) elements.push({ attributes: readTagAttributes(tag, name), name });
1089
+ index = end + 1;
1090
+ }
1091
+ return nodes;
1092
+ }
1093
+ function collectSourceMap(source) {
1094
+ const segments = [];
1095
+ const markers = [];
1096
+ const elements = [];
1097
+ let textOffset = 0;
1098
+ let index = 0;
1099
+ const textParts = [];
1100
+ const addText = (value) => {
1101
+ if (!value) return;
1102
+ const parent = elements[elements.length - 1];
1103
+ if (parent) parent.nextChildIndex += 1;
1104
+ const sourceNodePath = parent?.path ?? ["speak"];
1105
+ const start = textOffset;
1106
+ textOffset += value.length;
1107
+ textParts.push(value);
1108
+ segments.push({ text: value, range: { start, end: textOffset }, sourceNodePath: [...sourceNodePath] });
1109
+ };
1110
+ while (index < source.length) {
1111
+ if (source[index] !== "<") {
1112
+ const end2 = source.indexOf("<", index);
1113
+ const textEnd = end2 === -1 ? source.length : end2;
1114
+ addText(decodeXmlText(source.slice(index, textEnd)));
1115
+ index = textEnd;
1116
+ continue;
1117
+ }
1118
+ if (source.startsWith("<!--", index)) {
1119
+ const end2 = source.indexOf("-->", index + 4);
1120
+ index = end2 === -1 ? source.length : end2 + 3;
1121
+ continue;
1122
+ }
1123
+ if (source.startsWith("<![CDATA[", index)) {
1124
+ const contentStart = index + 9;
1125
+ const end2 = source.indexOf("]]>", contentStart);
1126
+ const contentEnd = end2 === -1 ? source.length : end2;
1127
+ addText(source.slice(contentStart, contentEnd));
1128
+ index = end2 === -1 ? source.length : end2 + 3;
1129
+ continue;
1130
+ }
1131
+ if (source.startsWith("<?", index)) {
1132
+ const end2 = source.indexOf("?>", index + 2);
1133
+ index = end2 === -1 ? source.length : end2 + 2;
1134
+ continue;
1135
+ }
1136
+ const end = findTagEnd(source, index + 1);
1137
+ const rawTag = source.slice(index, end + 1);
1138
+ if (rawTag.startsWith("</")) {
1139
+ elements.pop();
1140
+ index = end + 1;
1141
+ continue;
1142
+ }
1143
+ const name = readTagName(rawTag);
1144
+ if (!name) {
1145
+ index = end + 1;
1146
+ continue;
1147
+ }
1148
+ const parent = elements[elements.length - 1];
1149
+ const childIndex = parent?.nextChildIndex ?? 0;
1150
+ if (parent) parent.nextChildIndex += 1;
1151
+ const path = parent ? [...parent.path, `${name}[${childIndex}]`] : [name];
1152
+ const attributes = readTagAttributes(rawTag, name);
1153
+ const normalizedName = name.toLowerCase();
1154
+ if (normalizedName === "mark" || normalizedName === "bookmark") {
1155
+ const markerName = attributes[normalizedName === "mark" ? "name" : "mark"];
1156
+ if (markerName) {
1157
+ markers.push({
1158
+ kind: normalizedName,
1159
+ name: markerName,
1160
+ originalTextRange: { start: textOffset, end: textOffset },
1161
+ sourceNodePath: [...path]
1162
+ });
1163
+ }
1164
+ }
1165
+ if (!/\/\s*>$/.test(rawTag)) elements.push({ name, path, nextChildIndex: 0 });
1166
+ index = end + 1;
1167
+ }
1168
+ return { text: textParts.join(""), segments, markers };
1169
+ }
1170
+ function getSsmlSourceMap(ssml) {
1171
+ parseSsml(ssml);
1172
+ return collectSourceMap(ssml);
1173
+ }
1174
+ function extractSsmlText(ssml) {
1175
+ parseSsml(ssml);
1176
+ return collectTextNodes(ssml).map((node) => node.decodedText);
1177
+ }
1178
+ async function mapSsmlTextNodes(ssml, transform, options = {}) {
1179
+ parseSsml(ssml);
1180
+ const nodes = collectTextNodes(ssml);
1181
+ const skipTags = new Set((options.skipTags ?? ["phoneme", "say-as", "sub"]).map((tag) => tag.toLowerCase()));
1182
+ const replacements = await Promise.all(
1183
+ nodes.map(async (node) => {
1184
+ const context = {
1185
+ ancestorTags: [...node.context.ancestorTags],
1186
+ parentAttributes: { ...node.context.parentAttributes },
1187
+ parentTag: node.context.parentTag,
1188
+ path: [...node.context.path]
1189
+ };
1190
+ const shouldTransform = !skipTags.has(context.parentTag.toLowerCase()) && (options.filter?.(context) ?? true);
1191
+ if (!shouldTransform) return ssml.slice(node.sourceStart, node.sourceEnd);
1192
+ const transformed = await transform(node.decodedText, context);
1193
+ if (typeof transformed !== "string") {
1194
+ throw new TypeError("SSML text node transform must return a string");
1195
+ }
1196
+ return transformed === node.decodedText ? ssml.slice(node.sourceStart, node.sourceEnd) : encodeXmlText(transformed);
1197
+ })
1198
+ );
1199
+ let result = "";
1200
+ let cursor = 0;
1201
+ nodes.forEach((node, nodeIndex) => {
1202
+ result += ssml.slice(cursor, node.sourceStart) + replacements[nodeIndex];
1203
+ cursor = node.sourceEnd;
1204
+ });
1205
+ return result + ssml.slice(cursor);
1206
+ }
1207
+
976
1208
  // packages/ssml-core/src/split.ts
977
1209
  var DEFAULT_MAX_LENGTH = 1e4;
978
1210
  function cloneElement(element, children) {
@@ -1076,7 +1308,35 @@ function collectInheritedContext(nodes) {
1076
1308
  nodes.forEach(visit);
1077
1309
  return context;
1078
1310
  }
1079
- function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio) {
1311
+ function elementName(node) {
1312
+ return node.type === "custom" || node.type === "element" ? node.name : node.type;
1313
+ }
1314
+ function findSourceNodePath(nodes, targetOffset) {
1315
+ let textOffset = 0;
1316
+ let firstPath;
1317
+ let foundPath;
1318
+ const visit = (node, path) => {
1319
+ if (typeof node === "string" || node.type === "text") {
1320
+ const text = typeof node === "string" ? node : node.value;
1321
+ if (text && firstPath === void 0) firstPath = [...path];
1322
+ if (text && foundPath === void 0 && targetOffset < textOffset + text.length) foundPath = [...path];
1323
+ textOffset += text.length;
1324
+ return;
1325
+ }
1326
+ node.children?.forEach((child, index) => {
1327
+ const childPath = typeof child === "string" || child.type === "text" ? path : [...path, `${elementName(child)}[${index}]`];
1328
+ visit(child, childPath);
1329
+ });
1330
+ };
1331
+ nodes.forEach((node, index) => {
1332
+ if (!foundPath) {
1333
+ if (typeof node === "string" || node.type === "text") visit(node, ["speak"]);
1334
+ else visit(node, ["speak", `${elementName(node)}[${index}]`]);
1335
+ }
1336
+ });
1337
+ return foundPath ?? firstPath;
1338
+ }
1339
+ function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio, sourceMap, includeEndMarkers) {
1080
1340
  const chunkNodes = backgroundAudio && (replicateBackgroundAudio || chunkIndex === 0) ? [backgroundAudio, ...nodes] : nodes;
1081
1341
  const text = nodes.map(textFromNode).join("");
1082
1342
  const marks = [];
@@ -1091,7 +1351,24 @@ function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, re
1091
1351
  containedMarks: marks,
1092
1352
  hasBackgroundAudio: chunkNodes.some(
1093
1353
  (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1094
- )
1354
+ ),
1355
+ sourceNodePath: findSourceNodePath(document.children ?? [], textStart),
1356
+ sourceTextSegments: sourceMap.segments.filter(({ range }) => range.end > textStart && range.start < textStart + text.length).map((segment) => {
1357
+ const start = Math.max(segment.range.start, textStart);
1358
+ const end = Math.min(segment.range.end, textStart + text.length);
1359
+ return {
1360
+ text: segment.text.slice(start - segment.range.start, end - segment.range.start),
1361
+ range: { start, end },
1362
+ sourceNodePath: [...segment.sourceNodePath]
1363
+ };
1364
+ }),
1365
+ sourceMarkers: sourceMap.markers.filter(
1366
+ ({ originalTextRange }) => originalTextRange.start >= textStart && (originalTextRange.start < textStart + text.length || includeEndMarkers && originalTextRange.start === textStart + text.length)
1367
+ ).map((marker) => ({
1368
+ ...marker,
1369
+ originalTextRange: { ...marker.originalTextRange },
1370
+ sourceNodePath: [...marker.sourceNodePath]
1371
+ }))
1095
1372
  };
1096
1373
  }
1097
1374
  function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
@@ -1101,11 +1378,12 @@ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1101
1378
  throw new RangeError("maxLength must be a positive integer");
1102
1379
  }
1103
1380
  const document = parseSsml(ssml);
1381
+ const sourceMap = getSsmlSourceMap(ssml);
1104
1382
  const backgroundAudio = (document.children ?? []).find(
1105
1383
  (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1106
1384
  );
1107
1385
  if (ssml.length <= resolvedMaxLength) {
1108
- return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true)];
1386
+ return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true, sourceMap, true)];
1109
1387
  }
1110
1388
  const contentChildren = (document.children ?? []).filter((node) => node !== backgroundAudio);
1111
1389
  const plainDocumentLength = documentWithChildren(document, []).length;
@@ -1129,7 +1407,16 @@ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1129
1407
  }
1130
1408
  if (group.length > 0) chunks.push(group);
1131
1409
  if (chunks.length === 0) {
1132
- const result = createChunk(document, [], 0, 0, backgroundAudio, resolvedOptions.replicateBackgroundAudio ?? false);
1410
+ const result = createChunk(
1411
+ document,
1412
+ [],
1413
+ 0,
1414
+ 0,
1415
+ backgroundAudio,
1416
+ resolvedOptions.replicateBackgroundAudio ?? false,
1417
+ sourceMap,
1418
+ true
1419
+ );
1133
1420
  if (result.ssml.length > resolvedMaxLength) {
1134
1421
  throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1135
1422
  }
@@ -1143,7 +1430,9 @@ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1143
1430
  chunkIndex,
1144
1431
  textStart,
1145
1432
  backgroundAudio,
1146
- resolvedOptions.replicateBackgroundAudio ?? false
1433
+ resolvedOptions.replicateBackgroundAudio ?? false,
1434
+ sourceMap,
1435
+ chunkIndex === chunks.length - 1
1147
1436
  );
1148
1437
  textStart = result.originalTextRange.end;
1149
1438
  return result;
@@ -1166,158 +1455,9 @@ function validateSsml(xmlString) {
1166
1455
  }
1167
1456
  }
1168
1457
 
1169
- // packages/ssml-core/src/textNodes.ts
1170
- function decodeXmlText(value) {
1171
- return value.replace(/&(?:amp|apos|gt|lt|quot);|&#(?:x[\da-f]+|\d+);/gi, (entity) => {
1172
- if (entity === "&amp;") return "&";
1173
- if (entity === "&apos;") return "'";
1174
- if (entity === "&gt;") return ">";
1175
- if (entity === "&lt;") return "<";
1176
- if (entity === "&quot;") return '"';
1177
- const hexadecimal = entity.toLowerCase().startsWith("&#x");
1178
- const digits = entity.slice(hexadecimal ? 3 : 2, -1);
1179
- return String.fromCodePoint(Number.parseInt(digits, hexadecimal ? 16 : 10));
1180
- });
1181
- }
1182
- function encodeXmlText(value) {
1183
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1184
- }
1185
- function decodeXmlAttribute(value) {
1186
- return decodeXmlText(value);
1187
- }
1188
- function findTagEnd(source, start) {
1189
- let quote = "";
1190
- for (let index = start; index < source.length; index += 1) {
1191
- const character = source[index];
1192
- if (quote) {
1193
- if (character === quote) quote = "";
1194
- } else if (character === '"' || character === "'") {
1195
- quote = character;
1196
- } else if (character === ">") {
1197
- return index;
1198
- }
1199
- }
1200
- return source.length - 1;
1201
- }
1202
- function readTagName(tag) {
1203
- const match = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(tag);
1204
- return match?.[1];
1205
- }
1206
- function readTagAttributes(tag, name) {
1207
- const attributes = {};
1208
- const nameStart = tag.indexOf(name);
1209
- const attributeSource = tag.slice(nameStart + name.length, tag.length - 1).replace(/\/\s*$/, "");
1210
- const attributePattern = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])([\s\S]*?)\2/g;
1211
- for (const match of attributeSource.matchAll(attributePattern)) {
1212
- attributes[match[1].toLowerCase()] = decodeXmlAttribute(match[3]);
1213
- }
1214
- return attributes;
1215
- }
1216
- function collectTextNodes(source) {
1217
- const nodes = [];
1218
- const elements = [];
1219
- let index = 0;
1220
- const addText = (start, end, rawText, sourceStart = start, sourceEnd = end) => {
1221
- if (!rawText) return;
1222
- const path = elements.map((element) => element.name);
1223
- const parent = elements[elements.length - 1];
1224
- nodes.push({
1225
- context: {
1226
- ancestorTags: path.slice(0, -1),
1227
- parentAttributes: { ...parent?.attributes ?? {} },
1228
- parentTag: parent?.name ?? "",
1229
- path
1230
- },
1231
- decodedText: decodeXmlText(rawText),
1232
- end,
1233
- sourceEnd,
1234
- sourceStart,
1235
- start
1236
- });
1237
- };
1238
- while (index < source.length) {
1239
- if (source[index] !== "<") {
1240
- const nextTag = source.indexOf("<", index);
1241
- const end2 = nextTag === -1 ? source.length : nextTag;
1242
- addText(index, end2, source.slice(index, end2));
1243
- index = end2;
1244
- continue;
1245
- }
1246
- if (source.startsWith("<!--", index)) {
1247
- const end2 = source.indexOf("-->", index + 4);
1248
- index = end2 === -1 ? source.length : end2 + 3;
1249
- continue;
1250
- }
1251
- if (source.startsWith("<![CDATA[", index)) {
1252
- const contentStart = index + 9;
1253
- const end2 = source.indexOf("]]>", contentStart);
1254
- const contentEnd = end2 === -1 ? source.length : end2;
1255
- addText(
1256
- contentStart,
1257
- contentEnd,
1258
- source.slice(contentStart, contentEnd),
1259
- index,
1260
- end2 === -1 ? source.length : end2 + 3
1261
- );
1262
- index = end2 === -1 ? source.length : end2 + 3;
1263
- continue;
1264
- }
1265
- if (source.startsWith("<?", index)) {
1266
- const end2 = source.indexOf("?>", index + 2);
1267
- index = end2 === -1 ? source.length : end2 + 2;
1268
- continue;
1269
- }
1270
- if (source.startsWith("</", index)) {
1271
- const end2 = findTagEnd(source, index + 2);
1272
- elements.pop();
1273
- index = end2 + 1;
1274
- continue;
1275
- }
1276
- const end = findTagEnd(source, index + 1);
1277
- const tag = source.slice(index, end + 1);
1278
- const name = readTagName(tag);
1279
- if (name && !/\/\s*>$/.test(tag)) elements.push({ attributes: readTagAttributes(tag, name), name });
1280
- index = end + 1;
1281
- }
1282
- return nodes;
1283
- }
1284
- function extractSsmlText(ssml) {
1285
- parseSsml(ssml);
1286
- return collectTextNodes(ssml).map((node) => node.decodedText);
1287
- }
1288
- async function mapSsmlTextNodes(ssml, transform, options = {}) {
1289
- parseSsml(ssml);
1290
- const nodes = collectTextNodes(ssml);
1291
- const skipTags = new Set((options.skipTags ?? ["phoneme", "say-as", "sub"]).map((tag) => tag.toLowerCase()));
1292
- const replacements = await Promise.all(
1293
- nodes.map(async (node) => {
1294
- const context = {
1295
- ancestorTags: [...node.context.ancestorTags],
1296
- parentAttributes: { ...node.context.parentAttributes },
1297
- parentTag: node.context.parentTag,
1298
- path: [...node.context.path]
1299
- };
1300
- const shouldTransform = !skipTags.has(context.parentTag.toLowerCase()) && (options.filter?.(context) ?? true);
1301
- if (!shouldTransform) return ssml.slice(node.sourceStart, node.sourceEnd);
1302
- const transformed = await transform(node.decodedText, context);
1303
- if (typeof transformed !== "string") {
1304
- throw new TypeError("SSML text node transform must return a string");
1305
- }
1306
- return transformed === node.decodedText ? ssml.slice(node.sourceStart, node.sourceEnd) : encodeXmlText(transformed);
1307
- })
1308
- );
1309
- let result = "";
1310
- let cursor = 0;
1311
- nodes.forEach((node, nodeIndex) => {
1312
- result += ssml.slice(cursor, node.sourceStart) + replacements[nodeIndex];
1313
- cursor = node.sourceEnd;
1314
- });
1315
- return result + ssml.slice(cursor);
1316
- }
1317
-
1318
1458
  // packages/ssml-core/src/migration.ts
1319
1459
  var DEFAULT_TRANSLATION_SKIP_TAGS = ["phoneme", "say-as", "sayAs", "sub"];
1320
- function elementName(element) {
1460
+ function elementName2(element) {
1321
1461
  switch (element.type) {
1322
1462
  case "custom":
1323
1463
  case "element":
@@ -1470,7 +1610,7 @@ function extractSsmlTranslatableText(ssml, options = {}) {
1470
1610
  }
1471
1611
  return;
1472
1612
  }
1473
- const tag = elementName(node);
1613
+ const tag = elementName2(node);
1474
1614
  if (skipTags.has(tag.toLowerCase())) return;
1475
1615
  visit(childrenOf(node), [...ancestors, tag], [...path, String(index)]);
1476
1616
  });
@@ -1516,7 +1656,7 @@ function serializeDocument2(document) {
1516
1656
  const serialize = (node) => {
1517
1657
  if (typeof node === "string") return node.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1518
1658
  if (node.type === "text") return serialize(node.value);
1519
- const tag = elementName(node);
1659
+ const tag = elementName2(node);
1520
1660
  const nodeAttributes = elementAttributes(node);
1521
1661
  const serializedAttributes = Object.entries(nodeAttributes).map(([name, value]) => ` ${name}="${serialize(value).replace(/"/g, "&quot;")}"`).join("");
1522
1662
  const children = childrenOf(node).map(serialize).join("");
@@ -1530,7 +1670,7 @@ function flatten(document) {
1530
1670
  nodes.forEach((node, index) => {
1531
1671
  if (typeof node === "string" || node.type === "text") return;
1532
1672
  const currentPath = `${path}/${index}`;
1533
- result.push({ name: elementName(node), attributes: elementAttributes(node), path: currentPath });
1673
+ result.push({ name: elementName2(node), attributes: elementAttributes(node), path: currentPath });
1534
1674
  visit(childrenOf(node), currentPath);
1535
1675
  });
1536
1676
  };
@@ -1728,6 +1868,86 @@ var AZURE_VOICE_DEFINITIONS = [
1728
1868
  ];
1729
1869
 
1730
1870
  // packages/ssml-core/src/azureValidation.ts
1871
+ function createAzureUrlValidatorRunner(validator, options = {}) {
1872
+ if (typeof validator !== "function") throw new TypeError("A URL validator function is required.");
1873
+ const concurrency = options.concurrency === void 0 ? Infinity : Number.isFinite(options.concurrency) ? Math.max(1, Math.floor(options.concurrency)) : Infinity;
1874
+ const cache = options.cache ?? /* @__PURE__ */ new Map();
1875
+ const inFlight = /* @__PURE__ */ new Map();
1876
+ const waiters = [];
1877
+ let active = 0;
1878
+ const configuredSignal = options.signal ?? new AbortController().signal;
1879
+ const acquire = async (signal) => {
1880
+ if (signal.aborted) throw new Error("URL validation was aborted.");
1881
+ if (active < concurrency) {
1882
+ active += 1;
1883
+ return;
1884
+ }
1885
+ await new Promise((resolve, reject) => {
1886
+ let waiter;
1887
+ const abortHandler = () => {
1888
+ const index = waiters.indexOf(waiter);
1889
+ if (index >= 0) waiters.splice(index, 1);
1890
+ signal.removeEventListener("abort", abortHandler);
1891
+ reject(new Error("URL validation was aborted."));
1892
+ };
1893
+ signal.addEventListener("abort", abortHandler, { once: true });
1894
+ waiter = () => {
1895
+ signal.removeEventListener("abort", abortHandler);
1896
+ resolve();
1897
+ };
1898
+ waiters.push(waiter);
1899
+ });
1900
+ active += 1;
1901
+ };
1902
+ const release = () => {
1903
+ active -= 1;
1904
+ waiters.shift()?.();
1905
+ };
1906
+ const check = async (url, context, signal = configuredSignal) => {
1907
+ if (signal.aborted) throw new Error("URL validation was aborted.");
1908
+ const key = `${context.tag}:${context.attribute}:${url}`;
1909
+ const cached = cache.get(key);
1910
+ if (cached !== void 0) return cached;
1911
+ const existing = inFlight.get(key);
1912
+ if (existing) return existing;
1913
+ const promise = (async () => {
1914
+ await acquire(signal);
1915
+ try {
1916
+ if (signal.aborted) throw new Error("URL validation was aborted.");
1917
+ const validation = Promise.resolve(validator(url, context, signal));
1918
+ let timer;
1919
+ let abortHandler;
1920
+ const cancellation = new Promise((_resolve, reject) => {
1921
+ abortHandler = () => reject(new Error("URL validation was aborted."));
1922
+ signal.addEventListener("abort", abortHandler, { once: true });
1923
+ if (options.timeoutMs !== void 0 && options.timeoutMs > 0) {
1924
+ timer = setTimeout(
1925
+ () => reject(new Error(`URL validation timed out after ${options.timeoutMs} ms.`)),
1926
+ options.timeoutMs
1927
+ );
1928
+ }
1929
+ });
1930
+ try {
1931
+ const result = await (timer || abortHandler ? Promise.race([validation, cancellation]) : validation);
1932
+ cache.set(key, result);
1933
+ return result;
1934
+ } finally {
1935
+ if (timer) clearTimeout(timer);
1936
+ if (abortHandler) signal.removeEventListener("abort", abortHandler);
1937
+ }
1938
+ } finally {
1939
+ release();
1940
+ }
1941
+ })();
1942
+ inFlight.set(key, promise);
1943
+ try {
1944
+ return await promise;
1945
+ } finally {
1946
+ inFlight.delete(key);
1947
+ }
1948
+ };
1949
+ return (url, context, signal) => check(url, context, signal ?? configuredSignal);
1950
+ }
1731
1951
  var ALLOWED_BREAK_STRENGTHS = /* @__PURE__ */ new Set(["none", "x-weak", "weak", "medium", "strong", "x-strong"]);
1732
1952
  var ALLOWED_SAY_AS = /* @__PURE__ */ new Set([
1733
1953
  "characters",
@@ -2022,23 +2242,23 @@ function validateVoiceFeatureMatrix(token, source, diagnostics, voiceName, defin
2022
2242
  );
2023
2243
  }
2024
2244
  }
2025
- function validateAudioSource(token, source, diagnostics, options, elementName2) {
2245
+ function validateAudioSource(token, source, diagnostics, options, elementName3) {
2026
2246
  const src = attr(token, "src");
2027
2247
  if (!src) {
2028
- addDiagnostic(diagnostics, source, token.start, `<${elementName2}> requires a "src" attribute.`);
2248
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3}> requires a "src" attribute.`);
2029
2249
  return;
2030
2250
  }
2031
2251
  let parsed;
2032
2252
  try {
2033
2253
  parsed = new URL(src);
2034
2254
  } catch {
2035
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must be an absolute HTTP(S) URL.`);
2255
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must be an absolute HTTP(S) URL.`);
2036
2256
  return;
2037
2257
  }
2038
2258
  if (parsed.username || parsed.password)
2039
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must not contain URL credentials.`);
2259
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must not contain URL credentials.`);
2040
2260
  if (parsed.protocol !== "https:" && !(options.allowHttpAudio && parsed.protocol === "http:"))
2041
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must use HTTPS.`);
2261
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must use HTTPS.`);
2042
2262
  const isAllowedOrigin = options.allowedAudioOrigins?.some((allowedOrigin) => {
2043
2263
  try {
2044
2264
  const configured = new URL(allowedOrigin);
@@ -2050,13 +2270,13 @@ function validateAudioSource(token, source, diagnostics, options, elementName2)
2050
2270
  }
2051
2271
  }) ?? false;
2052
2272
  if (options.allowedAudioOrigins && !isAllowedOrigin)
2053
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> origin "${parsed.origin}" is not allowed.`);
2273
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> origin "${parsed.origin}" is not allowed.`);
2054
2274
  else if (!isAllowedOrigin && !options.allowExternalAudio)
2055
2275
  addDiagnostic(
2056
2276
  diagnostics,
2057
2277
  source,
2058
2278
  token.start,
2059
- `<${elementName2} src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`
2279
+ `<${elementName3} src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`
2060
2280
  );
2061
2281
  }
2062
2282
  function validateElement(token, source, diagnostics, voiceName, options, voiceCatalog) {
@@ -2374,6 +2594,15 @@ function validateAzureSsml(ssml, options = {}) {
2374
2594
  const diagnostics = validateAzureSsmlStatic(ssml, options);
2375
2595
  const validator = options.urlValidator ?? options.customUrlValidator;
2376
2596
  if (!validator || typeof ssml !== "string") return diagnostics;
2597
+ const runnerOptions = options.urlValidation ?? {};
2598
+ const boundedValidator = createAzureUrlValidatorRunner(validator, {
2599
+ ...runnerOptions,
2600
+ ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
2601
+ ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
2602
+ ...options.urlValidatorSignal ? { signal: options.urlValidatorSignal } : {},
2603
+ ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
2604
+ });
2605
+ const validationSignal = options.urlValidatorSignal ?? options.urlValidation?.signal ?? new AbortController().signal;
2377
2606
  let tokens;
2378
2607
  try {
2379
2608
  tokens = tokenizeElements(ssml);
@@ -2383,7 +2612,11 @@ function validateAzureSsml(ssml, options = {}) {
2383
2612
  const checks = tokens.flatMap(
2384
2613
  (token) => urlAttributes(token).map(async ({ attribute, value }) => {
2385
2614
  try {
2386
- const result = await validator(value, { tag: token.name, attribute });
2615
+ const result = await boundedValidator(
2616
+ value,
2617
+ { tag: token.name, attribute, ...options.sourceNodePath ? { sourceNodePath: options.sourceNodePath } : {} },
2618
+ validationSignal
2619
+ );
2387
2620
  const valid = typeof result === "boolean" ? result : result.valid;
2388
2621
  if (!valid) {
2389
2622
  const reason = typeof result === "boolean" ? void 0 : result.reason;
@@ -2429,11 +2662,13 @@ var getBuiltInVoiceCatalogMetadata = getAzureVoiceCatalogMetadata;
2429
2662
  areAzureLanguagesEquivalent,
2430
2663
  buildPartialSsml,
2431
2664
  buildSsml,
2665
+ createAzureUrlValidatorRunner,
2432
2666
  extractSsmlText,
2433
2667
  extractSsmlTranslatableText,
2434
2668
  fromPlainTextToSsml,
2435
2669
  getAzureVoiceCatalogMetadata,
2436
2670
  getBuiltInVoiceCatalogMetadata,
2671
+ getSsmlSourceMap,
2437
2672
  isValidAzureAudioDuration,
2438
2673
  mapSsmlTextNodes,
2439
2674
  normalizeAzureLanguage,