UNPKG

@xuda.io/runtime-bundle

Version:

The Xuda Runtime Bundle refers to a collection of scripts and libraries packaged together to provide the necessary runtime environment for executing plugins or components in the Xuda platform.

682 lines (600 loc) • 18.2 kB
/* Tags which contain arbitary non-parsed content For example: <script> JavaScript should not be parsed */ export const childlessTags = ["style", "script", "template"]; /* Tags which auto-close because they cannot be nested For example: <p>Outer<p>Inner is <p>Outer</p><p>Inner</p> */ export const closingTags = ["html", "head", "body", "p", "dt", "dd", "li", "option", "thead", "th", "tbody", "tr", "td", "tfoot", "colgroup"]; /* Closing tags which have ancestor tags which may exist within them which prevent the closing tag from auto-closing. For example: in <li><ul><li></ul></li>, the top-level <li> should not auto-close. */ export const closingTagAncestorBreakers = { li: ["ul", "ol", "menu"], dt: ["dl"], dd: ["dl"], tbody: ["table"], thead: ["table"], tfoot: ["table"], tr: ["table"], td: ["table"] }; /* Tags which do not need the closing tag For example: <img> does not need </img> */ export const voidTags = ["!doctype", "area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "meta", "param", "source", "track", "wbr"]; export function uuidv4() { return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)); } export function isObject(val) { return val instanceof Object; } export function formatAttributes(attributes) { return Object.keys(attributes).reduce((attrs, attrKey) => { const key = attrKey; var value = attributes[attrKey]; if (value === null || !value) { return `${attrs} ${key}`; } if (isObject(value)) value = JSON.stringify(value); // const quoteEscape = value.toString()?.includes("'"); // const quote = quoteEscape ? '"' : "'"; // return `${attrs} ${key}=${quote}${value}${quote}`; value = value.toString().replaceAll('"', "'"); return `${attrs} ${key}="${value}"`; }, ""); } export function toHTML(tree, options) { return tree .map((node) => { if (!node.type) return; if (node.type === "text") { return node.content; } if (node.type === "comment") { return `<!--${node.content}-->`; } var text = ""; if (node.content) { text = node.content; } const { tagName, attributes, children } = node; // debugger if (!attributes.internal_tree_id && !options.remove_tree_id) attributes.internal_tree_id = node.id; if (options.remove_tree_id) { delete attributes.internal_tree_id; } if (options.add_path) { attributes.internal_path = node?.path?.toString?.(); } const isSelfClosing = arrayIncludes(options.voidTags, tagName.toLowerCase()); return isSelfClosing ? `<${tagName}${formatAttributes(attributes)}>` : `<${tagName}${formatAttributes(attributes)}>${text}${toHTML(children, options)}</${tagName}>`; }) .join(""); } export function parser(tokens, options) { const root = { tagName: null, children: [] }; const state = { tokens, options, cursor: 0, stack: [root] }; parserParse(state); return root.children; } export function hasTerminalParent(tagName, stack, terminals) { const tagParents = terminals[tagName]; if (tagParents) { let currentIndex = stack.length - 1; while (currentIndex >= 0) { const parentTagName = stack[currentIndex].tagName; if (parentTagName === tagName) { break; } if (arrayIncludes(tagParents, parentTagName)) { return true; } currentIndex--; } } return false; } export function rewindStack(stack, newLength, childrenEndPosition, endPosition) { stack[newLength].position.end = endPosition; for (let i = newLength + 1, len = stack.length; i < len; i++) { stack[i].position.end = childrenEndPosition; } stack.splice(newLength); } export function parserParse(state) { const { tokens, options } = state; let { stack } = state; let nodes = stack[stack.length - 1].children; const len = tokens.length; let { cursor } = state; while (cursor < len) { const token = tokens[cursor]; if (token.type !== "tag-start") { nodes.push(token); cursor++; continue; } const tagToken = tokens[++cursor]; cursor++; const tagName = tagToken.content.toLowerCase(); if (token.close) { let index = stack.length; let shouldRewind = false; while (--index > -1) { if (stack[index].tagName === tagName) { shouldRewind = true; break; } } while (cursor < len) { const endToken = tokens[cursor]; if (endToken.type !== "tag-end") break; cursor++; } if (shouldRewind) { rewindStack(stack, index, token.position.start, tokens[cursor - 1].position.end); break; } else { continue; } } const isClosingTag = arrayIncludes(options.closingTags, tagName); let shouldRewindToAutoClose = isClosingTag; if (shouldRewindToAutoClose) { const { closingTagAncestorBreakers: terminals } = options; shouldRewindToAutoClose = !hasTerminalParent(tagName, stack, terminals); } if (shouldRewindToAutoClose) { // rewind the stack to just above the previous // closing tag of the same name let currentIndex = stack.length - 1; while (currentIndex > 0) { if (tagName === stack[currentIndex].tagName) { rewindStack(stack, currentIndex, token.position.start, token.position.start); const previousIndex = currentIndex - 1; nodes = stack[previousIndex].children; break; } currentIndex = currentIndex - 1; } } // let attributes = []; let attributes = {}; let attrToken; while (cursor < len) { attrToken = tokens[cursor]; if (attrToken.type === "tag-end") break; // debugger; // attributes.push(attrToken.content); attributes[attrToken.content] = ""; cursor++; } cursor++; const children = []; const position = { start: token.position.start, end: attrToken.position.end }; const elementNode = { type: "element", tagName: tagToken.content, attributes, children, position }; nodes.push(elementNode); const hasChildren = !(attrToken.close || arrayIncludes(options.voidTags, tagName)); if (hasChildren) { const size = stack.push({ tagName, children, position }); const innerState = { tokens, options, cursor, stack }; parserParse(innerState); cursor = innerState.cursor; const rewoundInElement = stack.length === size; if (rewoundInElement) { elementNode.position.end = tokens[cursor - 1].position.end; } } } state.cursor = cursor; } export function feedPosition(position, str, len) { const start = position.index; const end = (position.index = start + len); for (let i = start; i < end; i++) { const char = str.charAt(i); if (char === "\n") { position.line++; position.column = 0; } else { position.column++; } } } export function jumpPosition(position, str, end) { const len = end - position.index; return feedPosition(position, str, len); } export function makeInitialPosition() { return { index: 0, column: 0, line: 0 }; } export function copyPosition(position) { return { index: position.index, line: position.line, column: position.column }; } export function lexer(str, options) { const state = { str, options, position: makeInitialPosition(), tokens: [] }; lex(state); return state.tokens; } export function lex(state) { const { str, options: { childlessTags } } = state; const len = str.length; while (state.position.index < len) { const start = state.position.index; lexText(state); if (state.position.index === start) { const isComment = startsWith(str, "!--", start + 1); if (isComment) { lexComment(state); } else { const tagName = lexTag(state); const safeTag = tagName.toLowerCase(); if (arrayIncludes(childlessTags, safeTag)) { lexSkipTag(tagName, state); } } } } } const alphanumeric = /[A-Za-z0-9]/; export function findTextEnd(str, index) { while (true) { const textEnd = str.indexOf("<", index); if (textEnd === -1) { return textEnd; } const char = str.charAt(textEnd + 1); if (char === "/" || char === "!" || alphanumeric.test(char)) { return textEnd; } index = textEnd + 1; } } export function lexText(state) { const type = "text"; const { str, position } = state; let textEnd = findTextEnd(str, position.index); if (textEnd === position.index) return; if (textEnd === -1) { textEnd = str.length; } const start = copyPosition(position); const content = str.slice(position.index, textEnd)?.trim(); jumpPosition(position, str, textEnd); const end = copyPosition(position); if (content) state.tokens.push({ type, content, position: { start, end } }); } export function lexComment(state) { const { str, position } = state; const start = copyPosition(position); feedPosition(position, str, 4); // "<!--".length let contentEnd = str.indexOf("-->", position.index); let commentEnd = contentEnd + 3; // "-->".length if (contentEnd === -1) { contentEnd = commentEnd = str.length; } const content = str.slice(position.index, contentEnd); jumpPosition(position, str, commentEnd); state.tokens.push({ type: "comment", content, position: { start, end: copyPosition(position) } }); } export function lexTag(state) { const { str, position } = state; { const secondChar = str.charAt(position.index + 1); const close = secondChar === "/"; const start = copyPosition(position); feedPosition(position, str, close ? 2 : 1); state.tokens.push({ type: "tag-start", close, position: { start } }); } const tagName = lexTagName(state); lexTagAttributes(state); { const firstChar = str.charAt(position.index); const close = firstChar === "/"; feedPosition(position, str, close ? 2 : 1); const end = copyPosition(position); state.tokens.push({ type: "tag-end", close, position: { end } }); } return tagName; } // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#special-white-space const whitespace = /\s/; export function isWhitespaceChar(char) { return whitespace.test(char); } export function lexTagName(state) { const { str, position } = state; const len = str.length; let start = position.index; while (start < len) { const char = str.charAt(start); const isTagChar = !(isWhitespaceChar(char) || char === "/" || char === ">"); if (isTagChar) break; start++; } let end = start + 1; while (end < len) { const char = str.charAt(end); const isTagChar = !(isWhitespaceChar(char) || char === "/" || char === ">"); if (!isTagChar) break; end++; } jumpPosition(position, str, end); const tagName = str.slice(start, end); state.tokens.push({ type: "tag", content: tagName }); return tagName; } export function lexTagAttributes(state) { const { str, position, tokens } = state; let cursor = position.index; let quote = null; // null, single-, or double-quote let wordBegin = cursor; // index of word start const words = []; // "key", "key=value", "key='value'", etc const len = str.length; while (cursor < len) { const char = str.charAt(cursor); if (quote) { const isQuoteEnd = char === quote; if (isQuoteEnd) { quote = null; } cursor++; continue; } const isTagEnd = char === "/" || char === ">"; if (isTagEnd) { if (cursor !== wordBegin) { words.push(str.slice(wordBegin, cursor)); } break; } const isWordEnd = isWhitespaceChar(char); if (isWordEnd) { if (cursor !== wordBegin) { words.push(str.slice(wordBegin, cursor)); } wordBegin = cursor + 1; cursor++; continue; } const isQuoteStart = char === "'" || char === '"'; if (isQuoteStart) { quote = char; cursor++; continue; } cursor++; } jumpPosition(position, str, cursor); const wLen = words.length; const type = "attribute"; for (let i = 0; i < wLen; i++) { const word = words[i]; const isNotPair = word.indexOf("=") === -1; if (isNotPair) { const secondWord = words[i + 1]; if (secondWord && startsWith(secondWord, "=")) { if (secondWord.length > 1) { const newWord = word + secondWord; tokens.push({ type, content: newWord }); i += 1; continue; } const thirdWord = words[i + 2]; i += 1; if (thirdWord) { const newWord = word + "=" + thirdWord; tokens.push({ type, content: newWord }); i += 1; continue; } } } if (endsWith(word, "=")) { const secondWord = words[i + 1]; if (secondWord && !stringIncludes(secondWord, "=")) { const newWord = word + secondWord; tokens.push({ type, content: newWord }); i += 1; continue; } const newWord = word.slice(0, -1); tokens.push({ type, content: newWord }); continue; } tokens.push({ type, content: word }); } } const push = [].push; export function lexSkipTag(tagName, state) { const { str, position, tokens } = state; const safeTagName = tagName.toLowerCase(); const len = str.length; let index = position.index; while (index < len) { const nextTag = str.indexOf("</", index); if (nextTag === -1) { lexText(state); break; } const tagStartPosition = copyPosition(position); jumpPosition(tagStartPosition, str, nextTag); const tagState = { str, position: tagStartPosition, tokens: [] }; const name = lexTag(tagState); if (safeTagName !== name.toLowerCase()) { index = tagState.position.index; continue; } if (nextTag !== position.index) { const textStart = copyPosition(position); jumpPosition(position, str, nextTag); tokens.push({ type: "text", content: str.slice(textStart.index, nextTag), position: { start: textStart, end: copyPosition(position) } }); } push.apply(tokens, tagState.tokens); jumpPosition(position, str, tagState.position.index); break; } } export function startsWith(str, searchString, position) { return str.substr(position || 0, searchString.length) === searchString; } export function endsWith(str, searchString, position) { const index = (position || str.length) - searchString.length; const lastIndex = str.lastIndexOf(searchString, index); return lastIndex !== -1 && lastIndex === index; } export function stringIncludes(str, searchString, position) { return str.indexOf(searchString, position || 0) !== -1; } export function isRealNaN(x) { return typeof x === "number" && isNaN(x); } export function arrayIncludes(array, searchElement, position) { const len = array.length; if (len === 0) return false; const lookupIndex = position | 0; const isNaNElement = isRealNaN(searchElement); let searchIndex = lookupIndex >= 0 ? lookupIndex : len + lookupIndex; while (searchIndex < len) { const element = array[searchIndex++]; if (element === searchElement) return true; if (isNaNElement && isRealNaN(element)) return true; } return false; } export function splitHead(str, sep) { const idx = str.indexOf(sep); if (idx === -1) return [str]; return [str.slice(0, idx), str.slice(idx + sep.length)]; } export function unquote(str) { const car = str.charAt(0); const end = str.length - 1; const isQuoteStart = car === '"' || car === "'"; if (isQuoteStart && car === str.charAt(end)) { return str.slice(1, end); } return str; } export function format(nodes, options) { return nodes.map((node) => { var outputNode = {}; const type = node.type; if (node.children) { var textIndex = node.children?.findIndex((e) => { return e.type === "text"; }); if (textIndex !== -1) { outputNode.content = node.children[textIndex]?.content?.trim(); node.children.splice(textIndex, 1); } } switch (type) { case "element": var ATTRS = renderFormatAttributes(node.attributes); delete ATTRS.internal_tree_id; outputNode = { ...outputNode, type, tagName: node.tagName.toLowerCase(), attributes: ATTRS, children: format(node.children, options), id: node.id || generateTreeId() }; break; default: outputNode = { type, content: node.content?.trim() }; break; } if (options.includePositions) { outputNode.position = node.position; } return outputNode; }); } export function renderFormatAttributes(attributes) { var ret = {}; Object.entries(attributes).forEach(([attribute, val]) => { const parts = splitHead(attribute.trim(), "="); const key = parts[0]; const value = typeof parts[1] === "string" ? unquote(parts[1]) : null; var getValue = (value) => { try { return eval(value); // return JSON.parse(value); } catch (error) { return value; } }; ret[key] = getValue(value); }); return ret; } export const parseDefaults = { voidTags, closingTags, childlessTags, closingTagAncestorBreakers, includePositions: false }; export function generateTreeId() { return "node-" + uuidv4(); } export function xudaPrase(str, options = parseDefaults) { const tokens = lexer(str, options); const nodes = parser(tokens, options); return format(nodes, { ...parseDefaults, ...options }); } export function xudaStringify(ast, options) { return toHTML(ast, { ...parseDefaults, ...options }); }