Files
brevyscribe/README.md
T
luxick cf67918bc0
CI / check (push) Has been cancelled
Update README.md
2026-07-25 19:37:32 +02:00

8.8 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.
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. It is in two stages because of a chicken and egg: the real config names a certificate, and nginx refuses to load a config whose certificate does not exist yet - so nginx first comes up on port 80 only, just far enough for certbot to answer the challenge there.

sudo mkdir -p /var/www/brevyscribe /var/www/certbot
sudo chown "$USER" /var/www/brevyscribe

# Stage 1: HTTP only, so nginx starts without a certificate.
sudo tee /etc/nginx/sites-available/scribe.luxick.de >/dev/null <<'EOF'
server {
	listen 80;
	listen [::]:80;
	server_name scribe.luxick.de;
	location /.well-known/acme-challenge/ { root /var/www/certbot; }
}
EOF
sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

sudo certbot --nginx -d scribe.luxick.de

# Stage 2: the real config, now that the certificate is on disk.
sudo cp deploy/nginx-scribe.luxick.de.conf /etc/nginx/sites-available/scribe.luxick.de
sudo nginx -t && sudo systemctl reload nginx

certonly --webroot rather than --nginx: the config already carries its own redirect and TLS block, and the nginx plugin would rewrite the installed file, leaving it drifted from the one in the repo. The port 80 block keeps its acme-challenge location for exactly this reason, so renewals go on working unattended - but certbot's timer will not reload nginx by itself, so drop a one-line hook in /etc/letsencrypt/renewal-hooks/deploy/ that runs systemctl reload nginx, or a renewed certificate will not be served until the next restart.

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.

The fork also hides three things upstream shows, because a generic datasheet has nothing to say with them: the roster overview card and its charts, the unit composition, and the per-unit points cost. Those decisions live in src/fork.js - another file upstream does not have - and src/10th/Roster.jsx reaches for them on as few lines as possible:

  • it imports ShortSummaryTable from ../fork instead of ./ShortSummaryTable, a one-line change that leaves the render site untouched. The upstream component is still in the tree, unused, so its future diffs keep applying.
  • hideModelCount is pinned to HIDE_UNIT_COMPOSITION instead of being a piece of checkbox state.

Only the two checkboxes and the pts span are deleted outright, so a merge that touches them will say so rather than quietly bringing them back.

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.