ssml-builder-js 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core.js CHANGED
@@ -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,45 @@ 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 voiceLocalePrefix(voiceName) {
1253
+ const match = /^(?<language>[A-Za-z]{2,3})-(?<region>[A-Za-z]{2}|\d{3})(?:-|$)/.exec(voiceName.trim());
1254
+ if (!match?.groups) return void 0;
1255
+ return {
1256
+ language: match.groups.language.toLowerCase(),
1257
+ region: match.groups.region.toLowerCase()
1258
+ };
1259
+ }
1260
+ function languageLocalePrefix(language) {
1261
+ const match = /^(?<language>[A-Za-z]{2,3})(?:-(?<region>[A-Za-z]{2}|\d{3}))?(?:-|$)/.exec(language.trim());
1262
+ if (!match?.groups) return void 0;
1263
+ return {
1264
+ language: match.groups.language.toLowerCase(),
1265
+ region: match.groups.region?.toLowerCase()
1266
+ };
1267
+ }
1268
+ function voiceMatchesLanguage(voiceName, language) {
1269
+ const voiceLocale = voiceLocalePrefix(voiceName);
1270
+ const languageLocale = languageLocalePrefix(language);
1271
+ if (!voiceLocale || !languageLocale) return void 0;
1272
+ return voiceLocale.language === languageLocale.language && (languageLocale.region === void 0 || voiceLocale.region === languageLocale.region);
1273
+ }
1274
+ function validateElement(token, source, diagnostics, voiceName, options, voiceStyleMap) {
1197
1275
  const name = token.name.toLowerCase();
1198
1276
  if (name === "voice" && !attr(token, "name")?.trim())
1199
1277
  addDiagnostic(diagnostics, source, token.start, '<voice> requires a non-empty "name" attribute.');
@@ -1235,9 +1313,24 @@ function validateElement(token, source, diagnostics, voiceName, options) {
1235
1313
  const role = attr(token, "role");
1236
1314
  if (role && !ALLOWED_ROLES.has(role))
1237
1315
  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}".`);
1316
+ const supportedStyles = voiceName ? voiceStyleMap.get(voiceName.toLowerCase()) : void 0;
1317
+ const severity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
1318
+ if (style && supportedStyles && !supportedStyles.includes(style.toLowerCase()) && severity)
1319
+ addDiagnostic(
1320
+ diagnostics,
1321
+ source,
1322
+ token.start,
1323
+ `Unknown style "${style}" is not supported by voice "${voiceName}" according to the configured voice style map.`,
1324
+ severity
1325
+ );
1326
+ if (style && voiceName && !supportedStyles && severity)
1327
+ addDiagnostic(
1328
+ diagnostics,
1329
+ source,
1330
+ token.start,
1331
+ `Unknown style "${style}" cannot be verified because voice "${voiceName}" is not registered in the voice style map.`,
1332
+ severity
1333
+ );
1241
1334
  }
1242
1335
  if (name === "say-as" || name === "sayas") {
1243
1336
  const interpretAs = attr(token, "interpret-as");
@@ -1297,6 +1390,14 @@ function validateElement(token, source, diagnostics, voiceName, options) {
1297
1390
  addDiagnostic(diagnostics, source, token.start, "<audio src> must use HTTPS.");
1298
1391
  if (options.allowedAudioOrigins && !options.allowedAudioOrigins.includes(parsed.origin))
1299
1392
  addDiagnostic(diagnostics, source, token.start, `<audio src> origin "${parsed.origin}" is not allowed.`);
1393
+ else if (!options.allowExternalAudio)
1394
+ addDiagnostic(
1395
+ diagnostics,
1396
+ source,
1397
+ token.start,
1398
+ `<audio src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`,
1399
+ "error"
1400
+ );
1300
1401
  }
1301
1402
  }
1302
1403
  }
@@ -1327,7 +1428,33 @@ function validateAzureSsml(ssml, options = {}) {
1327
1428
  "Azure SSML requires at least one <voice> element under <speak>."
1328
1429
  );
1329
1430
  const voiceName = voices[0] ? attr(voices[0], "name") : void 0;
1330
- for (const token of tokens) validateElement(token, ssml, diagnostics, voiceName, options);
1431
+ const voiceStyleMap = normalizeVoiceStyleMap(options.customVoiceStyleMap);
1432
+ const policySeverity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
1433
+ const voicesToValidate = options.validateNestedVoices === false ? voices.slice(0, 1) : voices;
1434
+ for (const token of voicesToValidate) {
1435
+ const name = attr(token, "name")?.trim();
1436
+ const language = attr(token, "xml:lang")?.trim() || (speak ? attr(speak, "xml:lang")?.trim() : void 0);
1437
+ if (name && !voiceStyleMap.has(name.toLowerCase()) && policySeverity)
1438
+ addDiagnostic(
1439
+ diagnostics,
1440
+ ssml,
1441
+ token.start,
1442
+ `Unknown voice "${name}" is not registered in the voice style map.`,
1443
+ policySeverity
1444
+ );
1445
+ if (name && language && voiceMatchesLanguage(name, language) === false)
1446
+ addDiagnostic(
1447
+ diagnostics,
1448
+ ssml,
1449
+ token.start,
1450
+ `Voice "${name}" does not match language "${language}"; the voice name prefix indicates a different language or region.`,
1451
+ "warning"
1452
+ );
1453
+ }
1454
+ for (const token of tokens) {
1455
+ const tokenVoiceName = options.validateNestedVoices === false ? voiceName : token.parentVoiceName;
1456
+ validateElement(token, ssml, diagnostics, tokenVoiceName, options, voiceStyleMap);
1457
+ }
1331
1458
  return diagnostics;
1332
1459
  }
1333
1460
  // Annotate the CommonJS export names for ESM import in node: