143 lines
5.6 KiB
JavaScript
143 lines
5.6 KiB
JavaScript
const FLIGHT_PUSH_MARKER = "self.__next_f.push";
|
|
|
|
function fail(message, offset) {
|
|
const suffix = Number.isInteger(offset) ? ` at offset ${offset}` : "";
|
|
throw new Error(`Could not decode pinned builder Flight text${suffix}: ${message}`);
|
|
}
|
|
|
|
function parsePushArgument(html, openParen) {
|
|
let start = openParen + 1;
|
|
while (/\s/.test(html[start] ?? "")) start += 1;
|
|
if (html[start] !== "[") fail("push argument is not a JSON array", openParen);
|
|
|
|
let depth = 0;
|
|
let quoted = false;
|
|
let escaped = false;
|
|
for (let index = start; index < html.length; index += 1) {
|
|
const character = html[index];
|
|
if (quoted) {
|
|
if (escaped) escaped = false;
|
|
else if (character === "\\") escaped = true;
|
|
else if (character === '"') quoted = false;
|
|
continue;
|
|
}
|
|
if (character === '"') quoted = true;
|
|
else if (character === "[") depth += 1;
|
|
else if (character === "]") {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
const json = html.slice(start, index + 1);
|
|
let value;
|
|
try {
|
|
value = JSON.parse(json);
|
|
} catch (error) {
|
|
fail(`push argument is invalid JSON (${error.message})`, openParen);
|
|
}
|
|
return { value, end: index + 1 };
|
|
}
|
|
}
|
|
}
|
|
fail("push argument array is unterminated", openParen);
|
|
}
|
|
|
|
function collectTextChunks(builderHtml) {
|
|
if (typeof builderHtml !== "string") throw new TypeError("builderHtml must be a string");
|
|
const chunks = [];
|
|
let cursor = 0;
|
|
while (true) {
|
|
const markerAt = builderHtml.indexOf(FLIGHT_PUSH_MARKER, cursor);
|
|
if (markerAt < 0) break;
|
|
const openParen = builderHtml.indexOf("(", markerAt + FLIGHT_PUSH_MARKER.length);
|
|
if (openParen < 0) fail("push call has no opening parenthesis", markerAt);
|
|
const parsed = parsePushArgument(builderHtml, openParen);
|
|
if (!Array.isArray(parsed.value)) fail("push argument did not decode to an array", markerAt);
|
|
if (Number(parsed.value[0]) === 1 && typeof parsed.value[1] === "string") chunks.push(parsed.value[1]);
|
|
cursor = parsed.end;
|
|
}
|
|
if (chunks.length === 0) fail("no type-1 text chunks were found");
|
|
return chunks;
|
|
}
|
|
|
|
function collectTextRecords(builderHtml) {
|
|
const text = collectTextChunks(builderHtml).join("");
|
|
const bytes = Buffer.from(text, "utf8");
|
|
const records = new Map();
|
|
let offset = 0;
|
|
while (offset < bytes.length) {
|
|
while (bytes[offset] === 0x0a || bytes[offset] === 0x0d) offset += 1;
|
|
if (offset >= bytes.length) break;
|
|
|
|
const comma = bytes.indexOf(0x2c, offset);
|
|
const newline = bytes.indexOf(0x0a, offset);
|
|
if (comma >= 0 && (newline < 0 || comma < newline)) {
|
|
const header = bytes.toString("ascii", offset, comma);
|
|
const match = header.match(/^([0-9a-f]+):T([0-9a-f]+)$/i);
|
|
if (match) {
|
|
const id = match[1].toLowerCase();
|
|
const byteLength = Number.parseInt(match[2], 16);
|
|
const contentStart = comma + 1;
|
|
const contentEnd = contentStart + byteLength;
|
|
if (!Number.isSafeInteger(byteLength) || contentEnd > bytes.length) {
|
|
fail(`text row ${id} declares ${match[2]} bytes but is truncated`, offset);
|
|
}
|
|
const value = bytes.toString("utf8", contentStart, contentEnd);
|
|
if (records.has(id) && records.get(id) !== value) fail(`text row ${id} is defined more than once with different contents`, offset);
|
|
records.set(id, value);
|
|
offset = contentEnd;
|
|
if (bytes[offset] === 0x0d) offset += 1;
|
|
if (bytes[offset] === 0x0a) offset += 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const lineEnd = bytes.indexOf(0x0a, offset);
|
|
if (lineEnd < 0) break;
|
|
offset = lineEnd + 1;
|
|
}
|
|
if (records.size === 0) fail("no length-prefixed text rows were found");
|
|
return records;
|
|
}
|
|
|
|
function resolveDescription(value, records, location) {
|
|
if (typeof value !== "string" || value.startsWith("$$")) return value;
|
|
const match = value.match(/^\$([0-9a-f]+)$/i);
|
|
if (!match) return value;
|
|
const record = records.get(match[1].toLowerCase());
|
|
if (record === undefined) throw new Error(`Pinned builder description reference ${value} at ${location} has no Flight text row`);
|
|
return record;
|
|
}
|
|
|
|
function resolveNode(node, records, location) {
|
|
if (!node || typeof node !== "object" || Array.isArray(node)) return node;
|
|
if (Object.hasOwn(node, "description")) node.description = resolveDescription(node.description, records, `${location}.description`);
|
|
if (Array.isArray(node.rankDescriptions)) {
|
|
node.rankDescriptions.forEach((rank, index) => {
|
|
if (rank && typeof rank === "object" && !Array.isArray(rank) && Object.hasOwn(rank, "description")) {
|
|
rank.description = resolveDescription(rank.description, records, `${location}.rankDescriptions[${index}].description`);
|
|
}
|
|
});
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function resolveNodeCollections(owner, records, location) {
|
|
if (!owner || typeof owner !== "object" || Array.isArray(owner)) return;
|
|
for (const key of ["nodes", "entries", "talents"]) {
|
|
if (Array.isArray(owner[key])) {
|
|
owner[key].forEach((node, index) => resolveNode(node, records, `${location}.${key}[${index}]`));
|
|
}
|
|
}
|
|
if (Array.isArray(owner.tabs)) {
|
|
owner.tabs.forEach((tab, index) => resolveNodeCollections(tab, records, `${location}.tabs[${index}]`));
|
|
}
|
|
}
|
|
|
|
/** Resolve exact React Flight string references in archived CoA node descriptions. */
|
|
export function resolveArchiveDescriptions(classes, builderHtml) {
|
|
if (!Array.isArray(classes)) throw new TypeError("classes must be an array");
|
|
const records = collectTextRecords(builderHtml);
|
|
const resolved = structuredClone(classes);
|
|
resolved.forEach((classRecord, index) => resolveNodeCollections(classRecord, records, `classes[${index}]`));
|
|
return resolved;
|
|
}
|