From 62d6bd211bd0f4a135c824fa4711aa59d0d2ee99 Mon Sep 17 00:00:00 2001 From: luxick Date: Sat, 25 Jul 2026 15:11:10 +0200 Subject: [PATCH] Fork FancyScribe as BrevyScribe with the datasheet transforms built in Print generic datasheets - every option a unit could take, the way the official cards read - instead of a record of one particular list. The three Python scripts that used to rewrite the .ros file before upload are now transforms in src/transforms/, running in the browser between DOMParser and the roster parser. Most of each script was machinery for preserving the file byte for byte on the way back to disk; in the browser the document is never serialised, so only the domain logic came across. Because they run on the parsed document rather than the uploaded file, flipping a transform off rebuilds from the original XML with no re-upload. Saved rosters therefore hold the raw roster XML rather than the parsed object, under new localStorage keys. The scripts' output over four 11th-edition rosters is checked in as test fixtures, so the port is measured against an independent implementation that was verified by printing the cards; scripts/update-fixtures.mjs refuses to overwrite those without --force. Two parser call sites now use :scope> rather than repeating the scope element's own name, which means the same thing in a browser and also works under jsdom, where the old form matched nothing. Also: served at the root rather than a GitHub Pages subpath, PostHog analytics removed, and deploy/ carries an nginx site for scribe.luxick.de plus an rsync deploy script. Co-Authored-By: Claude Opus 5 --- .gitattributes | 8 + .gitea/workflows/ci.yml | 32 + .github/workflows/PagesDeploy.yml | 50 - README.md | 152 +++ deploy/deploy.sh | 29 + deploy/nginx-scribe.luxick.de.conf | 62 ++ index.html | 12 +- package-lock.json | 972 +++++++++++++++++- package.json | 11 +- scripts/update-fixtures.mjs | 88 ++ src/10th/Roster.jsx | 3 - src/App.jsx | 266 +++-- src/roster40k-10th.js | 4 +- src/roster40k-11th.js | 4 +- src/roster40k.js | 4 +- .../__fixtures__/bellisarius-cawl.all.ros | 41 + .../__fixtures__/bellisarius-cawl.convert.ros | 41 + .../__fixtures__/bellisarius-cawl.merge.ros | 28 + .../__fixtures__/bellisarius-cawl.ros | 28 + .../__fixtures__/bellisarius-cawl.strip.ros | 28 + src/transforms/__fixtures__/datasmith.all.ros | 37 + .../__fixtures__/datasmith.convert.ros | 40 + .../__fixtures__/datasmith.merge.ros | 24 + src/transforms/__fixtures__/datasmith.ros | 24 + .../__fixtures__/datasmith.strip.ros | 21 + src/transforms/__fixtures__/rangers.all.ros | 29 + .../__fixtures__/rangers.convert.ros | 55 + src/transforms/__fixtures__/rangers.merge.ros | 29 + src/transforms/__fixtures__/rangers.ros | 55 + src/transforms/__fixtures__/rangers.strip.ros | 55 + src/transforms/__fixtures__/skatros.all.ros | 19 + .../__fixtures__/skatros.convert.ros | 36 + src/transforms/__fixtures__/skatros.merge.ros | 19 + src/transforms/__fixtures__/skatros.ros | 36 + src/transforms/__fixtures__/skatros.strip.ros | 36 + src/transforms/config.js | 67 ++ src/transforms/convertChoiceAbilities.js | 278 +++++ src/transforms/dom.js | 123 +++ src/transforms/index.js | 46 + src/transforms/integration.test.js | 109 ++ src/transforms/mergeDuplicateUnits.js | 208 ++++ src/transforms/removeLeaderAbilities.js | 127 +++ src/transforms/transforms.test.js | 158 +++ vite.config.js | 3 +- vitest.config.js | 11 + 45 files changed, 3317 insertions(+), 191 deletions(-) create mode 100644 .gitattributes create mode 100644 .gitea/workflows/ci.yml delete mode 100644 .github/workflows/PagesDeploy.yml create mode 100644 README.md create mode 100644 deploy/deploy.sh create mode 100644 deploy/nginx-scribe.luxick.de.conf create mode 100644 scripts/update-fixtures.mjs create mode 100644 src/transforms/__fixtures__/bellisarius-cawl.all.ros create mode 100644 src/transforms/__fixtures__/bellisarius-cawl.convert.ros create mode 100644 src/transforms/__fixtures__/bellisarius-cawl.merge.ros create mode 100644 src/transforms/__fixtures__/bellisarius-cawl.ros create mode 100644 src/transforms/__fixtures__/bellisarius-cawl.strip.ros create mode 100644 src/transforms/__fixtures__/datasmith.all.ros create mode 100644 src/transforms/__fixtures__/datasmith.convert.ros create mode 100644 src/transforms/__fixtures__/datasmith.merge.ros create mode 100644 src/transforms/__fixtures__/datasmith.ros create mode 100644 src/transforms/__fixtures__/datasmith.strip.ros create mode 100644 src/transforms/__fixtures__/rangers.all.ros create mode 100644 src/transforms/__fixtures__/rangers.convert.ros create mode 100644 src/transforms/__fixtures__/rangers.merge.ros create mode 100644 src/transforms/__fixtures__/rangers.ros create mode 100644 src/transforms/__fixtures__/rangers.strip.ros create mode 100644 src/transforms/__fixtures__/skatros.all.ros create mode 100644 src/transforms/__fixtures__/skatros.convert.ros create mode 100644 src/transforms/__fixtures__/skatros.merge.ros create mode 100644 src/transforms/__fixtures__/skatros.ros create mode 100644 src/transforms/__fixtures__/skatros.strip.ros create mode 100644 src/transforms/config.js create mode 100644 src/transforms/convertChoiceAbilities.js create mode 100644 src/transforms/dom.js create mode 100644 src/transforms/index.js create mode 100644 src/transforms/integration.test.js create mode 100644 src/transforms/mergeDuplicateUnits.js create mode 100644 src/transforms/removeLeaderAbilities.js create mode 100644 src/transforms/transforms.test.js create mode 100644 vitest.config.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7495bd1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Roster files are test fixtures. Their ability text carries meaningful newlines +# and the paragraph splitter matches on \n, so a CRLF translation on checkout +# would silently change what the transforms see. +*.ros -text +*.rosz -text + +# Has to stay LF to run on the server. +*.sh text eol=lf diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a41f2b1 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,32 @@ +# 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 diff --git a/.github/workflows/PagesDeploy.yml b/.github/workflows/PagesDeploy.yml deleted file mode 100644 index 469b533..0000000 --- a/.github/workflows/PagesDeploy.yml +++ /dev/null @@ -1,50 +0,0 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and deploys to Github Pages - -name: Build with NodeJS & deploy static content to Pages - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - -# Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow one concurrent deployment -concurrency: - group: "pages" - cancel-in-progress: true - -jobs: - # Single deploy job to Github Pages - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v4 - - name: Set up NodeJS 19 - uses: actions/setup-node@v4 - with: - node-version: "19" - cache: "npm" - - name: Clean install dependencies - run: npm ci - - name: Build - run: npm run build - - name: Setup Pages - uses: actions/configure-pages@v4 - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - # Upload build dir - path: "./dist" - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b0df04 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# BrevyScribe + +A fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe) that prints +**generic datasheets** rather than a record of one particular army list. + +FancyScribe renders a BattleScribe or New Recruit roster as 10th-edition-style +datacards, showing exactly the wargear you picked. That is the right thing for a +list you are about to play, but the wrong thing for a reference card you want to +keep: the official cards show *every* option a unit could take. BrevyScribe +rewrites the roster on the way in so the printed cards read like the official +ones. + +Three transforms do the work, all of them toggleable in the UI: + +| Transform | What it does | +| --- | --- | +| **Merge duplicates** | Mutually exclusive wargear forces you to take a datasheet twice - one Skatros with a radium jezzail, another with a transuranic arquebus. Copies of the same unit are folded into one card carrying every option. Points are not summed; the card keeps the highest cost of the copies it absorbed. | +| **Drop Leader/Support** | Removes the attachment rules. Once the army is built they say nothing you need mid-game, and they are long enough to push the rules you *do* need off the card. | +| **Split choice abilities** | An ability like *Canticles of the Omnissiah* arrives as one blob of text. This splits it into the intro rule plus one titled row per option, which is how the datasheets print it. | + +Everything still runs in the browser. There is no server component, no upload, +no account, and no analytics; rosters are held in `localStorage` and never leave +the machine. + +## Running it + +```sh +npm install +npm run dev # http://localhost:5173 +npm test # the parity suite, see below +npm run build # static files into dist/ +``` + +`npm run lint` reports pre-existing findings inherited from upstream (mostly +`a11y/useButtonType`), so it is deliberately not part of CI. Compare against +`git show upstream/main:` before treating any of them as new. + +## Deploying to scribe.luxick.de + +The build output is **static files** - no application server, no socket, nothing +for nginx to proxy to. The app does all its work in the browser, which is why +upstream can live on GitHub Pages. + +One-time setup on the server: + +```sh +sudo mkdir -p /var/www/brevyscribe +sudo chown "$USER" /var/www/brevyscribe +sudo cp deploy/nginx-scribe.luxick.de.conf /etc/nginx/sites-available/scribe.luxick.de +sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/ +sudo certbot --nginx -d scribe.luxick.de +sudo nginx -t && sudo systemctl reload nginx +``` + +Then every deploy is one command from a checkout on your own machine - it runs +the tests, builds, and rsyncs `dist/` over. The server needs no Node. + +```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`. + +`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 +`` 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: `.{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 + +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 +[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. diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..4913a43 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Build BrevyScribe and push it to the server. The site is static, so a deploy +# is just "replace the files". +# +# ./deploy/deploy.sh +# TARGET=me@otherhost:/srv/site ./deploy/deploy.sh +# +# Override TARGET to send it somewhere else. Nothing on the server needs Node: +# the build happens here and only dist/ travels. + +set -euo pipefail + +TARGET="${TARGET:-luxick@scribe.luxick.de:/var/www/brevyscribe/}" + +cd "$(dirname "$0")/.." + +# Don't ship a build whose transforms no longer match the Python reference. +npm test +npm run build + +# --delete removes assets from previous builds; their filenames are hashed, so +# they would otherwise pile up forever. The trailing slash on dist/ copies the +# contents rather than the directory itself. +rsync --archive --compress --delete --human-readable --progress \ + dist/ "$TARGET" + +echo +echo "Deployed to $TARGET" diff --git a/deploy/nginx-scribe.luxick.de.conf b/deploy/nginx-scribe.luxick.de.conf new file mode 100644 index 0000000..5bf9873 --- /dev/null +++ b/deploy/nginx-scribe.luxick.de.conf @@ -0,0 +1,62 @@ +# BrevyScribe is a static site: the build produces plain files and the app does +# all its work in the browser, so nginx serves it directly - there is no +# application server, no socket and nothing to proxy to. +# +# Install as /etc/nginx/sites-available/scribe.luxick.de, symlink it into +# sites-enabled, then `nginx -t && systemctl reload nginx`. + +server { + listen 80; + listen [::]:80; + server_name scribe.luxick.de; + + # Let certbot answer the challenge, send everything else to HTTPS. + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + 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; + + root /var/www/brevyscribe; + index index.html; + + gzip on; + gzip_types text/css application/javascript image/svg+xml application/xml; + gzip_min_length 1024; + + # Vite gives these content-hashed filenames, so they can never go stale. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # The example rosters and the fonts keep their names across builds, so they + # get a short cache rather than an immutable one. + location ~* \.(rosz|woff2)$ { + expires 1h; + add_header Cache-Control "public"; + } + + # index.html names the hashed assets, so it must never be cached: a stale copy + # would point at a bundle that no longer exists. + location = /index.html { + add_header Cache-Control "no-cache"; + } + + # A single page app with no router, but serving index.html for an unknown path + # is friendlier than a bare 404. + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/index.html b/index.html index b89b5e2..73a73ec 100644 --- a/index.html +++ b/index.html @@ -3,10 +3,10 @@ - Fancyscribe + BrevyScribe @@ -14,14 +14,6 @@ href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" /> -
diff --git a/package-lock.json b/package-lock.json index 1285bfd..3817b94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "fancyscribe", + "name": "brevyscribe", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "fancyscribe", + "name": "brevyscribe", "version": "0.0.0", "dependencies": { "chart.js": "^4.4.8", @@ -22,11 +22,13 @@ "autoprefixer": "^10.4.20", "babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250328", "eslint-plugin-react-compiler": "^19.0.0-beta-e993439-20250328", + "jsdom": "^29.1.1", "postcss": "^8.5.3", "prettier": "^3.5.2", "prettier-plugin-tailwindcss": "^0.6.11", "tailwindcss": "^3.4.17", - "vite": "^6.2.5" + "vite": "^6.2.5", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -54,6 +56,57 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -589,6 +642,159 @@ "node": ">=14.21.3" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", @@ -1242,6 +1448,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1363,10 +1587,11 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -1709,6 +1934,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1750,6 +1982,24 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -1808,6 +2058,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -1908,6 +2271,16 @@ "license": "Python-2.0", "peer": true }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/autoprefixer": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", @@ -1961,6 +2334,16 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2067,6 +2450,16 @@ } ] }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2209,6 +2602,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2226,6 +2633,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -2243,6 +2664,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2281,6 +2709,26 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", @@ -2544,6 +2992,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2555,6 +3013,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2815,6 +3283,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2927,6 +3408,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "license": "MIT" @@ -2980,6 +3468,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3123,6 +3662,23 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3254,6 +3810,20 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3331,6 +3901,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3379,6 +3962,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3672,7 +4262,6 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -3771,6 +4360,16 @@ "node": ">=8.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -3876,6 +4475,19 @@ "version": "5.1.2", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", @@ -3916,6 +4528,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3937,6 +4556,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.1.1", "license": "MIT", @@ -4102,6 +4735,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -4160,6 +4800,101 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4173,6 +4908,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4193,6 +4954,16 @@ "node": ">= 0.8.0" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", @@ -4310,6 +5081,157 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4325,6 +5247,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4427,6 +5366,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 16af60c..6dcd30b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "fancyscribe", + "name": "brevyscribe", "private": true, "version": "0.0.0", "type": "module", @@ -7,7 +7,10 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "biome check ." + "lint": "biome check .", + "test": "vitest run", + "test:watch": "vitest", + "fixtures": "node scripts/update-fixtures.mjs" }, "dependencies": { "chart.js": "^4.4.8", @@ -17,18 +20,20 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@biomejs/biome": "^1.9.4", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250328", "eslint-plugin-react-compiler": "^19.0.0-beta-e993439-20250328", + "jsdom": "^29.1.1", "postcss": "^8.5.3", "prettier": "^3.5.2", "prettier-plugin-tailwindcss": "^0.6.11", "tailwindcss": "^3.4.17", "vite": "^6.2.5", - "@biomejs/biome": "^1.9.4" + "vitest": "^4.1.10" }, "prettier": { "useTabs": true, diff --git a/scripts/update-fixtures.mjs b/scripts/update-fixtures.mjs new file mode 100644 index 0000000..e87576f --- /dev/null +++ b/scripts/update-fixtures.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +// +// Write the expected-output fixtures for a roster added to +// src/transforms/__fixtures__/. +// +// node scripts/update-fixtures.mjs # only writes what is missing +// node scripts/update-fixtures.mjs --force # re-baselines everything +// +// Drop a new `.ros` in that directory and run this to generate the four +// `.{merge,strip,convert,all}.ros` files the test suite compares against. +// Read the diff before committing: this records whatever the transforms do +// today, so it cannot tell a fix from a regression. +// +// Existing fixtures are left alone unless --force is given, and that guard is +// the point rather than a convenience. The fixtures that came with this repo +// were produced by a separate Python implementation and verified by printing +// the cards; the test suite is worth something because it checks the transforms +// against that independent output. Re-baselining a fixture from the JS replaces +// it with the very thing under test, and the comparison becomes a tautology +// that passes no matter what the code does. + +import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { JSDOM } from "jsdom"; + +import { + convertChoiceAbilities, + defaultConfig, + mergeDuplicateUnits, + removeLeaderAbilities, +} from "../src/transforms/index.js"; + +const FIXTURES = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "src", + "transforms", + "__fixtures__", +); + +const STEPS = { + merge: (doc) => mergeDuplicateUnits(doc, defaultConfig), + strip: (doc) => removeLeaderAbilities(doc, defaultConfig), + convert: (doc) => convertChoiceAbilities(doc, defaultConfig), +}; + +// Chained, in the order the app runs them; see src/transforms/index.js. +const PIPELINE = ["merge", "strip", "convert"]; + +const force = process.argv.includes("--force"); +const { window } = new JSDOM(); + +const inputs = readdirSync(FIXTURES) + .filter((name) => name.endsWith(".ros") && name.split(".").length === 2) + .map((name) => name.replace(/\.ros$/, "")); + +let written = 0; +let kept = 0; + +for (const input of inputs) { + const xml = readFileSync(join(FIXTURES, `${input}.ros`), "utf8"); + + const outputs = { ...STEPS, all: null }; + for (const label of Object.keys(outputs)) { + const path = join(FIXTURES, `${input}.${label}.ros`); + if (existsSync(path) && !force) { + kept++; + continue; + } + + const doc = new window.DOMParser().parseFromString(xml, "text/xml"); + if (label === "all") { + for (const step of PIPELINE) STEPS[step](doc); + } else { + STEPS[label](doc); + } + + writeFileSync(path, new window.XMLSerializer().serializeToString(doc), "utf8"); + console.log(`wrote ${input}.${label}.ros`); + written++; + } +} + +console.log(`\n${written} written, ${kept} left alone.`); +if (kept > 0 && !force) { + console.log("Pass --force to re-baseline the rest, but read the header first."); +} diff --git a/src/10th/Roster.jsx b/src/10th/Roster.jsx index 94fb7b1..5624848 100644 --- a/src/10th/Roster.jsx +++ b/src/10th/Roster.jsx @@ -511,9 +511,6 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => { className="print-display-none" accept=".jpg,.png,.jpeg,.gif,.bmp,.tif,.tiff,.webp,.svg,.jfif,.pjpeg,.pjp,.avif,.apng,.ico,.cur,.ani" onChange={(e) => { - posthog?.capture?.("user_uploaded_image", { - unit_name: name, - }); if (e.target.files?.[0]) { const reader = new FileReader(); reader.onload = ((ev) => { diff --git a/src/App.jsx b/src/App.jsx index 617d59d..a801537 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,7 +1,7 @@ import JSZip from "jszip"; import { Create40kRoster } from "./roster40k"; import { Create40kRoster10th } from "./roster40k-10th"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Demo0 from "./assets/Demo0.png"; import Demo1 from "./assets/Demo1.png"; @@ -10,6 +10,34 @@ import { Roster as Roster10th } from "./10th/Roster"; import { useLocalStorage } from "./helpers/useLocalStorage"; import { parseJSON, stringifyJSON } from "./helpers/json"; import { Create40kRoster11th } from "./roster40k-11th"; +import { applyTransforms, defaultToggles } from "./transforms"; + +// The transforms, in the order they are offered in the UI. These are what makes +// this a fork: they rewrite the roster into the generic datasheets the official +// cards print, rather than a record of one particular list. +const TRANSFORMS = [ + [ + "mergeDuplicateUnits", + "Merge duplicates", + "Fold copies of a datasheet - taken only because their wargear is mutually exclusive - into a single card showing every option.", + ], + [ + "removeLeaderAbilities", + "Drop Leader/Support", + "Remove the attachment rules. Once the army is built they say nothing you need mid-game, and they push the rules you do need off the card.", + ], + [ + "convertChoiceAbilities", + "Split choice abilities", + "Print each option of a 'select one each turn' ability as its own titled row, the way the official datasheets do.", + ], +]; + +const PARSERS = { + "Warhammer 40,000 9th Edition": [Create40kRoster, 9], + "Warhammer 40,000 10th Edition": [Create40kRoster10th, 10], + "Warhammer 40,000 11th Edition": [Create40kRoster11th, 11], +}; const throttle = (func, limit) => { let lastFunc; @@ -34,11 +62,27 @@ const throttle = (func, limit) => { }; function App() { - const [rosters, setRosters] = useLocalStorage("rosters", "[]"); + // Saved rosters hold the *raw* roster XML rather than the parsed roster, so + // that flipping a transform toggle can rebuild them without a re-upload. + // The keys are BrevyScribe's own, so nothing upstream FancyScribe left in + // localStorage is read back in the wrong shape. + const [rosters, setRosters] = useLocalStorage("brevyscribe.rosters", "[]"); const rostersJSON = parseJSON(rosters ?? "[]"); + const [savedToggles, setSavedToggles] = useLocalStorage( + "brevyscribe.transforms", + stringifyJSON(defaultToggles), + ); + // Memoised so the rebuild effect can depend on the toggles themselves rather + // than their serialised form; a fresh object every render would re-run it. + const toggles = useMemo( + () => ({ ...defaultToggles, ...parseJSON(savedToggles ?? "{}") }), + [savedToggles], + ); + // { xml, save } - the roster to show, and whether it belongs in the list. + const [source, setSource] = useState(); const [error, setError] = useState(); const [roster, setRoster] = useState(); - const [edition, setEdition] = useState(10); // [9, 10] + const [edition, setEdition] = useState(10); // [9, 10, 11] const [onePerPage, setOnePerPage] = useState(false); const [primaryColor, setPrimaryColor] = useState("#536766"); const [colorUserChoice, setColorUserChoice] = useState(false); @@ -71,8 +115,7 @@ function App() { }; reader.onloadend = async () => { const content = reader.result; - const xmldata = await unzip(content); - parseXML(xmldata, true); + setSource({ xml: await unzip(content), save: true }); }; reader.readAsBinaryString(files[0]); } else { @@ -93,36 +136,18 @@ function App() { default: break; } - // load example - posthog?.capture?.("user_loaded_example", { - roster_faction: event, - }); + if (!example) return; const arrayBuffer = await example.arrayBuffer(); // Create a new Blob object from the zip file contents const zipBlob = new Blob([arrayBuffer], { type: "application/zip" }); - const xmldata = await unzip(zipBlob); - parseXML(xmldata, false, true); + setSource({ xml: await unzip(zipBlob), save: false }); } } - const loadFromLocalStorage = (roster) => { - if (roster.gameType == "Warhammer 40,000 9th Edition") { - setRoster(roster); - setEdition(9); - setError(""); - } else if (roster.gameType == "Warhammer 40,000 10th Edition") { - setRoster(roster); - setEdition(10); - setError(""); - } else if (roster.gameType == "Warhammer 40,000 11th Edition") { - setRoster(roster); - setEdition(11); - setError(""); - } - posthog?.capture?.("user_loaded_roster_from_localstorage", { - roster_faction: roster.forces[0].catalog, - roster_type: roster.gameType, - }); - }; + + // Already in the list, so there is nothing to save; the stored XML goes + // through the same rebuild as a fresh upload. + const loadFromLocalStorage = (saved) => + setSource({ xml: saved.xml, save: false }); const unzip = async (file) => { if (file?.charAt && file.charAt(0) !== "P") { @@ -138,61 +163,52 @@ function App() { } }; - function parseXML(xmldata, addToLocalStorage, isExample = false) { - const parser = new DOMParser(); - const doc = parser.parseFromString(xmldata, "text/xml"); - if (!doc) return; + // Rebuild whenever a new roster arrives or a transform is toggled. The + // transforms run here rather than over the uploaded file, so flipping a + // toggle rebuilds from the original XML and nothing has to be re-uploaded. + // + // biome-ignore lint/correctness/useExhaustiveDependencies: the roster list is + // read to append to it, so depending on it would re-run this on its own write. + useEffect(() => { + if (!source?.xml) return; - // Determine roster type (game system). - const info = doc.querySelector("roster"); + const doc = new DOMParser().parseFromString(source.xml, "text/xml"); + const info = doc?.querySelector("roster"); if (!info) return; + // Determine roster type (game system). const gameType = info.getAttribute("gameSystemName"); if (!gameType) return; - - const rosterName = info.getAttribute("name"); - if (rosterName) { - document.title = `FancyScribe ${rosterName}`; - } - - let roster; - if (gameType == "Warhammer 40,000 9th Edition") { - roster = Create40kRoster(doc, gameType); - if (roster && roster.forces.length > 0) { - setRoster(roster); - setEdition(9); - setError(""); - } - } else if (gameType == "Warhammer 40,000 10th Edition") { - roster = Create40kRoster10th(doc, gameType); - if (roster && roster.forces.length > 0) { - setRoster(roster); - setEdition(10); - setError(""); - } - } else if (gameType == "Warhammer 40,000 11th Edition") { - roster = Create40kRoster11th(doc, gameType); - if (roster && roster.forces.length > 0) { - setRoster(roster); - setEdition(11); - setError(""); - } - } else { - setError("No support for game type '" + gameType + "'."); - } - if (!roster) { + const parser = PARSERS[gameType]; + if (!parser) { + setError(`No support for game type '${gameType}'.`); return; } - if (addToLocalStorage) { - console.log(roster); - posthog?.capture?.("user_uploaded_roster", { - roster_faction: roster.forces[0].catalog, - roster_type: gameType, - name: rosterName, - }); - setRosters(stringifyJSON([roster, ...rostersJSON])); + + const report = applyTransforms(doc, toggles); + const [createRoster, rosterEdition] = parser; + const built = createRoster(doc, gameType); + if (!built || built.forces.length === 0) return; + + setRoster(built); + setEdition(rosterEdition); + setError(""); + + const name = info.getAttribute("name"); + if (name) document.title = `BrevyScribe ${name}`; + console.log(built, report); + + if (source.save) { + // Keyed on the XML, so re-saving the same roster moves it to the front + // of the list rather than adding a second copy. + setRosters( + stringifyJSON([ + { name: name || "Roster", xml: source.xml }, + ...rostersJSON.filter((entry) => entry.xml !== source.xml), + ]), + ); } - } + }, [source, toggles, setRosters]); useEffect(() => { // Check if the browser is Safari, and if so, remove the accept attribute @@ -239,7 +255,7 @@ function App() { >
- FancyScribe{" "} + BrevyScribe{" "} - Now with 11th edition support! + Generic datasheets, not list-specific ones
- A fancy way to view your Warhammer 40k BattleScribe rosters + A fancy way to print your Warhammer 40k datasheets
@@ -354,18 +370,47 @@ function App() { +
+ Datasheets: + {TRANSFORMS.map(([key, label, description]) => ( + + ))} +
+
About
- FancyScribe is a website that renders{" "} + BrevyScribe renders{" "} {" "} or New Recruit roster files - in an opinionated format inspired by the new 10th edition datacards. - Additional inspiration and large parts of the parsing logic come - from the{" "} + in an opinionated format inspired by the 10th edition datacards. +
+
+ It differs from its upstream in what it prints: rather than a record + of one particular list, it rewrites the roster into the generic + datasheets the official cards show. Duplicate copies of a unit are + folded into one card carrying every option, the Leader and Support + attachment rules are dropped, and a "select one each + turn" ability is split into one titled row per option. Use the{" "} + Datasheets toggles above to turn any of that off. +
+
+ BrevyScribe is a fork of{" "} + + FancyScribe + {" "} + by Nils Ueter, which does all the heavy lifting here. Additional + inspiration and large parts of the parsing logic come from the{" "} {" "} website.
- -
- If you have any feedback or find any bugs, write{" "} - - here - {" "} - or send me a message. -
Output Examples
diff --git a/src/roster40k-10th.js b/src/roster40k-10th.js index 21f2ea0..3e9d1c3 100644 --- a/src/roster40k-10th.js +++ b/src/roster40k-10th.js @@ -435,7 +435,7 @@ function ParseForces(doc, roster) { // Only include the allegiance rules once. if (!DuplicateForce(f, roster)) { - const rules = root.querySelectorAll("force>rules>rule"); + const rules = root.querySelectorAll(":scope>rules>rule"); for (let rule of rules) { ExtractRuleDescription(rule, f.rules); } @@ -449,7 +449,7 @@ function ParseForces(doc, roster) { } function ParseSelections(root, force) { - let selections = root.querySelectorAll("force>selections>selection"); + let selections = root.querySelectorAll(":scope>selections>selection"); for (let selection of selections) { // What kind of selection is this diff --git a/src/roster40k-11th.js b/src/roster40k-11th.js index 8ef0a77..858f259 100644 --- a/src/roster40k-11th.js +++ b/src/roster40k-11th.js @@ -437,7 +437,7 @@ function ParseForces(doc, roster) { // Only include the allegiance rules once. if (!DuplicateForce(f, roster)) { - const rules = root.querySelectorAll("force>rules>rule"); + const rules = root.querySelectorAll(":scope>rules>rule"); for (let rule of rules) { ExtractRuleDescription(rule, f.rules); } @@ -451,7 +451,7 @@ function ParseForces(doc, roster) { } function ParseSelections(root, force) { - let selections = root.querySelectorAll("force>selections>selection"); + let selections = root.querySelectorAll(":scope>selections>selection"); for (let selection of selections) { // What kind of selection is this diff --git a/src/roster40k.js b/src/roster40k.js index 8d94634..111a899 100644 --- a/src/roster40k.js +++ b/src/roster40k.js @@ -449,7 +449,7 @@ function ParseForces(doc, roster) { // Only include the allegiance rules once. if (!DuplicateForce(f, roster)) { - const rules = root.querySelectorAll("force>rules>rule"); + const rules = root.querySelectorAll(":scope>rules>rule"); for (let rule of rules) { ExtractRuleDescription(rule, f.rules); } @@ -463,7 +463,7 @@ function ParseForces(doc, roster) { } function ParseSelections(root, force) { - let selections = root.querySelectorAll("force>selections>selection"); + let selections = root.querySelectorAll(":scope>selections>selection"); for (let selection of selections) { // What kind of selection is this diff --git a/src/transforms/__fixtures__/bellisarius-cawl.all.ros b/src/transforms/__fixtures__/bellisarius-cawl.all.ros new file mode 100644 index 0000000..6e48222 --- /dev/null +++ b/src/transforms/__fixtures__/bellisarius-cawl.all.ros @@ -0,0 +1,41 @@ + + + + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/bellisarius-cawl.convert.ros b/src/transforms/__fixtures__/bellisarius-cawl.convert.ros new file mode 100644 index 0000000..6e48222 --- /dev/null +++ b/src/transforms/__fixtures__/bellisarius-cawl.convert.ros @@ -0,0 +1,41 @@ + + + + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/bellisarius-cawl.merge.ros b/src/transforms/__fixtures__/bellisarius-cawl.merge.ros new file mode 100644 index 0000000..52b9ddb --- /dev/null +++ b/src/transforms/__fixtures__/bellisarius-cawl.merge.ros @@ -0,0 +1,28 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/bellisarius-cawl.ros b/src/transforms/__fixtures__/bellisarius-cawl.ros new file mode 100644 index 0000000..52b9ddb --- /dev/null +++ b/src/transforms/__fixtures__/bellisarius-cawl.ros @@ -0,0 +1,28 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/bellisarius-cawl.strip.ros b/src/transforms/__fixtures__/bellisarius-cawl.strip.ros new file mode 100644 index 0000000..52b9ddb --- /dev/null +++ b/src/transforms/__fixtures__/bellisarius-cawl.strip.ros @@ -0,0 +1,28 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/datasmith.all.ros b/src/transforms/__fixtures__/datasmith.all.ros new file mode 100644 index 0000000..9c15df0 --- /dev/null +++ b/src/transforms/__fixtures__/datasmith.all.ros @@ -0,0 +1,37 @@ + + + + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/datasmith.convert.ros b/src/transforms/__fixtures__/datasmith.convert.ros new file mode 100644 index 0000000..ef66e2e --- /dev/null +++ b/src/transforms/__fixtures__/datasmith.convert.ros @@ -0,0 +1,40 @@ + + + + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/datasmith.merge.ros b/src/transforms/__fixtures__/datasmith.merge.ros new file mode 100644 index 0000000..08f0955 --- /dev/null +++ b/src/transforms/__fixtures__/datasmith.merge.ros @@ -0,0 +1,24 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/datasmith.ros b/src/transforms/__fixtures__/datasmith.ros new file mode 100644 index 0000000..08f0955 --- /dev/null +++ b/src/transforms/__fixtures__/datasmith.ros @@ -0,0 +1,24 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/datasmith.strip.ros b/src/transforms/__fixtures__/datasmith.strip.ros new file mode 100644 index 0000000..b831553 --- /dev/null +++ b/src/transforms/__fixtures__/datasmith.strip.ros @@ -0,0 +1,21 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/rangers.all.ros b/src/transforms/__fixtures__/rangers.all.ros new file mode 100644 index 0000000..ceecaf6 --- /dev/null +++ b/src/transforms/__fixtures__/rangers.all.ros @@ -0,0 +1,29 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/rangers.convert.ros b/src/transforms/__fixtures__/rangers.convert.ros new file mode 100644 index 0000000..f33e04b --- /dev/null +++ b/src/transforms/__fixtures__/rangers.convert.ros @@ -0,0 +1,55 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/rangers.merge.ros b/src/transforms/__fixtures__/rangers.merge.ros new file mode 100644 index 0000000..ceecaf6 --- /dev/null +++ b/src/transforms/__fixtures__/rangers.merge.ros @@ -0,0 +1,29 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/rangers.ros b/src/transforms/__fixtures__/rangers.ros new file mode 100644 index 0000000..f33e04b --- /dev/null +++ b/src/transforms/__fixtures__/rangers.ros @@ -0,0 +1,55 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/rangers.strip.ros b/src/transforms/__fixtures__/rangers.strip.ros new file mode 100644 index 0000000..f33e04b --- /dev/null +++ b/src/transforms/__fixtures__/rangers.strip.ros @@ -0,0 +1,55 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/skatros.all.ros b/src/transforms/__fixtures__/skatros.all.ros new file mode 100644 index 0000000..dc24956 --- /dev/null +++ b/src/transforms/__fixtures__/skatros.all.ros @@ -0,0 +1,19 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/skatros.convert.ros b/src/transforms/__fixtures__/skatros.convert.ros new file mode 100644 index 0000000..9afbbc9 --- /dev/null +++ b/src/transforms/__fixtures__/skatros.convert.ros @@ -0,0 +1,36 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/skatros.merge.ros b/src/transforms/__fixtures__/skatros.merge.ros new file mode 100644 index 0000000..dc24956 --- /dev/null +++ b/src/transforms/__fixtures__/skatros.merge.ros @@ -0,0 +1,19 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/skatros.ros b/src/transforms/__fixtures__/skatros.ros new file mode 100644 index 0000000..9afbbc9 --- /dev/null +++ b/src/transforms/__fixtures__/skatros.ros @@ -0,0 +1,36 @@ + + \ No newline at end of file diff --git a/src/transforms/__fixtures__/skatros.strip.ros b/src/transforms/__fixtures__/skatros.strip.ros new file mode 100644 index 0000000..9afbbc9 --- /dev/null +++ b/src/transforms/__fixtures__/skatros.strip.ros @@ -0,0 +1,36 @@ + + \ No newline at end of file diff --git a/src/transforms/config.js b/src/transforms/config.js new file mode 100644 index 0000000..4d3960f --- /dev/null +++ b/src/transforms/config.js @@ -0,0 +1,67 @@ +// Defaults for the roster transforms. These started life as module constants +// in the Python scripts this was ported from; the values are the same, but the app +// passes them in so they can be edited without touching the transform code. + +export const defaultConfig = { + // --- mergeDuplicateUnits ------------------------------------------------- + + // Two force-level selections are the same unit when all of these attributes + // agree. ``entryId`` identifies the catalogue entry plus the option path + // that produced it, so it is the real key; ``name`` and ``type`` are there + // so a unit the user renamed by hand stays a unit of its own. + unitMatchAttrs: ["entryId", "name", "type"], + + // Selections carrying one of these categories are roster bookkeeping + // (battle size, detachment, ...), not datasheets. Leave them alone. + skipCategories: ["Configuration"], + + // --- removeLeaderAbilities ---------------------------------------------- + + // Abilities to strip, matched against the ``name`` attribute (exactly, + // case-sensitively). Both the Abilities profile and the same-named rule go. + abilitiesToStrip: ["Leader", "Support"], + + // --- convertChoiceAbilities --------------------------------------------- + + // 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", + }, +}; + +// Which transforms run by default when a roster is loaded. +export const defaultToggles = { + mergeDuplicateUnits: true, + removeLeaderAbilities: true, + convertChoiceAbilities: true, +}; + +// typeName BattleScribe files datasheet abilities under. Only profiles of +// this type are matched, so a *weapon* that happened to be called "Support" +// stays, and a rule that happens to share an ability's name is still removed +// deliberately (the strip wants both halves). +export const ABILITY_TYPE_NAME = "Abilities"; + +// Name of the characteristic holding the rules text. +export const DESCRIPTION_CHARACTERISTIC = "Description"; + +// Child elements of a that get merged, in the order BattleScribe +// writes them. The order only matters when a container is missing from the +// unit we keep and has to be created. +export const CONTAINER_ORDER = [ + "rules", + "profiles", + "selections", + "costs", + "categories", +]; + +// Containers that are removed once a strip empties them. A container that was +// *already* empty is left as it was. +export const PRUNABLE_CONTAINERS = ["profiles", "rules"]; diff --git a/src/transforms/convertChoiceAbilities.js b/src/transforms/convertChoiceAbilities.js new file mode 100644 index 0000000..332a904 --- /dev/null +++ b/src/transforms/convertChoiceAbilities.js @@ -0,0 +1,278 @@ +// Split "choose one each turn" abilities into per-option profiles. Ported from +// convert_choice_abilities.py; see README.md on the fixtures. +// +// Abilities such as Belisarius Cawl's *Canticles of the Omnissiah* arrive from +// the catalogue as a single ``Abilities`` profile whose Description crams the +// intro sentence and every selectable option into one blob of text. Official +// datasheets instead print the options as their own titled block in the weapons +// column, one row per option. +// +// This rewrites the roster to match: +// +// * 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. +// +// Running it twice is a no-op: a converted ability has only an intro paragraph +// left, so there is nothing further to split. + +import { ABILITY_TYPE_NAME, DESCRIPTION_CHARACTERISTIC } from "./config.js"; +import { childElement, createElement, descendants, makeId } from "./dom.js"; + +// Whitespace that shows up in these files, including the non-breaking spaces +// that GW's text is littered with. +const WS = " \t  "; + +// An option paragraph looks like "Some Name: rules text...". A label longer +// than this is assumed to be prose that merely contains a colon, not an option. +const MAX_OPTION_NAME_LEN = 60; + +// Some abilities list their options as a bullet list inside a single paragraph +// instead of one paragraph per option (Battle Protocols does, Canticles of the +// Omnissiah does not). A line opening with one of these markers followed by a +// "Label:" starts an option there too. +const BULLET_CHARS = "-–—•■"; + +const PARAGRAPH_SPLIT_RE = new RegExp(`\n[${WS}]*\n`); +const OPTION_RE = new RegExp( + `^([^\n:]{1,${MAX_OPTION_NAME_LEN}}):[${WS}]*(.+)$`, + "s", +); +const BULLET_OPTION_RE = new RegExp( + `^[${WS}]*[${BULLET_CHARS}][${WS}]+[^\n:]{1,${MAX_OPTION_NAME_LEN}}:`, + "gm", +); + +// ``^^Keyword^^``, tolerating the ``**`` GW likes to nest inside (or across) +// the carets; ``[^^]`` keeps a match from swallowing the next keyword. +const KEYWORD_RE = /\^\^([^^]+?)\^\^/g; +const LEFTOVER_MARKER_RE = /\*\*|\^\^/g; + +/** Python's `str.strip(chars)`: trim any of `chars` from both ends. */ +function trimChars(text, chars) { + let start = 0; + let end = text.length; + while (start < end && chars.includes(text[start])) start++; + while (end > start && chars.includes(text[end - 1])) end--; + return text.slice(start, end); +} + +/** Python's `str.lstrip(chars)`. */ +function trimStartChars(text, chars) { + let start = 0; + while (start < text.length && chars.includes(text[start])) start++; + return text.slice(start); +} + +/** Trim whitespace and GW's markdown/keyword emphasis from an option label. */ +const cleanOptionName = (name) => + trimChars(trimChars(trimChars(name, `${WS}\n`), "*^"), WS); + +/** + * Resolve GW's ^^keyword^^ / **emphasis** markers into plain text. + * + * Keywords are upper-cased, emphasis is simply dropped, and any marker left + * 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. + * + * 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) => + text + .replace(KEYWORD_RE, (_, keyword) => + keyword.replaceAll("*", "").toUpperCase(), + ) + .replace(LEFTOVER_MARKER_RE, ""); + +/** + * Cut a paragraph before each bulleted ``- Label:`` line, dropping the bullet. + * + * Anything ahead of the first such line stays as one block, and a bulleted + * option runs until the next one, so multi-line option text stays together. + * Paragraphs without bulleted options come back unchanged. + */ +function splitBullets(paragraph) { + const starts = Array.from( + paragraph.matchAll(BULLET_OPTION_RE), + (m) => m.index, + ); + if (starts.length === 0) return [paragraph]; + + const bounds = [0, ...starts]; + const ends = [...starts, paragraph.length]; + return bounds.map((start, index) => { + const block = paragraph.slice(start, ends[index]); + // Everything but the lead-in opens with a bullet marker. Keyed on the + // index rather than the offset, so a paragraph whose very first + // character is a bullet still gets it stripped. + if (index === 0) return block; + return trimStartChars( + trimStartChars(trimStartChars(block, `${WS}\n`), BULLET_CHARS), + WS, + ); + }); +} + +/** + * Split an ability description into `{ intro, options }`. + * + * Paragraphs are separated by blank lines, or by bulleted option lines within a + * paragraph. The first paragraph is the intro. Each later paragraph starting + * ``Label: text`` opens a new option; anything else is treated as a + * continuation of whatever came before. + */ +export function splitDescription(text) { + const paragraphs = text + .split(PARAGRAPH_SPLIT_RE) + .flatMap(splitBullets) + .filter((block) => trimChars(block, `${WS}\n`) !== ""); + + const introParts = []; + const options = []; + + for (const paragraph of paragraphs) { + const match = OPTION_RE.exec(trimChars(paragraph, "\n")); + if (match) { + const name = cleanOptionName(match[1]); + if (name) { + options.push({ name, parts: [trimChars(match[2], `${WS}\n`)] }); + continue; + } + } + // Not an option header: continuation of the previous block. + if (options.length > 0) { + options.at(-1).parts.push(trimChars(paragraph, "\n")); + } else { + introParts.push(trimChars(paragraph, "\n")); + } + } + + return { + intro: trimChars(introParts.join("\n\n"), `${WS}\n`), + options: options.map(({ name, parts }) => ({ + name, + text: parts.join("\n\n"), + })), + }; +} + +/** Build one `` with a single Description characteristic. */ +function buildProfile( + doc, + template, + { id, name, typeId, typeName, charTypeId, description }, +) { + const profile = createElement(doc, "profile"); + profile.setAttribute("id", id); + profile.setAttribute("name", name); + profile.setAttribute("hidden", "false"); + profile.setAttribute("typeId", typeId); + profile.setAttribute("typeName", typeName); + + // Preserve attributes we do not manage ourselves (page, publicationId, ...). + const managed = new Set(["id", "name", "hidden", "typeId", "typeName"]); + for (const attr of Array.from(template.attributes)) { + if (!managed.has(attr.name)) profile.setAttribute(attr.name, attr.value); + } + + const characteristics = createElement(doc, "characteristics"); + const characteristic = createElement(doc, "characteristic"); + characteristic.setAttribute("name", DESCRIPTION_CHARACTERISTIC); + characteristic.setAttribute("typeId", charTypeId); + characteristic.textContent = description; + characteristics.appendChild(characteristic); + profile.appendChild(characteristics); + return profile; +} + +/** + * Split the configured choice abilities in `doc`, in place. + * + * Returns a report of `[{ ability, options: [names] }]`; an empty report means + * nothing matched (already converted, or no such ability in the roster). + */ +export function convertChoiceAbilities(doc, config) { + const wanted = new Set(config.abilitiesToConvert); + const overrides = config.groupTitleOverrides ?? {}; + const root = doc.documentElement; + if (!root) return []; + + const report = []; + + for (const profile of descendants(root, "profile")) { + const ability = profile.getAttribute("name"); + if ( + profile.getAttribute("typeName") !== ABILITY_TYPE_NAME || + !wanted.has(ability) + ) { + continue; + } + + const characteristics = childElement(profile, "characteristics"); + const characteristic = characteristics + ? childElement(characteristics, "characteristic") + : null; + if ( + !characteristic || + characteristic.getAttribute("name") !== DESCRIPTION_CHARACTERISTIC + ) { + // Not the shape this transform knows how to split; leave it be rather + // than throw, so one odd profile cannot stop the roster rendering. + continue; + } + + const { intro, options } = splitDescription( + characteristic.textContent ?? "", + ); + if (options.length === 0) continue; // Already converted, or a plain ability. + + const groupTitle = overrides[ability] ?? ability; + const typeId = makeId("profileType", groupTitle); + const charTypeId = makeId( + "characteristicType", + groupTitle, + DESCRIPTION_CHARACTERISTIC, + ); + + const replacements = []; + if (intro) { + // Keep the original ability profile - and its id - as the pointer text. + replacements.push( + buildProfile(doc, profile, { + id: profile.getAttribute("id"), + name: ability, + typeId: profile.getAttribute("typeId"), + typeName: profile.getAttribute("typeName"), + charTypeId: characteristic.getAttribute("typeId"), + description: intro, + }), + ); + } + + for (const option of options) { + replacements.push( + buildProfile(doc, profile, { + id: makeId("profile", profile.getAttribute("id"), option.name), + name: stripMarkup(option.name), + typeId, + typeName: groupTitle, + charTypeId, + description: stripMarkup(option.text), + }), + ); + } + + profile.replaceWith(...replacements); + report.push({ ability, options: options.map((option) => option.name) }); + } + + return report; +} diff --git a/src/transforms/dom.js b/src/transforms/dom.js new file mode 100644 index 0000000..ee70e67 --- /dev/null +++ b/src/transforms/dom.js @@ -0,0 +1,123 @@ +// Small DOM helpers shared by the transforms. +// +// The Python scripts these were ported from parse the roster into a tree of +// text offsets and edit the file as text, so that everything they do not touch +// survives byte for byte. In the browser there is nothing to preserve: the +// document goes straight into the roster parser and is never written back out, +// so the transforms mutate the DOM directly and all of that machinery +// (Node/Edit/apply_edits/attr escaping/XML re-validation) is gone. + +// Roster files declare a default namespace (the BattleScribe roster schema), so +// everything here matches on `localName` and creates elements in the document's +// own namespace. Matching on `tagName` would still work for these files, but +// only because nothing in them carries a prefix. + +/** Element children of `node`, as a real array (safe to mutate while iterating). */ +export const elementChildren = (node) => Array.from(node.children); + +/** The first element child named `name`, or null. */ +export const childElement = (node, name) => + elementChildren(node).find((child) => child.localName === name) ?? null; + +/** Every descendant element named `name`, in document order. */ +export const descendants = (node, name) => + Array.from(node.getElementsByTagNameNS("*", name)); + +/** Create an element in the same namespace as the document's root. */ +export const createElement = (doc, name) => + doc.createElementNS(doc.documentElement.namespaceURI, name); + +/** + * Remove `node`, taking the whitespace that only separated it from its sibling. + * + * Rosters come out of BattleScribe on a single line, so usually there is no + * whitespace to take; this only keeps the result tidy for a roster that has + * been pretty-printed at some point. + */ +export function removeElement(node) { + const previous = node.previousSibling; + const TEXT_NODE = 3; + if ( + previous && + previous.nodeType === TEXT_NODE && + previous.data.trim() === "" + ) { + previous.remove(); + } + node.remove(); +} + +/** + * Insert `child` into `parent`, keeping `order` among the containers listed in + * it. A container not mentioned in `order` is appended. + */ +export function insertOrdered(parent, child, order) { + const rank = order.indexOf(child.localName); + if (rank !== -1) { + const before = elementChildren(parent).find((existing) => { + const existingRank = order.indexOf(existing.localName); + return existingRank !== -1 && existingRank > rank; + }); + if (before) { + parent.insertBefore(child, before); + return; + } + } + parent.appendChild(child); +} + +/** + * Joins the parts of a key or a generated id, so that ("ab", "c") and + * ("a", "bc") cannot collide. + * + * ASCII unit separator: a control character cannot occur in a unit name, an + * ability name or an id, so nothing in a roster can contain it and no pair of + * different parts can ever join to the same string. + */ +export const KEY_SEPARATOR = String.fromCharCode(31); + +/** + * A stable id derived from `parts`, shaped like BattleScribe's + * ``xxxx-xxxx-xxxx-xxxx``. + * + * Deterministic so that re-running against an updated roster produces the same + * ids for the same ability rather than churning them, which is what lets the + * merge recognise two generated profiles as the same catalogue object. This is + * a plain string hash rather than the SHA-1 the Python script uses: the ids only + * have to be unique and stable within one document, and `crypto.subtle` is + * async, which would push a promise through every call site for no gain. + */ +export function makeId(...parts) { + const input = parts.join(KEY_SEPARATOR); + // Two independent FNV-1a passes, giving 64 bits of output. + let h1 = 0x811c9dc5; + let h2 = 0x01000193; + for (let i = 0; i < input.length; i++) { + const code = input.charCodeAt(i); + h1 = Math.imul(h1 ^ code, 0x01000193) >>> 0; + h2 = Math.imul(h2 ^ code, 0x811c9dc5) >>> 0; + } + const hex = + h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0"); + return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`; +} + +/** + * The datasheet an element sits on, for the run report: the outermost + * `` above it, since that is the force-level unit. + */ +export function enclosingUnit(node) { + let unit = null; + for ( + let current = node.parentElement; + current; + current = current.parentElement + ) { + if (current.localName === "selection") { + unit = current.getAttribute("name") ?? "?"; + } else if (current.localName === "force" && !unit) { + return current.getAttribute("name") ?? "Force"; + } + } + return unit ?? "Roster"; +} diff --git a/src/transforms/index.js b/src/transforms/index.js new file mode 100644 index 0000000..686fd78 --- /dev/null +++ b/src/transforms/index.js @@ -0,0 +1,46 @@ +// Roster transforms, run between parsing the .ros file and building the roster. +// +// The point of these is to print *generic* datasheets - every option a unit +// could take, the way the official cards read - rather than a record of the one +// list you happen to be playing. + +import { defaultConfig, defaultToggles } from "./config.js"; +import { convertChoiceAbilities } from "./convertChoiceAbilities.js"; +import { mergeDuplicateUnits } from "./mergeDuplicateUnits.js"; +import { removeLeaderAbilities } from "./removeLeaderAbilities.js"; + +export { defaultConfig, defaultToggles } from "./config.js"; +export { convertChoiceAbilities } from "./convertChoiceAbilities.js"; +export { mergeDuplicateUnits } from "./mergeDuplicateUnits.js"; +export { removeLeaderAbilities } from "./removeLeaderAbilities.js"; + +/** + * Apply the enabled transforms to `doc`, in place. + * + * Order matters. The merge runs first because it folds duplicate copies of a + * unit together, deduplicating their shared ability profiles on the way; doing + * it before the splitter means the splitter sees one copy of each ability + * instead of several. The strip then removes what neither of the others needs + * to look at, leaving the least text for the splitter to walk. + * + * Returns a report per transform, for the console and the UI. + */ +export function applyTransforms( + doc, + toggles = defaultToggles, + config = defaultConfig, +) { + const report = {}; + + if (toggles.mergeDuplicateUnits) { + report.mergeDuplicateUnits = mergeDuplicateUnits(doc, config); + } + if (toggles.removeLeaderAbilities) { + report.removeLeaderAbilities = removeLeaderAbilities(doc, config); + } + if (toggles.convertChoiceAbilities) { + report.convertChoiceAbilities = convertChoiceAbilities(doc, config); + } + + return report; +} diff --git a/src/transforms/integration.test.js b/src/transforms/integration.test.js new file mode 100644 index 0000000..a251c06 --- /dev/null +++ b/src/transforms/integration.test.js @@ -0,0 +1,109 @@ +// The transforms have to leave a document the roster parser still understands, +// and the whole point is that what comes out the far end reads like the official +// card. These tests go through the real parser rather than inspecting the DOM. + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { Create40kRoster11th } from "../roster40k-11th.js"; +import { applyTransforms } from "./index.js"; + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__"); +const GAME_TYPE = "Warhammer 40,000 11th Edition"; + +/** Parse a fixture, optionally transforming it, and build the roster. */ +function build(name, { transform }) { + const xml = readFileSync(join(FIXTURES, `${name}.ros`), "utf8"); + const doc = new DOMParser().parseFromString(xml, "text/xml"); + if (transform) applyTransforms(doc); + return Create40kRoster11th(doc, GAME_TYPE); +} + +const units = (roster) => roster.forces.flatMap((force) => force.units); +const unitNamed = (roster, name) => + units(roster).find((unit) => unit.name.includes(name)); + +describe("the transformed roster still parses", () => { + it.each(["bellisarius-cawl", "datasmith", "rangers", "skatros"])( + "builds a roster from %s", + (example) => { + const roster = build(example, { transform: true }); + expect(roster).toBeTruthy(); + expect(roster.forces.length).toBeGreaterThan(0); + expect(units(roster).length).toBeGreaterThan(0); + }, + ); +}); + +describe("merging duplicates", () => { + it("turns two Skatros cards into one carrying both weapons", () => { + const before = build("skatros", { transform: false }); + const after = build("skatros", { transform: true }); + + const skatrosBefore = units(before).filter((unit) => + unit.name.includes("Skatros"), + ); + const skatrosAfter = units(after).filter((unit) => + unit.name.includes("Skatros"), + ); + expect(skatrosBefore.length).toBe(2); + expect(skatrosAfter.length).toBe(1); + + // The single card now lists the weapon each copy was taken for. + const weapons = [ + ...skatrosAfter[0].rangedWeapons, + ...skatrosAfter[0].meleeWeapons, + ] + .map((weapon) => weapon.name) + .join(" | "); + expect(weapons).toMatch(/jezzail/i); + expect(weapons).toMatch(/arquebus/i); + }); + + it("brings an option's own ability along with the model that carries it", () => { + const after = build("rangers", { transform: true }); + const rangers = unitNamed(after, "Ranger"); + + const abilityNames = Object.values(rangers.abilities).flatMap((group) => + Array.from(group.keys()), + ); + expect(abilityNames.join(" | ")).toMatch(/data-tether/i); + }); +}); + +describe("stripping Leader/Support", () => { + // The Datasmith is a Support character, not a Leader; the transform strips + // both, and this is the example that has one. + it("leaves no attachment ability on the card", () => { + const before = build("datasmith", { transform: false }); + const after = build("datasmith", { transform: true }); + + const abilitiesOf = (roster) => + units(roster) + .flatMap((unit) => Object.values(unit.abilities)) + .flatMap((group) => Array.from(group.keys())) + .join(" | "); + + expect(abilitiesOf(before)).toMatch(/^Support \|/); + expect(abilitiesOf(after)).not.toMatch(/Leader|Support/); + }); +}); + +describe("splitting choice abilities", () => { + it("gives each Canticle its own row under one titled group", () => { + const after = build("bellisarius-cawl", { transform: true }); + const cawl = unitNamed(after, "Cawl"); + + const group = cawl.abilities["CANTICLES OF THE OMNISSIAH"]; + 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(/\^\^|\*\*/); + } + }); +}); diff --git a/src/transforms/mergeDuplicateUnits.js b/src/transforms/mergeDuplicateUnits.js new file mode 100644 index 0000000..ac27709 --- /dev/null +++ b/src/transforms/mergeDuplicateUnits.js @@ -0,0 +1,208 @@ +// Consolidate duplicate units into a single unit. Ported from +// python/merge_duplicate_units.py. +// +// Mutually exclusive wargear forces you to take the same datasheet twice: one +// Sydonian Skatros with a radium jezzail, another with a transuranic arquebus; +// one squad of Skitarii Rangers with an omnispex, another with a data-tether. +// Printing that gives you two near-identical cards that differ in one row. +// +// This folds those copies together. Duplicate units - same ``entryId``, same +// name, same type, in the same force - are merged into the first copy, and every +// selection the other copies contribute that the first one lacks is grafted on. +// The merge is recursive, so it works at whatever depth the models differ: +// +// * Skatros - the copies are single models, so the extra *weapon* selection is +// what gets added. +// * Rangers - the copies differ by a whole model (``Skitarii Ranger w/ +// data-tether``), which is added with its weapons *and* its ``Enhanced +// data-tether`` ability underneath it, because the ability hangs off the +// model that brings it. Nothing special is needed for the ability: grafting +// the model on carries its whole subtree along. +// +// Profiles, rules and categories are merged the same way, so an option that +// contributes a unit-level ability or keyword brings it with it. +// +// Points are *not* summed. The consolidated unit keeps the highest cost of the +// copies it absorbed, since the card now shows the most expensive loadout, and +// the roster total in ```` is left alone - it still reports what +// the army actually costs. + +import { CONTAINER_ORDER } from "./config.js"; +import { + KEY_SEPARATOR, + childElement, + descendants, + elementChildren, + insertOrdered, + removeElement, +} from "./dom.js"; + +// Attribute a merged pair keeps the larger of. Two copies of a squad can list +// different model counts for the same entry; the consolidated card shows the +// largest, the same way it shows the largest cost. +const COUNT_ATTR = "number"; + +/** + * Identity of a child element within its container. + * + * Selections are keyed on ``entryId`` - the catalogue entry plus option path - + * because their ``id`` is per-instance and differs between two copies of the + * same squad. Profiles, rules and categories are shared catalogue objects, so + * their ``id`` matches across copies. Costs are keyed by name/typeId alone. + * Anything missing its key falls back to the name, which is all a renderer would + * tell apart anyway. + */ +function childKey(node) { + let key = null; + if (node.localName === "selection") { + key = node.getAttribute("entryId"); + } else if (node.localName !== "cost") { + key = node.getAttribute("id"); + } + return [ + node.localName, + key, + node.getAttribute("name"), + node.getAttribute("typeId"), + node.getAttribute("typeName"), + ].join(KEY_SEPARATOR); +} + +const describe = (node) => { + const name = node.getAttribute("name") ?? `<${node.localName}>`; + const typeName = node.getAttribute("typeName"); + return typeName ? `${name} (${typeName})` : name; +}; + +const isConfiguration = (unit, skipCategories) => { + const categories = childElement(unit, "categories"); + if (!categories) return false; + return elementChildren(categories).some((category) => + skipCategories.has(category.getAttribute("name")), + ); +}; + +const unitKey = (unit, matchAttrs) => + matchAttrs.map((attr) => unit.getAttribute(attr)).join(KEY_SEPARATOR); + +/** Keep the larger of two numeric attributes on the unit we are keeping. */ +function raiseNumericAttr(target, donor, attr) { + if (!target.hasAttribute(attr)) return; + const keep = Number(target.getAttribute(attr)); + const other = donor.hasAttribute(attr) + ? Number(donor.getAttribute(attr)) + : keep; + if (Number.isNaN(keep) || Number.isNaN(other)) return; + if (other > keep) target.setAttribute(attr, donor.getAttribute(attr)); +} + +/** + * Log what the merge contributed, for the run report. + * + * Costs are skipped: a points line is not an option the reader was missing. + */ +function noteAdded(added, path, container, nodes) { + if (container === "costs") return; + for (const node of nodes) added.push([...path, describe(node)].join(" > ")); +} + +/** Fold everything `donor` has and `target` lacks into `target`. */ +function mergeSelection(target, donor, path, added) { + raiseNumericAttr(target, donor, COUNT_ATTR); + + for (const container of CONTAINER_ORDER) { + const donorContainer = childElement(donor, container); + if (!donorContainer || donorContainer.children.length === 0) continue; + + const targetContainer = childElement(target, container); + if (!targetContainer) { + // The whole container is new - graft it on, keeping CONTAINER_ORDER. + insertOrdered(target, donorContainer.cloneNode(true), CONTAINER_ORDER); + noteAdded(added, path, container, elementChildren(donorContainer)); + continue; + } + + // Pair each donor child with an unclaimed target child of the same + // identity; a container may legitimately hold two entries with the same + // key, so matches are consumed rather than looked up. + const unclaimed = elementChildren(targetContainer); + const additions = []; + for (const donorChild of elementChildren(donorContainer)) { + const key = childKey(donorChild); + const index = unclaimed.findIndex((child) => childKey(child) === key); + if (index === -1) { + additions.push(donorChild); + continue; + } + const [match] = unclaimed.splice(index, 1); + if (container === "selections") { + mergeSelection(match, donorChild, [...path, describe(match)], added); + } else if (container === "costs") { + // Same cost type on both copies: show the pricier loadout. + raiseNumericAttr(match, donorChild, "value"); + } + } + + if (additions.length > 0) { + for (const addition of additions) { + targetContainer.appendChild(addition.cloneNode(true)); + } + noteAdded(added, path, container, additions); + } + } +} + +/** + * Merge the duplicate units in `doc`, in place. + * + * Returns a report of `[{ unit, copies, additions }]`, where `copies` counts the + * copies the consolidated unit now stands for (2 means one was absorbed). An + * empty report means there were no duplicates. + * + * Unlike the Python original this merges every duplicate in a single pass: that + * script re-parses the file after each merge because applying a text edit + * invalidates the offsets it works from, which a DOM does not have. + */ +export function mergeDuplicateUnits(doc, config) { + const root = doc.documentElement; + if (!root) return []; + + const skipCategories = new Set(config.skipCategories); + const matchAttrs = config.unitMatchAttrs; + const report = []; + + for (const force of descendants(root, "force")) { + const selections = childElement(force, "selections"); + if (!selections) continue; + + const groups = new Map(); + for (const unit of elementChildren(selections)) { + if ( + unit.localName !== "selection" || + isConfiguration(unit, skipCategories) + ) { + continue; + } + const key = unitKey(unit, matchAttrs); + groups.set(key, [...(groups.get(key) ?? []), unit]); + } + + for (const units of groups.values()) { + if (units.length < 2) continue; + + const [target, ...donors] = units; + const additions = []; + for (const donor of donors) { + mergeSelection(target, donor, [], additions); + removeElement(donor); + } + report.push({ + unit: target.getAttribute("name") ?? "?", + copies: units.length, + additions, + }); + } + } + + return report; +} diff --git a/src/transforms/removeLeaderAbilities.js b/src/transforms/removeLeaderAbilities.js new file mode 100644 index 0000000..a2d1dd0 --- /dev/null +++ b/src/transforms/removeLeaderAbilities.js @@ -0,0 +1,127 @@ +// Strip Leader/Support abilities from a roster. Ported from +// remove_leader_abilities.py; see README.md on the fixtures. +// +// A printed datasheet is a reference card you hold during a game, and the +// Leader / Support abilities are not something you look up mid-turn: they only +// say which units a character may attach to. Once the army is built that +// decision is already made, and the block is long enough to push the rules you +// *do* need off the card. +// +// Each ability arrives from the catalogue in two places - an ``Abilities`` +// profile holding the "This model can be attached to the following units" +// text, and a same-named ``rule`` that puts *Leader* on the datasheet's RULES +// line and its full text in the rules appendix - so both are taken out. +// Containers left empty by the removal (````, ````) are +// dropped too, rather than left behind as empty elements. +// +// Points are deliberately left alone. Removing a ```` only removes it +// from the roster; the renderer prints the title bar unconditionally, so a +// stripped unit would read ``0pts`` rather than nothing - worse than just +// leaving the cost in. + +import { ABILITY_TYPE_NAME, PRUNABLE_CONTAINERS } from "./config.js"; +import { elementChildren, enclosingUnit, removeElement } from "./dom.js"; + +/** Should this element be stripped in its own right? */ +const isTarget = (node, strip) => { + const name = node.getAttribute("name"); + if (node.localName === "rule") return strip.has(name); + if (node.localName === "profile") { + return ( + node.getAttribute("typeName") === ABILITY_TYPE_NAME && strip.has(name) + ); + } + return false; +}; + +const describe = (node) => + `${node.localName === "profile" ? "ability" : "rule"}: ${ + node.getAttribute("name") ?? "?" + }`; + +/** + * Flag `node` and its descendants for removal, bottom-up, returning whether + * `node` itself is going. + * + * A container goes when everything it held is going: a unit whose only ability + * was *Leader* should not be left with an empty ````. + */ +function markDoomed(node, strip, doomed) { + if (isTarget(node, strip)) { + doomed.add(node); + return true; + } + + // Not `.every()` - every child has to be visited, not just until one stays. + const children = elementChildren(node); + const gone = children.map((child) => markDoomed(child, strip, doomed)); + + if ( + PRUNABLE_CONTAINERS.includes(node.localName) && + gone.length > 0 && + gone.every(Boolean) + ) { + doomed.add(node); + return true; + } + return false; +} + +/** The strip targets inside a doomed subtree, for the run report. */ +function* iterTargets(node, strip) { + if (isTarget(node, strip)) { + yield node; + return; + } + for (const child of elementChildren(node)) yield* iterTargets(child, strip); +} + +/** + * The outermost doomed elements, in document order. + * + * Doomed-ness propagates downwards - a container is only doomed when every + * child is - so removing these removes everything, and each one is a single + * entry in the report rather than one per profile inside it. + */ +function collectOutermost(node, doomed, found) { + if (doomed.has(node)) { + found.push(node); + return; + } + for (const child of elementChildren(node)) { + collectOutermost(child, doomed, found); + } +} + +/** + * Remove the Leader/Support abilities from `doc`, in place. + * + * Returns a report of `{ unit: [what was removed] }`; an empty report means + * there was nothing to strip (already stripped, or no such abilities). + */ +export function removeLeaderAbilities(doc, config) { + const strip = new Set(config.abilitiesToStrip); + const root = doc.documentElement; + if (!root) return {}; + + const doomed = new Set(); + markDoomed(root, strip, doomed); + + const outermost = []; + collectOutermost(root, doomed, outermost); + + // The report is built before anything is removed, so `enclosingUnit` can + // still walk up to the datasheet the element sat on. + const report = {}; + for (const node of outermost) { + const unit = enclosingUnit(node); + report[unit] = [ + ...(report[unit] ?? []), + ...Array.from(iterTargets(node, strip), describe), + ]; + } + + for (const node of outermost) removeElement(node); + + return report; +} diff --git a/src/transforms/transforms.test.js b/src/transforms/transforms.test.js new file mode 100644 index 0000000..40ed8dc --- /dev/null +++ b/src/transforms/transforms.test.js @@ -0,0 +1,158 @@ +// Parity tests: the transforms must produce the same roster as the Python +// scripts they were ported from, which are the version that had actually been +// used to print cards. +// +// The `.merge`/`.strip`/`.convert`/`.all` fixtures are that Python output, +// checked in - so this suite compares the code against an implementation that +// is genuinely independent of it, without needing a Python interpreter. See +// README.md and scripts/update-fixtures.mjs before regenerating any of them. +// +// Comparison is structural rather than textual. The Python scripts edit the +// file as text and preserve its byte-for-byte formatting; these transforms build +// DOM nodes, so attribute order and whitespace legitimately differ. What has to +// match is the tree: elements, nesting, attribute values, and text. + +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { defaultConfig } from "./config.js"; +import { + convertChoiceAbilities, + splitDescription, +} from "./convertChoiceAbilities.js"; +import { applyTransforms } from "./index.js"; +import { mergeDuplicateUnits } from "./mergeDuplicateUnits.js"; +import { removeLeaderAbilities } from "./removeLeaderAbilities.js"; + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__"); + +const examples = readdirSync(FIXTURES) + .filter((name) => name.endsWith(".ros") && name.split(".").length === 2) + .map((name) => name.replace(/\.ros$/, "")); + +const parse = (xml) => { + const doc = new DOMParser().parseFromString(xml, "text/xml"); + const failure = doc.querySelector("parsererror"); + if (failure) throw new Error(`fixture did not parse: ${failure.textContent}`); + return doc; +}; + +const load = (name) => + parse(readFileSync(join(FIXTURES, `${name}.ros`), "utf8")); + +/** + * A stable text rendering of a document's tree, for comparison. + * + * `id` and `typeId` are replaced with a token per distinct value, in + * first-appearance order: the splitter mints ids for the profiles it generates, + * and the JS port hashes them differently from the Python original. What + * matters is that ids are shared and distinct in the same *pattern*, which the + * 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. + */ +function canonicalize(doc) { + const tokens = new Map(); + const token = (value) => { + if (!tokens.has(value)) tokens.set(value, `#${tokens.size}`); + return tokens.get(value); + }; + const generated = new Set(["id", "typeId"]); + const lines = []; + + const walk = (element, depth) => { + const attributes = Array.from(element.attributes) + .map((attr) => [ + attr.name, + generated.has(attr.name) ? token(attr.value) : attr.value, + ]) + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([name, value]) => `${name}=${JSON.stringify(value)}`) + .join(" "); + + const text = Array.from(element.childNodes) + .filter((node) => node.nodeType === 3 /* Text */) + .map((node) => node.data) + .join("") + .trim(); + + lines.push( + `${" ".repeat(depth)}<${element.localName} ${attributes}>` + + (text ? ` ${JSON.stringify(text)}` : ""), + ); + for (const child of element.children) walk(child, depth + 1); + }; + + walk(doc.documentElement, 0); + return lines.join("\n"); +} + +const cases = [ + ["merge", (doc) => mergeDuplicateUnits(doc, defaultConfig)], + ["strip", (doc) => removeLeaderAbilities(doc, defaultConfig)], + ["convert", (doc) => convertChoiceAbilities(doc, defaultConfig)], + ["all", (doc) => applyTransforms(doc)], +]; + +describe.each(cases)("%s", (label, run) => { + it.each(examples)("matches the python output for %s", (example) => { + const doc = load(example); + run(doc); + expect(canonicalize(doc)).toBe(canonicalize(load(`${example}.${label}`))); + }); + + it.each(examples)("is a no-op on second run for %s", (example) => { + const doc = load(example); + run(doc); + const once = canonicalize(doc); + run(doc); + expect(canonicalize(doc)).toBe(once); + }); +}); + +describe("splitDescription", () => { + it("keeps a plain ability whole", () => { + const { intro, options } = splitDescription( + "While this model is leading a unit, add 1 to the Objective Control characteristic of models in that unit.", + ); + expect(options).toEqual([]); + expect(intro).toMatch(/^While this model/); + }); + + it("splits blank-line separated options", () => { + const { intro, options } = splitDescription( + "At the start of your Command phase, select one:\n\n" + + "Shroudpsalm: Models in this army have the Stealth ability.\n\n" + + "Chant of the Remorseless Fist: Melee weapons have the [LANCE] ability.", + ); + expect(intro).toBe("At the start of your Command phase, select one:"); + expect(options.map((option) => option.name)).toEqual([ + "Shroudpsalm", + "Chant of the Remorseless Fist", + ]); + expect(options[0].text).toBe( + "Models in this army have the Stealth ability.", + ); + }); + + it("splits a bulleted list inside one paragraph", () => { + const { intro, options } = splitDescription( + "Select one protocol:\n" + + "- Protocol of the Eternal Guardian: Models have Feel No Pain 6+.\n" + + "- Protocol of the Sudden Storm: Models have the Assault ability.", + ); + expect(intro).toBe("Select one protocol:"); + expect(options.map((option) => option.name)).toEqual([ + "Protocol of the Eternal Guardian", + "Protocol of the Sudden Storm", + ]); + }); + + it("treats a long prose colon as prose, not an option", () => { + const sentence = `${"a".repeat(70)}: still the same paragraph.`; + const { options } = splitDescription(`Intro paragraph.\n\n${sentence}`); + expect(options).toEqual([]); + }); +}); diff --git a/vite.config.js b/vite.config.js index e941cd6..7180261 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,5 +10,6 @@ export default defineConfig({ }, }), ], - base: "/fancyscribe", + // Served at the root of scribe.luxick.de, not from a GitHub Pages subpath. + base: "/", }); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..a33e502 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +// Kept separate from vite.config.js: the tests exercise plain modules, so they +// need a DOM but none of the React/build plugins. +// +export default defineConfig({ + test: { + environment: "jsdom", + include: ["src/**/*.test.js"], + }, +});