ssml-builder-js 2.14.0 → 2.16.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
@@ -36,12 +36,14 @@ __export(core_exports, {
36
36
  fromPlainTextToSsml: () => fromPlainTextToSsml,
37
37
  getAzureVoiceCatalogMetadata: () => getAzureVoiceCatalogMetadata,
38
38
  getBuiltInVoiceCatalogMetadata: () => getBuiltInVoiceCatalogMetadata,
39
+ getSsmlSourceMap: () => getSsmlSourceMap,
39
40
  isValidAzureAudioDuration: () => isValidAzureAudioDuration,
40
41
  mapSsmlTextNodes: () => mapSsmlTextNodes,
41
42
  normalizeAzureLanguage: () => normalizeAzureLanguage,
42
43
  parseSsml: () => parseSsml,
43
44
  splitSsmlDocument: () => splitSsmlDocument,
44
45
  validateAzureSsml: () => validateAzureSsml,
46
+ validateAzureSsmlChunks: () => validateAzureSsmlChunks,
45
47
  validateSsml: () => validateSsml,
46
48
  validateSsmlStructureIntegrity: () => validateSsmlStructureIntegrity
47
49
  });
@@ -974,6 +976,236 @@ function buildPartialSsml(textOrOptions, context) {
974
976
  return serializePartialSsml(textOrOptions.text, textOrOptions);
975
977
  }
976
978
 
979
+ // packages/ssml-core/src/textNodes.ts
980
+ function decodeXmlText(value) {
981
+ return value.replace(/&(?:amp|apos|gt|lt|quot);|&#(?:x[\da-f]+|\d+);/gi, (entity) => {
982
+ if (entity === "&") return "&";
983
+ if (entity === "'") return "'";
984
+ if (entity === ">") return ">";
985
+ if (entity === "&lt;") return "<";
986
+ if (entity === "&quot;") return '"';
987
+ const hexadecimal = entity.toLowerCase().startsWith("&#x");
988
+ const digits = entity.slice(hexadecimal ? 3 : 2, -1);
989
+ return String.fromCodePoint(Number.parseInt(digits, hexadecimal ? 16 : 10));
990
+ });
991
+ }
992
+ function encodeXmlText(value) {
993
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
994
+ }
995
+ function decodeXmlAttribute(value) {
996
+ return decodeXmlText(value);
997
+ }
998
+ function findTagEnd(source, start) {
999
+ let quote = "";
1000
+ for (let index = start; index < source.length; index += 1) {
1001
+ const character = source[index];
1002
+ if (quote) {
1003
+ if (character === quote) quote = "";
1004
+ } else if (character === '"' || character === "'") {
1005
+ quote = character;
1006
+ } else if (character === ">") {
1007
+ return index;
1008
+ }
1009
+ }
1010
+ return source.length - 1;
1011
+ }
1012
+ function readTagName(tag) {
1013
+ const match = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(tag);
1014
+ return match?.[1];
1015
+ }
1016
+ function readTagAttributes(tag, name) {
1017
+ const attributes = {};
1018
+ const nameStart = tag.indexOf(name);
1019
+ const attributeSource = tag.slice(nameStart + name.length, tag.length - 1).replace(/\/\s*$/, "");
1020
+ const attributePattern = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])([\s\S]*?)\2/g;
1021
+ for (const match of attributeSource.matchAll(attributePattern)) {
1022
+ attributes[match[1].toLowerCase()] = decodeXmlAttribute(match[3]);
1023
+ }
1024
+ return attributes;
1025
+ }
1026
+ function collectTextNodes(source) {
1027
+ const nodes = [];
1028
+ const elements = [];
1029
+ let index = 0;
1030
+ const addText = (start, end, rawText, sourceStart = start, sourceEnd = end) => {
1031
+ if (!rawText) return;
1032
+ const path = elements.map((element) => element.name);
1033
+ const parent = elements[elements.length - 1];
1034
+ nodes.push({
1035
+ context: {
1036
+ ancestorTags: path.slice(0, -1),
1037
+ parentAttributes: { ...parent?.attributes ?? {} },
1038
+ parentTag: parent?.name ?? "",
1039
+ path
1040
+ },
1041
+ decodedText: decodeXmlText(rawText),
1042
+ end,
1043
+ sourceEnd,
1044
+ sourceStart,
1045
+ start
1046
+ });
1047
+ };
1048
+ while (index < source.length) {
1049
+ if (source[index] !== "<") {
1050
+ const nextTag = source.indexOf("<", index);
1051
+ const end2 = nextTag === -1 ? source.length : nextTag;
1052
+ addText(index, end2, source.slice(index, end2));
1053
+ index = end2;
1054
+ continue;
1055
+ }
1056
+ if (source.startsWith("<!--", index)) {
1057
+ const end2 = source.indexOf("-->", index + 4);
1058
+ index = end2 === -1 ? source.length : end2 + 3;
1059
+ continue;
1060
+ }
1061
+ if (source.startsWith("<![CDATA[", index)) {
1062
+ const contentStart = index + 9;
1063
+ const end2 = source.indexOf("]]>", contentStart);
1064
+ const contentEnd = end2 === -1 ? source.length : end2;
1065
+ addText(
1066
+ contentStart,
1067
+ contentEnd,
1068
+ source.slice(contentStart, contentEnd),
1069
+ index,
1070
+ end2 === -1 ? source.length : end2 + 3
1071
+ );
1072
+ index = end2 === -1 ? source.length : end2 + 3;
1073
+ continue;
1074
+ }
1075
+ if (source.startsWith("<?", index)) {
1076
+ const end2 = source.indexOf("?>", index + 2);
1077
+ index = end2 === -1 ? source.length : end2 + 2;
1078
+ continue;
1079
+ }
1080
+ if (source.startsWith("</", index)) {
1081
+ const end2 = findTagEnd(source, index + 2);
1082
+ elements.pop();
1083
+ index = end2 + 1;
1084
+ continue;
1085
+ }
1086
+ const end = findTagEnd(source, index + 1);
1087
+ const tag = source.slice(index, end + 1);
1088
+ const name = readTagName(tag);
1089
+ if (name && !/\/\s*>$/.test(tag)) elements.push({ attributes: readTagAttributes(tag, name), name });
1090
+ index = end + 1;
1091
+ }
1092
+ return nodes;
1093
+ }
1094
+ function collectSourceMap(source) {
1095
+ const segments = [];
1096
+ const markers = [];
1097
+ const elements = [];
1098
+ let textOffset = 0;
1099
+ let index = 0;
1100
+ const textParts = [];
1101
+ const addText = (value) => {
1102
+ if (!value) return;
1103
+ const parent = elements[elements.length - 1];
1104
+ if (parent) parent.nextChildIndex += 1;
1105
+ const sourceNodePath = parent?.path ?? ["speak"];
1106
+ const start = textOffset;
1107
+ textOffset += value.length;
1108
+ textParts.push(value);
1109
+ segments.push({ text: value, range: { start, end: textOffset }, sourceNodePath: [...sourceNodePath] });
1110
+ };
1111
+ while (index < source.length) {
1112
+ if (source[index] !== "<") {
1113
+ const end2 = source.indexOf("<", index);
1114
+ const textEnd = end2 === -1 ? source.length : end2;
1115
+ addText(decodeXmlText(source.slice(index, textEnd)));
1116
+ index = textEnd;
1117
+ continue;
1118
+ }
1119
+ if (source.startsWith("<!--", index)) {
1120
+ const end2 = source.indexOf("-->", index + 4);
1121
+ index = end2 === -1 ? source.length : end2 + 3;
1122
+ continue;
1123
+ }
1124
+ if (source.startsWith("<![CDATA[", index)) {
1125
+ const contentStart = index + 9;
1126
+ const end2 = source.indexOf("]]>", contentStart);
1127
+ const contentEnd = end2 === -1 ? source.length : end2;
1128
+ addText(source.slice(contentStart, contentEnd));
1129
+ index = end2 === -1 ? source.length : end2 + 3;
1130
+ continue;
1131
+ }
1132
+ if (source.startsWith("<?", index)) {
1133
+ const end2 = source.indexOf("?>", index + 2);
1134
+ index = end2 === -1 ? source.length : end2 + 2;
1135
+ continue;
1136
+ }
1137
+ const end = findTagEnd(source, index + 1);
1138
+ const rawTag = source.slice(index, end + 1);
1139
+ if (rawTag.startsWith("</")) {
1140
+ elements.pop();
1141
+ index = end + 1;
1142
+ continue;
1143
+ }
1144
+ const name = readTagName(rawTag);
1145
+ if (!name) {
1146
+ index = end + 1;
1147
+ continue;
1148
+ }
1149
+ const parent = elements[elements.length - 1];
1150
+ const childIndex = parent?.nextChildIndex ?? 0;
1151
+ if (parent) parent.nextChildIndex += 1;
1152
+ const path = parent ? [...parent.path, `${name}[${childIndex}]`] : [name];
1153
+ const attributes = readTagAttributes(rawTag, name);
1154
+ const normalizedName = name.toLowerCase();
1155
+ if (normalizedName === "mark" || normalizedName === "bookmark") {
1156
+ const markerName = attributes[normalizedName === "mark" ? "name" : "mark"];
1157
+ if (markerName) {
1158
+ markers.push({
1159
+ kind: normalizedName,
1160
+ name: markerName,
1161
+ originalTextRange: { start: textOffset, end: textOffset },
1162
+ sourceNodePath: [...path]
1163
+ });
1164
+ }
1165
+ }
1166
+ if (!/\/\s*>$/.test(rawTag)) elements.push({ name, path, nextChildIndex: 0 });
1167
+ index = end + 1;
1168
+ }
1169
+ return { text: textParts.join(""), segments, markers };
1170
+ }
1171
+ function getSsmlSourceMap(ssml) {
1172
+ parseSsml(ssml);
1173
+ return collectSourceMap(ssml);
1174
+ }
1175
+ function extractSsmlText(ssml) {
1176
+ parseSsml(ssml);
1177
+ return collectTextNodes(ssml).map((node) => node.decodedText);
1178
+ }
1179
+ async function mapSsmlTextNodes(ssml, transform, options = {}) {
1180
+ parseSsml(ssml);
1181
+ const nodes = collectTextNodes(ssml);
1182
+ const skipTags = new Set((options.skipTags ?? ["phoneme", "say-as", "sub"]).map((tag) => tag.toLowerCase()));
1183
+ const replacements = await Promise.all(
1184
+ nodes.map(async (node) => {
1185
+ const context = {
1186
+ ancestorTags: [...node.context.ancestorTags],
1187
+ parentAttributes: { ...node.context.parentAttributes },
1188
+ parentTag: node.context.parentTag,
1189
+ path: [...node.context.path]
1190
+ };
1191
+ const shouldTransform = !skipTags.has(context.parentTag.toLowerCase()) && (options.filter?.(context) ?? true);
1192
+ if (!shouldTransform) return ssml.slice(node.sourceStart, node.sourceEnd);
1193
+ const transformed = await transform(node.decodedText, context);
1194
+ if (typeof transformed !== "string") {
1195
+ throw new TypeError("SSML text node transform must return a string");
1196
+ }
1197
+ return transformed === node.decodedText ? ssml.slice(node.sourceStart, node.sourceEnd) : encodeXmlText(transformed);
1198
+ })
1199
+ );
1200
+ let result = "";
1201
+ let cursor = 0;
1202
+ nodes.forEach((node, nodeIndex) => {
1203
+ result += ssml.slice(cursor, node.sourceStart) + replacements[nodeIndex];
1204
+ cursor = node.sourceEnd;
1205
+ });
1206
+ return result + ssml.slice(cursor);
1207
+ }
1208
+
977
1209
  // packages/ssml-core/src/split.ts
978
1210
  var DEFAULT_MAX_LENGTH = 1e4;
979
1211
  function cloneElement(element, children) {
@@ -1105,7 +1337,7 @@ function findSourceNodePath(nodes, targetOffset) {
1105
1337
  });
1106
1338
  return foundPath ?? firstPath;
1107
1339
  }
1108
- function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio) {
1340
+ function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio, sourceMap, includeEndMarkers) {
1109
1341
  const chunkNodes = backgroundAudio && (replicateBackgroundAudio || chunkIndex === 0) ? [backgroundAudio, ...nodes] : nodes;
1110
1342
  const text = nodes.map(textFromNode).join("");
1111
1343
  const marks = [];
@@ -1121,7 +1353,23 @@ function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, re
1121
1353
  hasBackgroundAudio: chunkNodes.some(
1122
1354
  (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1123
1355
  ),
1124
- sourceNodePath: findSourceNodePath(document.children ?? [], textStart)
1356
+ sourceNodePath: findSourceNodePath(document.children ?? [], textStart),
1357
+ sourceTextSegments: sourceMap.segments.filter(({ range }) => range.end > textStart && range.start < textStart + text.length).map((segment) => {
1358
+ const start = Math.max(segment.range.start, textStart);
1359
+ const end = Math.min(segment.range.end, textStart + text.length);
1360
+ return {
1361
+ text: segment.text.slice(start - segment.range.start, end - segment.range.start),
1362
+ range: { start, end },
1363
+ sourceNodePath: [...segment.sourceNodePath]
1364
+ };
1365
+ }),
1366
+ sourceMarkers: sourceMap.markers.filter(
1367
+ ({ originalTextRange }) => originalTextRange.start >= textStart && (originalTextRange.start < textStart + text.length || includeEndMarkers && originalTextRange.start === textStart + text.length)
1368
+ ).map((marker) => ({
1369
+ ...marker,
1370
+ originalTextRange: { ...marker.originalTextRange },
1371
+ sourceNodePath: [...marker.sourceNodePath]
1372
+ }))
1125
1373
  };
1126
1374
  }
1127
1375
  function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
@@ -1131,11 +1379,12 @@ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1131
1379
  throw new RangeError("maxLength must be a positive integer");
1132
1380
  }
1133
1381
  const document = parseSsml(ssml);
1382
+ const sourceMap = getSsmlSourceMap(ssml);
1134
1383
  const backgroundAudio = (document.children ?? []).find(
1135
1384
  (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1136
1385
  );
1137
1386
  if (ssml.length <= resolvedMaxLength) {
1138
- return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true)];
1387
+ return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true, sourceMap, true)];
1139
1388
  }
1140
1389
  const contentChildren = (document.children ?? []).filter((node) => node !== backgroundAudio);
1141
1390
  const plainDocumentLength = documentWithChildren(document, []).length;
@@ -1159,7 +1408,16 @@ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1159
1408
  }
1160
1409
  if (group.length > 0) chunks.push(group);
1161
1410
  if (chunks.length === 0) {
1162
- const result = createChunk(document, [], 0, 0, backgroundAudio, resolvedOptions.replicateBackgroundAudio ?? false);
1411
+ const result = createChunk(
1412
+ document,
1413
+ [],
1414
+ 0,
1415
+ 0,
1416
+ backgroundAudio,
1417
+ resolvedOptions.replicateBackgroundAudio ?? false,
1418
+ sourceMap,
1419
+ true
1420
+ );
1163
1421
  if (result.ssml.length > resolvedMaxLength) {
1164
1422
  throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1165
1423
  }
@@ -1173,7 +1431,9 @@ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1173
1431
  chunkIndex,
1174
1432
  textStart,
1175
1433
  backgroundAudio,
1176
- resolvedOptions.replicateBackgroundAudio ?? false
1434
+ resolvedOptions.replicateBackgroundAudio ?? false,
1435
+ sourceMap,
1436
+ chunkIndex === chunks.length - 1
1177
1437
  );
1178
1438
  textStart = result.originalTextRange.end;
1179
1439
  return result;
@@ -1196,155 +1456,6 @@ function validateSsml(xmlString) {
1196
1456
  }
1197
1457
  }
1198
1458
 
1199
- // packages/ssml-core/src/textNodes.ts
1200
- function decodeXmlText(value) {
1201
- return value.replace(/&(?:amp|apos|gt|lt|quot);|&#(?:x[\da-f]+|\d+);/gi, (entity) => {
1202
- if (entity === "&amp;") return "&";
1203
- if (entity === "&apos;") return "'";
1204
- if (entity === "&gt;") return ">";
1205
- if (entity === "&lt;") return "<";
1206
- if (entity === "&quot;") return '"';
1207
- const hexadecimal = entity.toLowerCase().startsWith("&#x");
1208
- const digits = entity.slice(hexadecimal ? 3 : 2, -1);
1209
- return String.fromCodePoint(Number.parseInt(digits, hexadecimal ? 16 : 10));
1210
- });
1211
- }
1212
- function encodeXmlText(value) {
1213
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1214
- }
1215
- function decodeXmlAttribute(value) {
1216
- return decodeXmlText(value);
1217
- }
1218
- function findTagEnd(source, start) {
1219
- let quote = "";
1220
- for (let index = start; index < source.length; index += 1) {
1221
- const character = source[index];
1222
- if (quote) {
1223
- if (character === quote) quote = "";
1224
- } else if (character === '"' || character === "'") {
1225
- quote = character;
1226
- } else if (character === ">") {
1227
- return index;
1228
- }
1229
- }
1230
- return source.length - 1;
1231
- }
1232
- function readTagName(tag) {
1233
- const match = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(tag);
1234
- return match?.[1];
1235
- }
1236
- function readTagAttributes(tag, name) {
1237
- const attributes = {};
1238
- const nameStart = tag.indexOf(name);
1239
- const attributeSource = tag.slice(nameStart + name.length, tag.length - 1).replace(/\/\s*$/, "");
1240
- const attributePattern = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])([\s\S]*?)\2/g;
1241
- for (const match of attributeSource.matchAll(attributePattern)) {
1242
- attributes[match[1].toLowerCase()] = decodeXmlAttribute(match[3]);
1243
- }
1244
- return attributes;
1245
- }
1246
- function collectTextNodes(source) {
1247
- const nodes = [];
1248
- const elements = [];
1249
- let index = 0;
1250
- const addText = (start, end, rawText, sourceStart = start, sourceEnd = end) => {
1251
- if (!rawText) return;
1252
- const path = elements.map((element) => element.name);
1253
- const parent = elements[elements.length - 1];
1254
- nodes.push({
1255
- context: {
1256
- ancestorTags: path.slice(0, -1),
1257
- parentAttributes: { ...parent?.attributes ?? {} },
1258
- parentTag: parent?.name ?? "",
1259
- path
1260
- },
1261
- decodedText: decodeXmlText(rawText),
1262
- end,
1263
- sourceEnd,
1264
- sourceStart,
1265
- start
1266
- });
1267
- };
1268
- while (index < source.length) {
1269
- if (source[index] !== "<") {
1270
- const nextTag = source.indexOf("<", index);
1271
- const end2 = nextTag === -1 ? source.length : nextTag;
1272
- addText(index, end2, source.slice(index, end2));
1273
- index = end2;
1274
- continue;
1275
- }
1276
- if (source.startsWith("<!--", index)) {
1277
- const end2 = source.indexOf("-->", index + 4);
1278
- index = end2 === -1 ? source.length : end2 + 3;
1279
- continue;
1280
- }
1281
- if (source.startsWith("<![CDATA[", index)) {
1282
- const contentStart = index + 9;
1283
- const end2 = source.indexOf("]]>", contentStart);
1284
- const contentEnd = end2 === -1 ? source.length : end2;
1285
- addText(
1286
- contentStart,
1287
- contentEnd,
1288
- source.slice(contentStart, contentEnd),
1289
- index,
1290
- end2 === -1 ? source.length : end2 + 3
1291
- );
1292
- index = end2 === -1 ? source.length : end2 + 3;
1293
- continue;
1294
- }
1295
- if (source.startsWith("<?", index)) {
1296
- const end2 = source.indexOf("?>", index + 2);
1297
- index = end2 === -1 ? source.length : end2 + 2;
1298
- continue;
1299
- }
1300
- if (source.startsWith("</", index)) {
1301
- const end2 = findTagEnd(source, index + 2);
1302
- elements.pop();
1303
- index = end2 + 1;
1304
- continue;
1305
- }
1306
- const end = findTagEnd(source, index + 1);
1307
- const tag = source.slice(index, end + 1);
1308
- const name = readTagName(tag);
1309
- if (name && !/\/\s*>$/.test(tag)) elements.push({ attributes: readTagAttributes(tag, name), name });
1310
- index = end + 1;
1311
- }
1312
- return nodes;
1313
- }
1314
- function extractSsmlText(ssml) {
1315
- parseSsml(ssml);
1316
- return collectTextNodes(ssml).map((node) => node.decodedText);
1317
- }
1318
- async function mapSsmlTextNodes(ssml, transform, options = {}) {
1319
- parseSsml(ssml);
1320
- const nodes = collectTextNodes(ssml);
1321
- const skipTags = new Set((options.skipTags ?? ["phoneme", "say-as", "sub"]).map((tag) => tag.toLowerCase()));
1322
- const replacements = await Promise.all(
1323
- nodes.map(async (node) => {
1324
- const context = {
1325
- ancestorTags: [...node.context.ancestorTags],
1326
- parentAttributes: { ...node.context.parentAttributes },
1327
- parentTag: node.context.parentTag,
1328
- path: [...node.context.path]
1329
- };
1330
- const shouldTransform = !skipTags.has(context.parentTag.toLowerCase()) && (options.filter?.(context) ?? true);
1331
- if (!shouldTransform) return ssml.slice(node.sourceStart, node.sourceEnd);
1332
- const transformed = await transform(node.decodedText, context);
1333
- if (typeof transformed !== "string") {
1334
- throw new TypeError("SSML text node transform must return a string");
1335
- }
1336
- return transformed === node.decodedText ? ssml.slice(node.sourceStart, node.sourceEnd) : encodeXmlText(transformed);
1337
- })
1338
- );
1339
- let result = "";
1340
- let cursor = 0;
1341
- nodes.forEach((node, nodeIndex) => {
1342
- result += ssml.slice(cursor, node.sourceStart) + replacements[nodeIndex];
1343
- cursor = node.sourceEnd;
1344
- });
1345
- return result + ssml.slice(cursor);
1346
- }
1347
-
1348
1459
  // packages/ssml-core/src/migration.ts
1349
1460
  var DEFAULT_TRANSLATION_SKIP_TAGS = ["phoneme", "say-as", "sayAs", "sub"];
1350
1461
  function elementName2(element) {
@@ -1765,34 +1876,51 @@ function createAzureUrlValidatorRunner(validator, options = {}) {
1765
1876
  const inFlight = /* @__PURE__ */ new Map();
1766
1877
  const waiters = [];
1767
1878
  let active = 0;
1768
- const acquire = async () => {
1879
+ const configuredSignal = options.signal ?? new AbortController().signal;
1880
+ const acquire = async (signal) => {
1881
+ if (signal.aborted) throw new Error("URL validation was aborted.");
1769
1882
  if (active < concurrency) {
1770
1883
  active += 1;
1771
1884
  return;
1772
1885
  }
1773
- await new Promise((resolve) => waiters.push(resolve));
1886
+ await new Promise((resolve, reject) => {
1887
+ let waiter;
1888
+ const abortHandler = () => {
1889
+ const index = waiters.indexOf(waiter);
1890
+ if (index >= 0) waiters.splice(index, 1);
1891
+ signal.removeEventListener("abort", abortHandler);
1892
+ reject(new Error("URL validation was aborted."));
1893
+ };
1894
+ signal.addEventListener("abort", abortHandler, { once: true });
1895
+ waiter = () => {
1896
+ signal.removeEventListener("abort", abortHandler);
1897
+ resolve();
1898
+ };
1899
+ waiters.push(waiter);
1900
+ });
1774
1901
  active += 1;
1775
1902
  };
1776
1903
  const release = () => {
1777
1904
  active -= 1;
1778
1905
  waiters.shift()?.();
1779
1906
  };
1780
- const check = async (url, context) => {
1781
- if (options.signal?.aborted) throw new Error("URL validation was aborted.");
1782
- const cached = cache.get(url);
1907
+ const check = async (url, context, signal = configuredSignal) => {
1908
+ if (signal.aborted) throw new Error("URL validation was aborted.");
1909
+ const key = `${context.tag}:${context.attribute}:${url}`;
1910
+ const cached = cache.get(key);
1783
1911
  if (cached !== void 0) return cached;
1784
- const existing = inFlight.get(url);
1912
+ const existing = inFlight.get(key);
1785
1913
  if (existing) return existing;
1786
1914
  const promise = (async () => {
1787
- await acquire();
1915
+ await acquire(signal);
1788
1916
  try {
1789
- if (options.signal?.aborted) throw new Error("URL validation was aborted.");
1790
- const validation = Promise.resolve(validator(url, context));
1917
+ if (signal.aborted) throw new Error("URL validation was aborted.");
1918
+ const validation = Promise.resolve(validator(url, context, signal));
1791
1919
  let timer;
1792
1920
  let abortHandler;
1793
1921
  const cancellation = new Promise((_resolve, reject) => {
1794
1922
  abortHandler = () => reject(new Error("URL validation was aborted."));
1795
- options.signal?.addEventListener("abort", abortHandler, { once: true });
1923
+ signal.addEventListener("abort", abortHandler, { once: true });
1796
1924
  if (options.timeoutMs !== void 0 && options.timeoutMs > 0) {
1797
1925
  timer = setTimeout(
1798
1926
  () => reject(new Error(`URL validation timed out after ${options.timeoutMs} ms.`)),
@@ -1802,24 +1930,24 @@ function createAzureUrlValidatorRunner(validator, options = {}) {
1802
1930
  });
1803
1931
  try {
1804
1932
  const result = await (timer || abortHandler ? Promise.race([validation, cancellation]) : validation);
1805
- cache.set(url, result);
1933
+ cache.set(key, result);
1806
1934
  return result;
1807
1935
  } finally {
1808
1936
  if (timer) clearTimeout(timer);
1809
- if (abortHandler) options.signal?.removeEventListener("abort", abortHandler);
1937
+ if (abortHandler) signal.removeEventListener("abort", abortHandler);
1810
1938
  }
1811
1939
  } finally {
1812
1940
  release();
1813
1941
  }
1814
1942
  })();
1815
- inFlight.set(url, promise);
1943
+ inFlight.set(key, promise);
1816
1944
  try {
1817
1945
  return await promise;
1818
1946
  } finally {
1819
- inFlight.delete(url);
1947
+ inFlight.delete(key);
1820
1948
  }
1821
1949
  };
1822
- return (url, context) => check(url, context);
1950
+ return (url, context, signal) => check(url, context, signal ?? configuredSignal);
1823
1951
  }
1824
1952
  var ALLOWED_BREAK_STRENGTHS = /* @__PURE__ */ new Set(["none", "x-weak", "weak", "medium", "strong", "x-strong"]);
1825
1953
  var ALLOWED_SAY_AS = /* @__PURE__ */ new Set([
@@ -1932,6 +2060,7 @@ function tokenizeElements(source) {
1932
2060
  if (parent) parent.childElementCount += 1;
1933
2061
  const parentVoiceName = [...openElements].reverse().find((element) => element.voiceName)?.voiceName;
1934
2062
  const tokenName = nameMatch[1];
2063
+ const path = parent ? [...parent.path, `${tokenName}[${childElementIndex ?? 0}]`] : [tokenName];
1935
2064
  const tokenVoiceName = tokenName.toLowerCase() === "voice" ? attributes.get("name") : tokenName.toLowerCase() === "mstts:turn" ? attributes.get("voice") ?? parentVoiceName : parentVoiceName;
1936
2065
  tokens.push({
1937
2066
  attributes,
@@ -1942,12 +2071,14 @@ function tokenizeElements(source) {
1942
2071
  parentName: parent?.name,
1943
2072
  parentVoiceName,
1944
2073
  selfClosing,
1945
- start
2074
+ start,
2075
+ path
1946
2076
  });
1947
2077
  if (!selfClosing) {
1948
2078
  openElements.push({
1949
2079
  childElementCount: 0,
1950
2080
  name: tokenName,
2081
+ path,
1951
2082
  voiceName: tokenVoiceName
1952
2083
  });
1953
2084
  }
@@ -1960,13 +2091,14 @@ function location(source, offset) {
1960
2091
  const line = before.split("\n").length;
1961
2092
  return { line, column: before.length - (before.lastIndexOf("\n") + 1) + 1 };
1962
2093
  }
1963
- function addDiagnostic(diagnostics, source, offset, message, severity = "error", code) {
2094
+ function addDiagnostic(diagnostics, source, offset, message, severity = "error", code, metadata = {}) {
1964
2095
  diagnostics.push({
1965
2096
  ...location(source, offset),
1966
2097
  message,
1967
2098
  severity,
1968
2099
  source: "ssml-static-validator",
1969
- ...code ? { code } : {}
2100
+ ...code ? { code } : {},
2101
+ ...metadata
1970
2102
  });
1971
2103
  }
1972
2104
  function isSupportedProsodyRate(value) {
@@ -2439,9 +2571,11 @@ function validateAzureSsmlStatic(ssml, options = {}) {
2439
2571
  for (const token of tokens) {
2440
2572
  const tokenName = token.name.toLowerCase();
2441
2573
  const tokenVoiceName = tokenName === "voice" ? attr(token, "name")?.trim() : tokenName === "mstts:turn" ? attr(token, "voice")?.trim() || token.parentVoiceName : options.validateNestedVoices === false ? voiceName : token.parentVoiceName;
2574
+ const tokenDiagnosticStart = diagnostics.length;
2442
2575
  validateElement(token, ssml, diagnostics, tokenVoiceName, options, voiceCatalog);
2443
2576
  const definition = tokenVoiceName ? voiceCatalog.get(tokenVoiceName.toLowerCase()) : void 0;
2444
2577
  validateVoiceFeatureMatrix(token, ssml, diagnostics, tokenVoiceName, definition);
2578
+ annotateTokenDiagnostics(diagnostics, tokenDiagnosticStart, token, options, tokenVoiceName);
2445
2579
  if (tokenName === "voice" && options.model && definition?.models && !definition.models.some((model) => model.toLowerCase() === options.model?.toLowerCase())) {
2446
2580
  addDiagnostic(
2447
2581
  diagnostics,
@@ -2463,18 +2597,37 @@ function urlAttributes(token) {
2463
2597
  return value === void 0 ? [] : [{ attribute, value }];
2464
2598
  });
2465
2599
  }
2600
+ function annotateTokenDiagnostics(diagnostics, startIndex, token, options, voiceName) {
2601
+ const attributes = [...token.attributes.keys()];
2602
+ for (const diagnostic of diagnostics.slice(startIndex)) {
2603
+ const attributeName = attributes.find(
2604
+ (attribute) => new RegExp(`(?:<[^> ]+\\s+|")${attribute}(?:"|>|\\s)`, "i").test(diagnostic.message)
2605
+ );
2606
+ const nodePath = options.sourceNodePath ? [...options.sourceNodePath] : [...token.path];
2607
+ Object.assign(diagnostic, {
2608
+ range: { start: token.start, end: token.end + 1 },
2609
+ tagName: token.name,
2610
+ ...attributeName ? { attributeName } : {},
2611
+ ...voiceName ? { voiceName } : {},
2612
+ ...options.chunkIndex !== void 0 ? { chunkIndex: options.chunkIndex } : {},
2613
+ nodePath,
2614
+ targetNodePath: [...token.path]
2615
+ });
2616
+ }
2617
+ }
2466
2618
  function validateAzureSsml(ssml, options = {}) {
2467
2619
  const diagnostics = validateAzureSsmlStatic(ssml, options);
2468
2620
  const validator = options.urlValidator ?? options.customUrlValidator;
2469
2621
  if (!validator || typeof ssml !== "string") return diagnostics;
2470
2622
  const runnerOptions = options.urlValidation ?? {};
2471
- const boundedValidator = createAzureUrlValidatorRunner(validator, {
2623
+ const boundedValidator = options.urlValidatorRunner ?? createAzureUrlValidatorRunner(validator, {
2472
2624
  ...runnerOptions,
2473
2625
  ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
2474
2626
  ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
2475
2627
  ...options.urlValidatorSignal ? { signal: options.urlValidatorSignal } : {},
2476
2628
  ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
2477
2629
  });
2630
+ const validationSignal = options.urlValidatorSignal ?? options.urlValidation?.signal ?? new AbortController().signal;
2478
2631
  let tokens;
2479
2632
  try {
2480
2633
  tokens = tokenizeElements(ssml);
@@ -2484,30 +2637,54 @@ function validateAzureSsml(ssml, options = {}) {
2484
2637
  const checks = tokens.flatMap(
2485
2638
  (token) => urlAttributes(token).map(async ({ attribute, value }) => {
2486
2639
  try {
2487
- const result = await boundedValidator(value, { tag: token.name, attribute });
2640
+ const result = await boundedValidator(
2641
+ value,
2642
+ { tag: token.name, attribute, ...options.sourceNodePath ? { sourceNodePath: options.sourceNodePath } : {} },
2643
+ validationSignal
2644
+ );
2488
2645
  const valid = typeof result === "boolean" ? result : result.valid;
2489
2646
  if (!valid) {
2490
2647
  const reason = typeof result === "boolean" ? void 0 : result.reason;
2648
+ const diagnosticStart = diagnostics.length;
2491
2649
  addDiagnostic(
2492
2650
  diagnostics,
2493
2651
  ssml,
2494
2652
  token.start,
2495
2653
  `<${token.name} ${attribute}> was rejected by the custom URL validator${reason ? `: ${reason}` : "."}`
2496
2654
  );
2655
+ annotateTokenDiagnostics(diagnostics, diagnosticStart, token, options, token.parentVoiceName);
2497
2656
  }
2498
2657
  } catch (error) {
2499
2658
  const reason = error instanceof Error ? error.message : String(error);
2659
+ const diagnosticStart = diagnostics.length;
2500
2660
  addDiagnostic(
2501
2661
  diagnostics,
2502
2662
  ssml,
2503
2663
  token.start,
2504
2664
  `<${token.name} ${attribute}> could not be validated by the custom URL validator: ${reason}`
2505
2665
  );
2666
+ annotateTokenDiagnostics(diagnostics, diagnosticStart, token, options, token.parentVoiceName);
2506
2667
  }
2507
2668
  })
2508
2669
  );
2509
2670
  return Promise.all(checks).then(() => diagnostics);
2510
2671
  }
2672
+ async function validateAzureSsmlChunks(chunks, options = {}) {
2673
+ const validator = options.urlValidator ?? options.customUrlValidator;
2674
+ const sharedOptions = validator ? {
2675
+ ...options,
2676
+ urlValidatorRunner: options.urlValidatorRunner ?? createAzureUrlValidatorRunner(validator, {
2677
+ ...options.urlValidation ?? {},
2678
+ ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
2679
+ ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
2680
+ ...options.urlValidatorSignal ? { signal: options.urlValidatorSignal } : {},
2681
+ ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
2682
+ })
2683
+ } : options;
2684
+ return Promise.all(
2685
+ chunks.map((chunk, chunkIndex) => Promise.resolve(validateAzureSsml(chunk, { ...sharedOptions, chunkIndex })))
2686
+ );
2687
+ }
2511
2688
 
2512
2689
  // packages/ssml-core/src/generated/azureVoiceCatalog.ts
2513
2690
  var AZURE_VOICE_CATALOG_METADATA = {
@@ -2536,12 +2713,14 @@ var getBuiltInVoiceCatalogMetadata = getAzureVoiceCatalogMetadata;
2536
2713
  fromPlainTextToSsml,
2537
2714
  getAzureVoiceCatalogMetadata,
2538
2715
  getBuiltInVoiceCatalogMetadata,
2716
+ getSsmlSourceMap,
2539
2717
  isValidAzureAudioDuration,
2540
2718
  mapSsmlTextNodes,
2541
2719
  normalizeAzureLanguage,
2542
2720
  parseSsml,
2543
2721
  splitSsmlDocument,
2544
2722
  validateAzureSsml,
2723
+ validateAzureSsmlChunks,
2545
2724
  validateSsml,
2546
2725
  validateSsmlStructureIntegrity
2547
2726
  });