Fork FancyScribe as BrevyScribe with the datasheet transforms built in
CI / check (push) Has been cancelled
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:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user