Improve Keyword rendering
CI / check (push) Has been cancelled

This commit is contained in:
2026-07-25 16:08:21 +02:00
parent b35c88b451
commit 444d26fd47
5 changed files with 54 additions and 29 deletions
+14 -3
View File
@@ -1358,8 +1358,15 @@ const makeKeywordsBold = (text) => {
}
}
// replace words wrapped in ^^ ^^ with <strong> tags
newValue = newValue.replace(/\^\^([^\^]+)\^\^/g, "<strong>$1</strong>");
// replace words wrapped in ^^ ^^ with <strong> tags. ^^ is the exporter's
// datasheet-keyword marker, and the printed cards set keywords in bold
// uppercase - the same treatment boldKeywords gives the ones it knows. The
// case comes from CSS rather than toUpperCase() because the replacements above
// have already put HTML in here, and var(--primary-color) is case-sensitive.
newValue = newValue.replace(
/\^\^([^\^]+)\^\^/g,
'<strong style="text-transform: uppercase;">$1</strong>',
);
// replace words wrapped in ** ** with <strong> tags
newValue = newValue.replace(/\*\*([^\*]+)\*\*/g, "<strong>$1</strong>");
@@ -1484,7 +1491,11 @@ const OtherAbilities = ({ abilities }) => {
paddingBottom: 4,
}}
>
<span style={{ fontWeight: 700 }}>{name}:</span> {value}
<span style={{ fontWeight: 700 }}>{name}:</span>{" "}
<span
className="whitespace-pre-line"
dangerouslySetInnerHTML={{ __html: makeKeywordsBold(value) }}
/>
</td>
</tr>
))}
+1 -8
View File
@@ -25,14 +25,7 @@ export const defaultConfig = {
// Abilities to split into per-option profiles, matched against the
// profile's ``name`` attribute.
abilitiesToConvert: ["Canticles of the Omnissiah", "Battle Protocols"],
// Title of the generated profile group, per ability. Anything not listed
// here uses the ability's own name, which is what the datasheets do.
groupTitleOverrides: {
"Canticles of the Omnissiah": "CANTICLES OF THE OMNISSIAH",
"Battle Protocols": "BATTLE PROTOCOLS",
},
abilitiesToConvert: ["Canticles of the Omnissiah", "Battle Protocols", "Icon of War"],
};
// Which transforms run by default when a roster is loaded.
+19 -9
View File
@@ -12,7 +12,8 @@
// * the original ``Abilities`` profile is kept, trimmed to the intro
// paragraph (the "select one of the following" rule), and
// * each option becomes its own profile under a new profile type named after
// the ability, so the renderer groups them into a single titled table.
// the ability, in capitals, so the renderer groups them into a single table
// titled the way the datasheets title it.
//
// Running it twice is a no-op: a converted ability has only an intro paragraph
// left, so there is nothing further to split.
@@ -76,16 +77,23 @@ const cleanOptionName = (name) =>
* over from mis-nested pairs goes too - a stray ``**`` is punctuation the
* reader never wanted to see either way.
*
* The generated group renders through the renderer's generic ability table,
* which prints its text verbatim, so the markers would otherwise show up as
* literal punctuation. The intro profile keeps its original typeName, renders
* through the normal path, and is therefore left alone.
* Only the option *name* goes through this. It ends up as a profile ``name``
* attribute, which the renderer prints as plain text, so a marker left in there
* would show up as literal punctuation. The option text keeps its markers:
* ``makeKeywordsBold`` in 10th/Roster.jsx formats them into the bold uppercase
* keywords and bold emphasis the printed card uses, which is more than
* upper-casing here could manage. The intro profile keeps its original
* typeName, renders through the normal path, and was never touched either way.
*
* The Python original also stripped the option text, so this is the one place
* the port deliberately differs from it; transforms.test.js resolves markers on
* both sides when it compares against the fixtures.
*
* Unlike the Python original this needs no entity bookkeeping: the DOM hands us
* resolved text, so there is no ``&quot;`` here that upper-casing could turn
* into an entity no parser would recognise.
*/
const stripMarkup = (text) =>
export const stripMarkup = (text) =>
text
.replace(KEYWORD_RE, (_, keyword) =>
keyword.replaceAll("*", "").toUpperCase(),
@@ -201,7 +209,6 @@ function buildProfile(
*/
export function convertChoiceAbilities(doc, config) {
const wanted = new Set(config.abilitiesToConvert);
const overrides = config.groupTitleOverrides ?? {};
const root = doc.documentElement;
if (!root) return [];
@@ -234,7 +241,10 @@ export function convertChoiceAbilities(doc, config) {
);
if (options.length === 0) continue; // Already converted, or a plain ability.
const groupTitle = overrides[ability] ?? ability;
// The group's title becomes the generated profileType's typeName, which the
// renderer prints verbatim as the table heading - and the datasheets set
// that heading in capitals.
const groupTitle = ability.toUpperCase();
const typeId = makeId("profileType", groupTitle);
const charTypeId = makeId(
"characteristicType",
@@ -265,7 +275,7 @@ export function convertChoiceAbilities(doc, config) {
typeId,
typeName: groupTitle,
charTypeId,
description: stripMarkup(option.text),
description: option.text,
}),
);
}
+6 -4
View File
@@ -100,10 +100,12 @@ describe("splitting choice abilities", () => {
expect(group).toBeTruthy();
expect(group.size).toBeGreaterThan(1);
// Options print as plain text: GW's ^^keyword^^ / **emphasis** markers
// are resolved, not passed through as punctuation.
for (const text of group.values()) {
expect(JSON.stringify(text)).not.toMatch(/\^\^|\*\*/);
// The option text keeps GW's ^^keyword^^ / **emphasis** markers for the
// renderer to format; only the row titles are plain text, since those
// render as-is.
expect([...group.values()].join("\n")).toMatch(/\^\^/);
for (const name of group.keys()) {
expect(name).not.toMatch(/\^\^|\*\*/);
}
});
});
+11 -2
View File
@@ -21,6 +21,7 @@ import { defaultConfig } from "./config.js";
import {
convertChoiceAbilities,
splitDescription,
stripMarkup,
} from "./convertChoiceAbilities.js";
import { applyTransforms } from "./index.js";
import { mergeDuplicateUnits } from "./mergeDuplicateUnits.js";
@@ -52,6 +53,12 @@ const load = (name) =>
* tokens preserve, not that the two implementations agree on a hash function.
* Every other attribute - including `entryId` and `publicationId`, which are
* never generated - has to match exactly.
*
* Text goes through `stripMarkup` for the same reason. Python resolved GW's
* ^^keyword^^ / **emphasis** markers in the option text it split out, because
* the renderer printed that text verbatim; the renderer now formats the markers
* itself, so the transform leaves them in place. Resolving them on both sides
* keeps the fixtures an oracle for everything else about the split.
*/
function canonicalize(doc) {
const tokens = new Map();
@@ -72,11 +79,13 @@ function canonicalize(doc) {
.map(([name, value]) => `${name}=${JSON.stringify(value)}`)
.join(" ");
const text = Array.from(element.childNodes)
const text = stripMarkup(
Array.from(element.childNodes)
.filter((node) => node.nodeType === 3 /* Text */)
.map((node) => node.data)
.join("")
.trim();
.trim(),
);
lines.push(
`${" ".repeat(depth)}<${element.localName} ${attributes}>` +