UNPKG

@ssml-utilities/highlighter

Version:
180 lines (173 loc) 7.89 kB
import { success, failure, parseSSML } from '@ssml-utilities/core'; function escapeHtml(text) { return text .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;"); } function highlightAttributes(attributes, options) { let result = ""; let remaining = attributes; while (remaining.length > 0) { // 先頭の空白を処理 const leadingSpaceMatch = remaining.match(/^\s+/); if (leadingSpaceMatch) { result += leadingSpaceMatch[0]; remaining = remaining.slice(leadingSpaceMatch[0].length); continue; } // 属性名を処理(名前空間のコロンを含む場合も対応) const nameMatch = remaining.match(/^([a-zA-Z_][\w-]*(?::[a-zA-Z_][\w-]*)?)/); if (nameMatch) { const name = nameMatch[0]; result += `<span class="${options.classes.attribute}">${escapeHtml(name)}</span>`; remaining = remaining.slice(name.length); // 等号と値を処理 const valueMatch = remaining.match(/^(\s*=\s*)(?:("[^"]*"|'[^']*')|(\S+))/); if (valueMatch) { const [fullMatch, equals, quotedValue, unquotedValue] = valueMatch; const value = quotedValue || unquotedValue; result += escapeHtml(equals); if (quotedValue) { const quote = value[0]; const innerValue = value.slice(1, -1); result += `${quote}<span class="${options.classes.attributeValue}">${escapeHtml(innerValue)}</span>${quote}`; } else { result += `<span class="${options.classes.attributeValue}">${escapeHtml(value)}</span>`; } remaining = remaining.slice(fullMatch.length); } continue; } // マッチしない文字があれば、そのまま追加して次へ result += escapeHtml(remaining[0]); remaining = remaining.slice(1); } return success(result); } function extractAttributesFromNode(node) { if (!node.value) { return ""; } const value = node.value; // 自己閉じタグ: <tag ... /> または <namespace:tag ... /> // 名前空間を含むタグ名全体をキャプチャするように正規表現を変更 const selfClosingMatch = value.match(/^<([\w-]+(?::[\w-]+)?)([\s\S]*?)\/>/); if (selfClosingMatch) { // タグ名(名前空間含む)の直後から、'/>' の直前までを抽出 const extracted = value.substring(selfClosingMatch[1].length + 1, value.lastIndexOf("/>")); return extracted; } // 開始タグ: <tag ...> または <namespace:tag ...> // 名前空間を含むタグ名全体をキャプチャするように正規表現を変更 const openingTagMatch = value.match(/^<([\w-]+(?::[\w-]+)?)([\s\S]*?)>/); if (openingTagMatch) { // タグ名(名前空間含む)の直後から、'>' の直前までを抽出 const extracted = value.substring(openingTagMatch[1].length + 1, value.lastIndexOf(">")); return extracted; } // タグとして認識できない場合は空文字列を返す return ""; } function highlightNode(nodeId, dag, options) { const node = dag.nodes.get(nodeId); if (!node) { return failure(`Node with id ${nodeId} not found`); } switch (node.type) { case "element": { const tagMatch = node.value.match(/^<(\/?(?:[\w:-]+(?:[\w:-]+)*))(.*)>?$/s); if (tagMatch) { const [_, tagName, rest] = tagMatch; // restはタグ名の後に続く属性部分の文字列 const attributesResult = highlightAttributes(extractAttributesFromNode(node), options); if (!attributesResult.ok) { return failure(attributesResult.error); } const contentResult = highlightChildren(node, dag, options); if (!contentResult.ok) { return failure(contentResult.error); } let tagContent; if (attributesResult.value) { tagContent = `&lt;${escapeHtml(tagName)}${attributesResult.value}`; if (node.value.trim().endsWith("/>") || rest.trim().endsWith("/")) { tagContent += "/"; } tagContent += "&gt;"; // > 文字実体参照 } else { // restは既にフォーマットされている可能性があるため、適切に処理 // 基本的なHTMLエンティティのみをエスケープし、既にエスケープされた部分は保持 const safeRest = rest.replace(/[<>&]/g, (c) => { switch (c) { case "<": return "&lt;"; case ">": return "&gt;"; case "&": // &で始まりセミコロンで終わる場合はHTMLエンティティとして保持 return /&[a-zA-Z0-9#]+;/.test(rest) ? c : "&amp;"; default: return c; } }); tagContent = `&lt;${escapeHtml(tagName)}${safeRest}`; } return success(`<span class="${options.classes.tag}">${tagContent}</span>${contentResult.value}`); } return success(`<span class="${options.classes.tag}">${escapeHtml(node.value)}</span>`); } case "attribute": { if (node.value) { return success(` <span class="${options.classes.attribute}">${escapeHtml(node.name)}</span>=<span class="${options.classes.attributeValue}">"${escapeHtml(node.value)}"</span>`); } else { return success(` <span class="${options.classes.attribute}">${escapeHtml(node.name)}</span>`); } } case "text": { return success(`<span class="${options.classes.text}">${escapeHtml(node.value)}</span>`); } default: { return failure(`Unknown node type: ${node.type}`); } } } function highlightChildren(rootNode, dag, options) { const results = Array.from(rootNode.children) .map((childId) => dag.nodes.get(childId)) .filter((child) => child !== undefined && child.type !== "attribute") .map((child) => highlightNode(child.id, dag, options)); const errors = results.filter((result) => !result.ok); if (errors.length > 0) { return failure(errors.map((err) => err.error).join(", ")); } return success(results .filter((result) => result.ok) .map((result) => result.value) .join("")); } const ssmlHighlighter = { highlight: (ssmlOrDag, options) => { const dagResult = typeof ssmlOrDag === "string" ? parseSSML(ssmlOrDag) : ssmlOrDag; if (!dagResult.ok) { return failure(`Failed to parse SSML: ${dagResult.error}`); } return highlightSSML(dagResult.value, options); }, }; function highlightSSML(dag, options) { const rootNode = Array.from(dag.nodes.values()).find((node) => node.type === "root"); if (!rootNode) { return failure("Root node not found"); } const result = highlightChildren(rootNode, dag, options); if (!result.ok) { return failure(`Failed to highlight SSML: ${result.error}`); } return success(result.value); } export { ssmlHighlighter };