ssml-builder-js 2.4.0 → 2.5.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.d.mts CHANGED
@@ -158,10 +158,18 @@ declare function validateSsml(xmlString: string): SsmlValidationError | null;
158
158
 
159
159
  interface SsmlTextNodeContext {
160
160
  parentTag: string;
161
+ parentAttributes: Record<string, string>;
162
+ ancestorTags: string[];
161
163
  path: string[];
162
164
  }
165
+ interface MapSsmlTextNodesOptions {
166
+ /** Element names whose text should not be passed to the transform. */
167
+ skipTags?: readonly string[];
168
+ /** Decides whether an individual text node should be passed to the transform. */
169
+ filter?: (context: SsmlTextNodeContext) => boolean;
170
+ }
163
171
  declare function extractSsmlText(ssml: string): string[];
164
- declare function mapSsmlTextNodes(ssml: string, transform: (text: string, context: SsmlTextNodeContext) => string | Promise<string>): Promise<string>;
172
+ declare function mapSsmlTextNodes(ssml: string, transform: (text: string, context: SsmlTextNodeContext) => string | Promise<string>, options?: MapSsmlTextNodesOptions): Promise<string>;
165
173
 
166
174
  type SsmlDiagnosticSeverity = "error" | "warning";
167
175
  interface SsmlDiagnostic {
@@ -170,11 +178,17 @@ interface SsmlDiagnostic {
170
178
  message: string;
171
179
  severity: SsmlDiagnosticSeverity;
172
180
  }
173
- interface AzureSsmlValidationOptions {
181
+ interface AzureValidationOptions {
174
182
  allowedAudioOrigins?: readonly string[];
183
+ allowExternalAudio?: boolean;
175
184
  allowHttpAudio?: boolean;
185
+ customVoiceStyleMap?: Record<string, readonly string[]>;
176
186
  maxLength?: number;
187
+ unknownVoicePolicy?: "error" | "warn" | "ignore";
188
+ validateNestedVoices?: boolean;
177
189
  }
178
- declare function validateAzureSsml(ssml: string, options?: AzureSsmlValidationOptions): SsmlDiagnostic[];
190
+ /** @deprecated Use AzureValidationOptions instead. */
191
+ type AzureSsmlValidationOptions = AzureValidationOptions;
192
+ declare function validateAzureSsml(ssml: string, options?: AzureValidationOptions): SsmlDiagnostic[];
179
193
 
180
- export { type AudioElement, type AzureSsmlValidationOptions, type BookmarkElement, type BreakElement, type BuildPartialSsmlOptions, type CustomElement, type EmphasisElement, type ExpressAsElement, type LangElement, type LexiconElement, type MarkElement, type MsttsSilenceElement, type MsttsVisemeElement, type NamedElement, type ParagraphElement, type PhonemeElement, type ProsodyElement, type SayAsElement, type SentenceElement, type SsmlAttributeValue, type SsmlAttributes, type SsmlBreakElement, type SsmlDiagnostic, type SsmlDiagnosticSeverity, type SsmlDocument, type SsmlElement, type SsmlElementBase, type SsmlExpressAsElement, type SsmlNode, type SsmlPartialContext, type SsmlPartialProsody, type SsmlPartialVoice, type SsmlPhonemeElement, type SsmlProsodyElement, type SsmlSayAsElement, type SsmlText, type SsmlTextNodeContext, type SsmlValidationError, type SsmlVoiceElement, type SubElement, type VoiceElement, type WordElement, buildPartialSsml, buildSsml, extractSsmlText, mapSsmlTextNodes, parseSsml, validateAzureSsml, validateSsml };
194
+ export { type AudioElement, type AzureSsmlValidationOptions, type AzureValidationOptions, type BookmarkElement, type BreakElement, type BuildPartialSsmlOptions, type CustomElement, type EmphasisElement, type ExpressAsElement, type LangElement, type LexiconElement, type MapSsmlTextNodesOptions, type MarkElement, type MsttsSilenceElement, type MsttsVisemeElement, type NamedElement, type ParagraphElement, type PhonemeElement, type ProsodyElement, type SayAsElement, type SentenceElement, type SsmlAttributeValue, type SsmlAttributes, type SsmlBreakElement, type SsmlDiagnostic, type SsmlDiagnosticSeverity, type SsmlDocument, type SsmlElement, type SsmlElementBase, type SsmlExpressAsElement, type SsmlNode, type SsmlPartialContext, type SsmlPartialProsody, type SsmlPartialVoice, type SsmlPhonemeElement, type SsmlProsodyElement, type SsmlSayAsElement, type SsmlText, type SsmlTextNodeContext, type SsmlValidationError, type SsmlVoiceElement, type SubElement, type VoiceElement, type WordElement, buildPartialSsml, buildSsml, extractSsmlText, mapSsmlTextNodes, parseSsml, validateAzureSsml, validateSsml };
package/dist/core.d.ts CHANGED
@@ -158,10 +158,18 @@ declare function validateSsml(xmlString: string): SsmlValidationError | null;
158
158
 
159
159
  interface SsmlTextNodeContext {
160
160
  parentTag: string;
161
+ parentAttributes: Record<string, string>;
162
+ ancestorTags: string[];
161
163
  path: string[];
162
164
  }
165
+ interface MapSsmlTextNodesOptions {
166
+ /** Element names whose text should not be passed to the transform. */
167
+ skipTags?: readonly string[];
168
+ /** Decides whether an individual text node should be passed to the transform. */
169
+ filter?: (context: SsmlTextNodeContext) => boolean;
170
+ }
163
171
  declare function extractSsmlText(ssml: string): string[];
164
- declare function mapSsmlTextNodes(ssml: string, transform: (text: string, context: SsmlTextNodeContext) => string | Promise<string>): Promise<string>;
172
+ declare function mapSsmlTextNodes(ssml: string, transform: (text: string, context: SsmlTextNodeContext) => string | Promise<string>, options?: MapSsmlTextNodesOptions): Promise<string>;
165
173
 
166
174
  type SsmlDiagnosticSeverity = "error" | "warning";
167
175
  interface SsmlDiagnostic {
@@ -170,11 +178,17 @@ interface SsmlDiagnostic {
170
178
  message: string;
171
179
  severity: SsmlDiagnosticSeverity;
172
180
  }
173
- interface AzureSsmlValidationOptions {
181
+ interface AzureValidationOptions {
174
182
  allowedAudioOrigins?: readonly string[];
183
+ allowExternalAudio?: boolean;
175
184
  allowHttpAudio?: boolean;
185
+ customVoiceStyleMap?: Record<string, readonly string[]>;
176
186
  maxLength?: number;
187
+ unknownVoicePolicy?: "error" | "warn" | "ignore";
188
+ validateNestedVoices?: boolean;
177
189
  }
178
- declare function validateAzureSsml(ssml: string, options?: AzureSsmlValidationOptions): SsmlDiagnostic[];
190
+ /** @deprecated Use AzureValidationOptions instead. */
191
+ type AzureSsmlValidationOptions = AzureValidationOptions;
192
+ declare function validateAzureSsml(ssml: string, options?: AzureValidationOptions): SsmlDiagnostic[];
179
193
 
180
- export { type AudioElement, type AzureSsmlValidationOptions, type BookmarkElement, type BreakElement, type BuildPartialSsmlOptions, type CustomElement, type EmphasisElement, type ExpressAsElement, type LangElement, type LexiconElement, type MarkElement, type MsttsSilenceElement, type MsttsVisemeElement, type NamedElement, type ParagraphElement, type PhonemeElement, type ProsodyElement, type SayAsElement, type SentenceElement, type SsmlAttributeValue, type SsmlAttributes, type SsmlBreakElement, type SsmlDiagnostic, type SsmlDiagnosticSeverity, type SsmlDocument, type SsmlElement, type SsmlElementBase, type SsmlExpressAsElement, type SsmlNode, type SsmlPartialContext, type SsmlPartialProsody, type SsmlPartialVoice, type SsmlPhonemeElement, type SsmlProsodyElement, type SsmlSayAsElement, type SsmlText, type SsmlTextNodeContext, type SsmlValidationError, type SsmlVoiceElement, type SubElement, type VoiceElement, type WordElement, buildPartialSsml, buildSsml, extractSsmlText, mapSsmlTextNodes, parseSsml, validateAzureSsml, validateSsml };
194
+ export { type AudioElement, type AzureSsmlValidationOptions, type AzureValidationOptions, type BookmarkElement, type BreakElement, type BuildPartialSsmlOptions, type CustomElement, type EmphasisElement, type ExpressAsElement, type LangElement, type LexiconElement, type MapSsmlTextNodesOptions, type MarkElement, type MsttsSilenceElement, type MsttsVisemeElement, type NamedElement, type ParagraphElement, type PhonemeElement, type ProsodyElement, type SayAsElement, type SentenceElement, type SsmlAttributeValue, type SsmlAttributes, type SsmlBreakElement, type SsmlDiagnostic, type SsmlDiagnosticSeverity, type SsmlDocument, type SsmlElement, type SsmlElementBase, type SsmlExpressAsElement, type SsmlNode, type SsmlPartialContext, type SsmlPartialProsody, type SsmlPartialVoice, type SsmlPhonemeElement, type SsmlProsodyElement, type SsmlSayAsElement, type SsmlText, type SsmlTextNodeContext, type SsmlValidationError, type SsmlVoiceElement, type SubElement, type VoiceElement, type WordElement, buildPartialSsml, buildSsml, extractSsmlText, mapSsmlTextNodes, parseSsml, validateAzureSsml, validateSsml };
package/dist/core.js CHANGED
@@ -897,6 +897,9 @@ function decodeXmlText(value) {
897
897
  function encodeXmlText(value) {
898
898
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
899
899
  }
900
+ function decodeXmlAttribute(value) {
901
+ return decodeXmlText(value);
902
+ }
900
903
  function findTagEnd(source, start) {
901
904
  let quote = "";
902
905
  for (let index = start; index < source.length; index += 1) {
@@ -915,14 +918,31 @@ function readTagName(tag) {
915
918
  const match = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(tag);
916
919
  return match?.[1];
917
920
  }
921
+ function readTagAttributes(tag, name) {
922
+ const attributes = {};
923
+ const nameStart = tag.indexOf(name);
924
+ const attributeSource = tag.slice(nameStart + name.length, tag.length - 1).replace(/\/\s*$/, "");
925
+ const attributePattern = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])([\s\S]*?)\2/g;
926
+ for (const match of attributeSource.matchAll(attributePattern)) {
927
+ attributes[match[1].toLowerCase()] = decodeXmlAttribute(match[3]);
928
+ }
929
+ return attributes;
930
+ }
918
931
  function collectTextNodes(source) {
919
932
  const nodes = [];
920
- const path = [];
933
+ const elements = [];
921
934
  let index = 0;
922
935
  const addText = (start, end, rawText, sourceStart = start, sourceEnd = end) => {
923
936
  if (!rawText) return;
937
+ const path = elements.map((element) => element.name);
938
+ const parent = elements[elements.length - 1];
924
939
  nodes.push({
925
- context: { parentTag: path[path.length - 1] ?? "", path: [...path] },
940
+ context: {
941
+ ancestorTags: path.slice(0, -1),
942
+ parentAttributes: { ...parent?.attributes ?? {} },
943
+ parentTag: parent?.name ?? "",
944
+ path
945
+ },
926
946
  decodedText: decodeXmlText(rawText),
927
947
  end,
928
948
  sourceEnd,
@@ -964,14 +984,14 @@ function collectTextNodes(source) {
964
984
  }
965
985
  if (source.startsWith("</", index)) {
966
986
  const end2 = findTagEnd(source, index + 2);
967
- path.pop();
987
+ elements.pop();
968
988
  index = end2 + 1;
969
989
  continue;
970
990
  }
971
991
  const end = findTagEnd(source, index + 1);
972
992
  const tag = source.slice(index, end + 1);
973
993
  const name = readTagName(tag);
974
- if (name && !/\/\s*>$/.test(tag)) path.push(name);
994
+ if (name && !/\/\s*>$/.test(tag)) elements.push({ attributes: readTagAttributes(tag, name), name });
975
995
  index = end + 1;
976
996
  }
977
997
  return nodes;
@@ -980,15 +1000,21 @@ function extractSsmlText(ssml) {
980
1000
  parseSsml(ssml);
981
1001
  return collectTextNodes(ssml).map((node) => node.decodedText);
982
1002
  }
983
- async function mapSsmlTextNodes(ssml, transform) {
1003
+ async function mapSsmlTextNodes(ssml, transform, options = {}) {
984
1004
  parseSsml(ssml);
985
1005
  const nodes = collectTextNodes(ssml);
1006
+ const skipTags = new Set((options.skipTags ?? ["phoneme", "say-as", "sub"]).map((tag) => tag.toLowerCase()));
986
1007
  const replacements = await Promise.all(
987
1008
  nodes.map(async (node) => {
988
- const transformed = await transform(node.decodedText, {
1009
+ const context = {
1010
+ ancestorTags: [...node.context.ancestorTags],
1011
+ parentAttributes: { ...node.context.parentAttributes },
989
1012
  parentTag: node.context.parentTag,
990
1013
  path: [...node.context.path]
991
- });
1014
+ };
1015
+ const shouldTransform = !skipTags.has(context.parentTag.toLowerCase()) && (options.filter?.(context) ?? true);
1016
+ if (!shouldTransform) return ssml.slice(node.sourceStart, node.sourceEnd);
1017
+ const transformed = await transform(node.decodedText, context);
992
1018
  if (typeof transformed !== "string") {
993
1019
  throw new TypeError("SSML text node transform must return a string");
994
1020
  }
@@ -1145,6 +1171,7 @@ function findTagEnd2(source, start) {
1145
1171
  }
1146
1172
  function tokenizeElements(source) {
1147
1173
  const tokens = [];
1174
+ const openElements = [];
1148
1175
  let index = 0;
1149
1176
  while (index < source.length) {
1150
1177
  const start = source.indexOf("<", index);
@@ -1166,8 +1193,13 @@ function tokenizeElements(source) {
1166
1193
  }
1167
1194
  const end = findTagEnd2(source, start + 1);
1168
1195
  const raw = source.slice(start, end + 1);
1196
+ if (raw.startsWith("</")) {
1197
+ openElements.pop();
1198
+ index = end + 1;
1199
+ continue;
1200
+ }
1169
1201
  const nameMatch = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(raw);
1170
- if (!nameMatch?.[1] || raw.startsWith("</")) {
1202
+ if (!nameMatch?.[1]) {
1171
1203
  index = end + 1;
1172
1204
  continue;
1173
1205
  }
@@ -1177,7 +1209,15 @@ function tokenizeElements(source) {
1177
1209
  for (const match of attributeSource.matchAll(attributePattern)) {
1178
1210
  attributes.set(match[1].toLowerCase(), decodeAttribute(match[3]));
1179
1211
  }
1180
- tokens.push({ attributes, end, name: nameMatch[1], selfClosing: /\/\s*>$/.test(raw), start });
1212
+ const selfClosing = /\/\s*>$/.test(raw);
1213
+ const parentVoiceName = [...openElements].reverse().find((element) => element.voiceName)?.voiceName;
1214
+ tokens.push({ attributes, end, name: nameMatch[1], parentVoiceName, selfClosing, start });
1215
+ if (!selfClosing) {
1216
+ openElements.push({
1217
+ name: nameMatch[1],
1218
+ voiceName: nameMatch[1].toLowerCase() === "voice" ? attributes.get("name") : parentVoiceName
1219
+ });
1220
+ }
1181
1221
  index = end + 1;
1182
1222
  }
1183
1223
  return tokens;
@@ -1193,7 +1233,23 @@ function addDiagnostic(diagnostics, source, offset, message, severity = "error")
1193
1233
  function attr(token, name) {
1194
1234
  return token.attributes.get(name.toLowerCase());
1195
1235
  }
1196
- function validateElement(token, source, diagnostics, voiceName, options) {
1236
+ function normalizeVoiceStyleMap(customVoiceStyleMap) {
1237
+ const map = new Map(
1238
+ Object.entries(EXPRESS_AS_STYLES).map(([voiceName, styles]) => [voiceName.toLowerCase(), styles])
1239
+ );
1240
+ for (const [voiceName, styles] of Object.entries(customVoiceStyleMap ?? {})) {
1241
+ map.set(
1242
+ voiceName.toLowerCase(),
1243
+ styles.map((style) => style.toLowerCase())
1244
+ );
1245
+ }
1246
+ return map;
1247
+ }
1248
+ function diagnosticSeverity(policy) {
1249
+ if (policy === "ignore") return void 0;
1250
+ return policy === "error" ? "error" : "warning";
1251
+ }
1252
+ function validateElement(token, source, diagnostics, voiceName, options, voiceStyleMap) {
1197
1253
  const name = token.name.toLowerCase();
1198
1254
  if (name === "voice" && !attr(token, "name")?.trim())
1199
1255
  addDiagnostic(diagnostics, source, token.start, '<voice> requires a non-empty "name" attribute.');
@@ -1235,9 +1291,24 @@ function validateElement(token, source, diagnostics, voiceName, options) {
1235
1291
  const role = attr(token, "role");
1236
1292
  if (role && !ALLOWED_ROLES.has(role))
1237
1293
  addDiagnostic(diagnostics, source, token.start, `Unsupported <mstts:express-as role> value "${role}".`);
1238
- const supportedStyles = voiceName ? EXPRESS_AS_STYLES[voiceName.toLowerCase()] : void 0;
1239
- if (style && supportedStyles && !supportedStyles.includes(style.toLowerCase()))
1240
- addDiagnostic(diagnostics, source, token.start, `Style "${style}" is not supported by voice "${voiceName}".`);
1294
+ const supportedStyles = voiceName ? voiceStyleMap.get(voiceName.toLowerCase()) : void 0;
1295
+ const severity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
1296
+ if (style && supportedStyles && !supportedStyles.includes(style.toLowerCase()) && severity)
1297
+ addDiagnostic(
1298
+ diagnostics,
1299
+ source,
1300
+ token.start,
1301
+ `Unknown style "${style}" is not supported by voice "${voiceName}" according to the configured voice style map.`,
1302
+ severity
1303
+ );
1304
+ if (style && voiceName && !supportedStyles && severity)
1305
+ addDiagnostic(
1306
+ diagnostics,
1307
+ source,
1308
+ token.start,
1309
+ `Unknown style "${style}" cannot be verified because voice "${voiceName}" is not registered in the voice style map.`,
1310
+ severity
1311
+ );
1241
1312
  }
1242
1313
  if (name === "say-as" || name === "sayas") {
1243
1314
  const interpretAs = attr(token, "interpret-as");
@@ -1297,6 +1368,14 @@ function validateElement(token, source, diagnostics, voiceName, options) {
1297
1368
  addDiagnostic(diagnostics, source, token.start, "<audio src> must use HTTPS.");
1298
1369
  if (options.allowedAudioOrigins && !options.allowedAudioOrigins.includes(parsed.origin))
1299
1370
  addDiagnostic(diagnostics, source, token.start, `<audio src> origin "${parsed.origin}" is not allowed.`);
1371
+ else if (!options.allowExternalAudio)
1372
+ addDiagnostic(
1373
+ diagnostics,
1374
+ source,
1375
+ token.start,
1376
+ `<audio src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`,
1377
+ "error"
1378
+ );
1300
1379
  }
1301
1380
  }
1302
1381
  }
@@ -1327,7 +1406,24 @@ function validateAzureSsml(ssml, options = {}) {
1327
1406
  "Azure SSML requires at least one <voice> element under <speak>."
1328
1407
  );
1329
1408
  const voiceName = voices[0] ? attr(voices[0], "name") : void 0;
1330
- for (const token of tokens) validateElement(token, ssml, diagnostics, voiceName, options);
1409
+ const voiceStyleMap = normalizeVoiceStyleMap(options.customVoiceStyleMap);
1410
+ const policySeverity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
1411
+ const voicesToValidate = options.validateNestedVoices === false ? voices.slice(0, 1) : voices;
1412
+ for (const token of voicesToValidate) {
1413
+ const name = attr(token, "name")?.trim();
1414
+ if (name && !voiceStyleMap.has(name.toLowerCase()) && policySeverity)
1415
+ addDiagnostic(
1416
+ diagnostics,
1417
+ ssml,
1418
+ token.start,
1419
+ `Unknown voice "${name}" is not registered in the voice style map.`,
1420
+ policySeverity
1421
+ );
1422
+ }
1423
+ for (const token of tokens) {
1424
+ const tokenVoiceName = options.validateNestedVoices === false ? voiceName : token.parentVoiceName;
1425
+ validateElement(token, ssml, diagnostics, tokenVoiceName, options, voiceStyleMap);
1426
+ }
1331
1427
  return diagnostics;
1332
1428
  }
1333
1429
  // Annotate the CommonJS export names for ESM import in node: