Fork FancyScribe as BrevyScribe with the datasheet transforms built in
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>
This commit is contained in:
2026-07-25 15:11:10 +02:00
parent 3a0fdc2c26
commit 62d6bd211b
45 changed files with 3317 additions and 191 deletions
+123
View File
@@ -0,0 +1,123 @@
// 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";
}