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
+156 -110
View File
@@ -1,7 +1,7 @@
import JSZip from "jszip";
import { Create40kRoster } from "./roster40k";
import { Create40kRoster10th } from "./roster40k-10th";
import { useEffect, useState, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Demo0 from "./assets/Demo0.png";
import Demo1 from "./assets/Demo1.png";
@@ -10,6 +10,34 @@ import { Roster as Roster10th } from "./10th/Roster";
import { useLocalStorage } from "./helpers/useLocalStorage";
import { parseJSON, stringifyJSON } from "./helpers/json";
import { Create40kRoster11th } from "./roster40k-11th";
import { applyTransforms, defaultToggles } from "./transforms";
// The transforms, in the order they are offered in the UI. These are what makes
// this a fork: they rewrite the roster into the generic datasheets the official
// cards print, rather than a record of one particular list.
const TRANSFORMS = [
[
"mergeDuplicateUnits",
"Merge duplicates",
"Fold copies of a datasheet - taken only because their wargear is mutually exclusive - into a single card showing every option.",
],
[
"removeLeaderAbilities",
"Drop Leader/Support",
"Remove the attachment rules. Once the army is built they say nothing you need mid-game, and they push the rules you do need off the card.",
],
[
"convertChoiceAbilities",
"Split choice abilities",
"Print each option of a 'select one each turn' ability as its own titled row, the way the official datasheets do.",
],
];
const PARSERS = {
"Warhammer 40,000 9th Edition": [Create40kRoster, 9],
"Warhammer 40,000 10th Edition": [Create40kRoster10th, 10],
"Warhammer 40,000 11th Edition": [Create40kRoster11th, 11],
};
const throttle = (func, limit) => {
let lastFunc;
@@ -34,11 +62,27 @@ const throttle = (func, limit) => {
};
function App() {
const [rosters, setRosters] = useLocalStorage("rosters", "[]");
// Saved rosters hold the *raw* roster XML rather than the parsed roster, so
// that flipping a transform toggle can rebuild them without a re-upload.
// The keys are BrevyScribe's own, so nothing upstream FancyScribe left in
// localStorage is read back in the wrong shape.
const [rosters, setRosters] = useLocalStorage("brevyscribe.rosters", "[]");
const rostersJSON = parseJSON(rosters ?? "[]");
const [savedToggles, setSavedToggles] = useLocalStorage(
"brevyscribe.transforms",
stringifyJSON(defaultToggles),
);
// Memoised so the rebuild effect can depend on the toggles themselves rather
// than their serialised form; a fresh object every render would re-run it.
const toggles = useMemo(
() => ({ ...defaultToggles, ...parseJSON(savedToggles ?? "{}") }),
[savedToggles],
);
// { xml, save } - the roster to show, and whether it belongs in the list.
const [source, setSource] = useState();
const [error, setError] = useState();
const [roster, setRoster] = useState();
const [edition, setEdition] = useState(10); // [9, 10]
const [edition, setEdition] = useState(10); // [9, 10, 11]
const [onePerPage, setOnePerPage] = useState(false);
const [primaryColor, setPrimaryColor] = useState("#536766");
const [colorUserChoice, setColorUserChoice] = useState(false);
@@ -71,8 +115,7 @@ function App() {
};
reader.onloadend = async () => {
const content = reader.result;
const xmldata = await unzip(content);
parseXML(xmldata, true);
setSource({ xml: await unzip(content), save: true });
};
reader.readAsBinaryString(files[0]);
} else {
@@ -93,36 +136,18 @@ function App() {
default:
break;
}
// load example
posthog?.capture?.("user_loaded_example", {
roster_faction: event,
});
if (!example) return;
const arrayBuffer = await example.arrayBuffer();
// Create a new Blob object from the zip file contents
const zipBlob = new Blob([arrayBuffer], { type: "application/zip" });
const xmldata = await unzip(zipBlob);
parseXML(xmldata, false, true);
setSource({ xml: await unzip(zipBlob), save: false });
}
}
const loadFromLocalStorage = (roster) => {
if (roster.gameType == "Warhammer 40,000 9th Edition") {
setRoster(roster);
setEdition(9);
setError("");
} else if (roster.gameType == "Warhammer 40,000 10th Edition") {
setRoster(roster);
setEdition(10);
setError("");
} else if (roster.gameType == "Warhammer 40,000 11th Edition") {
setRoster(roster);
setEdition(11);
setError("");
}
posthog?.capture?.("user_loaded_roster_from_localstorage", {
roster_faction: roster.forces[0].catalog,
roster_type: roster.gameType,
});
};
// Already in the list, so there is nothing to save; the stored XML goes
// through the same rebuild as a fresh upload.
const loadFromLocalStorage = (saved) =>
setSource({ xml: saved.xml, save: false });
const unzip = async (file) => {
if (file?.charAt && file.charAt(0) !== "P") {
@@ -138,61 +163,52 @@ function App() {
}
};
function parseXML(xmldata, addToLocalStorage, isExample = false) {
const parser = new DOMParser();
const doc = parser.parseFromString(xmldata, "text/xml");
if (!doc) return;
// Rebuild whenever a new roster arrives or a transform is toggled. The
// transforms run here rather than over the uploaded file, so flipping a
// toggle rebuilds from the original XML and nothing has to be re-uploaded.
//
// biome-ignore lint/correctness/useExhaustiveDependencies: the roster list is
// read to append to it, so depending on it would re-run this on its own write.
useEffect(() => {
if (!source?.xml) return;
// Determine roster type (game system).
const info = doc.querySelector("roster");
const doc = new DOMParser().parseFromString(source.xml, "text/xml");
const info = doc?.querySelector("roster");
if (!info) return;
// Determine roster type (game system).
const gameType = info.getAttribute("gameSystemName");
if (!gameType) return;
const rosterName = info.getAttribute("name");
if (rosterName) {
document.title = `FancyScribe ${rosterName}`;
}
let roster;
if (gameType == "Warhammer 40,000 9th Edition") {
roster = Create40kRoster(doc, gameType);
if (roster && roster.forces.length > 0) {
setRoster(roster);
setEdition(9);
setError("");
}
} else if (gameType == "Warhammer 40,000 10th Edition") {
roster = Create40kRoster10th(doc, gameType);
if (roster && roster.forces.length > 0) {
setRoster(roster);
setEdition(10);
setError("");
}
} else if (gameType == "Warhammer 40,000 11th Edition") {
roster = Create40kRoster11th(doc, gameType);
if (roster && roster.forces.length > 0) {
setRoster(roster);
setEdition(11);
setError("");
}
} else {
setError("No support for game type '" + gameType + "'.");
}
if (!roster) {
const parser = PARSERS[gameType];
if (!parser) {
setError(`No support for game type '${gameType}'.`);
return;
}
if (addToLocalStorage) {
console.log(roster);
posthog?.capture?.("user_uploaded_roster", {
roster_faction: roster.forces[0].catalog,
roster_type: gameType,
name: rosterName,
});
setRosters(stringifyJSON([roster, ...rostersJSON]));
const report = applyTransforms(doc, toggles);
const [createRoster, rosterEdition] = parser;
const built = createRoster(doc, gameType);
if (!built || built.forces.length === 0) return;
setRoster(built);
setEdition(rosterEdition);
setError("");
const name = info.getAttribute("name");
if (name) document.title = `BrevyScribe ${name}`;
console.log(built, report);
if (source.save) {
// Keyed on the XML, so re-saving the same roster moves it to the front
// of the list rather than adding a second copy.
setRosters(
stringifyJSON([
{ name: name || "Roster", xml: source.xml },
...rostersJSON.filter((entry) => entry.xml !== source.xml),
]),
);
}
}
}, [source, toggles, setRosters]);
useEffect(() => {
// Check if the browser is Safari, and if so, remove the accept attribute
@@ -239,7 +255,7 @@ function App() {
>
<div className="header print-display-none">
<a
href="/fancyscribe"
href="/"
style={{
color: "#fff",
fontWeight: 800,
@@ -247,18 +263,18 @@ function App() {
flexDirection: "column",
}}
>
FancyScribe{" "}
BrevyScribe{" "}
<span
style={{
fontSize: "0.8rem",
fontWeight: 400,
}}
>
Now with 11th edition support!
Generic datasheets, not list-specific ones
</span>
</a>
<div className="subheader">
A fancy way to view your Warhammer 40k BattleScribe rosters
A fancy way to print your Warhammer 40k datasheets
</div>
</div>
@@ -354,18 +370,47 @@ function App() {
<button
style={{ display: roster ? "" : "none" }}
onClick={() => {
posthog?.capture?.("user_printed_roster", {
roster_faction: roster.forces[0].catalog,
roster_type: roster.gameType,
});
window.print();
}}
onClick={() => window.print()}
>
Print roster
</button>
</div>
<div
className="print-display-none max-w-[95vw]"
style={{
display: roster ? "flex" : "none",
width: "100%",
gap: 16,
flexWrap: "wrap",
}}
>
<span style={{ fontWeight: 600, minHeight: 26 }}>Datasheets:</span>
{TRANSFORMS.map(([key, label, description]) => (
<label
key={key}
title={description}
style={{
display: "flex",
alignItems: "center",
gap: 4,
minHeight: 26,
}}
>
<input
type="checkbox"
checked={toggles[key]}
onChange={(e) =>
setSavedToggles(
stringifyJSON({ ...toggles, [key]: e.target.checked }),
)
}
/>
<span className="select-none">{label}</span>
</label>
))}
</div>
<div
className="print-display-none max-w-[95vw]"
style={{ display: roster ? "flex" : "none", width: "100%", gap: 16 }}
@@ -512,7 +557,7 @@ function App() {
>
<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>About</div>
<div style={{ fontSize: "1.2rem" }}>
FancyScribe is a website that renders{" "}
BrevyScribe renders{" "}
<a
href="https://www.battlescribe.net/"
target="_blank"
@@ -521,9 +566,28 @@ function App() {
BattleScribe
</a>{" "}
or <a href="https://www.newrecruit.eu/">New Recruit</a> roster files
in an opinionated format inspired by the new 10th edition datacards.
Additional inspiration and large parts of the parsing logic come
from the{" "}
in an opinionated format inspired by the 10th edition datacards.
</div>
<div style={{ fontSize: "1.2rem" }}>
It differs from its upstream in what it prints: rather than a record
of one particular list, it rewrites the roster into the generic
datasheets the official cards show. Duplicate copies of a unit are
folded into one card carrying every option, the Leader and Support
attachment rules are dropped, and a &quot;select one each
turn&quot; ability is split into one titled row per option. Use the{" "}
<b>Datasheets</b> toggles above to turn any of that off.
</div>
<div style={{ fontSize: "1.2rem" }}>
BrevyScribe is a fork of{" "}
<a
href="https://github.com/NilsUeter/fancyscribe"
target="_blank"
rel="noreferrer"
>
FancyScribe
</a>{" "}
by Nils Ueter, which does all the heavy lifting here. Additional
inspiration and large parts of the parsing logic come from the{" "}
<a
href="https://rweyrauch.github.io/PrettyScribe"
target="_blank"
@@ -533,24 +597,6 @@ function App() {
</a>{" "}
website.
</div>
<div style={{ fontSize: "1.2rem" }}>
FancyScribe is an open-source project and can be found on Github (
<a
href="https://github.com/NilsUeter/fancyscribe"
target="_blank"
rel="noreferrer"
>
FancyScribe
</a>
).
</div>
<div style={{ fontSize: "1.2rem" }}>
If you have any feedback or find any bugs, write{" "}
<a href="https://www.reddit.com/r/WarhammerCompetitive/comments/13ajo3b/fancyscribe_convert_9th_edition_battlescribe">
here
</a>{" "}
or send me a message.
</div>
<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>
Output Examples
</div>