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
|
# BrevyScribe
|
||||||
|
|
||||||
A fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe) that prints
|
BrevyScribe is a fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe).
|
||||||
**generic datasheets** rather than a record of one particular army list.
|
It prints **generic datasheets** instead of a record of one army list.
|
||||||
|
|
||||||
FancyScribe renders a BattleScribe or New Recruit roster as 10th-edition-style
|
FancyScribe shows a BattleScribe or New Recruit roster as 10th-edition
|
||||||
datacards, showing exactly the wargear you picked. That is the right thing for a
|
datacards. The cards show only the wargear that you selected. That is correct
|
||||||
list you are about to play, but the wrong thing for a reference card you want to
|
for a list that you play today. It is not correct for a reference card that you
|
||||||
keep: the official cards show *every* option a unit could take. BrevyScribe
|
keep, because the official cards show *every* option of a unit. BrevyScribe
|
||||||
rewrites the roster on the way in so the printed cards read like the official
|
rewrites the roster before it renders the cards. As a result, the printed cards
|
||||||
ones.
|
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 |
|
| 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. |
|
| **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** | 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. |
|
| **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 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. |
|
| **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,
|
The app does all of its work in the browser. There is no server component, no
|
||||||
no account, and no analytics; rosters are held in `localStorage` and never leave
|
upload, no account and no analytics. Rosters stay in `localStorage` and never
|
||||||
the machine.
|
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
|
```sh
|
||||||
npm install
|
npm install
|
||||||
npm run dev # http://localhost:5173
|
npm run dev # http://localhost:5173
|
||||||
npm test # the parity suite, see below
|
npm test
|
||||||
npm run build # static files into dist/
|
npm run build # static files in dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run lint` reports pre-existing findings inherited from upstream (mostly
|
## Deploy to scribe.luxick.de
|
||||||
`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**. 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
|
The server needs no Node. Do the setup one time, in two stages. The two stages
|
||||||
for nginx to proxy to. The app does all its work in the browser, which is why
|
are necessary because the real configuration names a certificate, and nginx
|
||||||
upstream can live on GitHub Pages.
|
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
|
||||||
One-time setup on the server:
|
the challenge.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo mkdir -p /var/www/brevyscribe
|
sudo mkdir -p /var/www/brevyscribe /var/www/certbot
|
||||||
sudo chown "$USER" /var/www/brevyscribe
|
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 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
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
Then every deploy is one command from a checkout on your own machine - it runs
|
Use `certonly --webroot`, not `--nginx`. The configuration in the repository
|
||||||
the tests, builds, and rsyncs `dist/` over. The server needs no Node.
|
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
|
```sh
|
||||||
./deploy/deploy.sh
|
./deploy/deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
`--delete` is deliberate: asset filenames are content-hashed, so without it old
|
The script uses `--delete` on purpose. The asset filenames contain a hash of
|
||||||
bundles would pile up forever. There is no state on the server and nothing to
|
the content, so old bundles collect on the server without this flag. There is
|
||||||
back up; every roster lives in the browser's `localStorage`.
|
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 -
|
`index.html` loads Noto Sans from Google Fonts, so a page load contacts
|
||||||
upstream sets `/fancyscribe` for GitHub Pages. If you ever serve it from a
|
`fonts.googleapis.com`. If you do not want this request, delete the `<link>`
|
||||||
subpath, that is the setting to change.
|
tags. Then host the font next to the fonts in `public/fonts/`.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Credit
|
## Credit
|
||||||
|
|
||||||
All the hard parts - the parsing, the card layout, the print CSS - are
|
The difficult parts are the work of
|
||||||
[Nils Ueter's](https://github.com/NilsUeter/fancyscribe), with parsing logic
|
[Nils Ueter](https://github.com/NilsUeter/fancyscribe): the parser, the card
|
||||||
descended in turn from
|
layout and the print CSS. The parsing logic comes in turn from
|
||||||
[PrettyScribe](https://github.com/rweyrauch/PrettyScribe). Upstream ships no
|
[PrettyScribe](https://github.com/rweyrauch/PrettyScribe). Upstream ships no
|
||||||
licence file, so treat this fork as a private, personal deployment rather than
|
license file. Treat this fork as a private, personal deployment, not as
|
||||||
something to redistribute.
|
something that you redistribute.
|
||||||
|
|||||||
@@ -20,14 +20,25 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
# The `http2 on;` directive only exists from nginx 1.25.1; on older builds
|
||||||
listen [::]:443 ssl;
|
# (Debian bookworm ships 1.22, Ubuntu 22.04 ships 1.18) it is an unknown
|
||||||
http2 on;
|
# 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;
|
server_name scribe.luxick.de;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/scribe.luxick.de/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/scribe.luxick.de/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/scribe.luxick.de/privkey.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;
|
root /var/www/brevyscribe;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
|||||||
+1713
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+176
-35
@@ -19,8 +19,18 @@ import { Arrow, wavyLine } from "../assets/icons";
|
|||||||
import { Weapons, hasDifferentProfiles } from "./Weapons";
|
import { Weapons, hasDifferentProfiles } from "./Weapons";
|
||||||
import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB
|
import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB
|
||||||
import { ImgEditor } from "./ImgEditor";
|
import { ImgEditor } from "./ImgEditor";
|
||||||
import { trySettingLocalStorage } from "../helpers/useLocalStorage";
|
import {
|
||||||
import { ShortSummaryTable } from "./ShortSummaryTable";
|
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 getShortSummarySubtitle = (force) => {
|
||||||
const details = [];
|
const details = [];
|
||||||
@@ -46,6 +56,7 @@ export const Roster = ({
|
|||||||
onePerPage,
|
onePerPage,
|
||||||
colorUserChoice,
|
colorUserChoice,
|
||||||
primaryColor,
|
primaryColor,
|
||||||
|
showLore,
|
||||||
}) => {
|
}) => {
|
||||||
if (!roster) {
|
if (!roster) {
|
||||||
return null;
|
return null;
|
||||||
@@ -70,6 +81,7 @@ export const Roster = ({
|
|||||||
force={force}
|
force={force}
|
||||||
onePerPage={onePerPage}
|
onePerPage={onePerPage}
|
||||||
colorUserChoice={colorUserChoice}
|
colorUserChoice={colorUserChoice}
|
||||||
|
showLore={showLore}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</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 { units, factionRules, rules, catalog } = force;
|
||||||
const mergedRules = new Map([...factionRules, ...rules]);
|
const mergedRules = new Map([...factionRules, ...rules]);
|
||||||
|
|
||||||
@@ -173,6 +185,7 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
|
|||||||
onePerPage={onePerPage}
|
onePerPage={onePerPage}
|
||||||
forceRules={rules}
|
forceRules={rules}
|
||||||
colorUserChoice={colorUserChoice}
|
colorUserChoice={colorUserChoice}
|
||||||
|
showLore={showLore}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 [hide, setHide] = useState(false);
|
||||||
const [hideModelCount, setHideModelCount] = useState(false);
|
const hideModelCount = HIDE_UNIT_COMPOSITION;
|
||||||
const uploadRef = useRef();
|
const uploadRef = useRef();
|
||||||
let {
|
let {
|
||||||
name,
|
name,
|
||||||
@@ -201,6 +221,38 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
|||||||
const hasImage = image && image !== "undefined";
|
const hasImage = image && image !== "undefined";
|
||||||
const [bgRemoved, setBgRemoved] = useState(false);
|
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 weapons = [...meleeWeapons, ...rangedWeapons];
|
||||||
|
|
||||||
const weaponDescriptions = weapons
|
const weaponDescriptions = weapons
|
||||||
@@ -315,23 +367,6 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex justify-end gap-3 pb-0.5">
|
<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
|
<label
|
||||||
className="print-display-none"
|
className="print-display-none"
|
||||||
style={{
|
style={{
|
||||||
@@ -349,6 +384,8 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
|||||||
<div
|
<div
|
||||||
className="min-h-[15rem]"
|
className="min-h-[15rem]"
|
||||||
style={{
|
style={{
|
||||||
|
// 15rem unless the legend needs more; 44px is the panel's padding.
|
||||||
|
minHeight: hasLore ? `max(15rem, ${loreHeight + 44}px)` : undefined,
|
||||||
paddingTop: 24,
|
paddingTop: 24,
|
||||||
paddingBottom: 4,
|
paddingBottom: 4,
|
||||||
background:
|
background:
|
||||||
@@ -357,6 +394,9 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
|||||||
backgroundSize: "cover",
|
backgroundSize: "cover",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
position: "relative",
|
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
|
<div
|
||||||
@@ -375,6 +415,11 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
|||||||
top: 0,
|
top: 0,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
display: "flex",
|
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
|
<div
|
||||||
@@ -415,25 +460,29 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
|||||||
lineHeight: "1",
|
lineHeight: "1",
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
zIndex: 1,
|
zIndex: 3,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: 2,
|
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={{ pointerEvents: "auto" }}>{name}</span>
|
||||||
<span style={{ textTransform: "initial", fontSize: "1.2rem" }}>
|
|
||||||
{cost.points}pts
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex gap-4">
|
<div className="pointer-events-none relative z-[3] flex gap-4">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 6,
|
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) => (
|
{modelStats.map((model, index) => (
|
||||||
@@ -450,20 +499,101 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: 0,
|
// 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,
|
top: 0,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: "60%",
|
width: hasLore ? "40%" : "60%",
|
||||||
zIndex: 100,
|
zIndex: 2,
|
||||||
overflow: "hidden",
|
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} />}
|
{hasImage && <ImgEditor image={image} name={name} />}
|
||||||
<div className="absolute right-[1px] top-[3px] flex items-center gap-1.5">
|
<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 && (
|
{hasImage && !bgRemoved && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -900,7 +1030,7 @@ const InvulRow = ({ hasInvul }) => {
|
|||||||
backgroundColor: "var(--primary-color)",
|
backgroundColor: "var(--primary-color)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
INVULNERABLE SAVE{isSpecialInvul ? "*" : ""}
|
INSV{isSpecialInvul ? "*" : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1378,8 +1508,15 @@ const makeKeywordsBold = (text) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// replace words wrapped in ^^ ^^ with <strong> tags
|
// replace words wrapped in ^^ ^^ with <strong> tags. ^^ is the exporter's
|
||||||
newValue = newValue.replace(/\^\^([^\^]+)\^\^/g, "<strong>$1</strong>");
|
// 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
|
// replace words wrapped in ** ** with <strong> tags
|
||||||
newValue = newValue.replace(/\*\*([^\*]+)\*\*/g, "<strong>$1</strong>");
|
newValue = newValue.replace(/\*\*([^\*]+)\*\*/g, "<strong>$1</strong>");
|
||||||
@@ -1504,7 +1641,11 @@ const OtherAbilities = ({ abilities }) => {
|
|||||||
paddingBottom: 4,
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+18
-36
@@ -84,26 +84,15 @@ function App() {
|
|||||||
const [roster, setRoster] = useState();
|
const [roster, setRoster] = useState();
|
||||||
const [edition, setEdition] = useState(10); // [9, 10, 11]
|
const [edition, setEdition] = useState(10); // [9, 10, 11]
|
||||||
const [onePerPage, setOnePerPage] = useState(false);
|
const [onePerPage, setOnePerPage] = useState(false);
|
||||||
|
const [showLore, setShowLore] = useState(true);
|
||||||
const [primaryColor, setPrimaryColor] = useState("#536766");
|
const [primaryColor, setPrimaryColor] = useState("#536766");
|
||||||
const [colorUserChoice, setColorUserChoice] = useState(false);
|
const [colorUserChoice, setColorUserChoice] = useState(false);
|
||||||
const [hideModelSelections, setHideModelSelections] = useState(false);
|
|
||||||
const uploadRef = useRef();
|
const uploadRef = useRef();
|
||||||
|
|
||||||
const throttledSetPrimaryColor = useRef(
|
const throttledSetPrimaryColor = useRef(
|
||||||
throttle((color) => setPrimaryColor(color), 50),
|
throttle((color) => setPrimaryColor(color), 50),
|
||||||
).current;
|
).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) {
|
async function handleFileSelect(event) {
|
||||||
const files = event?.target?.files;
|
const files = event?.target?.files;
|
||||||
|
|
||||||
@@ -431,30 +420,22 @@ function App() {
|
|||||||
One Datacard per Page when Printing
|
One Datacard per Page when Printing
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{
|
<label
|
||||||
// only show when 10th or 11th edition
|
style={{
|
||||||
(edition === 10 || edition === 11) && (
|
display: "flex",
|
||||||
<label
|
alignItems: "center",
|
||||||
style={{
|
gap: 4,
|
||||||
display: "flex",
|
minHeight: 26,
|
||||||
alignItems: "center",
|
}}
|
||||||
gap: 4,
|
title="Print the flavour text from public/Lore.csv beside the model image, the way the official datasheets do."
|
||||||
minHeight: 26,
|
>
|
||||||
}}
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
<input
|
checked={showLore}
|
||||||
type="checkbox"
|
onChange={(e) => setShowLore(e.target.checked)}
|
||||||
value={hideModelSelections}
|
/>
|
||||||
onChange={(e) => {
|
<span className="select-none">Show Lore Text</span>
|
||||||
setHideModelSelections(e.target.checked);
|
</label>
|
||||||
toggleHideModelSelections(e.target.checked);
|
|
||||||
}}
|
|
||||||
className="hide-model-selection"
|
|
||||||
/>
|
|
||||||
<span className="select-none">Hide all Unit Compositions</span>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
<input
|
<input
|
||||||
@@ -500,6 +481,7 @@ function App() {
|
|||||||
onePerPage={onePerPage}
|
onePerPage={onePerPage}
|
||||||
colorUserChoice={colorUserChoice}
|
colorUserChoice={colorUserChoice}
|
||||||
primaryColor={primaryColor}
|
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 {
|
@layer base {
|
||||||
:root {
|
: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;
|
Arial, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -40,6 +40,14 @@
|
|||||||
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
|
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
|
||||||
font-weight: 400;
|
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;
|
box-sizing: border-box;
|
||||||
@@ -200,6 +208,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@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,
|
||||||
.print-display-none * {
|
.print-display-none * {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
|
|||||||
@@ -25,14 +25,7 @@ export const defaultConfig = {
|
|||||||
|
|
||||||
// Abilities to split into per-option profiles, matched against the
|
// Abilities to split into per-option profiles, matched against the
|
||||||
// profile's ``name`` attribute.
|
// profile's ``name`` attribute.
|
||||||
abilitiesToConvert: ["Canticles of the Omnissiah", "Battle Protocols"],
|
abilitiesToConvert: ["Canticles of the Omnissiah", "Battle Protocols", "Icon of War"],
|
||||||
|
|
||||||
// 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",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Which transforms run by default when a roster is loaded.
|
// Which transforms run by default when a roster is loaded.
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
// * the original ``Abilities`` profile is kept, trimmed to the intro
|
// * the original ``Abilities`` profile is kept, trimmed to the intro
|
||||||
// paragraph (the "select one of the following" rule), and
|
// paragraph (the "select one of the following" rule), and
|
||||||
// * each option becomes its own profile under a new profile type named after
|
// * 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
|
// Running it twice is a no-op: a converted ability has only an intro paragraph
|
||||||
// left, so there is nothing further to split.
|
// 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
|
* over from mis-nested pairs goes too - a stray ``**`` is punctuation the
|
||||||
* reader never wanted to see either way.
|
* reader never wanted to see either way.
|
||||||
*
|
*
|
||||||
* The generated group renders through the renderer's generic ability table,
|
* Only the option *name* goes through this. It ends up as a profile ``name``
|
||||||
* which prints its text verbatim, so the markers would otherwise show up as
|
* attribute, which the renderer prints as plain text, so a marker left in there
|
||||||
* literal punctuation. The intro profile keeps its original typeName, renders
|
* would show up as literal punctuation. The option text keeps its markers:
|
||||||
* through the normal path, and is therefore left alone.
|
* ``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
|
* 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
|
* resolved text, so there is no ``"`` here that upper-casing could turn
|
||||||
* into an entity no parser would recognise.
|
* into an entity no parser would recognise.
|
||||||
*/
|
*/
|
||||||
const stripMarkup = (text) =>
|
export const stripMarkup = (text) =>
|
||||||
text
|
text
|
||||||
.replace(KEYWORD_RE, (_, keyword) =>
|
.replace(KEYWORD_RE, (_, keyword) =>
|
||||||
keyword.replaceAll("*", "").toUpperCase(),
|
keyword.replaceAll("*", "").toUpperCase(),
|
||||||
@@ -201,7 +209,6 @@ function buildProfile(
|
|||||||
*/
|
*/
|
||||||
export function convertChoiceAbilities(doc, config) {
|
export function convertChoiceAbilities(doc, config) {
|
||||||
const wanted = new Set(config.abilitiesToConvert);
|
const wanted = new Set(config.abilitiesToConvert);
|
||||||
const overrides = config.groupTitleOverrides ?? {};
|
|
||||||
const root = doc.documentElement;
|
const root = doc.documentElement;
|
||||||
if (!root) return [];
|
if (!root) return [];
|
||||||
|
|
||||||
@@ -234,7 +241,10 @@ export function convertChoiceAbilities(doc, config) {
|
|||||||
);
|
);
|
||||||
if (options.length === 0) continue; // Already converted, or a plain ability.
|
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 typeId = makeId("profileType", groupTitle);
|
||||||
const charTypeId = makeId(
|
const charTypeId = makeId(
|
||||||
"characteristicType",
|
"characteristicType",
|
||||||
@@ -265,7 +275,7 @@ export function convertChoiceAbilities(doc, config) {
|
|||||||
typeId,
|
typeId,
|
||||||
typeName: groupTitle,
|
typeName: groupTitle,
|
||||||
charTypeId,
|
charTypeId,
|
||||||
description: stripMarkup(option.text),
|
description: option.text,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,10 +100,12 @@ describe("splitting choice abilities", () => {
|
|||||||
expect(group).toBeTruthy();
|
expect(group).toBeTruthy();
|
||||||
expect(group.size).toBeGreaterThan(1);
|
expect(group.size).toBeGreaterThan(1);
|
||||||
|
|
||||||
// Options print as plain text: GW's ^^keyword^^ / **emphasis** markers
|
// The option text keeps GW's ^^keyword^^ / **emphasis** markers for the
|
||||||
// are resolved, not passed through as punctuation.
|
// renderer to format; only the row titles are plain text, since those
|
||||||
for (const text of group.values()) {
|
// render as-is.
|
||||||
expect(JSON.stringify(text)).not.toMatch(/\^\^|\*\*/);
|
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 {
|
import {
|
||||||
convertChoiceAbilities,
|
convertChoiceAbilities,
|
||||||
splitDescription,
|
splitDescription,
|
||||||
|
stripMarkup,
|
||||||
} from "./convertChoiceAbilities.js";
|
} from "./convertChoiceAbilities.js";
|
||||||
import { applyTransforms } from "./index.js";
|
import { applyTransforms } from "./index.js";
|
||||||
import { mergeDuplicateUnits } from "./mergeDuplicateUnits.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.
|
* tokens preserve, not that the two implementations agree on a hash function.
|
||||||
* Every other attribute - including `entryId` and `publicationId`, which are
|
* Every other attribute - including `entryId` and `publicationId`, which are
|
||||||
* never generated - has to match exactly.
|
* 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) {
|
function canonicalize(doc) {
|
||||||
const tokens = new Map();
|
const tokens = new Map();
|
||||||
@@ -72,11 +79,13 @@ function canonicalize(doc) {
|
|||||||
.map(([name, value]) => `${name}=${JSON.stringify(value)}`)
|
.map(([name, value]) => `${name}=${JSON.stringify(value)}`)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
const text = Array.from(element.childNodes)
|
const text = stripMarkup(
|
||||||
.filter((node) => node.nodeType === 3 /* Text */)
|
Array.from(element.childNodes)
|
||||||
.map((node) => node.data)
|
.filter((node) => node.nodeType === 3 /* Text */)
|
||||||
.join("")
|
.map((node) => node.data)
|
||||||
.trim();
|
.join("")
|
||||||
|
.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
lines.push(
|
lines.push(
|
||||||
`${" ".repeat(depth)}<${element.localName} ${attributes}>` +
|
`${" ".repeat(depth)}<${element.localName} ${attributes}>` +
|
||||||
|
|||||||
Reference in New Issue
Block a user