@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.
704 lines (616 loc) • 19.3 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, options = {}) {
return Object.keys(attributes).reduce((attrs, attrKey) => {
const key = attrKey;
var value = attributes[attrKey];
if (value === null || (typeof value !== 'boolean' && !value)) {
return `${attrs} ${key}`;
}
if (isObject(value)) {
// For editor mode, just put raw JSON with single quotes around the attribute
const jsonString = JSON.stringify(value);
return `${attrs} ${key}='${jsonString}'`;
}
value = value.toString();
// For simple strings, use double quotes
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}-->`;
}
const escapeHtml = (unsafe) => {
return unsafe.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); // or '
};
var text = (node.attributes?.['xu-text'] && escapeHtml(node.attributes?.['xu-text'])) || node.attributes?.['xu-html'] || (node?.content && escapeHtml(node.content)) || '';
// if (node.content) {
// text = node.content;
// }
var { tagName, attributes, children } = node;
delete attributes.internal_tree_id;
delete attributes.internal_path;
attributes = JSON.parse(JSON.stringify(attributes));
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_studio_meta) {
attributes.internal_path = node?.path?.toString?.();
attributes.xuda_hide = node.$hide;
}
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) => {
if (!value) return value;
// Check if it looks like JSON (starts with [, {, or ")
const trimmed = value.trim();
const looksLikeJSON = trimmed.startsWith('[') || trimmed.startsWith('{') || (trimmed.startsWith('"') && trimmed.endsWith('"'));
if (looksLikeJSON) {
try {
return JSON.parse(value);
} catch (error) {
console.warn(`Failed to parse JSON attribute ${key}:`, value, error);
return value; // Return as string if parsing fails
}
}
// For non-JSON values, return as-is
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 });
}