yarn-spinner-runner-ts
Version:
TypeScript parser, compiler, and runtime for Yarn Spinner 3.x with React adapter [NPM package](https://www.npmjs.com/package/yarn-spinner-runner-ts)
342 lines • 11.4 kB
JavaScript
const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark", "br"]);
const SELF_CLOSING_TAGS = new Set(["br"]);
const SELF_CLOSING_SPACE_REGEX = /\s+\/$/;
const ATTRIBUTE_REGEX = /^([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/;
export function parseMarkup(input) {
const segments = [];
const stack = [];
const chars = [];
let currentSegment = null;
let nomarkupDepth = 0;
const pushSegment = (segment) => {
if (segment.selfClosing || segment.end > segment.start) {
segments.push(segment);
}
};
const wrappersEqual = (a, b) => {
if (a.length !== b.length)
return false;
for (let i = 0; i < a.length; i++) {
const wa = a[i];
const wb = b[i];
if (wa.name !== wb.name || wa.type !== wb.type)
return false;
const keysA = Object.keys(wa.properties);
const keysB = Object.keys(wb.properties);
if (keysA.length !== keysB.length)
return false;
for (const key of keysA) {
if (wa.properties[key] !== wb.properties[key])
return false;
}
}
return true;
};
const flushCurrentSegment = () => {
if (currentSegment) {
segments.push(currentSegment);
currentSegment = null;
}
};
const cloneWrappers = () => stack.map((entry) => ({
name: entry.name,
type: entry.type,
properties: { ...entry.properties },
}));
const appendChar = (char) => {
const index = chars.length;
chars.push(char);
const wrappers = cloneWrappers();
if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappers)) {
currentSegment.end = index + 1;
}
else {
flushCurrentSegment();
currentSegment = {
start: index,
end: index + 1,
wrappers,
};
}
};
const appendLiteral = (literal) => {
for (const ch of literal) {
appendChar(ch);
}
};
const parseTag = (contentRaw) => {
let content = contentRaw.trim();
if (!content)
return null;
if (content.startsWith("/")) {
const name = content.slice(1).trim().toLowerCase();
if (!name)
return null;
return { kind: "close", name, properties: {} };
}
let kind = "open";
if (content.endsWith("/")) {
content = content.replace(SELF_CLOSING_SPACE_REGEX, "").trim();
if (content.endsWith("/")) {
content = content.slice(0, -1).trim();
}
kind = "self";
}
const nameMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)/);
if (!nameMatch)
return null;
const name = nameMatch[1].toLowerCase();
let rest = content.slice(nameMatch[0].length).trim();
const properties = {};
while (rest.length > 0) {
const attrMatch = rest.match(ATTRIBUTE_REGEX);
if (!attrMatch) {
break;
}
const [, keyRaw, doubleQuoted, singleQuoted, bare] = attrMatch;
const key = keyRaw.toLowerCase();
let value = true;
const rawValue = doubleQuoted ?? singleQuoted ?? bare;
if (rawValue !== undefined) {
value = parseAttributeValue(rawValue);
}
properties[key] = value;
rest = rest.slice(attrMatch[0].length).trim();
}
const finalKind = kind === "self" || SELF_CLOSING_TAGS.has(name) ? "self" : kind;
return { kind: finalKind, name, properties };
};
const parseAttributeValue = (raw) => {
const trimmed = raw.trim();
if (/^(true|false)$/i.test(trimmed)) {
return /^true$/i.test(trimmed);
}
if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
const num = Number(trimmed);
if (!Number.isNaN(num)) {
return num;
}
}
return trimmed;
};
const handleSelfClosing = (tag) => {
const wrapper = {
name: tag.name,
type: DEFAULT_HTML_TAGS.has(tag.name) ? "default" : "custom",
properties: tag.properties,
};
const position = chars.length;
pushSegment({
start: position,
end: position,
wrappers: [wrapper],
selfClosing: true,
});
};
let i = 0;
while (i < input.length) {
const char = input[i];
if (char === "\\" && i + 1 < input.length) {
const next = input[i + 1];
if (next === "[" || next === "]" || next === "\\") {
appendChar(next);
i += 2;
continue;
}
}
if (char === "[") {
const closeIndex = findClosingBracket(input, i + 1);
if (closeIndex === -1) {
appendChar(char);
i += 1;
continue;
}
const content = input.slice(i + 1, closeIndex);
const originalText = input.slice(i, closeIndex + 1);
const parsed = parseTag(content);
if (!parsed) {
appendLiteral(originalText);
i = closeIndex + 1;
continue;
}
if (parsed.name === "nomarkup") {
if (parsed.kind === "open") {
nomarkupDepth += 1;
}
else if (parsed.kind === "close" && nomarkupDepth > 0) {
nomarkupDepth -= 1;
}
i = closeIndex + 1;
continue;
}
if (nomarkupDepth > 0) {
appendLiteral(originalText);
i = closeIndex + 1;
continue;
}
if (parsed.kind === "open") {
const entry = {
name: parsed.name,
type: DEFAULT_HTML_TAGS.has(parsed.name) ? "default" : "custom",
properties: parsed.properties,
originalText,
};
stack.push(entry);
flushCurrentSegment();
i = closeIndex + 1;
continue;
}
if (parsed.kind === "self") {
handleSelfClosing(parsed);
i = closeIndex + 1;
continue;
}
// closing tag
if (stack.length === 0) {
if (SELF_CLOSING_TAGS.has(parsed.name)) {
i = closeIndex + 1;
continue;
}
appendLiteral(originalText);
i = closeIndex + 1;
continue;
}
const top = stack[stack.length - 1];
if (top.name === parsed.name) {
flushCurrentSegment();
stack.pop();
i = closeIndex + 1;
continue;
}
if (SELF_CLOSING_TAGS.has(parsed.name)) {
i = closeIndex + 1;
continue;
}
// mismatched closing; treat as literal
appendLiteral(originalText);
i = closeIndex + 1;
continue;
}
appendChar(char);
i += 1;
}
flushCurrentSegment();
// If any tags remain open, treat them as literal text appended at end
while (stack.length > 0) {
const entry = stack.pop();
appendLiteral(entry.originalText);
}
flushCurrentSegment();
const text = chars.join("");
return {
text,
segments: mergeSegments(segments, text.length),
};
}
function mergeSegments(segments, textLength) {
const sorted = [...segments].sort((a, b) => a.start - b.start || a.end - b.end);
const merged = [];
let last = null;
for (const seg of sorted) {
if (seg.start === seg.end && !seg.selfClosing) {
continue;
}
if (last && !seg.selfClosing && last.end === seg.start && wrappersMatch(last.wrappers, seg.wrappers)) {
last.end = seg.end;
}
else {
last = {
start: seg.start,
end: seg.end,
wrappers: seg.wrappers,
selfClosing: seg.selfClosing,
};
merged.push(last);
}
}
if (merged.length === 0 && textLength > 0) {
merged.push({
start: 0,
end: textLength,
wrappers: [],
});
}
return merged;
}
function wrappersMatch(a, b) {
if (a.length !== b.length)
return false;
for (let i = 0; i < a.length; i++) {
if (a[i].name !== b[i].name || a[i].type !== b[i].type)
return false;
const keysA = Object.keys(a[i].properties);
const keysB = Object.keys(b[i].properties);
if (keysA.length !== keysB.length)
return false;
for (const key of keysA) {
if (a[i].properties[key] !== b[i].properties[key])
return false;
}
}
return true;
}
function findClosingBracket(text, start) {
for (let i = start; i < text.length; i++) {
if (text[i] === "]") {
let backslashCount = 0;
let j = i - 1;
while (j >= 0 && text[j] === "\\") {
backslashCount++;
j--;
}
if (backslashCount % 2 === 0) {
return i;
}
}
}
return -1;
}
export function sliceMarkup(result, start, end) {
const textLength = result.text.length;
const sliceStart = Math.max(0, Math.min(start, textLength));
const sliceEnd = end === undefined ? textLength : Math.max(sliceStart, Math.min(end, textLength));
const slicedSegments = [];
for (const seg of result.segments) {
const segStart = Math.max(seg.start, sliceStart);
const segEnd = Math.min(seg.end, sliceEnd);
if (seg.selfClosing) {
if (segStart >= sliceStart && segStart <= sliceEnd) {
slicedSegments.push({
start: segStart - sliceStart,
end: segStart - sliceStart,
wrappers: seg.wrappers,
selfClosing: true,
});
}
continue;
}
if (segEnd <= segStart)
continue;
slicedSegments.push({
start: segStart - sliceStart,
end: segEnd - sliceStart,
wrappers: seg.wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
})),
});
}
if (slicedSegments.length === 0 && sliceEnd - sliceStart > 0) {
slicedSegments.push({
start: 0,
end: sliceEnd - sliceStart,
wrappers: [],
});
}
return {
text: result.text.slice(sliceStart, sliceEnd),
segments: mergeSegments(slicedSegments, sliceEnd - sliceStart),
};
}
//# sourceMappingURL=parser.js.map