Compare commits
12 Commits
62d6bd211b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 836a53e95e | |||
| 0623ba903b | |||
| 96bcc975a7 | |||
| e7ecbcabc0 | |||
| 1846df6ac2 | |||
| 163ee34d8d | |||
| 783ff0df1e | |||
| 41edd40cd5 | |||
| cf67918bc0 | |||
| 60850f026c | |||
| 444d26fd47 | |||
| b35c88b451 |
@@ -1,32 +0,0 @@
|
||||
# Lint, test and build on every push.
|
||||
#
|
||||
# This needs a Gitea Actions runner registered against the instance. If there
|
||||
# isn't one, delete this file - nothing else depends on it, and `npm test &&
|
||||
# npm run build` locally covers the same ground.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- run: npm ci
|
||||
|
||||
# Asserts the transforms still produce the rosters the Python scripts they
|
||||
# were ported from produced; see README.md.
|
||||
- run: npm test
|
||||
|
||||
- run: npm run build
|
||||
@@ -0,0 +1,171 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for agents that work in this repository. [README.md](README.md)
|
||||
describes what the app does for its users. This file describes how it is built.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
npm test # the parity suite, see "Tests"
|
||||
npm run build # static files in dist/
|
||||
npm run fixtures # regenerate test fixtures, see "Tests"
|
||||
```
|
||||
|
||||
`npm run lint` reports findings that come from upstream, mostly
|
||||
`a11y/useButtonType`. These findings are old, so lint is not part of CI. Before
|
||||
you treat a finding as new, compare it against `git show upstream/main:<file>`.
|
||||
|
||||
## Where the transforms fit
|
||||
|
||||
```
|
||||
upload/.rosz -> unzip -> DOMParser -> applyTransforms -> Create40kRoster* -> render
|
||||
^
|
||||
src/transforms/index.js
|
||||
```
|
||||
|
||||
`src/App.jsx` runs the transforms between the parse of the XML and the build of
|
||||
the roster. A toggle therefore rebuilds the cards from the original XML, and
|
||||
the user uploads nothing a second time. For the same reason, a saved roster
|
||||
holds the raw roster XML and not the parsed object.
|
||||
|
||||
[`src/transforms/config.js`](src/transforms/config.js) lists the abilities that
|
||||
the transforms strip and split. When you find more of them, add them to
|
||||
`abilitiesToStrip` and `abilitiesToConvert`.
|
||||
|
||||
## Lore text
|
||||
|
||||
[`src/helpers/lore.js`](src/helpers/lore.js) matches a roster name against
|
||||
`public/Lore.csv` in three steps:
|
||||
|
||||
1. The normalized name. Case, accents, curly quotes and punctuation all differ
|
||||
between the roster and the export.
|
||||
2. The name with its last word in the singular form. Example: "Myphitic
|
||||
Blight-haulers" against "Myphitic Blight-hauler".
|
||||
3. The longest known name that the roster name *ends* with. Example: "Thousand
|
||||
Sons Chaos Spawn" against "Chaos Spawn".
|
||||
|
||||
This search resolves every unit in the bundled 10th-edition examples.
|
||||
|
||||
`parseLore` finds columns by header name. If someone exports the file again
|
||||
with a `faction` column, `parseLore` can match that column against the
|
||||
catalogue of the force. Nothing else has to change. Today the export has no
|
||||
such column, so the longest entry wins for a name with lore in more than one
|
||||
faction.
|
||||
|
||||
Two properties of the card header are less obvious than they look:
|
||||
|
||||
- The italic text needs a **fourth font file**. `ConduitITCStd` shipped as
|
||||
three upright faces, and `:root` in [`src/index.css`](src/index.css) sets
|
||||
`font-synthesis: none`. A request for italic therefore printed upright text
|
||||
and gave no warning. `public/fonts/ConduitITCStd Italic.woff2` and its
|
||||
`@font-face` rule correct this. The element also sets
|
||||
`font-synthesis: style`. As a result, a face that fails to load degrades to a
|
||||
slanted upright face and not to no italic at all.
|
||||
- The lore panel has absolute position, so it cannot make the header taller.
|
||||
Without help, a long legend is clipped: the longest entry in the export
|
||||
overruns a 15rem header at each width below approximately 1300px. A
|
||||
`ResizeObserver` on the text feeds the `min-height` of the header instead.
|
||||
The observer is necessary because the text wraps differently at each card
|
||||
width.
|
||||
|
||||
Only the 10th-edition and 11th-edition renderers show lore. Leave the
|
||||
9th-edition renderer in `src/9th/` alone. It lays out its header differently.
|
||||
|
||||
## The header has five layers
|
||||
|
||||
The card header stacks the coloured accent bar (1), the model image (2), the
|
||||
name and the stat line (3) and the two lore elements (4, 5). The order comes
|
||||
from the official cards, where a wide image passes *behind* the text. Every
|
||||
layer therefore carries an explicit `z-index`, and the header sets
|
||||
`isolation: isolate` so those five numbers never meet the rest of the card. A
|
||||
new absolutely positioned element in the header needs a number from this scale;
|
||||
without one it lands under the image.
|
||||
|
||||
The name and the stat line are wide, mostly empty boxes, and they now lie over
|
||||
the image. `pointer-events: none` on the box with `auto` on the text inside
|
||||
keeps the drag and the wheel that place the image (`ImgEditor`) reaching it. A
|
||||
new element on layer 3 needs the same treatment, or it takes the pointer away
|
||||
from the image behind it.
|
||||
|
||||
The image box also carries a `mask-image` (`imageFade` in
|
||||
[`src/10th/Roster.jsx`](src/10th/Roster.jsx)). A picture wider than its box
|
||||
would otherwise end at a hard vertical edge in the middle of the stat line.
|
||||
|
||||
## Tests
|
||||
|
||||
The transforms began as three standalone Python scripts
|
||||
(`merge_duplicate_units`, `remove_leader_abilities`, `convert_choice_abilities`)
|
||||
that rewrote the `.ros` file before the upload to FancyScribe. The scripts are
|
||||
gone, but the tests measure against their output.
|
||||
|
||||
`src/transforms/__fixtures__/` holds four 11th-edition rosters. For each roster
|
||||
it also holds the output of those scripts: `<name>.{merge,strip,convert,all}.ros`.
|
||||
The suite runs each transform over the input and asserts that the result is the
|
||||
same roster. This is worth more than a snapshot of the current code, because
|
||||
the expected output comes from a **different implementation**. A person
|
||||
verified that implementation with printed cards.
|
||||
|
||||
CAUTION: Do not regenerate an existing fixture from the JavaScript code. The
|
||||
test then compares the code against itself and passes whatever the code does.
|
||||
`scripts/update-fixtures.mjs` therefore refuses to overwrite a fixture without
|
||||
`--force`. Use the script when you add a new example roster, and read the diff:
|
||||
|
||||
```sh
|
||||
# Put a new roster in src/transforms/__fixtures__/, then:
|
||||
npm run fixtures
|
||||
```
|
||||
|
||||
The comparison is structural, not textual. The Python scripts edited the file
|
||||
as text and kept its format byte for byte. These transforms build DOM nodes, so
|
||||
attribute order and whitespace differ legitimately. The suite compares
|
||||
generated `id` and `typeId` values as *tokens*. The requirement is that ids are
|
||||
shared and distinct in the same pattern, not that both implementations hash
|
||||
alike.
|
||||
|
||||
`integration.test.js` pushes the transformed document through the real roster
|
||||
parser and checks what lands on the card:
|
||||
|
||||
- one Skitarii card that carries both weapons
|
||||
- the data-tether ability on the model that brings it
|
||||
- no Support ability
|
||||
- one row for each Canticle
|
||||
|
||||
The tests run under jsdom, which does not implement scoped selectors like a
|
||||
browser. `force.querySelectorAll("force>selections>…")` finds nothing under
|
||||
jsdom. A browser matches the selector against the whole tree and then keeps the
|
||||
descendants of `force`, so the parser found no units at all. The two call sites
|
||||
that depend on this behavior now use `:scope>…`, which works in both.
|
||||
|
||||
## Merges from upstream
|
||||
|
||||
FancyScribe is under active development, and 11th-edition support landed
|
||||
recently. This fork touches little of it. The `upstream` remote is configured:
|
||||
|
||||
```sh
|
||||
git fetch upstream
|
||||
git merge upstream/main
|
||||
```
|
||||
|
||||
Expect conflicts only in `src/App.jsx`, `index.html`, `vite.config.js` and
|
||||
`package.json`. `src/transforms/` is completely new and never conflicts.
|
||||
|
||||
The fork also hides three things that upstream shows, because a generic
|
||||
datasheet cannot use them: the roster overview card with its charts, the unit
|
||||
composition, and the points cost of each unit. These decisions live in
|
||||
[`src/fork.js`](src/fork.js), which upstream does not have.
|
||||
`src/10th/Roster.jsx` uses them on as few lines as possible:
|
||||
|
||||
- It imports `ShortSummaryTable` from `../fork`, not from `./ShortSummaryTable`.
|
||||
This one-line change leaves the render site untouched. The upstream component
|
||||
stays in the tree, unused, so its future diffs continue to apply.
|
||||
- `hideModelCount` is pinned to `HIDE_UNIT_COMPOSITION` and is no longer a
|
||||
piece of checkbox state.
|
||||
|
||||
Only the two checkboxes and the `pts` span are deleted. A merge that touches
|
||||
them therefore reports a conflict instead of quietly bringing them back.
|
||||
|
||||
`vite.config.js` sets `base: "/"`, because the app is served at the root of a
|
||||
domain. Upstream sets `/fancyscribe` for GitHub Pages. If you serve the app
|
||||
from a subpath, change this setting.
|
||||
@@ -1,152 +1,138 @@
|
||||
# BrevyScribe
|
||||
|
||||
A fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe) that prints
|
||||
**generic datasheets** rather than a record of one particular army list.
|
||||
BrevyScribe is a fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe).
|
||||
It prints **generic datasheets** instead of a record of one 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.
|
||||
FancyScribe shows a BattleScribe or New Recruit roster as 10th-edition
|
||||
datacards. The cards show only the wargear that you selected. That is correct
|
||||
for a list that you play today. It is not correct for a reference card that you
|
||||
keep, because the official cards show *every* option of a unit. BrevyScribe
|
||||
rewrites the roster before it renders the cards. As a result, the printed cards
|
||||
read like the official ones.
|
||||
|
||||
Three transforms do the work, all of them toggleable in the UI:
|
||||
Three transforms do this work. You can switch each one on or off in the user
|
||||
interface.
|
||||
|
||||
| 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. |
|
||||
| **Merge duplicates** | Mutually exclusive wargear makes you take one datasheet two times. One Skatros has a radium jezzail, another has a transuranic arquebus. This transform folds the copies of a unit into one card that carries all options. |
|
||||
| **Drop Leader/Support** | This transform removes the attachment rules. After the army is built, these rules tell you nothing that you need during a game. They are also long, and they push the necessary rules off the card. |
|
||||
| **Split choice abilities** | An ability such as *Canticles of the Omnissiah* arrives as one block of text. This transform divides it into the intro rule and one titled row for each option. The official datasheets print it in this form. |
|
||||
|
||||
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.
|
||||
The app does all of its work in the browser. There is no server component, no
|
||||
upload, no account and no analytics. Rosters stay in `localStorage` and never
|
||||
leave your machine.
|
||||
|
||||
## Running it
|
||||
## Lore text
|
||||
|
||||
Official datasheets print a paragraph of flavor text to the right of the model
|
||||
image. The **Show Lore Text** toggle prints this text. The card then gives the
|
||||
right third of its image to the text, behind a gradient that fades the image
|
||||
into the dark. Cards without an entry keep the full-width image.
|
||||
|
||||
[`public/Lore.csv`](public/Lore.csv) supplies the text. The file is a
|
||||
pipe-delimited export in this form:
|
||||
|
||||
```
|
||||
name|legend
|
||||
Custodian Guard|These warriors form the backbone of the shield companies, ...
|
||||
```
|
||||
|
||||
The app fetches this file only when a roster is on screen. A missing or bad
|
||||
file means that the cards print without lore text.
|
||||
|
||||
The names in a roster do not match the export exactly, so the app searches for
|
||||
the closest entry. This search is a heuristic, and you can correct it:
|
||||
|
||||
- To change the text of a card, edit it in place. The app keeps your edit in
|
||||
`localStorage` under `lore_<unit name>`. If you clear the edit, the text of
|
||||
the export comes back.
|
||||
- If the export does not cover a unit, the card shows an **Add lore** button.
|
||||
|
||||
About 40 names carry lore for more than one faction. Chaos Daemons and Death
|
||||
Guard both field Plaguebearers, and three armies field a Ministorum Priest. The
|
||||
export has no column that separates them, so the longest entry wins.
|
||||
|
||||
Only the 10th-edition and 11th-edition cards show lore text. The 9th-edition
|
||||
renderer lays out its header differently.
|
||||
|
||||
## Run it on your machine
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
npm test # the parity suite, see below
|
||||
npm run build # static files into dist/
|
||||
npm test
|
||||
npm run build # static files in 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.
|
||||
## Deploy to scribe.luxick.de
|
||||
|
||||
## Deploying to scribe.luxick.de
|
||||
The build output is **static files**. There is no application server and no
|
||||
socket, so nginx serves the files directly. The app does all of its work in the
|
||||
browser. For the same reason, upstream FancyScribe can live on GitHub Pages.
|
||||
|
||||
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:
|
||||
The server needs no Node. Do the setup one time, in two stages. The two stages
|
||||
are necessary because the real configuration names a certificate, and nginx
|
||||
refuses to load a configuration whose certificate does not exist. Therefore
|
||||
nginx first comes up on port 80 only. That is far enough for certbot to answer
|
||||
the challenge.
|
||||
|
||||
```sh
|
||||
sudo mkdir -p /var/www/brevyscribe
|
||||
sudo mkdir -p /var/www/brevyscribe /var/www/certbot
|
||||
sudo chown "$USER" /var/www/brevyscribe
|
||||
sudo cp deploy/nginx-scribe.luxick.de.conf /etc/nginx/sites-available/scribe.luxick.de
|
||||
|
||||
# Stage 1: HTTP only, so that 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 certbot --nginx -d scribe.luxick.de
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
sudo certbot certonly --webroot -w /var/www/certbot -d scribe.luxick.de
|
||||
|
||||
# Stage 2: the real configuration, 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
|
||||
```
|
||||
|
||||
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.
|
||||
Use `certonly --webroot`, not `--nginx`. The configuration in the repository
|
||||
carries its own redirect and TLS block. The nginx plugin rewrites the installed
|
||||
file, and the installed file then drifts away from the one in the repository.
|
||||
The port 80 block keeps its `acme-challenge` location for this reason, so
|
||||
renewals continue to work without your attention.
|
||||
|
||||
CAUTION: The timer of certbot does not reload nginx. Put a one-line hook that
|
||||
runs `systemctl reload nginx` in `/etc/letsencrypt/renewal-hooks/deploy/`.
|
||||
Without this hook, the server serves a renewed certificate only after the next
|
||||
restart.
|
||||
|
||||
Each deploy is then one command from a checkout on your own machine. The
|
||||
command runs the tests, builds the app and copies `dist/` to the server with
|
||||
rsync.
|
||||
|
||||
```sh
|
||||
./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`.
|
||||
The script uses `--delete` on purpose. The asset filenames contain a hash of
|
||||
the content, so old bundles collect on the server without this flag. There is
|
||||
no state on the server and nothing to back up. Every roster stays in the
|
||||
`localStorage` of the browser.
|
||||
|
||||
`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`](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:
|
||||
|
||||
```sh
|
||||
# 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:
|
||||
|
||||
```sh
|
||||
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.
|
||||
`index.html` loads Noto Sans from Google Fonts, so a page load contacts
|
||||
`fonts.googleapis.com`. If you do not want this request, delete the `<link>`
|
||||
tags. Then host the font next to the fonts in `public/fonts/`.
|
||||
|
||||
## Credit
|
||||
|
||||
All the hard parts - the parsing, the card layout, the print CSS - are
|
||||
[Nils Ueter's](https://github.com/NilsUeter/fancyscribe), with parsing logic
|
||||
descended in turn from
|
||||
The difficult parts are the work of
|
||||
[Nils Ueter](https://github.com/NilsUeter/fancyscribe): the parser, the card
|
||||
layout and the print CSS. The parsing logic comes in turn from
|
||||
[PrettyScribe](https://github.com/rweyrauch/PrettyScribe). Upstream ships no
|
||||
licence file, so treat this fork as a private, personal deployment rather than
|
||||
something to redistribute.
|
||||
license file. Treat this fork as a private, personal deployment, not as
|
||||
something that you redistribute.
|
||||
|
||||
@@ -20,14 +20,25 @@ server {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
# The `http2 on;` directive only exists from nginx 1.25.1; on older builds
|
||||
# (Debian bookworm ships 1.22, Ubuntu 22.04 ships 1.18) it is an unknown
|
||||
# directive and the config will not load. This form works everywhere, at the
|
||||
# price of a deprecation warning on 1.25.1+.
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name scribe.luxick.de;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/scribe.luxick.de/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/scribe.luxick.de/privkey.pem;
|
||||
|
||||
# `certbot certonly` issues the certificate and stops there - unlike the nginx
|
||||
# plugin it never writes an `options-ssl-nginx.conf` include, so the protocol
|
||||
# and session settings have to live here.
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
root /var/www/brevyscribe;
|
||||
index index.html;
|
||||
|
||||
|
||||
+1713
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+175
-34
@@ -19,8 +19,18 @@ import { Arrow, wavyLine } from "../assets/icons";
|
||||
import { Weapons, hasDifferentProfiles } from "./Weapons";
|
||||
import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB
|
||||
import { ImgEditor } from "./ImgEditor";
|
||||
import { trySettingLocalStorage } from "../helpers/useLocalStorage";
|
||||
import { ShortSummaryTable } from "./ShortSummaryTable";
|
||||
import {
|
||||
trySettingLocalStorage,
|
||||
useLocalStorage,
|
||||
} from "../helpers/useLocalStorage";
|
||||
import { useLore } from "../helpers/useLore";
|
||||
import { HIDE_UNIT_COMPOSITION, ShortSummaryTable } from "../fork";
|
||||
|
||||
// Soft left edge for the model image in the card header. Opaque over the right
|
||||
// two thirds of the image box, so only the part that reaches across the stat
|
||||
// line is faded.
|
||||
const imageFade =
|
||||
"linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.55) 16%, rgba(0,0,0,1) 36%)";
|
||||
|
||||
const getShortSummarySubtitle = (force) => {
|
||||
const details = [];
|
||||
@@ -46,6 +56,7 @@ export const Roster = ({
|
||||
onePerPage,
|
||||
colorUserChoice,
|
||||
primaryColor,
|
||||
showLore,
|
||||
}) => {
|
||||
if (!roster) {
|
||||
return null;
|
||||
@@ -70,6 +81,7 @@ export const Roster = ({
|
||||
force={force}
|
||||
onePerPage={onePerPage}
|
||||
colorUserChoice={colorUserChoice}
|
||||
showLore={showLore}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
@@ -77,7 +89,7 @@ export const Roster = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Force = ({ force, onePerPage, colorUserChoice }) => {
|
||||
const Force = ({ force, onePerPage, colorUserChoice, showLore }) => {
|
||||
const { units, factionRules, rules, catalog } = force;
|
||||
const mergedRules = new Map([...factionRules, ...rules]);
|
||||
|
||||
@@ -173,6 +185,7 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
|
||||
onePerPage={onePerPage}
|
||||
forceRules={rules}
|
||||
colorUserChoice={colorUserChoice}
|
||||
showLore={showLore}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -181,9 +194,16 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
const Unit = ({
|
||||
unit,
|
||||
catalog,
|
||||
onePerPage,
|
||||
forceRules,
|
||||
colorUserChoice,
|
||||
showLore,
|
||||
}) => {
|
||||
const [hide, setHide] = useState(false);
|
||||
const [hideModelCount, setHideModelCount] = useState(false);
|
||||
const hideModelCount = HIDE_UNIT_COMPOSITION;
|
||||
const uploadRef = useRef();
|
||||
let {
|
||||
name,
|
||||
@@ -201,6 +221,38 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
const hasImage = image && image !== "undefined";
|
||||
const [bgRemoved, setBgRemoved] = useState(false);
|
||||
|
||||
// Flavour text, as the official cards print it beside the model image. The
|
||||
// export is matched on the unit name, and whatever it comes back with can be
|
||||
// edited in place - the name match is a heuristic, and a few names carry
|
||||
// lore for more than one faction. An empty edit falls back to the export.
|
||||
const lore = useLore();
|
||||
const [loreOverride, setLoreOverride] = useLocalStorage(`lore_${name}`);
|
||||
const loreText =
|
||||
loreOverride && loreOverride !== "undefined"
|
||||
? loreOverride
|
||||
: lore?.lookup(name, catalog);
|
||||
const hasLore = showLore && Boolean(loreText);
|
||||
|
||||
// The panel is positioned absolutely, so a long legend cannot push the header
|
||||
// taller by itself and the last lines would be clipped - the longest entry in
|
||||
// the export overruns a 15rem header at any width below about 1300px. Measure
|
||||
// the text instead and let the header grow. Observed rather than measured
|
||||
// once, because the wrap changes with the card width.
|
||||
const loreRef = useRef(null);
|
||||
const [loreHeight, setLoreHeight] = useState(0);
|
||||
// hasLore is what mounts the observed element, so the effect has to re-run on
|
||||
// it; a ref is not a reactive value, so the rule cannot see that.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: see above
|
||||
useEffect(() => {
|
||||
const element = loreRef.current;
|
||||
if (!element) return;
|
||||
const observer = new ResizeObserver(() =>
|
||||
setLoreHeight(element.scrollHeight),
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [hasLore]);
|
||||
|
||||
const weapons = [...meleeWeapons, ...rangedWeapons];
|
||||
|
||||
const weaponDescriptions = weapons
|
||||
@@ -315,23 +367,6 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-end gap-3 pb-0.5">
|
||||
<label
|
||||
className="print-display-none"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 4,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="hide-model-selection"
|
||||
type="checkbox"
|
||||
onChange={(e) => setHideModelCount(e.target.checked)}
|
||||
/>
|
||||
<span className="print-display-none">Hide Unit Composition</span>
|
||||
</label>
|
||||
<label
|
||||
className="print-display-none"
|
||||
style={{
|
||||
@@ -349,6 +384,8 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
<div
|
||||
className="min-h-[15rem]"
|
||||
style={{
|
||||
// 15rem unless the legend needs more; 44px is the panel's padding.
|
||||
minHeight: hasLore ? `max(15rem, ${loreHeight + 44}px)` : undefined,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 4,
|
||||
background:
|
||||
@@ -357,6 +394,9 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
backgroundSize: "cover",
|
||||
color: "#fff",
|
||||
position: "relative",
|
||||
// Keeps the header's five layers to itself, so their z-indices
|
||||
// never have to be compared against the rest of the card.
|
||||
isolation: "isolate",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -375,6 +415,11 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
top: 0,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
// The model image sits between this accent bar and the text,
|
||||
// as it does on the official cards. Every layer of the header
|
||||
// therefore needs an explicit z-index: bar 1, image 2, text 3,
|
||||
// lore 4 and 5.
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -415,25 +460,29 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
lineHeight: "1",
|
||||
fontWeight: 800,
|
||||
textTransform: "uppercase",
|
||||
zIndex: 1,
|
||||
zIndex: 3,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 2,
|
||||
// This box is as wide as the card and now lies over the image,
|
||||
// where it would swallow the drag and the wheel that place the
|
||||
// image. Only the glyphs need the pointer.
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
<span style={{ textTransform: "initial", fontSize: "1.2rem" }}>
|
||||
{cost.points}pts
|
||||
</span>
|
||||
<span style={{ pointerEvents: "auto" }}>{name}</span>
|
||||
</div>
|
||||
<div className="relative flex gap-4">
|
||||
<div className="pointer-events-none relative z-[3] flex gap-4">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
// See the note on the name above. The stat boxes are narrow,
|
||||
// but the box around them reaches the far edge of the card.
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{modelStats.map((model, index) => (
|
||||
@@ -450,20 +499,101 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasLore && (
|
||||
<>
|
||||
{/* Wider than the text it sits behind, so the model image fades
|
||||
into the dark rather than ending at a hard edge. */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "40%",
|
||||
zIndex: 4,
|
||||
pointerEvents: "none",
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.55) 40%, rgba(0,0,0,.7) 100%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "29%",
|
||||
zIndex: 5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "30px 14px 14px 6px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Editable in place: see the note on `loreText` above. */}
|
||||
<div
|
||||
ref={loreRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
title="Click to correct this text. Clearing it restores the text from Lore.csv."
|
||||
onBlur={(e) =>
|
||||
setLoreOverride(e.currentTarget.innerText.trim())
|
||||
}
|
||||
style={{
|
||||
fontStyle: "italic",
|
||||
// index.css turns font synthesis off globally, so if the
|
||||
// italic face fails to load this would print upright. Let
|
||||
// this one element fall back to a slanted upright face.
|
||||
fontSynthesis: "style",
|
||||
fontSize: "1rem",
|
||||
lineHeight: 1.32,
|
||||
textShadow: "0 1px 2px rgba(0,0,0,.6)",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{loreText}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
// The image gives up its right-hand third to the lore panel, which
|
||||
// is where the official cards put the flavour text. Cards without
|
||||
// lore keep the full-width image they have always had.
|
||||
right: hasLore ? "29%" : 0,
|
||||
top: 0,
|
||||
height: "100%",
|
||||
bottom: 0,
|
||||
width: "60%",
|
||||
zIndex: 100,
|
||||
width: hasLore ? "40%" : "60%",
|
||||
zIndex: 2,
|
||||
overflow: "hidden",
|
||||
// A picture wider than its box would otherwise end at a hard
|
||||
// vertical edge over the stat line. The official cards let it
|
||||
// fade into the dark background instead. The mask fades the
|
||||
// image itself, so the background stays untouched.
|
||||
maskImage: imageFade,
|
||||
WebkitMaskImage: imageFade,
|
||||
}}
|
||||
>
|
||||
{hasImage && <ImgEditor image={image} name={name} />}
|
||||
<div className="absolute right-[1px] top-[3px] flex items-center gap-1.5">
|
||||
{showLore && lore && !loreText && (
|
||||
<button
|
||||
type="button"
|
||||
className="button print-display-none border-none bg-[#f0f0f0e6] hover:bg-[#f0f0f0]"
|
||||
style={{
|
||||
padding: "1px 4px",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
onClick={() => setLoreOverride(`Lore for ${name}.`)}
|
||||
title="Lore.csv has no entry under this name. Add the text by hand."
|
||||
>
|
||||
Add lore
|
||||
</button>
|
||||
)}
|
||||
{hasImage && !bgRemoved && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -900,7 +1030,7 @@ const InvulRow = ({ hasInvul }) => {
|
||||
backgroundColor: "var(--primary-color)",
|
||||
}}
|
||||
>
|
||||
INVULNERABLE SAVE{isSpecialInvul ? "*" : ""}
|
||||
INSV{isSpecialInvul ? "*" : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1378,8 +1508,15 @@ const makeKeywordsBold = (text) => {
|
||||
}
|
||||
}
|
||||
|
||||
// replace words wrapped in ^^ ^^ with <strong> tags
|
||||
newValue = newValue.replace(/\^\^([^\^]+)\^\^/g, "<strong>$1</strong>");
|
||||
// replace words wrapped in ^^ ^^ with <strong> tags. ^^ is the exporter's
|
||||
// datasheet-keyword marker, and the printed cards set keywords in bold
|
||||
// uppercase - the same treatment boldKeywords gives the ones it knows. The
|
||||
// case comes from CSS rather than toUpperCase() because the replacements above
|
||||
// have already put HTML in here, and var(--primary-color) is case-sensitive.
|
||||
newValue = newValue.replace(
|
||||
/\^\^([^\^]+)\^\^/g,
|
||||
'<strong style="text-transform: uppercase;">$1</strong>',
|
||||
);
|
||||
|
||||
// replace words wrapped in ** ** with <strong> tags
|
||||
newValue = newValue.replace(/\*\*([^\*]+)\*\*/g, "<strong>$1</strong>");
|
||||
@@ -1504,7 +1641,11 @@ const OtherAbilities = ({ abilities }) => {
|
||||
paddingBottom: 4,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 700 }}>{name}:</span> {value}
|
||||
<span style={{ fontWeight: 700 }}>{name}:</span>{" "}
|
||||
<span
|
||||
className="whitespace-pre-line"
|
||||
dangerouslySetInnerHTML={{ __html: makeKeywordsBold(value) }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
+6
-24
@@ -84,26 +84,15 @@ function App() {
|
||||
const [roster, setRoster] = useState();
|
||||
const [edition, setEdition] = useState(10); // [9, 10, 11]
|
||||
const [onePerPage, setOnePerPage] = useState(false);
|
||||
const [showLore, setShowLore] = useState(true);
|
||||
const [primaryColor, setPrimaryColor] = useState("#536766");
|
||||
const [colorUserChoice, setColorUserChoice] = useState(false);
|
||||
const [hideModelSelections, setHideModelSelections] = useState(false);
|
||||
const uploadRef = useRef();
|
||||
|
||||
const throttledSetPrimaryColor = useRef(
|
||||
throttle((color) => setPrimaryColor(color), 50),
|
||||
).current;
|
||||
|
||||
const toggleHideModelSelections = (hide) => {
|
||||
const checkboxes = document.querySelectorAll(
|
||||
'input[type="checkbox"].hide-model-selection',
|
||||
);
|
||||
checkboxes.forEach((checkbox) => {
|
||||
if (checkbox.checked !== hide) {
|
||||
checkbox.click();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
async function handleFileSelect(event) {
|
||||
const files = event?.target?.files;
|
||||
|
||||
@@ -431,9 +420,6 @@ function App() {
|
||||
One Datacard per Page when Printing
|
||||
</span>
|
||||
</label>
|
||||
{
|
||||
// only show when 10th or 11th edition
|
||||
(edition === 10 || edition === 11) && (
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -441,20 +427,15 @@ function App() {
|
||||
gap: 4,
|
||||
minHeight: 26,
|
||||
}}
|
||||
title="Print the flavour text from public/Lore.csv beside the model image, the way the official datasheets do."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={hideModelSelections}
|
||||
onChange={(e) => {
|
||||
setHideModelSelections(e.target.checked);
|
||||
toggleHideModelSelections(e.target.checked);
|
||||
}}
|
||||
className="hide-model-selection"
|
||||
checked={showLore}
|
||||
onChange={(e) => setShowLore(e.target.checked)}
|
||||
/>
|
||||
<span className="select-none">Hide all Unit Compositions</span>
|
||||
<span className="select-none">Show Lore Text</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<input
|
||||
@@ -500,6 +481,7 @@ function App() {
|
||||
onePerPage={onePerPage}
|
||||
colorUserChoice={colorUserChoice}
|
||||
primaryColor={primaryColor}
|
||||
showLore={showLore}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 454 KiB After Width: | Height: | Size: 928 KiB |
+23
@@ -0,0 +1,23 @@
|
||||
// Fork-local display decisions.
|
||||
//
|
||||
// BrevyScribe prints the generic datasheet for a unit rather than a record of
|
||||
// one particular list, so a few of the things FancyScribe shows have nothing
|
||||
// left to say here. They are switched off from this file - which upstream does
|
||||
// not have, and which therefore can never conflict - so that the components
|
||||
// upstream owns are touched on as few lines as possible.
|
||||
|
||||
// The roster overview table summarises the list that was uploaded: its total
|
||||
// cost, a unit-by-unit breakdown and the charts drawn from them. None of that
|
||||
// survives the transforms, so the card is not rendered at all.
|
||||
//
|
||||
// 10th/Roster.jsx imports this in place of ./ShortSummaryTable, which leaves the
|
||||
// render site there byte-identical to upstream. ShortSummaryTable.jsx itself is
|
||||
// kept in the tree, unused, so upstream changes to it keep merging cleanly (Vite
|
||||
// tree-shakes it, and chart.js with it, out of the build).
|
||||
export const ShortSummaryTable = () => null;
|
||||
|
||||
// The unit composition lists the models this particular list took, which is the
|
||||
// list-specific detail the fork exists to strip - and after duplicate units are
|
||||
// merged into one card it is misleading as well. It is always hidden, and the
|
||||
// checkboxes that used to toggle it are gone.
|
||||
export const HIDE_UNIT_COMPOSITION = true;
|
||||
@@ -0,0 +1,158 @@
|
||||
// Lookup of the flavour text that official datasheets print to the right of the
|
||||
// model image. The data lives in `public/Lore.csv`, a pipe-delimited export of
|
||||
// `name|legend` (plus an optional `faction` column, see `buildLoreIndex`).
|
||||
//
|
||||
// Nothing about roster names is reliable enough for an exact lookup: the same
|
||||
// unit is spelled "Tech-priest Dominus" in one place and "Tech-Priest Dominus"
|
||||
// in another, a roster may name a unit in the plural where the export uses the
|
||||
// singular ("Myphitic Blight-haulers" / "Myphitic Blight-hauler"), and faction
|
||||
// catalogues prefix names that the export does not ("Thousand Sons Chaos
|
||||
// Spawn" / "Chaos Spawn"). So the index is keyed by a normalised form and
|
||||
// consulted through three widening attempts.
|
||||
|
||||
/**
|
||||
* Case, accents, curly quotes and punctuation all vary between the export and
|
||||
* the roster, and none of them carry meaning here, so collapse the lot.
|
||||
*/
|
||||
export const normalizeName = (name) =>
|
||||
String(name ?? "")
|
||||
.normalize("NFKD")
|
||||
.replace(/\p{M}/gu, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* Crude singularisation of the last word, which is the only place a roster and
|
||||
* the export tend to disagree on number. Deliberately not a real stemmer: it
|
||||
* runs over both sides of the comparison, so it only has to be consistent, not
|
||||
* correct.
|
||||
*/
|
||||
const singularize = (word) => {
|
||||
if (word.length < 4) return word;
|
||||
if (word.endsWith("ies")) return `${word.slice(0, -3)}y`;
|
||||
if (/(?:ss|sh|ch|x|z)es$/.test(word)) return word.slice(0, -2);
|
||||
if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
|
||||
return word;
|
||||
};
|
||||
|
||||
const stemKey = (normalized) => {
|
||||
const words = normalized.split(" ");
|
||||
if (!words.length) return normalized;
|
||||
words[words.length - 1] = singularize(words[words.length - 1]);
|
||||
return words.join(" ");
|
||||
};
|
||||
|
||||
const splitLines = (text) =>
|
||||
String(text ?? "")
|
||||
.replace(/^\ufeff/, "")
|
||||
.split(/\r?\n/);
|
||||
|
||||
/**
|
||||
* Parses the export into `{ name, legend, faction }` rows. Rows without a
|
||||
* legend are dropped - the export carries a few hundred of them, one per unit
|
||||
* whose lore has not been transcribed yet, and they would otherwise shadow a
|
||||
* usable entry for the same name.
|
||||
*
|
||||
* Columns are located by the header line, so adding a `faction` column (see
|
||||
* `pickEntry`) does not need a code change.
|
||||
*/
|
||||
export const parseLore = (text) => {
|
||||
const lines = splitLines(text).filter((line) => line.trim());
|
||||
if (!lines.length) return [];
|
||||
|
||||
const header = lines[0].split("|").map((h) => h.trim().toLowerCase());
|
||||
const nameCol = header.indexOf("name");
|
||||
const legendCol = header.indexOf("legend");
|
||||
const factionCol = header.indexOf("faction");
|
||||
if (nameCol === -1 || legendCol === -1) return [];
|
||||
|
||||
const rows = [];
|
||||
for (const line of lines.slice(1)) {
|
||||
const fields = line.split("|");
|
||||
const name = fields[nameCol]?.trim();
|
||||
const legend = fields[legendCol]?.trim();
|
||||
if (!name || !legend) continue;
|
||||
rows.push({
|
||||
name,
|
||||
legend,
|
||||
faction: factionCol === -1 ? "" : (fields[factionCol]?.trim() ?? ""),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Several names carry more than one legend - either the same unit reworded
|
||||
* between editions, or a genuinely different unit sharing a name across
|
||||
* factions (Chaos Daemons and Death Guard both field Plaguebearers; three
|
||||
* different armies field a Ministorum Priest).
|
||||
*
|
||||
* Given a `faction` column in the export, that ambiguity is resolvable and we
|
||||
* prefer the entry whose faction matches the card. Without one - which is the
|
||||
* case for today's export - fall back to the longest legend. That is not
|
||||
* always the *right* variant, but it is deterministic, and the competing
|
||||
* variants are near-identical rewrites in all but a handful of cases.
|
||||
*/
|
||||
const pickEntry = (entries, faction) => {
|
||||
const wanted = normalizeName(faction);
|
||||
if (wanted) {
|
||||
const match = entries.find((entry) => {
|
||||
const entryFaction = normalizeName(entry.faction);
|
||||
return (
|
||||
entryFaction &&
|
||||
(entryFaction === wanted ||
|
||||
wanted.includes(entryFaction) ||
|
||||
entryFaction.includes(wanted))
|
||||
);
|
||||
});
|
||||
if (match) return match;
|
||||
}
|
||||
return entries.reduce((best, entry) =>
|
||||
entry.legend.length > best.legend.length ? entry : best,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the lookup. `lookup(name, faction)` returns the legend string, or
|
||||
* `undefined` when the unit has no entry.
|
||||
*/
|
||||
export const buildLoreIndex = (text) => {
|
||||
const exact = new Map();
|
||||
const stems = new Map();
|
||||
|
||||
for (const row of parseLore(text)) {
|
||||
const key = normalizeName(row.name);
|
||||
if (!key) continue;
|
||||
if (!exact.has(key)) exact.set(key, []);
|
||||
exact.get(key).push(row);
|
||||
|
||||
const stem = stemKey(key);
|
||||
if (!stems.has(stem)) stems.set(stem, []);
|
||||
stems.get(stem).push(row);
|
||||
}
|
||||
|
||||
// Multi-word keys, longest first, for the trailing-name pass below. Single
|
||||
// word keys are excluded: "Guard" or "Rangers" would match half the export.
|
||||
const suffixKeys = [...stems.keys()]
|
||||
.filter((key) => key.includes(" "))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
|
||||
const lookup = (name, faction) => {
|
||||
const normalized = normalizeName(name);
|
||||
if (!normalized) return undefined;
|
||||
|
||||
const entries =
|
||||
exact.get(normalized) ??
|
||||
stems.get(stemKey(normalized)) ??
|
||||
// Last resort: the roster name ends with a name we know, which is how
|
||||
// faction-prefixed datasheets ("Thousand Sons Chaos Spawn") arrive.
|
||||
stems.get(
|
||||
suffixKeys.find((key) => stemKey(normalized).endsWith(` ${key}`)),
|
||||
);
|
||||
|
||||
return entries ? pickEntry(entries, faction).legend : undefined;
|
||||
};
|
||||
|
||||
return { lookup, size: exact.size };
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildLoreIndex, normalizeName, parseLore } from "./lore";
|
||||
|
||||
const csv = (...lines) => `name|legend\n${lines.join("\n")}\n`;
|
||||
|
||||
describe("normalizeName", () => {
|
||||
it("collapses the casing, punctuation and quote style that vary between exports", () => {
|
||||
expect(normalizeName("Tech-priest Dominus")).toBe("tech priest dominus");
|
||||
expect(normalizeName("Tech-Priest Dominus")).toBe("tech priest dominus");
|
||||
expect(normalizeName("Khorne’s Hounds")).toBe("khorne s hounds");
|
||||
expect(normalizeName("Khorne's Hounds")).toBe("khorne s hounds");
|
||||
});
|
||||
|
||||
it("survives missing input", () => {
|
||||
expect(normalizeName(undefined)).toBe("");
|
||||
expect(normalizeName(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLore", () => {
|
||||
it("reads the pipe-delimited export, BOM and CRLF included", () => {
|
||||
const rows = parseLore("name|legend\r\nCustodian Guard|Stalwart.\r\n");
|
||||
expect(rows).toEqual([
|
||||
{ name: "Custodian Guard", legend: "Stalwart.", faction: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops rows whose legend has not been filled in", () => {
|
||||
const rows = parseLore(csv("Webway Gate|", "Bonesinger|Sings to bone."));
|
||||
expect(rows.map((row) => row.name)).toEqual(["Bonesinger"]);
|
||||
});
|
||||
|
||||
it("locates columns by header, so an added faction column just works", () => {
|
||||
const rows = parseLore(
|
||||
"legend|faction|name\nSings to bone.|Aeldari|Bonesinger\n",
|
||||
);
|
||||
expect(rows).toEqual([
|
||||
{ name: "Bonesinger", legend: "Sings to bone.", faction: "Aeldari" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing for junk rather than throwing", () => {
|
||||
expect(parseLore("")).toEqual([]);
|
||||
expect(parseLore("unit;text\nfoo;bar")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLoreIndex", () => {
|
||||
it("matches names that differ only in case or punctuation", () => {
|
||||
const { lookup } = buildLoreIndex(csv("Tech-priest Dominus|Theocrat."));
|
||||
expect(lookup("Tech-Priest Dominus")).toBe("Theocrat.");
|
||||
});
|
||||
|
||||
it("matches a plural roster name against a singular entry", () => {
|
||||
const { lookup } = buildLoreIndex(csv("Myphitic Blight-hauler|Belching."));
|
||||
expect(lookup("Myphitic Blight-haulers")).toBe("Belching.");
|
||||
});
|
||||
|
||||
it("matches a singular roster name against a plural entry", () => {
|
||||
const { lookup } = buildLoreIndex(csv("Plaguebearers|Foot soldiers."));
|
||||
expect(lookup("Plaguebearer")).toBe("Foot soldiers.");
|
||||
});
|
||||
|
||||
it("strips a faction prefix the export does not carry", () => {
|
||||
const { lookup } = buildLoreIndex(csv("Chaos Spawn|Roiling flesh."));
|
||||
expect(lookup("Thousand Sons Chaos Spawn")).toBe("Roiling flesh.");
|
||||
});
|
||||
|
||||
it("will not match on a single trailing word", () => {
|
||||
const { lookup } = buildLoreIndex(csv("Guard|Some other unit entirely."));
|
||||
expect(lookup("Custodian Guard")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers the entry whose faction matches the card", () => {
|
||||
const { lookup } = buildLoreIndex(
|
||||
"name|legend|faction\n" +
|
||||
"Plaguebearers|Daemon version, which is the longer of the two.|Chaos Daemons\n" +
|
||||
"Plaguebearers|Guard version.|Death Guard\n",
|
||||
);
|
||||
expect(lookup("Plaguebearers", "Death Guard")).toBe("Guard version.");
|
||||
expect(lookup("Plaguebearers", "Chaos Daemons")).toBe(
|
||||
"Daemon version, which is the longer of the two.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the longest legend when the faction cannot decide it", () => {
|
||||
const { lookup } = buildLoreIndex(
|
||||
csv("Servitors|Short.", "Servitors|The longer, fuller entry."),
|
||||
);
|
||||
expect(lookup("Servitors")).toBe("The longer, fuller entry.");
|
||||
expect(lookup("Servitors", "Adeptus Mechanicus")).toBe(
|
||||
"The longer, fuller entry.",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for a unit the export does not cover", () => {
|
||||
const { lookup } = buildLoreIndex(csv("Bonesinger|Sings to bone."));
|
||||
expect(lookup("Rein and Raus")).toBeUndefined();
|
||||
expect(lookup("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Guards the shipped export itself: a re-export that changes the delimiter or
|
||||
// the header names would otherwise fail silently, every card simply losing its
|
||||
// flavour text.
|
||||
describe("public/Lore.csv", () => {
|
||||
const index = buildLoreIndex(readFileSync("public/Lore.csv", "utf8"));
|
||||
|
||||
it("parses into a usable number of entries", () => {
|
||||
expect(index.size).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("covers the units in the bundled example rosters", () => {
|
||||
for (const name of [
|
||||
"Custodian Guard",
|
||||
"Bladeguard Veteran Squad",
|
||||
"Plague Marines",
|
||||
"Myphitic Blight-haulers",
|
||||
"Thousand Sons Chaos Spawn",
|
||||
]) {
|
||||
expect(index.lookup(name), name).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { buildLoreIndex } from "./lore";
|
||||
|
||||
// `public/Lore.csv` is ~400KB, so it is fetched once, lazily, and shared by
|
||||
// every card rather than bundled into the main chunk. The promise is cached at
|
||||
// module scope: a roster renders 20-odd Units, and they must not each kick off
|
||||
// their own request.
|
||||
let pending;
|
||||
|
||||
const loadLore = () => {
|
||||
if (!pending) {
|
||||
pending = fetch("Lore.csv")
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`Lore.csv: ${response.status}`);
|
||||
return response.text();
|
||||
})
|
||||
.then(buildLoreIndex)
|
||||
.catch((error) => {
|
||||
// A missing or unreadable export is not worth failing a card over -
|
||||
// the datasheet simply prints without its flavour text.
|
||||
console.error(error);
|
||||
return { lookup: () => undefined, size: 0 };
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves to the lore index, or `null` until it has loaded.
|
||||
*/
|
||||
export const useLore = () => {
|
||||
const [index, setIndex] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
loadLore().then((loaded) => live && setIndex(loaded));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return index;
|
||||
};
|
||||
+21
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
font-family: "Noto Sans", sans-serif, Inter, system-ui, Avenir, Helvetica,
|
||||
font-family: ConduitITCStd, "Noto Sans", sans-serif, Inter, system-ui, Avenir, Helvetica,
|
||||
Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
@@ -40,6 +40,14 @@
|
||||
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
}
|
||||
/* Datasheet lore text. Without a real italic face there would be none at all:
|
||||
font-synthesis is off below, so the browser may not slant an upright one. */
|
||||
@font-face {
|
||||
font-family: "ConduitITCStd";
|
||||
src: url("/fonts/ConduitITCStd Italic.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -200,6 +208,18 @@
|
||||
}
|
||||
|
||||
@media print {
|
||||
/* The cards get cut apart, so the white border around a cut card comes
|
||||
from two different places: the page margin on the outer edges, and
|
||||
half of the gap between two cards on the edges in between. The gap is
|
||||
therefore twice the page margin, and every edge ends up the same. */
|
||||
@page {
|
||||
margin: 8mm;
|
||||
}
|
||||
|
||||
.avoid-page-break {
|
||||
margin-bottom: 16mm !important;
|
||||
}
|
||||
|
||||
.print-display-none,
|
||||
.print-display-none * {
|
||||
display: none !important;
|
||||
|
||||
@@ -25,14 +25,7 @@ export const defaultConfig = {
|
||||
|
||||
// Abilities to split into per-option profiles, matched against the
|
||||
// profile's ``name`` attribute.
|
||||
abilitiesToConvert: ["Canticles of the Omnissiah", "Battle Protocols"],
|
||||
|
||||
// Title of the generated profile group, per ability. Anything not listed
|
||||
// here uses the ability's own name, which is what the datasheets do.
|
||||
groupTitleOverrides: {
|
||||
"Canticles of the Omnissiah": "CANTICLES OF THE OMNISSIAH",
|
||||
"Battle Protocols": "BATTLE PROTOCOLS",
|
||||
},
|
||||
abilitiesToConvert: ["Canticles of the Omnissiah", "Battle Protocols", "Icon of War"],
|
||||
};
|
||||
|
||||
// Which transforms run by default when a roster is loaded.
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
// * the original ``Abilities`` profile is kept, trimmed to the intro
|
||||
// paragraph (the "select one of the following" rule), and
|
||||
// * each option becomes its own profile under a new profile type named after
|
||||
// the ability, so the renderer groups them into a single titled table.
|
||||
// the ability, in capitals, so the renderer groups them into a single table
|
||||
// titled the way the datasheets title it.
|
||||
//
|
||||
// Running it twice is a no-op: a converted ability has only an intro paragraph
|
||||
// left, so there is nothing further to split.
|
||||
@@ -76,16 +77,23 @@ const cleanOptionName = (name) =>
|
||||
* over from mis-nested pairs goes too - a stray ``**`` is punctuation the
|
||||
* reader never wanted to see either way.
|
||||
*
|
||||
* The generated group renders through the renderer's generic ability table,
|
||||
* which prints its text verbatim, so the markers would otherwise show up as
|
||||
* literal punctuation. The intro profile keeps its original typeName, renders
|
||||
* through the normal path, and is therefore left alone.
|
||||
* Only the option *name* goes through this. It ends up as a profile ``name``
|
||||
* attribute, which the renderer prints as plain text, so a marker left in there
|
||||
* would show up as literal punctuation. The option text keeps its markers:
|
||||
* ``makeKeywordsBold`` in 10th/Roster.jsx formats them into the bold uppercase
|
||||
* keywords and bold emphasis the printed card uses, which is more than
|
||||
* upper-casing here could manage. The intro profile keeps its original
|
||||
* typeName, renders through the normal path, and was never touched either way.
|
||||
*
|
||||
* The Python original also stripped the option text, so this is the one place
|
||||
* the port deliberately differs from it; transforms.test.js resolves markers on
|
||||
* both sides when it compares against the fixtures.
|
||||
*
|
||||
* Unlike the Python original this needs no entity bookkeeping: the DOM hands us
|
||||
* resolved text, so there is no ``"`` here that upper-casing could turn
|
||||
* into an entity no parser would recognise.
|
||||
*/
|
||||
const stripMarkup = (text) =>
|
||||
export const stripMarkup = (text) =>
|
||||
text
|
||||
.replace(KEYWORD_RE, (_, keyword) =>
|
||||
keyword.replaceAll("*", "").toUpperCase(),
|
||||
@@ -201,7 +209,6 @@ function buildProfile(
|
||||
*/
|
||||
export function convertChoiceAbilities(doc, config) {
|
||||
const wanted = new Set(config.abilitiesToConvert);
|
||||
const overrides = config.groupTitleOverrides ?? {};
|
||||
const root = doc.documentElement;
|
||||
if (!root) return [];
|
||||
|
||||
@@ -234,7 +241,10 @@ export function convertChoiceAbilities(doc, config) {
|
||||
);
|
||||
if (options.length === 0) continue; // Already converted, or a plain ability.
|
||||
|
||||
const groupTitle = overrides[ability] ?? ability;
|
||||
// The group's title becomes the generated profileType's typeName, which the
|
||||
// renderer prints verbatim as the table heading - and the datasheets set
|
||||
// that heading in capitals.
|
||||
const groupTitle = ability.toUpperCase();
|
||||
const typeId = makeId("profileType", groupTitle);
|
||||
const charTypeId = makeId(
|
||||
"characteristicType",
|
||||
@@ -265,7 +275,7 @@ export function convertChoiceAbilities(doc, config) {
|
||||
typeId,
|
||||
typeName: groupTitle,
|
||||
charTypeId,
|
||||
description: stripMarkup(option.text),
|
||||
description: option.text,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,10 +100,12 @@ describe("splitting choice abilities", () => {
|
||||
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(/\^\^|\*\*/);
|
||||
// The option text keeps GW's ^^keyword^^ / **emphasis** markers for the
|
||||
// renderer to format; only the row titles are plain text, since those
|
||||
// render as-is.
|
||||
expect([...group.values()].join("\n")).toMatch(/\^\^/);
|
||||
for (const name of group.keys()) {
|
||||
expect(name).not.toMatch(/\^\^|\*\*/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { defaultConfig } from "./config.js";
|
||||
import {
|
||||
convertChoiceAbilities,
|
||||
splitDescription,
|
||||
stripMarkup,
|
||||
} from "./convertChoiceAbilities.js";
|
||||
import { applyTransforms } from "./index.js";
|
||||
import { mergeDuplicateUnits } from "./mergeDuplicateUnits.js";
|
||||
@@ -52,6 +53,12 @@ const load = (name) =>
|
||||
* tokens preserve, not that the two implementations agree on a hash function.
|
||||
* Every other attribute - including `entryId` and `publicationId`, which are
|
||||
* never generated - has to match exactly.
|
||||
*
|
||||
* Text goes through `stripMarkup` for the same reason. Python resolved GW's
|
||||
* ^^keyword^^ / **emphasis** markers in the option text it split out, because
|
||||
* the renderer printed that text verbatim; the renderer now formats the markers
|
||||
* itself, so the transform leaves them in place. Resolving them on both sides
|
||||
* keeps the fixtures an oracle for everything else about the split.
|
||||
*/
|
||||
function canonicalize(doc) {
|
||||
const tokens = new Map();
|
||||
@@ -72,11 +79,13 @@ function canonicalize(doc) {
|
||||
.map(([name, value]) => `${name}=${JSON.stringify(value)}`)
|
||||
.join(" ");
|
||||
|
||||
const text = Array.from(element.childNodes)
|
||||
const text = stripMarkup(
|
||||
Array.from(element.childNodes)
|
||||
.filter((node) => node.nodeType === 3 /* Text */)
|
||||
.map((node) => node.data)
|
||||
.join("")
|
||||
.trim();
|
||||
.trim(),
|
||||
);
|
||||
|
||||
lines.push(
|
||||
`${" ".repeat(depth)}<${element.localName} ${attributes}>` +
|
||||
|
||||
Reference in New Issue
Block a user