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/README.md CHANGED
@@ -240,7 +240,7 @@ const diagnostics = validateAzureSsml(ssml, {
240
240
  });
241
241
  ```
242
242
 
243
- `audio`、`mstts:backgroundaudio`、`lexicon`、`mstts:voiceconversion` の URL は `urlValidator`(または `customUrlValidator`)へ渡せます。Promise を返す検証関数で DNS 解決後のプライベート IP 遮断などを実装できます。
243
+ `audio`、`mstts:backgroundaudio`、`lexicon`、`mstts:voiceconversion` の URL は `urlValidator`(または `customUrlValidator`)へ渡せます。コールバックは `(url, context, signal)` を受け取り、Promise を返す検証関数で DNS 解決後のプライベート IP 遮断などを実装できます。検証キャッシュはタグ・属性・URL ごとに分離されます。
244
244
 
245
245
  `maxXmlDepth` は `<speak>` を深さ 1 として XML の過剰なネストを検出します。長文は `splitSsmlDocument` で `<p>`/`<s>` と親の `voice`、`prosody` コンテキストを保ったまま分割できます。
246
246
 
@@ -342,6 +342,7 @@ export function App() {
342
342
  - `customInsertions` / `additionalInsertions`: カスタム SSML 挿入定義。`customInsertions` は同じ ID の標準定義を置き換え、`additionalInsertions` は標準定義へ追加します
343
343
  - `customInspectors`: Visual Editor のタグ名ごとに Inspector を差し替えるレンダラー
344
344
  - `renderVoiceSelector`: 音声セレクターのカスタムレンダラー。`voiceCatalog` と `voiceLocale`、`voiceRegion`、`voiceStyle` で候補を絞り込めます。プレビュー音声にはバッジ情報も渡されます
345
+ - `voiceModel`(または `model`): 選択中の Azure 音声モデル。音声カタログの対応モデル、SSML タグ、スタイル、ロケールとの不整合を Visual Editor にリアルタイム表示します
345
346
  - `className` / `style`: エディター全体のクラス名とインラインスタイル
346
347
  - `toolbarClassName` / `toolbarStyle`: ツールバーのクラス名とインラインスタイル
347
348
  - `displayClassName` / `displayStyle`: 本文表示エリアのクラス名とインラインスタイル
@@ -708,7 +709,7 @@ const diagnostics = validateAzureSsml(ssml, {
708
709
  });
709
710
  ```
710
711
 
711
- URLs in `audio`, `mstts:backgroundaudio`, `lexicon`, and `mstts:voiceconversion` can be passed to `urlValidator` (or `customUrlValidator`). The callback may be asynchronous, making it suitable for host-side DNS/private-IP and SSRF policy checks.
712
+ URLs in `audio`, `mstts:backgroundaudio`, `lexicon`, and `mstts:voiceconversion` can be passed to `urlValidator` (or `customUrlValidator`). The callback receives `(url, context, signal)` and may be asynchronous, making it suitable for host-side DNS/private-IP and SSRF policy checks. Validation cache entries are isolated by tag, attribute, and URL.
712
713
 
713
714
  The catalog is represented by `AzureVoiceDefinition` with `name`, `locale`, optional `secondaryLocales`, `styles`, `supportedTags`, `unsupportedTags`, `models`, `regions`, and `status`. Pass `voiceDefinitions` (or `voiceCatalog`) to supplement or override the built-in catalog with an external definition. A tag that violates `supportedTags` or `unsupportedTags` produces `azure-unsupported-tag-for-voice` with error severity. `customVoiceStyleMap` remains supported for backward compatibility and overrides styles for the named voice. Diagnostics distinguish an unregistered voice (`azure-unknown-voice`, controlled by `unknownVoicePolicy`), an unsupported style on a registered voice (`azure-unsupported-style`), and a locale mismatch (`azure-locale-mismatch`). The `<mstts:audioduration value="10s"/>` element accepts positive `ms` or `s` values and `hh:mm:ss[.fff]` clock values.
714
715
 
@@ -834,6 +835,7 @@ export function App() {
834
835
  - `customInsertions` / `additionalInsertions`: Custom SSML insertion definitions. `customInsertions` replaces a built-in definition with the same ID, while `additionalInsertions` adds definitions to the built-ins
835
836
  - `customInspectors`: Custom renderers keyed by element type or serialized tag name for the Visual Editor
836
837
  - `renderVoiceSelector`: Custom voice selector renderer. Supply `voiceCatalog` and optional `voiceLocale`, `voiceRegion`, or `voiceStyle` filters; preview status is included in each voice entry
838
+ - `voiceModel` (or `model`): Selected Azure voice model. The Visual Editor reports live mismatches between the catalog's supported models, SSML tags, styles, and locale
837
839
  - `className` / `style`: A class name and inline styles for the editor container
838
840
  - `toolbarClassName` / `toolbarStyle`: A class name and inline styles for the toolbar
839
841
  - `displayClassName` / `displayStyle`: A class name and inline styles for the text display area
@@ -911,13 +913,17 @@ result.bookmarks; // { name, audioOffsetMs }[]
911
913
 
912
914
  長文を分割して合成する場合は `synthesizeSsmlChunks` または `AzureTtsClient.synthesizeChunks` を使います。音声バイナリを連結し、`boundaries`、`visemes`、`bookmarks` のオフセットを累積 `durationMs` 分だけ補正します。`synthesizeSsmlSafe(client, ssml, { validation })` は検証エラー時に Azure API を呼び出さず、`status: "validation-error"` / `"azure-api-error"` / `"success"` の結果を返します。
913
915
 
914
- v2.14.0 では `synthesizeSsmlChunksSafe(client, chunks, options)` が全チャンクを事前検証し、エラー時に `ChunkValidationError.chunkIndex` を返します。`onProgress` には `chunkIndex`、`originalTextRange`、`status`、`durationMs`、`error` が含まれます。`mergeAudioBuffers(buffers, format)` PCM WAV のヘッダーを再構築し、MP3 ID3 タグを除去して結合します。Ogg/WebM など安全に連結できない形式は `UnsupportedMergeFormatError` になります。同期イベントには `chunkIndex`、`sourceNodePath`、`originalTextRange`、`chunkAudioOffsetMs` が付与されます。
916
+ v2.15.0 では `synthesizeSsmlChunksSafe(client, chunks, options)` が全チャンクを事前検証し、`outputFormat`、`signal`、`timeoutMs`、`sourceNodePath` を各合成へ伝播します。`mergeAudioBuffers(buffers, { format })` `mergeSynthesisResults(results, { format })` は形式指定を必須とし、結合結果には `mimeType` が含まれます。Ogg/WebM などは `customMerger` で外部 Muxer に委譲できます。エラーは `validation-error`、`azure-api-error`、`merge-error`、`unsupported-format-error`、`cancelled`、`timeout` の判別可能な `kind` を持ちます。同期イベントは個別の `sourceNodePath` と `originalTextRange` にマッピングされ、URL 検証コールバックには `AbortSignal` が渡されます。
917
+
918
+ v2.16.0 では `concurrency` と `retryOptions`(429/5xx・ネットワーク障害のみ、Jitter 付き指数バックオフ)でチャンク合成を制御できます。`onProgress` には `retryAttempt`、`nextRetryDelayMs`、`isRetrying` が追加され、結合は常に `chunkIndex` 順です。`SsmlSynthesisResult.audioSpec` は WAV/MP3 ヘッダーから音声仕様を抽出し、チャンク間の仕様不一致は `AudioFormatMismatchError` になります。同期イベントには `mappingStatus`、Azure 診断には `nodePath`、`range`、`tagName`、`attributeName`、`voiceName`、`chunkIndex` が含まれます。`validateAzureSsmlChunks` は URL 検証プールを全チャンクで共有し、カスタム結合器には `inputSpecs` と `signal` を渡します。
915
919
 
916
920
  `validateAzureSsml` の `urlValidation` オプションは URL の重複排除、キャッシュ、`concurrency`、`signal`、`timeoutMs` を制御します。
917
921
 
918
922
  For long documents, use `synthesizeSsmlChunks` or `AzureTtsClient.synthesizeChunks`; `onProgress` reports completed chunks while audio and synchronization offsets are merged. `synthesizeSsmlSafe(client, ssml, { validation })` validates before synthesis and returns a discriminated result without calling Azure when static validation fails.
919
923
 
920
- In v2.14.0, `synthesizeSsmlChunksSafe(client, chunks, options)` validates every chunk before contacting Azure and returns a `ChunkValidationError` with its `chunkIndex` when validation fails. `onProgress` events include `chunkIndex`, `originalTextRange`, `status`, `durationMs`, and `error`. `mergeAudioBuffers(buffers, format)` rebuilds PCM WAV headers and strips ID3 tags from MP3 streams; formats such as Ogg and WebM throw `UnsupportedMergeFormatError` because they require re-multiplexing. Synchronization events retain `chunkIndex`, `sourceNodePath`, `originalTextRange`, and `chunkAudioOffsetMs`.
924
+ In v2.15.0, `synthesizeSsmlChunksSafe(client, chunks, options)` validates every chunk before contacting Azure and propagates `outputFormat`, `signal`, `timeoutMs`, and `sourceNodePath` to each synthesis. `mergeAudioBuffers(buffers, { format })` and `mergeSynthesisResults(results, { format })` require an explicit format and merged results expose `mimeType`. Ogg and WebM can be delegated to an external Muxer through `customMerger`. Errors have discriminated `kind` values: `validation-error`, `azure-api-error`, `merge-error`, `unsupported-format-error`, `cancelled`, and `timeout`. Synchronization events receive individual `sourceNodePath` and `originalTextRange` mappings, and URL validators receive an `AbortSignal`.
925
+
926
+ In v2.16.0, use `concurrency` and `retryOptions` to control chunk synthesis; only 429/5xx and network failures are retried with jittered exponential backoff. Progress events include `retryAttempt`, `nextRetryDelayMs`, and `isRetrying`, while merging always follows `chunkIndex` order. `SsmlSynthesisResult.audioSpec` is extracted from WAV/MP3 headers, and incompatible chunk specs throw `AudioFormatMismatchError`. Synchronization events include `mappingStatus`, diagnostics include structured node/range/tag/attribute/voice/chunk fields, `validateAzureSsmlChunks` shares one URL validation pool, and custom mergers receive `inputSpecs` and an `AbortSignal`.
921
927
 
922
928
  The `urlValidation` option of `validateAzureSsml` provides URL deduplication, in-memory caching, bounded `concurrency`, `signal`, and `timeoutMs` controls.
923
929
 
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  buildSsml,
3
3
  parseSsml
4
- } from "./chunk-QFIBPCO4.mjs";
4
+ } from "./chunk-HI74FTKY.mjs";
5
5
  import {
6
6
  __export
7
7
  } from "./chunk-6S5ODO6A.mjs";
@@ -3112,4 +3112,4 @@ export {
3112
3112
  findSsmlHoverTarget,
3113
3113
  formatSsmlHover
3114
3114
  };
3115
- //# sourceMappingURL=chunk-UZSCXPC5.mjs.map
3115
+ //# sourceMappingURL=chunk-F2EMU3HM.mjs.map
@@ -925,6 +925,130 @@ function buildPartialSsml(textOrOptions, context) {
925
925
  }
926
926
  return serializePartialSsml(textOrOptions.text, textOrOptions);
927
927
  }
928
+ function decodeXmlText(value) {
929
+ return value.replace(/&(?:amp|apos|gt|lt|quot);|&#(?:x[\da-f]+|\d+);/gi, (entity) => {
930
+ if (entity === "&amp;") return "&";
931
+ if (entity === "&apos;") return "'";
932
+ if (entity === "&gt;") return ">";
933
+ if (entity === "&lt;") return "<";
934
+ if (entity === "&quot;") return '"';
935
+ const hexadecimal = entity.toLowerCase().startsWith("&#x");
936
+ const digits = entity.slice(hexadecimal ? 3 : 2, -1);
937
+ return String.fromCodePoint(Number.parseInt(digits, hexadecimal ? 16 : 10));
938
+ });
939
+ }
940
+ function decodeXmlAttribute(value) {
941
+ return decodeXmlText(value);
942
+ }
943
+ function findTagEnd(source, start) {
944
+ let quote = "";
945
+ for (let index = start; index < source.length; index += 1) {
946
+ const character = source[index];
947
+ if (quote) {
948
+ if (character === quote) quote = "";
949
+ } else if (character === '"' || character === "'") {
950
+ quote = character;
951
+ } else if (character === ">") {
952
+ return index;
953
+ }
954
+ }
955
+ return source.length - 1;
956
+ }
957
+ function readTagName(tag) {
958
+ const match = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(tag);
959
+ return match?.[1];
960
+ }
961
+ function readTagAttributes(tag, name) {
962
+ const attributes = {};
963
+ const nameStart = tag.indexOf(name);
964
+ const attributeSource = tag.slice(nameStart + name.length, tag.length - 1).replace(/\/\s*$/, "");
965
+ const attributePattern = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])([\s\S]*?)\2/g;
966
+ for (const match of attributeSource.matchAll(attributePattern)) {
967
+ attributes[match[1].toLowerCase()] = decodeXmlAttribute(match[3]);
968
+ }
969
+ return attributes;
970
+ }
971
+ function collectSourceMap(source) {
972
+ const segments = [];
973
+ const markers = [];
974
+ const elements = [];
975
+ let textOffset = 0;
976
+ let index = 0;
977
+ const textParts = [];
978
+ const addText = (value) => {
979
+ if (!value) return;
980
+ const parent = elements[elements.length - 1];
981
+ if (parent) parent.nextChildIndex += 1;
982
+ const sourceNodePath = parent?.path ?? ["speak"];
983
+ const start = textOffset;
984
+ textOffset += value.length;
985
+ textParts.push(value);
986
+ segments.push({ text: value, range: { start, end: textOffset }, sourceNodePath: [...sourceNodePath] });
987
+ };
988
+ while (index < source.length) {
989
+ if (source[index] !== "<") {
990
+ const end2 = source.indexOf("<", index);
991
+ const textEnd = end2 === -1 ? source.length : end2;
992
+ addText(decodeXmlText(source.slice(index, textEnd)));
993
+ index = textEnd;
994
+ continue;
995
+ }
996
+ if (source.startsWith("<!--", index)) {
997
+ const end2 = source.indexOf("-->", index + 4);
998
+ index = end2 === -1 ? source.length : end2 + 3;
999
+ continue;
1000
+ }
1001
+ if (source.startsWith("<![CDATA[", index)) {
1002
+ const contentStart = index + 9;
1003
+ const end2 = source.indexOf("]]>", contentStart);
1004
+ const contentEnd = end2 === -1 ? source.length : end2;
1005
+ addText(source.slice(contentStart, contentEnd));
1006
+ index = end2 === -1 ? source.length : end2 + 3;
1007
+ continue;
1008
+ }
1009
+ if (source.startsWith("<?", index)) {
1010
+ const end2 = source.indexOf("?>", index + 2);
1011
+ index = end2 === -1 ? source.length : end2 + 2;
1012
+ continue;
1013
+ }
1014
+ const end = findTagEnd(source, index + 1);
1015
+ const rawTag = source.slice(index, end + 1);
1016
+ if (rawTag.startsWith("</")) {
1017
+ elements.pop();
1018
+ index = end + 1;
1019
+ continue;
1020
+ }
1021
+ const name = readTagName(rawTag);
1022
+ if (!name) {
1023
+ index = end + 1;
1024
+ continue;
1025
+ }
1026
+ const parent = elements[elements.length - 1];
1027
+ const childIndex = parent?.nextChildIndex ?? 0;
1028
+ if (parent) parent.nextChildIndex += 1;
1029
+ const path = parent ? [...parent.path, `${name}[${childIndex}]`] : [name];
1030
+ const attributes = readTagAttributes(rawTag, name);
1031
+ const normalizedName = name.toLowerCase();
1032
+ if (normalizedName === "mark" || normalizedName === "bookmark") {
1033
+ const markerName = attributes[normalizedName === "mark" ? "name" : "mark"];
1034
+ if (markerName) {
1035
+ markers.push({
1036
+ kind: normalizedName,
1037
+ name: markerName,
1038
+ originalTextRange: { start: textOffset, end: textOffset },
1039
+ sourceNodePath: [...path]
1040
+ });
1041
+ }
1042
+ }
1043
+ if (!/\/\s*>$/.test(rawTag)) elements.push({ name, path, nextChildIndex: 0 });
1044
+ index = end + 1;
1045
+ }
1046
+ return { text: textParts.join(""), segments, markers };
1047
+ }
1048
+ function getSsmlSourceMap(ssml) {
1049
+ parseSsml(ssml);
1050
+ return collectSourceMap(ssml);
1051
+ }
928
1052
  var PARSER_POSITION_SUFFIX = / at position (\d+)$/;
929
1053
  function validateSsml(xmlString) {
930
1054
  try {
@@ -1066,34 +1190,51 @@ function createAzureUrlValidatorRunner(validator, options = {}) {
1066
1190
  const inFlight = /* @__PURE__ */ new Map();
1067
1191
  const waiters = [];
1068
1192
  let active = 0;
1069
- const acquire = async () => {
1193
+ const configuredSignal = options.signal ?? new AbortController().signal;
1194
+ const acquire = async (signal) => {
1195
+ if (signal.aborted) throw new Error("URL validation was aborted.");
1070
1196
  if (active < concurrency) {
1071
1197
  active += 1;
1072
1198
  return;
1073
1199
  }
1074
- await new Promise((resolve) => waiters.push(resolve));
1200
+ await new Promise((resolve, reject) => {
1201
+ let waiter;
1202
+ const abortHandler = () => {
1203
+ const index = waiters.indexOf(waiter);
1204
+ if (index >= 0) waiters.splice(index, 1);
1205
+ signal.removeEventListener("abort", abortHandler);
1206
+ reject(new Error("URL validation was aborted."));
1207
+ };
1208
+ signal.addEventListener("abort", abortHandler, { once: true });
1209
+ waiter = () => {
1210
+ signal.removeEventListener("abort", abortHandler);
1211
+ resolve();
1212
+ };
1213
+ waiters.push(waiter);
1214
+ });
1075
1215
  active += 1;
1076
1216
  };
1077
1217
  const release = () => {
1078
1218
  active -= 1;
1079
1219
  waiters.shift()?.();
1080
1220
  };
1081
- const check = async (url, context) => {
1082
- if (options.signal?.aborted) throw new Error("URL validation was aborted.");
1083
- const cached = cache.get(url);
1221
+ const check = async (url, context, signal = configuredSignal) => {
1222
+ if (signal.aborted) throw new Error("URL validation was aborted.");
1223
+ const key = `${context.tag}:${context.attribute}:${url}`;
1224
+ const cached = cache.get(key);
1084
1225
  if (cached !== void 0) return cached;
1085
- const existing = inFlight.get(url);
1226
+ const existing = inFlight.get(key);
1086
1227
  if (existing) return existing;
1087
1228
  const promise = (async () => {
1088
- await acquire();
1229
+ await acquire(signal);
1089
1230
  try {
1090
- if (options.signal?.aborted) throw new Error("URL validation was aborted.");
1091
- const validation = Promise.resolve(validator(url, context));
1231
+ if (signal.aborted) throw new Error("URL validation was aborted.");
1232
+ const validation = Promise.resolve(validator(url, context, signal));
1092
1233
  let timer;
1093
1234
  let abortHandler;
1094
1235
  const cancellation = new Promise((_resolve, reject) => {
1095
1236
  abortHandler = () => reject(new Error("URL validation was aborted."));
1096
- options.signal?.addEventListener("abort", abortHandler, { once: true });
1237
+ signal.addEventListener("abort", abortHandler, { once: true });
1097
1238
  if (options.timeoutMs !== void 0 && options.timeoutMs > 0) {
1098
1239
  timer = setTimeout(
1099
1240
  () => reject(new Error(`URL validation timed out after ${options.timeoutMs} ms.`)),
@@ -1103,24 +1244,24 @@ function createAzureUrlValidatorRunner(validator, options = {}) {
1103
1244
  });
1104
1245
  try {
1105
1246
  const result = await (timer || abortHandler ? Promise.race([validation, cancellation]) : validation);
1106
- cache.set(url, result);
1247
+ cache.set(key, result);
1107
1248
  return result;
1108
1249
  } finally {
1109
1250
  if (timer) clearTimeout(timer);
1110
- if (abortHandler) options.signal?.removeEventListener("abort", abortHandler);
1251
+ if (abortHandler) signal.removeEventListener("abort", abortHandler);
1111
1252
  }
1112
1253
  } finally {
1113
1254
  release();
1114
1255
  }
1115
1256
  })();
1116
- inFlight.set(url, promise);
1257
+ inFlight.set(key, promise);
1117
1258
  try {
1118
1259
  return await promise;
1119
1260
  } finally {
1120
- inFlight.delete(url);
1261
+ inFlight.delete(key);
1121
1262
  }
1122
1263
  };
1123
- return (url, context) => check(url, context);
1264
+ return (url, context, signal) => check(url, context, signal ?? configuredSignal);
1124
1265
  }
1125
1266
  var ALLOWED_BREAK_STRENGTHS = /* @__PURE__ */ new Set(["none", "x-weak", "weak", "medium", "strong", "x-strong"]);
1126
1267
  var ALLOWED_SAY_AS = /* @__PURE__ */ new Set([
@@ -1233,6 +1374,7 @@ function tokenizeElements(source) {
1233
1374
  if (parent) parent.childElementCount += 1;
1234
1375
  const parentVoiceName = [...openElements].reverse().find((element) => element.voiceName)?.voiceName;
1235
1376
  const tokenName = nameMatch[1];
1377
+ const path = parent ? [...parent.path, `${tokenName}[${childElementIndex ?? 0}]`] : [tokenName];
1236
1378
  const tokenVoiceName = tokenName.toLowerCase() === "voice" ? attributes.get("name") : tokenName.toLowerCase() === "mstts:turn" ? attributes.get("voice") ?? parentVoiceName : parentVoiceName;
1237
1379
  tokens.push({
1238
1380
  attributes,
@@ -1243,12 +1385,14 @@ function tokenizeElements(source) {
1243
1385
  parentName: parent?.name,
1244
1386
  parentVoiceName,
1245
1387
  selfClosing,
1246
- start
1388
+ start,
1389
+ path
1247
1390
  });
1248
1391
  if (!selfClosing) {
1249
1392
  openElements.push({
1250
1393
  childElementCount: 0,
1251
1394
  name: tokenName,
1395
+ path,
1252
1396
  voiceName: tokenVoiceName
1253
1397
  });
1254
1398
  }
@@ -1261,13 +1405,14 @@ function location(source, offset) {
1261
1405
  const line = before.split("\n").length;
1262
1406
  return { line, column: before.length - (before.lastIndexOf("\n") + 1) + 1 };
1263
1407
  }
1264
- function addDiagnostic(diagnostics, source, offset, message, severity = "error", code) {
1408
+ function addDiagnostic(diagnostics, source, offset, message, severity = "error", code, metadata = {}) {
1265
1409
  diagnostics.push({
1266
1410
  ...location(source, offset),
1267
1411
  message,
1268
1412
  severity,
1269
1413
  source: "ssml-static-validator",
1270
- ...code ? { code } : {}
1414
+ ...code ? { code } : {},
1415
+ ...metadata
1271
1416
  });
1272
1417
  }
1273
1418
  function isSupportedProsodyRate(value) {
@@ -1730,9 +1875,11 @@ function validateAzureSsmlStatic(ssml, options = {}) {
1730
1875
  for (const token of tokens) {
1731
1876
  const tokenName = token.name.toLowerCase();
1732
1877
  const tokenVoiceName = tokenName === "voice" ? attr(token, "name")?.trim() : tokenName === "mstts:turn" ? attr(token, "voice")?.trim() || token.parentVoiceName : options.validateNestedVoices === false ? voiceName : token.parentVoiceName;
1878
+ const tokenDiagnosticStart = diagnostics.length;
1733
1879
  validateElement(token, ssml, diagnostics, tokenVoiceName, options, voiceCatalog);
1734
1880
  const definition = tokenVoiceName ? voiceCatalog.get(tokenVoiceName.toLowerCase()) : void 0;
1735
1881
  validateVoiceFeatureMatrix(token, ssml, diagnostics, tokenVoiceName, definition);
1882
+ annotateTokenDiagnostics(diagnostics, tokenDiagnosticStart, token, options, tokenVoiceName);
1736
1883
  if (tokenName === "voice" && options.model && definition?.models && !definition.models.some((model) => model.toLowerCase() === options.model?.toLowerCase())) {
1737
1884
  addDiagnostic(
1738
1885
  diagnostics,
@@ -1754,18 +1901,37 @@ function urlAttributes(token) {
1754
1901
  return value === void 0 ? [] : [{ attribute, value }];
1755
1902
  });
1756
1903
  }
1904
+ function annotateTokenDiagnostics(diagnostics, startIndex, token, options, voiceName) {
1905
+ const attributes = [...token.attributes.keys()];
1906
+ for (const diagnostic of diagnostics.slice(startIndex)) {
1907
+ const attributeName = attributes.find(
1908
+ (attribute) => new RegExp(`(?:<[^> ]+\\s+|")${attribute}(?:"|>|\\s)`, "i").test(diagnostic.message)
1909
+ );
1910
+ const nodePath = options.sourceNodePath ? [...options.sourceNodePath] : [...token.path];
1911
+ Object.assign(diagnostic, {
1912
+ range: { start: token.start, end: token.end + 1 },
1913
+ tagName: token.name,
1914
+ ...attributeName ? { attributeName } : {},
1915
+ ...voiceName ? { voiceName } : {},
1916
+ ...options.chunkIndex !== void 0 ? { chunkIndex: options.chunkIndex } : {},
1917
+ nodePath,
1918
+ targetNodePath: [...token.path]
1919
+ });
1920
+ }
1921
+ }
1757
1922
  function validateAzureSsml(ssml, options = {}) {
1758
1923
  const diagnostics = validateAzureSsmlStatic(ssml, options);
1759
1924
  const validator = options.urlValidator ?? options.customUrlValidator;
1760
1925
  if (!validator || typeof ssml !== "string") return diagnostics;
1761
1926
  const runnerOptions = options.urlValidation ?? {};
1762
- const boundedValidator = createAzureUrlValidatorRunner(validator, {
1927
+ const boundedValidator = options.urlValidatorRunner ?? createAzureUrlValidatorRunner(validator, {
1763
1928
  ...runnerOptions,
1764
1929
  ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
1765
1930
  ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1766
1931
  ...options.urlValidatorSignal ? { signal: options.urlValidatorSignal } : {},
1767
1932
  ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
1768
1933
  });
1934
+ const validationSignal = options.urlValidatorSignal ?? options.urlValidation?.signal ?? new AbortController().signal;
1769
1935
  let tokens;
1770
1936
  try {
1771
1937
  tokens = tokenizeElements(ssml);
@@ -1775,25 +1941,33 @@ function validateAzureSsml(ssml, options = {}) {
1775
1941
  const checks = tokens.flatMap(
1776
1942
  (token) => urlAttributes(token).map(async ({ attribute, value }) => {
1777
1943
  try {
1778
- const result = await boundedValidator(value, { tag: token.name, attribute });
1944
+ const result = await boundedValidator(
1945
+ value,
1946
+ { tag: token.name, attribute, ...options.sourceNodePath ? { sourceNodePath: options.sourceNodePath } : {} },
1947
+ validationSignal
1948
+ );
1779
1949
  const valid = typeof result === "boolean" ? result : result.valid;
1780
1950
  if (!valid) {
1781
1951
  const reason = typeof result === "boolean" ? void 0 : result.reason;
1952
+ const diagnosticStart = diagnostics.length;
1782
1953
  addDiagnostic(
1783
1954
  diagnostics,
1784
1955
  ssml,
1785
1956
  token.start,
1786
1957
  `<${token.name} ${attribute}> was rejected by the custom URL validator${reason ? `: ${reason}` : "."}`
1787
1958
  );
1959
+ annotateTokenDiagnostics(diagnostics, diagnosticStart, token, options, token.parentVoiceName);
1788
1960
  }
1789
1961
  } catch (error) {
1790
1962
  const reason = error instanceof Error ? error.message : String(error);
1963
+ const diagnosticStart = diagnostics.length;
1791
1964
  addDiagnostic(
1792
1965
  diagnostics,
1793
1966
  ssml,
1794
1967
  token.start,
1795
1968
  `<${token.name} ${attribute}> could not be validated by the custom URL validator: ${reason}`
1796
1969
  );
1970
+ annotateTokenDiagnostics(diagnostics, diagnosticStart, token, options, token.parentVoiceName);
1797
1971
  }
1798
1972
  })
1799
1973
  );
@@ -1810,7 +1984,9 @@ export {
1810
1984
  buildSsml,
1811
1985
  parseSsml,
1812
1986
  buildPartialSsml,
1987
+ getSsmlSourceMap,
1813
1988
  validateSsml,
1989
+ createAzureUrlValidatorRunner,
1814
1990
  validateAzureSsml
1815
1991
  };
1816
- //# sourceMappingURL=chunk-QFIBPCO4.mjs.map
1992
+ //# sourceMappingURL=chunk-HI74FTKY.mjs.map