@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
JavaScript
/*
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 });
}