ssml-builder-js 2.11.0 → 2.13.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
@@ -236,9 +236,26 @@ const diagnostics = validateAzureSsml(ssml, {
236
236
  customVoiceStyleMap: { "my-custom-voice": ["narration"] },
237
237
  unknownVoicePolicy: "error", // "error" | "warn" | "ignore"
238
238
  allowedAudioOrigins: ["https://cdn.example.com"],
239
+ maxXmlDepth: 24,
239
240
  });
240
241
  ```
241
242
 
243
+ `audio`、`mstts:backgroundaudio`、`lexicon`、`mstts:voiceconversion` の URL は `urlValidator`(または `customUrlValidator`)へ渡せます。Promise を返す検証関数で DNS 解決後のプライベート IP 遮断などを実装できます。
244
+
245
+ `maxXmlDepth` は `<speak>` を深さ 1 として XML の過剰なネストを検出します。長文は `splitSsmlDocument` で `<p>`/`<s>` と親の `voice`、`prosody` コンテキストを保ったまま分割できます。
246
+
247
+ ```ts
248
+ import { splitSsmlDocument } from "ssml-builder-js/core";
249
+
250
+ const blocks = splitSsmlDocument(longSsml, 10_000);
251
+ for (const block of blocks) {
252
+ // 各 block は独立した <speak> 文書として Azure へ送信できます。
253
+ await client.synthesize(block.ssml);
254
+ }
255
+ ```
256
+
257
+ 各ブロックは `SsmlChunk`(`chunkIndex`、`originalTextRange`、継承した音声/言語/韻律、内包マーク、背景音声の有無)です。`<mstts:backgroundaudio>` はデフォルトで先頭チャンクだけに含まれ、全チャンクへ複製する場合は `splitSsmlDocument(longSsml, 10_000, { replicateBackgroundAudio: true })` を指定します。
258
+
242
259
  Azure Speech は `<audio>` の URL を取得するため、任意の URL をそのまま受け付けるサーバーは SSRF の踏み台になり得ます。ユーザー入力の SSML を合成する場合は、HTTPS、許可オリジン、リダイレクト先、応答サイズをサーバー側でも制限し、`allowExternalAudio` だけで無制限に許可しないでください。
243
260
 
244
261
  ## `ssml-editor-react` の利用方法
@@ -251,10 +268,14 @@ Visual Editor と Code Editor の対応要素は次のとおりです。
251
268
 
252
269
  | 要素 | Visual Editor | Code Editor |
253
270
  | --- | --- | --- |
254
- | `voice`、`prosody`、`break`、`express-as`、`say-as`、`phoneme` | フォーム操作・構造ツリー | 完全対応 |
271
+ | `voice`、`prosody` | 音声名/effect、rate/pitch/volume/contour/range のフォーム | 完全対応 |
272
+ | `say-as`、`phoneme` | 属性フォーム・構造ツリー | 完全対応 |
273
+ | `audio` | URL、説明、clip、速度、繰り返し、音量のフォーム | 完全対応 |
274
+ | `mark`、`bookmark` | 名前のフォーム | 完全対応 |
275
+ | `mstts:silence`、`mstts:audioduration` | 種別/value・duration のフォーム | 完全対応 |
255
276
  | `mstts:dialog` / `mstts:turn` | 話者ターンの追加・音声/話者/本文編集 | 完全対応・補完あり |
256
277
  | `mstts:backgroundaudio` | URL、音量、フェードイン/アウト編集 | 完全対応・補完あり |
257
- | `mstts:ttsembedding`、`mstts:embedding`、`mstts:voiceconversion` | 構造ツリーで保持 | 完全対応 |
278
+ | `mstts:ttsembedding`、`mstts:embedding`、`mstts:voiceconversion` | profile/id/url のフォーム | 完全対応 |
258
279
  | 未定義の XML 要素 | 構造ツリーで保持 | `custom` として編集 |
259
280
 
260
281
  Azure Speech のライフサイクル対応状況は次のとおりです。GA 要素は通常の静的検証対象、プレビュー要素・音声は Warning、非推奨要素・音声は Info の診断を返します。`AzureVoiceDefinition.status` と `AzureValidationOptions.tagStatuses` で外部カタログの状態も指定できます。
@@ -301,7 +322,7 @@ export function App() {
301
322
  - `onSsmlChange`: 編集後に生成された SSML 文字列を受け取るコールバック
302
323
  - `ref`: `SsmlEditorRef` の `getFullSsml()` で全体の SSML、`getSelectedSsml()` で選択範囲(未選択時は内容が空でないカーソル行)の SSML、`getCurrentLineSsml()` で現在行の SSML を取得。選択範囲やカーソル行が空の場合は `null` を返します
303
324
  - `onSelectionChange`: 選択テキスト、文字数、選択状態を受け取るコールバック
304
- - `onPreviewSelection`: フローティングアクションの試聴ボタン押下時に、選択部分の SSML を受け取るコールバック。省略時は試聴ボタンが無効になります。Azure などの音声 API はこのコールバックから呼び出してください
325
+ - `onPreviewSelection`: フローティングアクションの試聴ボタン押下時に、選択部分を元の `speak` 属性、`voice`、`prosody` などの親コンテキストで包んだ一時 SSML を受け取るコールバック。省略時は試聴ボタンが無効になります。Azure などの音声 API はこのコールバックから呼び出してください
305
326
  - `editMode`: 初期編集モード(`"code"` または `"visual"`)。Visual モードは構造ツリーとフォームによる構造化編集を提供します
306
327
  - `locale`: 画面表示の言語(`"ja"` または `"en"`)。省略時は `"ja"`。ホバーヘルプを含む UI の翻訳にも使用されます
307
328
  - `language`: `locale` の旧名称。既存コードとの互換性のため利用できますが、新しいコードでは `locale` を使用してください
@@ -319,6 +340,8 @@ export function App() {
319
340
  - `insertionGroups`: 挿入メニューを縦線で区切るグループ設定。`toolbarGroups` を省略した場合は、この設定からツールバーの既定グループも作成されます。省略時は間・無音、声の調整、表現、読み上げに分けて表示されます
320
341
  - `emotionStyles`: `emotion` メニューに表示する音声スタイル候補
321
342
  - `customInsertions` / `additionalInsertions`: カスタム SSML 挿入定義。`customInsertions` は同じ ID の標準定義を置き換え、`additionalInsertions` は標準定義へ追加します
343
+ - `customInspectors`: Visual Editor のタグ名ごとに Inspector を差し替えるレンダラー
344
+ - `renderVoiceSelector`: 音声セレクターのカスタムレンダラー。`voiceCatalog` と `voiceLocale`、`voiceRegion`、`voiceStyle` で候補を絞り込めます。プレビュー音声にはバッジ情報も渡されます
322
345
  - `className` / `style`: エディター全体のクラス名とインラインスタイル
323
346
  - `toolbarClassName` / `toolbarStyle`: ツールバーのクラス名とインラインスタイル
324
347
  - `displayClassName` / `displayStyle`: 本文表示エリアのクラス名とインラインスタイル
@@ -685,6 +708,8 @@ const diagnostics = validateAzureSsml(ssml, {
685
708
  });
686
709
  ```
687
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
+
688
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.
689
714
 
690
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.
@@ -718,6 +743,17 @@ const diagnostics = validateAzureSsml(ssml, {
718
743
 
719
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.
720
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
+
721
757
  ## Using `ssml-editor-react`
722
758
 
723
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.
@@ -796,6 +832,8 @@ export function App() {
796
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
797
833
  - `emotionStyles`: Candidate voice styles shown by the `emotion` menu
798
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
799
837
  - `className` / `style`: A class name and inline styles for the editor container
800
838
  - `toolbarClassName` / `toolbarStyle`: A class name and inline styles for the toolbar
801
839
  - `displayClassName` / `displayStyle`: A class name and inline styles for the text display area
@@ -855,6 +893,26 @@ const catalog = await fetchAzureVoiceCatalog({
855
893
  });
856
894
  ```
857
895
 
896
+ 同期情報が必要な場合は `synthesizeSsml`(または `AzureTtsClient.synthesizeSsml`)を使用します。SDK の 100 ナノ秒単位のオフセットはミリ秒へ変換されます。
897
+
898
+ ```ts
899
+ import { synthesizeSsml } from "ssml-builder-js";
900
+
901
+ const result = await synthesizeSsml(ssml, {
902
+ subscriptionKey: process.env.AZURE_SPEECH_KEY!,
903
+ region: process.env.AZURE_SPEECH_REGION!,
904
+ });
905
+ result.audioData; // ArrayBuffer
906
+ result.durationMs;
907
+ result.boundaries; // { text, audioOffsetMs, durationMs }[]
908
+ result.visemes; // { visemeId, audioOffsetMs }[]
909
+ result.bookmarks; // { name, audioOffsetMs }[]
910
+ ```
911
+
912
+ 長文を分割して合成する場合は `synthesizeSsmlChunks` または `AzureTtsClient.synthesizeChunks` を使います。音声バイナリを連結し、`boundaries`、`visemes`、`bookmarks` のオフセットを累積 `durationMs` 分だけ補正します。`synthesizeSsmlSafe(client, ssml, { validation })` は検証エラー時に Azure API を呼び出さず、`status: "validation-error"` / `"azure-api-error"` / `"success"` の結果を返します。
913
+
914
+ 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.
915
+
858
916
  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)`).
859
917
 
860
918
  ```ts
@@ -931,6 +931,183 @@ function buildPartialSsml(textOrOptions, context) {
931
931
  return serializePartialSsml(textOrOptions.text, textOrOptions);
932
932
  }
933
933
 
934
+ // packages/ssml-core/src/split.ts
935
+ var DEFAULT_MAX_LENGTH = 1e4;
936
+ function cloneElement(element, children) {
937
+ return { ...element, children };
938
+ }
939
+ function documentWithChildren(document, children) {
940
+ return buildSsml({ ...document, children });
941
+ }
942
+ function wrapWithContext(node, context) {
943
+ return context.reduceRight((current, parent) => cloneElement(parent, [current]), node);
944
+ }
945
+ function documentWithNode(document, node, context) {
946
+ return documentWithChildren(document, [wrapWithContext(node, context)]);
947
+ }
948
+ function splitTextNode(text, document, context, maxLength) {
949
+ if (!text) return [text];
950
+ const parts = [];
951
+ let start = 0;
952
+ while (start < text.length) {
953
+ let end = start + 1;
954
+ let bestEnd = end;
955
+ while (end <= text.length) {
956
+ if (documentWithNode(document, text.slice(start, end), context).length > maxLength) break;
957
+ bestEnd = end;
958
+ end += 1;
959
+ }
960
+ if (bestEnd === start) {
961
+ throw new RangeError("maxLength is too small to contain the SSML document wrapper");
962
+ }
963
+ const segment = text.slice(start, bestEnd);
964
+ const boundary = Math.max(segment.lastIndexOf(" "), segment.lastIndexOf("\n"), segment.lastIndexOf(" "));
965
+ const splitEnd = boundary > 0 ? start + boundary + 1 : bestEnd;
966
+ parts.push(text.slice(start, splitEnd));
967
+ start = splitEnd;
968
+ }
969
+ return parts;
970
+ }
971
+ function splitNode(document, node, maxLength, context = []) {
972
+ if (typeof node === "string" || node.type === "text") {
973
+ const value = typeof node === "string" ? node : node.value;
974
+ if (documentWithNode(document, node, context).length <= maxLength) return [node];
975
+ return splitTextNode(value, document, context, maxLength);
976
+ }
977
+ if (documentWithNode(document, node, context).length <= maxLength) return [node];
978
+ const children = node.children ?? [];
979
+ if (children.length === 0) {
980
+ throw new RangeError(`maxLength is too small to contain <${node.type}>`);
981
+ }
982
+ const splitChildren = children.flatMap((child) => splitNode(document, child, maxLength, [...context, node]));
983
+ const parts = [];
984
+ let group = [];
985
+ const flush = () => {
986
+ if (group.length > 0) {
987
+ parts.push(cloneElement(node, group));
988
+ group = [];
989
+ }
990
+ };
991
+ for (const child of splitChildren) {
992
+ const candidate = cloneElement(node, [...group, child]);
993
+ if (documentWithNode(document, candidate, context).length <= maxLength) {
994
+ group.push(child);
995
+ continue;
996
+ }
997
+ flush();
998
+ if (documentWithNode(document, cloneElement(node, [child]), context).length > maxLength) {
999
+ throw new RangeError(`maxLength is too small to contain <${node.type}>`);
1000
+ }
1001
+ group.push(child);
1002
+ }
1003
+ flush();
1004
+ return parts;
1005
+ }
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 createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio) {
1038
+ const chunkNodes = backgroundAudio && (replicateBackgroundAudio || chunkIndex === 0) ? [backgroundAudio, ...nodes] : nodes;
1039
+ const text = nodes.map(textFromNode).join("");
1040
+ const marks = [];
1041
+ for (const node of nodes) collectMarks(node, marks);
1042
+ const inheritedContext = collectInheritedContext(nodes);
1043
+ if (inheritedContext.lang === void 0 && document.lang) inheritedContext.lang = document.lang;
1044
+ return {
1045
+ chunkIndex,
1046
+ ssml: documentWithChildren(document, chunkNodes),
1047
+ originalTextRange: { start: textStart, end: textStart + text.length },
1048
+ inheritedContext,
1049
+ containedMarks: marks,
1050
+ hasBackgroundAudio: chunkNodes.some(
1051
+ (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1052
+ )
1053
+ };
1054
+ }
1055
+ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1056
+ const resolvedMaxLength = typeof maxLength === "number" ? maxLength : maxLength.maxLength ?? DEFAULT_MAX_LENGTH;
1057
+ const resolvedOptions = typeof maxLength === "number" ? options : maxLength;
1058
+ if (!Number.isInteger(resolvedMaxLength) || resolvedMaxLength <= 0) {
1059
+ throw new RangeError("maxLength must be a positive integer");
1060
+ }
1061
+ const document = parseSsml(ssml);
1062
+ const backgroundAudio = (document.children ?? []).find(
1063
+ (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1064
+ );
1065
+ if (ssml.length <= resolvedMaxLength) {
1066
+ return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true)];
1067
+ }
1068
+ const contentChildren = (document.children ?? []).filter((node) => node !== backgroundAudio);
1069
+ const plainDocumentLength = documentWithChildren(document, []).length;
1070
+ const backgroundDocumentLength = backgroundAudio ? documentWithChildren(document, [backgroundAudio]).length : plainDocumentLength;
1071
+ const backgroundOverhead = Math.max(0, backgroundDocumentLength - plainDocumentLength);
1072
+ const contentMaxLength = Math.max(1, resolvedMaxLength - backgroundOverhead);
1073
+ const splitChildren = contentChildren.flatMap((child) => splitNode(document, child, contentMaxLength));
1074
+ const chunks = [];
1075
+ let group = [];
1076
+ for (const child of splitChildren) {
1077
+ const candidate = [...group, child];
1078
+ if (documentWithChildren(document, candidate).length <= contentMaxLength) {
1079
+ group = candidate;
1080
+ continue;
1081
+ }
1082
+ if (group.length > 0) chunks.push(group);
1083
+ group = [child];
1084
+ if (documentWithChildren(document, group).length > contentMaxLength) {
1085
+ throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1086
+ }
1087
+ }
1088
+ if (group.length > 0) chunks.push(group);
1089
+ if (chunks.length === 0) {
1090
+ const result = createChunk(document, [], 0, 0, backgroundAudio, resolvedOptions.replicateBackgroundAudio ?? false);
1091
+ if (result.ssml.length > resolvedMaxLength) {
1092
+ throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1093
+ }
1094
+ return [result];
1095
+ }
1096
+ let textStart = 0;
1097
+ return chunks.map((chunk, chunkIndex) => {
1098
+ const result = createChunk(
1099
+ document,
1100
+ chunk,
1101
+ chunkIndex,
1102
+ textStart,
1103
+ backgroundAudio,
1104
+ resolvedOptions.replicateBackgroundAudio ?? false
1105
+ );
1106
+ textStart = result.originalTextRange.end;
1107
+ return result;
1108
+ });
1109
+ }
1110
+
934
1111
  // packages/ssml-core/src/validation.ts
935
1112
  var PARSER_POSITION_SUFFIX = / at position (\d+)$/;
936
1113
  function validateSsml(xmlString) {
@@ -1625,6 +1802,7 @@ function tokenizeElements(source) {
1625
1802
  attributes,
1626
1803
  childElementIndex,
1627
1804
  end,
1805
+ depth: openElements.length + 1,
1628
1806
  name: tokenName,
1629
1807
  parentName: parent?.name,
1630
1808
  parentVoiceName,
@@ -1815,13 +1993,18 @@ function validateAudioSource(token, source, diagnostics, options, elementName2)
1815
1993
  addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must be an absolute HTTP(S) URL.`);
1816
1994
  return;
1817
1995
  }
1996
+ if (parsed.username || parsed.password)
1997
+ addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must not contain URL credentials.`);
1818
1998
  if (parsed.protocol !== "https:" && !(options.allowHttpAudio && parsed.protocol === "http:"))
1819
1999
  addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must use HTTPS.`);
1820
2000
  const isAllowedOrigin = options.allowedAudioOrigins?.some((allowedOrigin) => {
1821
2001
  try {
1822
- return new URL(allowedOrigin).origin === parsed.origin;
2002
+ const configured = new URL(allowedOrigin);
2003
+ if (configured.protocol !== "https:" && configured.protocol !== "http:" || configured.username || configured.password || configured.pathname !== "/" || configured.search || configured.hash)
2004
+ return false;
2005
+ return configured.origin === parsed.origin;
1823
2006
  } catch {
1824
- return allowedOrigin === parsed.origin;
2007
+ return false;
1825
2008
  }
1826
2009
  }) ?? false;
1827
2010
  if (options.allowedAudioOrigins && !isAllowedOrigin)
@@ -2013,7 +2196,7 @@ function validateElement(token, source, diagnostics, voiceName, options, voiceCa
2013
2196
  addDiagnostic(diagnostics, source, token.start, "<mstts:backgroundaudio> must be self-closing.");
2014
2197
  }
2015
2198
  }
2016
- function validateAzureSsml(ssml, options = {}) {
2199
+ function validateAzureSsmlStatic(ssml, options = {}) {
2017
2200
  const diagnostics = [];
2018
2201
  if (typeof ssml !== "string") {
2019
2202
  return [
@@ -2029,6 +2212,9 @@ function validateAzureSsml(ssml, options = {}) {
2029
2212
  const maxLength = options.maxLength ?? 1e4;
2030
2213
  if (ssml.length > maxLength)
2031
2214
  addDiagnostic(diagnostics, ssml, maxLength, `SSML exceeds the maximum length of ${maxLength} characters.`);
2215
+ if (options.maxXmlDepth !== void 0 && (!Number.isInteger(options.maxXmlDepth) || options.maxXmlDepth <= 0)) {
2216
+ addDiagnostic(diagnostics, ssml, 0, "maxXmlDepth must be a positive integer.");
2217
+ }
2032
2218
  try {
2033
2219
  parseSsml(ssml);
2034
2220
  } catch (error) {
@@ -2038,6 +2224,18 @@ function validateAzureSsml(ssml, options = {}) {
2038
2224
  return diagnostics;
2039
2225
  }
2040
2226
  const tokens = tokenizeElements(ssml);
2227
+ if (options.maxXmlDepth !== void 0) {
2228
+ for (const token of tokens) {
2229
+ if (token.depth > options.maxXmlDepth) {
2230
+ addDiagnostic(
2231
+ diagnostics,
2232
+ ssml,
2233
+ token.start,
2234
+ `XML nesting depth ${token.depth} exceeds the configured maximum of ${options.maxXmlDepth}.`
2235
+ );
2236
+ }
2237
+ }
2238
+ }
2041
2239
  const speak = tokens.find((token) => token.name.toLowerCase() === "speak");
2042
2240
  const voices = tokens.filter((token) => token.name.toLowerCase() === "voice");
2043
2241
  const backgroundAudioTokens = tokens.filter((token) => token.name.toLowerCase() === "mstts:backgroundaudio");
@@ -2122,6 +2320,51 @@ function validateAzureSsml(ssml, options = {}) {
2122
2320
  }
2123
2321
  return diagnostics;
2124
2322
  }
2323
+ function urlAttributes(token) {
2324
+ const tag = canonicalTagName(token.name);
2325
+ const attributes = tag === "audio" || tag === "mstts:backgroundaudio" ? ["src"] : tag === "lexicon" ? ["uri"] : tag === "mstts:voiceconversion" ? ["url"] : [];
2326
+ return attributes.flatMap((attribute) => {
2327
+ const value = attr(token, attribute);
2328
+ return value === void 0 ? [] : [{ attribute, value }];
2329
+ });
2330
+ }
2331
+ function validateAzureSsml(ssml, options = {}) {
2332
+ const diagnostics = validateAzureSsmlStatic(ssml, options);
2333
+ const validator = options.urlValidator ?? options.customUrlValidator;
2334
+ if (!validator || typeof ssml !== "string") return diagnostics;
2335
+ let tokens;
2336
+ try {
2337
+ tokens = tokenizeElements(ssml);
2338
+ } catch {
2339
+ return diagnostics;
2340
+ }
2341
+ const checks = tokens.flatMap(
2342
+ (token) => urlAttributes(token).map(async ({ attribute, value }) => {
2343
+ try {
2344
+ const result = await validator(value, { tag: token.name, attribute });
2345
+ const valid = typeof result === "boolean" ? result : result.valid;
2346
+ if (!valid) {
2347
+ const reason = typeof result === "boolean" ? void 0 : result.reason;
2348
+ addDiagnostic(
2349
+ diagnostics,
2350
+ ssml,
2351
+ token.start,
2352
+ `<${token.name} ${attribute}> was rejected by the custom URL validator${reason ? `: ${reason}` : "."}`
2353
+ );
2354
+ }
2355
+ } catch (error) {
2356
+ const reason = error instanceof Error ? error.message : String(error);
2357
+ addDiagnostic(
2358
+ diagnostics,
2359
+ ssml,
2360
+ token.start,
2361
+ `<${token.name} ${attribute}> could not be validated by the custom URL validator: ${reason}`
2362
+ );
2363
+ }
2364
+ })
2365
+ );
2366
+ return Promise.all(checks).then(() => diagnostics);
2367
+ }
2125
2368
 
2126
2369
  // packages/ssml-core/src/generated/azureVoiceCatalog.ts
2127
2370
  var AZURE_VOICE_CATALOG_METADATA = {
@@ -2144,6 +2387,7 @@ export {
2144
2387
  buildSsml,
2145
2388
  parseSsml,
2146
2389
  buildPartialSsml,
2390
+ splitSsmlDocument,
2147
2391
  validateSsml,
2148
2392
  extractSsmlText,
2149
2393
  mapSsmlTextNodes,
@@ -2157,4 +2401,4 @@ export {
2157
2401
  getAzureVoiceCatalogMetadata,
2158
2402
  getBuiltInVoiceCatalogMetadata
2159
2403
  };
2160
- //# sourceMappingURL=chunk-SNRI43QJ.mjs.map
2404
+ //# sourceMappingURL=chunk-25LOR4AJ.mjs.map