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
+109
View File
@@ -0,0 +1,109 @@
// The transforms have to leave a document the roster parser still understands,
// and the whole point is that what comes out the far end reads like the official
// card. These tests go through the real parser rather than inspecting the DOM.
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { Create40kRoster11th } from "../roster40k-11th.js";
import { applyTransforms } from "./index.js";
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__");
const GAME_TYPE = "Warhammer 40,000 11th Edition";
/** Parse a fixture, optionally transforming it, and build the roster. */
function build(name, { transform }) {
const xml = readFileSync(join(FIXTURES, `${name}.ros`), "utf8");
const doc = new DOMParser().parseFromString(xml, "text/xml");
if (transform) applyTransforms(doc);
return Create40kRoster11th(doc, GAME_TYPE);
}
const units = (roster) => roster.forces.flatMap((force) => force.units);
const unitNamed = (roster, name) =>
units(roster).find((unit) => unit.name.includes(name));
describe("the transformed roster still parses", () => {
it.each(["bellisarius-cawl", "datasmith", "rangers", "skatros"])(
"builds a roster from %s",
(example) => {
const roster = build(example, { transform: true });
expect(roster).toBeTruthy();
expect(roster.forces.length).toBeGreaterThan(0);
expect(units(roster).length).toBeGreaterThan(0);
},
);
});
describe("merging duplicates", () => {
it("turns two Skatros cards into one carrying both weapons", () => {
const before = build("skatros", { transform: false });
const after = build("skatros", { transform: true });
const skatrosBefore = units(before).filter((unit) =>
unit.name.includes("Skatros"),
);
const skatrosAfter = units(after).filter((unit) =>
unit.name.includes("Skatros"),
);
expect(skatrosBefore.length).toBe(2);
expect(skatrosAfter.length).toBe(1);
// The single card now lists the weapon each copy was taken for.
const weapons = [
...skatrosAfter[0].rangedWeapons,
...skatrosAfter[0].meleeWeapons,
]
.map((weapon) => weapon.name)
.join(" | ");
expect(weapons).toMatch(/jezzail/i);
expect(weapons).toMatch(/arquebus/i);
});
it("brings an option's own ability along with the model that carries it", () => {
const after = build("rangers", { transform: true });
const rangers = unitNamed(after, "Ranger");
const abilityNames = Object.values(rangers.abilities).flatMap((group) =>
Array.from(group.keys()),
);
expect(abilityNames.join(" | ")).toMatch(/data-tether/i);
});
});
describe("stripping Leader/Support", () => {
// The Datasmith is a Support character, not a Leader; the transform strips
// both, and this is the example that has one.
it("leaves no attachment ability on the card", () => {
const before = build("datasmith", { transform: false });
const after = build("datasmith", { transform: true });
const abilitiesOf = (roster) =>
units(roster)
.flatMap((unit) => Object.values(unit.abilities))
.flatMap((group) => Array.from(group.keys()))
.join(" | ");
expect(abilitiesOf(before)).toMatch(/^Support \|/);
expect(abilitiesOf(after)).not.toMatch(/Leader|Support/);
});
});
describe("splitting choice abilities", () => {
it("gives each Canticle its own row under one titled group", () => {
const after = build("bellisarius-cawl", { transform: true });
const cawl = unitNamed(after, "Cawl");
const group = cawl.abilities["CANTICLES OF THE OMNISSIAH"];
expect(group).toBeTruthy();
expect(group.size).toBeGreaterThan(1);
// Options print as plain text: GW's ^^keyword^^ / **emphasis** markers
// are resolved, not passed through as punctuation.
for (const text of group.values()) {
expect(JSON.stringify(text)).not.toMatch(/\^\^|\*\*/);
}
});
});