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>
6.9 KiB
BrevyScribe
A fork of FancyScribe that prints generic datasheets rather than a record of one particular army list.
FancyScribe renders a BattleScribe or New Recruit roster as 10th-edition-style datacards, showing exactly the wargear you picked. That is the right thing for a list you are about to play, but the wrong thing for a reference card you want to keep: the official cards show every option a unit could take. BrevyScribe rewrites the roster on the way in so the printed cards read like the official ones.
Three transforms do the work, all of them toggleable in the UI:
| Transform | What it does |
|---|---|
| Merge duplicates | Mutually exclusive wargear forces you to take a datasheet twice - one Skatros with a radium jezzail, another with a transuranic arquebus. Copies of the same unit are folded into one card carrying every option. Points are not summed; the card keeps the highest cost of the copies it absorbed. |
| Drop Leader/Support | Removes the attachment rules. Once the army is built they say nothing you need mid-game, and they are long enough to push the rules you do need off the card. |
| Split choice abilities | An ability like Canticles of the Omnissiah arrives as one blob of text. This splits it into the intro rule plus one titled row per option, which is how the datasheets print it. |
Everything still runs in the browser. There is no server component, no upload,
no account, and no analytics; rosters are held in localStorage and never leave
the machine.
Running it
npm install
npm run dev # http://localhost:5173
npm test # the parity suite, see below
npm run build # static files into dist/
npm run lint reports pre-existing findings inherited from upstream (mostly
a11y/useButtonType), so it is deliberately not part of CI. Compare against
git show upstream/main:<file> before treating any of them as new.
Deploying to scribe.luxick.de
The build output is static files - no application server, no socket, nothing for nginx to proxy to. The app does all its work in the browser, which is why upstream can live on GitHub Pages.
One-time setup on the server:
sudo mkdir -p /var/www/brevyscribe
sudo chown "$USER" /var/www/brevyscribe
sudo cp deploy/nginx-scribe.luxick.de.conf /etc/nginx/sites-available/scribe.luxick.de
sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/
sudo certbot --nginx -d scribe.luxick.de
sudo nginx -t && sudo systemctl reload nginx
Then every deploy is one command from a checkout on your own machine - it runs
the tests, builds, and rsyncs dist/ over. The server needs no Node.
./deploy/deploy.sh
--delete is deliberate: asset filenames are content-hashed, so without it old
bundles would pile up forever. There is no state on the server and nothing to
back up; every roster lives in the browser's localStorage.
vite.config.js sets base: "/", because this is served at a domain root -
upstream sets /fancyscribe for GitHub Pages. If you ever serve it from a
subpath, that is the setting to change.
Note that index.html loads Noto Sans from Google Fonts, so a page load reaches
out to fonts.googleapis.com. If you would rather it did not, drop the
<link> tags and self-host the font next to the ones already in public/fonts/.
How the transforms fit in
upload/.rosz -> unzip -> DOMParser -> applyTransforms -> Create40kRoster* -> render
^
src/transforms/index.js
src/App.jsx runs them between parsing the XML and building the roster, so
flipping a toggle rebuilds from the original XML with no re-upload. That is also
why saved rosters hold the raw roster XML rather than the parsed object.
Which abilities get stripped and split is configured in
src/transforms/config.js - add to
abilitiesToStrip and abilitiesToConvert as you meet more of them.
The test suite
The transforms began as three standalone Python scripts (merge_duplicate_units,
remove_leader_abilities, convert_choice_abilities) that rewrote the .ros
file before it was uploaded to FancyScribe. They are gone now, but they are what
the tests measure against.
src/transforms/__fixtures__/ holds four 11th-edition rosters plus, for each,
the output those scripts produced: <name>.{merge,strip,convert,all}.ros. The
suite runs each transform over the input and asserts it produces the same roster.
That is worth more than a snapshot of the current code, because the expected
output came from a different implementation that was verified by actually
printing the cards.
That property is easy to destroy and hard to notice: regenerate a fixture from
the JS and the test compares the code against itself, passing no matter what it
does. So scripts/update-fixtures.mjs will not overwrite an existing fixture
without --force. Use it when you add a new example roster, and read the diff:
# drop a new roster in src/transforms/__fixtures__/, then
npm run fixtures
Comparison is structural rather than textual. The scripts edited the file as text
to keep its formatting byte for byte; these transforms build DOM nodes, so
attribute order and whitespace legitimately differ. Generated id/typeId
values are compared as tokens, so what has to match is that ids are shared and
distinct in the same pattern, not that both implementations hash alike.
integration.test.js goes further and pushes the transformed document through
the real roster parser, checking what lands on the card: one Skitarii card
carrying both weapons, the data-tether ability travelling with the model that
brings it, no Support ability left, and each Canticle its own row.
One wrinkle: the tests run under jsdom, which does not implement scoped selectors
the way browsers do. force.querySelectorAll("force>selections>…") finds nothing
there, where a browser matches the selector against the whole tree and then keeps
the descendants of force - so under jsdom the parser found no units at all. The
two call sites that relied on it now say :scope>…, which means the same thing in
a browser and works in both.
Staying current with upstream
FancyScribe is actively developed - 11th edition support landed recently - and
this fork touches little of it. upstream is wired up:
git fetch upstream
git merge upstream/main
Conflicts should be confined to src/App.jsx, index.html, vite.config.js and
package.json. src/transforms/ is entirely new and will never conflict.
Credit
All the hard parts - the parsing, the card layout, the print CSS - are Nils Ueter's, with parsing logic descended in turn from PrettyScribe. Upstream ships no licence file, so treat this fork as a private, personal deployment rather than something to redistribute.