62d6bd211b
CI / check (push) Has been cancelled
Print generic datasheets - every option a unit could take, the way the official cards read - instead of a record of one particular list. The three Python scripts that used to rewrite the .ros file before upload are now transforms in src/transforms/, running in the browser between DOMParser and the roster parser. Most of each script was machinery for preserving the file byte for byte on the way back to disk; in the browser the document is never serialised, so only the domain logic came across. Because they run on the parsed document rather than the uploaded file, flipping a transform off rebuilds from the original XML with no re-upload. Saved rosters therefore hold the raw roster XML rather than the parsed object, under new localStorage keys. The scripts' output over four 11th-edition rosters is checked in as test fixtures, so the port is measured against an independent implementation that was verified by printing the cards; scripts/update-fixtures.mjs refuses to overwrite those without --force. Two parser call sites now use :scope> rather than repeating the scope element's own name, which means the same thing in a browser and also works under jsdom, where the old form matched nothing. Also: served at the root rather than a GitHub Pages subpath, PostHog analytics removed, and deploy/ carries an nginx site for scribe.luxick.de plus an rsync deploy script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
124 lines
4.4 KiB
JavaScript
124 lines
4.4 KiB
JavaScript
// Small DOM helpers shared by the transforms.
|
|
//
|
|
// The Python scripts these were ported from parse the roster into a tree of
|
|
// text offsets and edit the file as text, so that everything they do not touch
|
|
// survives byte for byte. In the browser there is nothing to preserve: the
|
|
// document goes straight into the roster parser and is never written back out,
|
|
// so the transforms mutate the DOM directly and all of that machinery
|
|
// (Node/Edit/apply_edits/attr escaping/XML re-validation) is gone.
|
|
|
|
// Roster files declare a default namespace (the BattleScribe roster schema), so
|
|
// everything here matches on `localName` and creates elements in the document's
|
|
// own namespace. Matching on `tagName` would still work for these files, but
|
|
// only because nothing in them carries a prefix.
|
|
|
|
/** Element children of `node`, as a real array (safe to mutate while iterating). */
|
|
export const elementChildren = (node) => Array.from(node.children);
|
|
|
|
/** The first element child named `name`, or null. */
|
|
export const childElement = (node, name) =>
|
|
elementChildren(node).find((child) => child.localName === name) ?? null;
|
|
|
|
/** Every descendant element named `name`, in document order. */
|
|
export const descendants = (node, name) =>
|
|
Array.from(node.getElementsByTagNameNS("*", name));
|
|
|
|
/** Create an element in the same namespace as the document's root. */
|
|
export const createElement = (doc, name) =>
|
|
doc.createElementNS(doc.documentElement.namespaceURI, name);
|
|
|
|
/**
|
|
* Remove `node`, taking the whitespace that only separated it from its sibling.
|
|
*
|
|
* Rosters come out of BattleScribe on a single line, so usually there is no
|
|
* whitespace to take; this only keeps the result tidy for a roster that has
|
|
* been pretty-printed at some point.
|
|
*/
|
|
export function removeElement(node) {
|
|
const previous = node.previousSibling;
|
|
const TEXT_NODE = 3;
|
|
if (
|
|
previous &&
|
|
previous.nodeType === TEXT_NODE &&
|
|
previous.data.trim() === ""
|
|
) {
|
|
previous.remove();
|
|
}
|
|
node.remove();
|
|
}
|
|
|
|
/**
|
|
* Insert `child` into `parent`, keeping `order` among the containers listed in
|
|
* it. A container not mentioned in `order` is appended.
|
|
*/
|
|
export function insertOrdered(parent, child, order) {
|
|
const rank = order.indexOf(child.localName);
|
|
if (rank !== -1) {
|
|
const before = elementChildren(parent).find((existing) => {
|
|
const existingRank = order.indexOf(existing.localName);
|
|
return existingRank !== -1 && existingRank > rank;
|
|
});
|
|
if (before) {
|
|
parent.insertBefore(child, before);
|
|
return;
|
|
}
|
|
}
|
|
parent.appendChild(child);
|
|
}
|
|
|
|
/**
|
|
* Joins the parts of a key or a generated id, so that ("ab", "c") and
|
|
* ("a", "bc") cannot collide.
|
|
*
|
|
* ASCII unit separator: a control character cannot occur in a unit name, an
|
|
* ability name or an id, so nothing in a roster can contain it and no pair of
|
|
* different parts can ever join to the same string.
|
|
*/
|
|
export const KEY_SEPARATOR = String.fromCharCode(31);
|
|
|
|
/**
|
|
* A stable id derived from `parts`, shaped like BattleScribe's
|
|
* ``xxxx-xxxx-xxxx-xxxx``.
|
|
*
|
|
* Deterministic so that re-running against an updated roster produces the same
|
|
* ids for the same ability rather than churning them, which is what lets the
|
|
* merge recognise two generated profiles as the same catalogue object. This is
|
|
* a plain string hash rather than the SHA-1 the Python script uses: the ids only
|
|
* have to be unique and stable within one document, and `crypto.subtle` is
|
|
* async, which would push a promise through every call site for no gain.
|
|
*/
|
|
export function makeId(...parts) {
|
|
const input = parts.join(KEY_SEPARATOR);
|
|
// Two independent FNV-1a passes, giving 64 bits of output.
|
|
let h1 = 0x811c9dc5;
|
|
let h2 = 0x01000193;
|
|
for (let i = 0; i < input.length; i++) {
|
|
const code = input.charCodeAt(i);
|
|
h1 = Math.imul(h1 ^ code, 0x01000193) >>> 0;
|
|
h2 = Math.imul(h2 ^ code, 0x811c9dc5) >>> 0;
|
|
}
|
|
const hex =
|
|
h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0");
|
|
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
|
|
}
|
|
|
|
/**
|
|
* The datasheet an element sits on, for the run report: the outermost
|
|
* `<selection>` above it, since that is the force-level unit.
|
|
*/
|
|
export function enclosingUnit(node) {
|
|
let unit = null;
|
|
for (
|
|
let current = node.parentElement;
|
|
current;
|
|
current = current.parentElement
|
|
) {
|
|
if (current.localName === "selection") {
|
|
unit = current.getAttribute("name") ?? "?";
|
|
} else if (current.localName === "force" && !unit) {
|
|
return current.getAttribute("name") ?? "Force";
|
|
}
|
|
}
|
|
return unit ?? "Roster";
|
|
}
|