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/README.md +169 -3
- package/dist/{chunk-I3BR2WCQ.mjs → chunk-GF4QZHLJ.mjs} +1 -1
- package/dist/chunk-GF4QZHLJ.mjs.map +1 -0
- package/dist/{chunk-RUIEMCWP.mjs → chunk-GTARZC43.mjs} +142 -15
- package/dist/chunk-GTARZC43.mjs.map +1 -0
- package/dist/core.d.mts +18 -4
- package/dist/core.d.ts +18 -4
- package/dist/core.js +141 -14
- package/dist/core.js.map +1 -1
- package/dist/core.mjs +1 -1
- package/dist/elements.js.map +1 -1
- package/dist/elements.mjs +1 -1
- package/dist/index.d.mts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +169 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +29 -3
- package/dist/index.mjs.map +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/package.json +3 -3
- package/dist/chunk-I3BR2WCQ.mjs.map +0 -1
- package/dist/chunk-RUIEMCWP.mjs.map +0 -1
package/dist/core.js
CHANGED
|
@@ -897,6 +897,9 @@ function decodeXmlText(value) {
|
|
|
897
897
|
function encodeXmlText(value) {
|
|
898
898
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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))
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
-
|
|
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:
|