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/README.md CHANGED
@@ -240,6 +240,8 @@ const diagnostics = validateAzureSsml(ssml, {
240
240
  });
241
241
  ```
242
242
 
243
+ `audio`、`mstts:backgroundaudio`、`lexicon`、`mstts:voiceconversion` の URL は `urlValidator`(または `customUrlValidator`)へ渡せます。Promise を返す検証関数で DNS 解決後のプライベート IP 遮断などを実装できます。
244
+
243
245
  `maxXmlDepth` は `<speak>` を深さ 1 として XML の過剰なネストを検出します。長文は `splitSsmlDocument` で `<p>`/`<s>` と親の `voice`、`prosody` コンテキストを保ったまま分割できます。
244
246
 
245
247
  ```ts
@@ -248,10 +250,12 @@ import { splitSsmlDocument } from "ssml-builder-js/core";
248
250
  const blocks = splitSsmlDocument(longSsml, 10_000);
249
251
  for (const block of blocks) {
250
252
  // 各 block は独立した <speak> 文書として Azure へ送信できます。
251
- await client.synthesize(block);
253
+ await client.synthesize(block.ssml);
252
254
  }
253
255
  ```
254
256
 
257
+ 各ブロックは `SsmlChunk`(`chunkIndex`、`originalTextRange`、継承した音声/言語/韻律、内包マーク、背景音声の有無)です。`<mstts:backgroundaudio>` はデフォルトで先頭チャンクだけに含まれ、全チャンクへ複製する場合は `splitSsmlDocument(longSsml, 10_000, { replicateBackgroundAudio: true })` を指定します。
258
+
255
259
  Azure Speech は `<audio>` の URL を取得するため、任意の URL をそのまま受け付けるサーバーは SSRF の踏み台になり得ます。ユーザー入力の SSML を合成する場合は、HTTPS、許可オリジン、リダイレクト先、応答サイズをサーバー側でも制限し、`allowExternalAudio` だけで無制限に許可しないでください。
256
260
 
257
261
  ## `ssml-editor-react` の利用方法
@@ -336,6 +340,8 @@ export function App() {
336
340
  - `insertionGroups`: 挿入メニューを縦線で区切るグループ設定。`toolbarGroups` を省略した場合は、この設定からツールバーの既定グループも作成されます。省略時は間・無音、声の調整、表現、読み上げに分けて表示されます
337
341
  - `emotionStyles`: `emotion` メニューに表示する音声スタイル候補
338
342
  - `customInsertions` / `additionalInsertions`: カスタム SSML 挿入定義。`customInsertions` は同じ ID の標準定義を置き換え、`additionalInsertions` は標準定義へ追加します
343
+ - `customInspectors`: Visual Editor のタグ名ごとに Inspector を差し替えるレンダラー
344
+ - `renderVoiceSelector`: 音声セレクターのカスタムレンダラー。`voiceCatalog` と `voiceLocale`、`voiceRegion`、`voiceStyle` で候補を絞り込めます。プレビュー音声にはバッジ情報も渡されます
339
345
  - `className` / `style`: エディター全体のクラス名とインラインスタイル
340
346
  - `toolbarClassName` / `toolbarStyle`: ツールバーのクラス名とインラインスタイル
341
347
  - `displayClassName` / `displayStyle`: 本文表示エリアのクラス名とインラインスタイル
@@ -702,6 +708,8 @@ const diagnostics = validateAzureSsml(ssml, {
702
708
  });
703
709
  ```
704
710
 
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
+
705
713
  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.
706
714
 
707
715
  `npm run sync:voices -- --regions eastus,japaneast` fetches Azure's List Voices API for each region, deduplicates the results, and updates the generated TypeScript definitions plus `azureVoiceCatalog.json` with generation time, API version, regions, and voice count. Provide credentials through `AZURE_SPEECH_KEY` and `AZURE_SPEECH_REGION(S)`, or CLI options.
@@ -735,6 +743,17 @@ const diagnostics = validateAzureSsml(ssml, {
735
743
 
736
744
  Azure Speech fetches `<audio>` URLs, so a server that accepts arbitrary user-provided SSML can become an SSRF proxy. For untrusted SSML, enforce HTTPS, an origin allowlist, redirect policy, and response-size limits on the server; do not treat `allowExternalAudio` as a substitute for those controls.
737
745
 
746
+ Long documents can be split into independently synthesizable `SsmlChunk` values:
747
+
748
+ ```ts
749
+ import { splitSsmlDocument } from "ssml-builder-js/core";
750
+
751
+ const chunks = splitSsmlDocument(longSsml, 10_000);
752
+ for (const chunk of chunks) await client.synthesize(chunk.ssml);
753
+ ```
754
+
755
+ Each chunk includes `chunkIndex`, `originalTextRange`, inherited voice/language/prosody context, contained markers, and background-audio state. Background audio is placed in the first chunk by default; pass `{ replicateBackgroundAudio: true }` as the third argument to include it in every chunk.
756
+
738
757
  ## Using `ssml-editor-react`
739
758
 
740
759
  `SsmlEditor` accepts an `SsmlDocument` and renders only a toolbar and text display area. The toolbar applies rate, volume, and pitch settings to the selection and provides undo and redo actions. The application is responsible for selecting and displaying the voice. Monaco Editor is used for text editing, and SSML syntax is validated whenever the text changes. Syntax errors are shown with editor markers and an error message. Hovering over XML tag names or parameters shows SSML descriptions. Selecting text displays a floating action bar with the character count and preview action. When `enableCodeLens` is enabled (the default), CodeLens quick controls for editing and unwrapping `prosody` tags and editing or deleting `break` tags are shown above those tags. When `showDecorations` is enabled, inline badges for pause and pitch changes are rendered next to `break` and `prosody` tags, and Monaco inline decorations are enabled. Generated SSML is provided through `onSsmlChange` so the application can display it wherever it needs. Pass an `SsmlEditorRef` through `ref` to retrieve full, selected, or current-line SSML, and use `onSelectionChange` to observe selection text and state. The UI supports Japanese (the default) and English.
@@ -813,6 +832,8 @@ export function App() {
813
832
  - `insertionGroups`: Groups insertion menus with vertical separators; when `toolbarGroups` is omitted, these groups also define the default toolbar groups. Menus are grouped into pauses, voice, expression, and pronunciation by default
814
833
  - `emotionStyles`: Candidate voice styles shown by the `emotion` menu
815
834
  - `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
+ - `customInspectors`: Custom renderers keyed by element type or serialized tag name for the Visual Editor
836
+ - `renderVoiceSelector`: Custom voice selector renderer. Supply `voiceCatalog` and optional `voiceLocale`, `voiceRegion`, or `voiceStyle` filters; preview status is included in each voice entry
816
837
  - `className` / `style`: A class name and inline styles for the editor container
817
838
  - `toolbarClassName` / `toolbarStyle`: A class name and inline styles for the toolbar
818
839
  - `displayClassName` / `displayStyle`: A class name and inline styles for the text display area
@@ -888,6 +909,18 @@ result.visemes; // { visemeId, audioOffsetMs }[]
888
909
  result.bookmarks; // { name, audioOffsetMs }[]
889
910
  ```
890
911
 
912
+ 長文を分割して合成する場合は `synthesizeSsmlChunks` または `AzureTtsClient.synthesizeChunks` を使います。音声バイナリを連結し、`boundaries`、`visemes`、`bookmarks` のオフセットを累積 `durationMs` 分だけ補正します。`synthesizeSsmlSafe(client, ssml, { validation })` は検証エラー時に Azure API を呼び出さず、`status: "validation-error"` / `"azure-api-error"` / `"success"` の結果を返します。
913
+
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` が付与されます。
915
+
916
+ `validateAzureSsml` の `urlValidation` オプションは URL の重複排除、キャッシュ、`concurrency`、`signal`、`timeoutMs` を制御します。
917
+
918
+ 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
+
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`.
921
+
922
+ The `urlValidation` option of `validateAzureSsml` provides URL deduplication, in-memory caching, bounded `concurrency`, `signal`, and `timeoutMs` controls.
923
+
891
924
  The public updater CLI is `npx ssml-builder sync-voices --region eastus --output ./azure-voices.json`. It reads the key from `AZURE_SPEECH_KEY` (or `--key`) and regions from `AZURE_SPEECH_REGION(S)` (or `--region(s)`).
892
925
 
893
926
  ```ts
@@ -1003,30 +1003,138 @@ function splitNode(document, node, maxLength, context = []) {
1003
1003
  flush();
1004
1004
  return parts;
1005
1005
  }
1006
- function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH) {
1007
- if (!Number.isInteger(maxLength) || maxLength <= 0) {
1006
+ function textFromNode(node) {
1007
+ if (typeof node === "string") return node;
1008
+ if (node.type === "text") return node.value;
1009
+ return (node.children ?? []).map(textFromNode).join("");
1010
+ }
1011
+ function collectMarks(node, marks) {
1012
+ if (typeof node === "string" || node.type === "text") return;
1013
+ if (node.type === "mark" && node.name) marks.push(node.name);
1014
+ if (node.type === "bookmark" && node.mark) marks.push(node.mark);
1015
+ for (const child of node.children ?? []) collectMarks(child, marks);
1016
+ }
1017
+ function collectInheritedContext(nodes) {
1018
+ const context = {};
1019
+ const visit = (node) => {
1020
+ if (typeof node === "string" || node.type === "text") return;
1021
+ if (context.voice === void 0 && node.type === "voice" && node.name) context.voice = node.name;
1022
+ if (context.lang === void 0 && node.type === "lang" && node.lang) context.lang = node.lang;
1023
+ if (context.prosody === void 0 && node.type === "prosody") {
1024
+ const prosody = {};
1025
+ for (const [key, value] of Object.entries(node.attributes ?? {})) prosody[key] = String(value);
1026
+ for (const key of ["rate", "pitch", "volume", "contour", "range"]) {
1027
+ const value = node[key];
1028
+ if (value !== void 0) prosody[key] = String(value);
1029
+ }
1030
+ if (Object.keys(prosody).length > 0) context.prosody = prosody;
1031
+ }
1032
+ for (const child of node.children ?? []) visit(child);
1033
+ };
1034
+ nodes.forEach(visit);
1035
+ return context;
1036
+ }
1037
+ function elementName(node) {
1038
+ return node.type === "custom" || node.type === "element" ? node.name : node.type;
1039
+ }
1040
+ function findSourceNodePath(nodes, targetOffset) {
1041
+ let textOffset = 0;
1042
+ let firstPath;
1043
+ let foundPath;
1044
+ const visit = (node, path) => {
1045
+ if (typeof node === "string" || node.type === "text") {
1046
+ const text = typeof node === "string" ? node : node.value;
1047
+ if (text && firstPath === void 0) firstPath = [...path];
1048
+ if (text && foundPath === void 0 && targetOffset < textOffset + text.length) foundPath = [...path];
1049
+ textOffset += text.length;
1050
+ return;
1051
+ }
1052
+ node.children?.forEach((child, index) => {
1053
+ const childPath = typeof child === "string" || child.type === "text" ? path : [...path, `${elementName(child)}[${index}]`];
1054
+ visit(child, childPath);
1055
+ });
1056
+ };
1057
+ nodes.forEach((node, index) => {
1058
+ if (!foundPath) {
1059
+ if (typeof node === "string" || node.type === "text") visit(node, ["speak"]);
1060
+ else visit(node, ["speak", `${elementName(node)}[${index}]`]);
1061
+ }
1062
+ });
1063
+ return foundPath ?? firstPath;
1064
+ }
1065
+ function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio) {
1066
+ const chunkNodes = backgroundAudio && (replicateBackgroundAudio || chunkIndex === 0) ? [backgroundAudio, ...nodes] : nodes;
1067
+ const text = nodes.map(textFromNode).join("");
1068
+ const marks = [];
1069
+ for (const node of nodes) collectMarks(node, marks);
1070
+ const inheritedContext = collectInheritedContext(nodes);
1071
+ if (inheritedContext.lang === void 0 && document.lang) inheritedContext.lang = document.lang;
1072
+ return {
1073
+ chunkIndex,
1074
+ ssml: documentWithChildren(document, chunkNodes),
1075
+ originalTextRange: { start: textStart, end: textStart + text.length },
1076
+ inheritedContext,
1077
+ containedMarks: marks,
1078
+ hasBackgroundAudio: chunkNodes.some(
1079
+ (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1080
+ ),
1081
+ sourceNodePath: findSourceNodePath(document.children ?? [], textStart)
1082
+ };
1083
+ }
1084
+ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1085
+ const resolvedMaxLength = typeof maxLength === "number" ? maxLength : maxLength.maxLength ?? DEFAULT_MAX_LENGTH;
1086
+ const resolvedOptions = typeof maxLength === "number" ? options : maxLength;
1087
+ if (!Number.isInteger(resolvedMaxLength) || resolvedMaxLength <= 0) {
1008
1088
  throw new RangeError("maxLength must be a positive integer");
1009
1089
  }
1010
1090
  const document = parseSsml(ssml);
1011
- if (ssml.length <= maxLength) return [ssml];
1012
- const children = document.children ?? [];
1013
- const splitChildren = children.flatMap((child) => splitNode(document, child, maxLength));
1091
+ const backgroundAudio = (document.children ?? []).find(
1092
+ (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1093
+ );
1094
+ if (ssml.length <= resolvedMaxLength) {
1095
+ return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true)];
1096
+ }
1097
+ const contentChildren = (document.children ?? []).filter((node) => node !== backgroundAudio);
1098
+ const plainDocumentLength = documentWithChildren(document, []).length;
1099
+ const backgroundDocumentLength = backgroundAudio ? documentWithChildren(document, [backgroundAudio]).length : plainDocumentLength;
1100
+ const backgroundOverhead = Math.max(0, backgroundDocumentLength - plainDocumentLength);
1101
+ const contentMaxLength = Math.max(1, resolvedMaxLength - backgroundOverhead);
1102
+ const splitChildren = contentChildren.flatMap((child) => splitNode(document, child, contentMaxLength));
1014
1103
  const chunks = [];
1015
1104
  let group = [];
1016
1105
  for (const child of splitChildren) {
1017
1106
  const candidate = [...group, child];
1018
- if (documentWithChildren(document, candidate).length <= maxLength) {
1107
+ if (documentWithChildren(document, candidate).length <= contentMaxLength) {
1019
1108
  group = candidate;
1020
1109
  continue;
1021
1110
  }
1022
1111
  if (group.length > 0) chunks.push(group);
1023
1112
  group = [child];
1024
- if (documentWithChildren(document, group).length > maxLength) {
1113
+ if (documentWithChildren(document, group).length > contentMaxLength) {
1025
1114
  throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1026
1115
  }
1027
1116
  }
1028
1117
  if (group.length > 0) chunks.push(group);
1029
- return chunks.map((chunk) => documentWithChildren(document, chunk));
1118
+ if (chunks.length === 0) {
1119
+ const result = createChunk(document, [], 0, 0, backgroundAudio, resolvedOptions.replicateBackgroundAudio ?? false);
1120
+ if (result.ssml.length > resolvedMaxLength) {
1121
+ throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1122
+ }
1123
+ return [result];
1124
+ }
1125
+ let textStart = 0;
1126
+ return chunks.map((chunk, chunkIndex) => {
1127
+ const result = createChunk(
1128
+ document,
1129
+ chunk,
1130
+ chunkIndex,
1131
+ textStart,
1132
+ backgroundAudio,
1133
+ resolvedOptions.replicateBackgroundAudio ?? false
1134
+ );
1135
+ textStart = result.originalTextRange.end;
1136
+ return result;
1137
+ });
1030
1138
  }
1031
1139
 
1032
1140
  // packages/ssml-core/src/validation.ts
@@ -1196,7 +1304,7 @@ async function mapSsmlTextNodes(ssml, transform, options = {}) {
1196
1304
 
1197
1305
  // packages/ssml-core/src/migration.ts
1198
1306
  var DEFAULT_TRANSLATION_SKIP_TAGS = ["phoneme", "say-as", "sayAs", "sub"];
1199
- function elementName(element) {
1307
+ function elementName2(element) {
1200
1308
  switch (element.type) {
1201
1309
  case "custom":
1202
1310
  case "element":
@@ -1349,7 +1457,7 @@ function extractSsmlTranslatableText(ssml, options = {}) {
1349
1457
  }
1350
1458
  return;
1351
1459
  }
1352
- const tag = elementName(node);
1460
+ const tag = elementName2(node);
1353
1461
  if (skipTags.has(tag.toLowerCase())) return;
1354
1462
  visit(childrenOf(node), [...ancestors, tag], [...path, String(index)]);
1355
1463
  });
@@ -1395,7 +1503,7 @@ function serializeDocument2(document) {
1395
1503
  const serialize = (node) => {
1396
1504
  if (typeof node === "string") return node.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1397
1505
  if (node.type === "text") return serialize(node.value);
1398
- const tag = elementName(node);
1506
+ const tag = elementName2(node);
1399
1507
  const nodeAttributes = elementAttributes(node);
1400
1508
  const serializedAttributes = Object.entries(nodeAttributes).map(([name, value]) => ` ${name}="${serialize(value).replace(/"/g, "&quot;")}"`).join("");
1401
1509
  const children = childrenOf(node).map(serialize).join("");
@@ -1409,7 +1517,7 @@ function flatten(document) {
1409
1517
  nodes.forEach((node, index) => {
1410
1518
  if (typeof node === "string" || node.type === "text") return;
1411
1519
  const currentPath = `${path}/${index}`;
1412
- result.push({ name: elementName(node), attributes: elementAttributes(node), path: currentPath });
1520
+ result.push({ name: elementName2(node), attributes: elementAttributes(node), path: currentPath });
1413
1521
  visit(childrenOf(node), currentPath);
1414
1522
  });
1415
1523
  };
@@ -1607,6 +1715,69 @@ var AZURE_VOICE_DEFINITIONS = [
1607
1715
  ];
1608
1716
 
1609
1717
  // packages/ssml-core/src/azureValidation.ts
1718
+ function createAzureUrlValidatorRunner(validator, options = {}) {
1719
+ if (typeof validator !== "function") throw new TypeError("A URL validator function is required.");
1720
+ const concurrency = options.concurrency === void 0 ? Infinity : Number.isFinite(options.concurrency) ? Math.max(1, Math.floor(options.concurrency)) : Infinity;
1721
+ const cache = options.cache ?? /* @__PURE__ */ new Map();
1722
+ const inFlight = /* @__PURE__ */ new Map();
1723
+ const waiters = [];
1724
+ let active = 0;
1725
+ const acquire = async () => {
1726
+ if (active < concurrency) {
1727
+ active += 1;
1728
+ return;
1729
+ }
1730
+ await new Promise((resolve) => waiters.push(resolve));
1731
+ active += 1;
1732
+ };
1733
+ const release = () => {
1734
+ active -= 1;
1735
+ waiters.shift()?.();
1736
+ };
1737
+ const check = async (url, context) => {
1738
+ if (options.signal?.aborted) throw new Error("URL validation was aborted.");
1739
+ const cached = cache.get(url);
1740
+ if (cached !== void 0) return cached;
1741
+ const existing = inFlight.get(url);
1742
+ if (existing) return existing;
1743
+ const promise = (async () => {
1744
+ await acquire();
1745
+ try {
1746
+ if (options.signal?.aborted) throw new Error("URL validation was aborted.");
1747
+ const validation = Promise.resolve(validator(url, context));
1748
+ let timer;
1749
+ let abortHandler;
1750
+ const cancellation = new Promise((_resolve, reject) => {
1751
+ abortHandler = () => reject(new Error("URL validation was aborted."));
1752
+ options.signal?.addEventListener("abort", abortHandler, { once: true });
1753
+ if (options.timeoutMs !== void 0 && options.timeoutMs > 0) {
1754
+ timer = setTimeout(
1755
+ () => reject(new Error(`URL validation timed out after ${options.timeoutMs} ms.`)),
1756
+ options.timeoutMs
1757
+ );
1758
+ }
1759
+ });
1760
+ try {
1761
+ const result = await (timer || abortHandler ? Promise.race([validation, cancellation]) : validation);
1762
+ cache.set(url, result);
1763
+ return result;
1764
+ } finally {
1765
+ if (timer) clearTimeout(timer);
1766
+ if (abortHandler) options.signal?.removeEventListener("abort", abortHandler);
1767
+ }
1768
+ } finally {
1769
+ release();
1770
+ }
1771
+ })();
1772
+ inFlight.set(url, promise);
1773
+ try {
1774
+ return await promise;
1775
+ } finally {
1776
+ inFlight.delete(url);
1777
+ }
1778
+ };
1779
+ return (url, context) => check(url, context);
1780
+ }
1610
1781
  var ALLOWED_BREAK_STRENGTHS = /* @__PURE__ */ new Set(["none", "x-weak", "weak", "medium", "strong", "x-strong"]);
1611
1782
  var ALLOWED_SAY_AS = /* @__PURE__ */ new Set([
1612
1783
  "characters",
@@ -1901,23 +2072,23 @@ function validateVoiceFeatureMatrix(token, source, diagnostics, voiceName, defin
1901
2072
  );
1902
2073
  }
1903
2074
  }
1904
- function validateAudioSource(token, source, diagnostics, options, elementName2) {
2075
+ function validateAudioSource(token, source, diagnostics, options, elementName3) {
1905
2076
  const src = attr(token, "src");
1906
2077
  if (!src) {
1907
- addDiagnostic(diagnostics, source, token.start, `<${elementName2}> requires a "src" attribute.`);
2078
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3}> requires a "src" attribute.`);
1908
2079
  return;
1909
2080
  }
1910
2081
  let parsed;
1911
2082
  try {
1912
2083
  parsed = new URL(src);
1913
2084
  } catch {
1914
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must be an absolute HTTP(S) URL.`);
2085
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must be an absolute HTTP(S) URL.`);
1915
2086
  return;
1916
2087
  }
1917
2088
  if (parsed.username || parsed.password)
1918
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must not contain URL credentials.`);
2089
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must not contain URL credentials.`);
1919
2090
  if (parsed.protocol !== "https:" && !(options.allowHttpAudio && parsed.protocol === "http:"))
1920
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must use HTTPS.`);
2091
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must use HTTPS.`);
1921
2092
  const isAllowedOrigin = options.allowedAudioOrigins?.some((allowedOrigin) => {
1922
2093
  try {
1923
2094
  const configured = new URL(allowedOrigin);
@@ -1929,13 +2100,13 @@ function validateAudioSource(token, source, diagnostics, options, elementName2)
1929
2100
  }
1930
2101
  }) ?? false;
1931
2102
  if (options.allowedAudioOrigins && !isAllowedOrigin)
1932
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> origin "${parsed.origin}" is not allowed.`);
2103
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> origin "${parsed.origin}" is not allowed.`);
1933
2104
  else if (!isAllowedOrigin && !options.allowExternalAudio)
1934
2105
  addDiagnostic(
1935
2106
  diagnostics,
1936
2107
  source,
1937
2108
  token.start,
1938
- `<${elementName2} src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`
2109
+ `<${elementName3} src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`
1939
2110
  );
1940
2111
  }
1941
2112
  function validateElement(token, source, diagnostics, voiceName, options, voiceCatalog) {
@@ -2117,7 +2288,7 @@ function validateElement(token, source, diagnostics, voiceName, options, voiceCa
2117
2288
  addDiagnostic(diagnostics, source, token.start, "<mstts:backgroundaudio> must be self-closing.");
2118
2289
  }
2119
2290
  }
2120
- function validateAzureSsml(ssml, options = {}) {
2291
+ function validateAzureSsmlStatic(ssml, options = {}) {
2121
2292
  const diagnostics = [];
2122
2293
  if (typeof ssml !== "string") {
2123
2294
  return [
@@ -2241,6 +2412,59 @@ function validateAzureSsml(ssml, options = {}) {
2241
2412
  }
2242
2413
  return diagnostics;
2243
2414
  }
2415
+ function urlAttributes(token) {
2416
+ const tag = canonicalTagName(token.name);
2417
+ const attributes = tag === "audio" || tag === "mstts:backgroundaudio" ? ["src"] : tag === "lexicon" ? ["uri"] : tag === "mstts:voiceconversion" ? ["url"] : [];
2418
+ return attributes.flatMap((attribute) => {
2419
+ const value = attr(token, attribute);
2420
+ return value === void 0 ? [] : [{ attribute, value }];
2421
+ });
2422
+ }
2423
+ function validateAzureSsml(ssml, options = {}) {
2424
+ const diagnostics = validateAzureSsmlStatic(ssml, options);
2425
+ const validator = options.urlValidator ?? options.customUrlValidator;
2426
+ if (!validator || typeof ssml !== "string") return diagnostics;
2427
+ const runnerOptions = options.urlValidation ?? {};
2428
+ const boundedValidator = createAzureUrlValidatorRunner(validator, {
2429
+ ...runnerOptions,
2430
+ ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
2431
+ ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
2432
+ ...options.urlValidatorSignal ? { signal: options.urlValidatorSignal } : {},
2433
+ ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
2434
+ });
2435
+ let tokens;
2436
+ try {
2437
+ tokens = tokenizeElements(ssml);
2438
+ } catch {
2439
+ return diagnostics;
2440
+ }
2441
+ const checks = tokens.flatMap(
2442
+ (token) => urlAttributes(token).map(async ({ attribute, value }) => {
2443
+ try {
2444
+ const result = await boundedValidator(value, { tag: token.name, attribute });
2445
+ const valid = typeof result === "boolean" ? result : result.valid;
2446
+ if (!valid) {
2447
+ const reason = typeof result === "boolean" ? void 0 : result.reason;
2448
+ addDiagnostic(
2449
+ diagnostics,
2450
+ ssml,
2451
+ token.start,
2452
+ `<${token.name} ${attribute}> was rejected by the custom URL validator${reason ? `: ${reason}` : "."}`
2453
+ );
2454
+ }
2455
+ } catch (error) {
2456
+ const reason = error instanceof Error ? error.message : String(error);
2457
+ addDiagnostic(
2458
+ diagnostics,
2459
+ ssml,
2460
+ token.start,
2461
+ `<${token.name} ${attribute}> could not be validated by the custom URL validator: ${reason}`
2462
+ );
2463
+ }
2464
+ })
2465
+ );
2466
+ return Promise.all(checks).then(() => diagnostics);
2467
+ }
2244
2468
 
2245
2469
  // packages/ssml-core/src/generated/azureVoiceCatalog.ts
2246
2470
  var AZURE_VOICE_CATALOG_METADATA = {
@@ -2270,6 +2494,7 @@ export {
2270
2494
  extractSsmlTranslatableText,
2271
2495
  fromPlainTextToSsml,
2272
2496
  validateSsmlStructureIntegrity,
2497
+ createAzureUrlValidatorRunner,
2273
2498
  isValidAzureAudioDuration,
2274
2499
  normalizeAzureLanguage,
2275
2500
  areAzureLanguagesEquivalent,
@@ -2277,4 +2502,4 @@ export {
2277
2502
  getAzureVoiceCatalogMetadata,
2278
2503
  getBuiltInVoiceCatalogMetadata
2279
2504
  };
2280
- //# sourceMappingURL=chunk-4BVNAUVR.mjs.map
2505
+ //# sourceMappingURL=chunk-AQ55MOPU.mjs.map