diff --git a/src/10th/Roster.jsx b/src/10th/Roster.jsx index aba0d12..992cdb0 100644 --- a/src/10th/Roster.jsx +++ b/src/10th/Roster.jsx @@ -1358,8 +1358,15 @@ const makeKeywordsBold = (text) => { } } - // replace words wrapped in ^^ ^^ with tags - newValue = newValue.replace(/\^\^([^\^]+)\^\^/g, "$1"); + // replace words wrapped in ^^ ^^ with 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, + '$1', + ); // replace words wrapped in ** ** with tags newValue = newValue.replace(/\*\*([^\*]+)\*\*/g, "$1"); @@ -1484,7 +1491,11 @@ const OtherAbilities = ({ abilities }) => { paddingBottom: 4, }} > - {name}: {value} + {name}:{" "} + ))} diff --git a/src/transforms/config.js b/src/transforms/config.js index 4d3960f..254f080 100644 --- a/src/transforms/config.js +++ b/src/transforms/config.js @@ -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. diff --git a/src/transforms/convertChoiceAbilities.js b/src/transforms/convertChoiceAbilities.js index 332a904..1d003b4 100644 --- a/src/transforms/convertChoiceAbilities.js +++ b/src/transforms/convertChoiceAbilities.js @@ -12,7 +12,8 @@ // * the original ``Abilities`` profile is kept, trimmed to the intro // paragraph (the "select one of the following" rule), and // * each option becomes its own profile under a new profile type named after -// the ability, so the renderer groups them into a single titled table. +// the ability, in capitals, so the renderer groups them into a single table +// titled the way the datasheets title it. // // Running it twice is a no-op: a converted ability has only an intro paragraph // left, so there is nothing further to split. @@ -76,16 +77,23 @@ const cleanOptionName = (name) => * over from mis-nested pairs goes too - a stray ``**`` is punctuation the * reader never wanted to see either way. * - * The generated group renders through the renderer's generic ability table, - * which prints its text verbatim, so the markers would otherwise show up as - * literal punctuation. The intro profile keeps its original typeName, renders - * through the normal path, and is therefore left alone. + * Only the option *name* goes through this. It ends up as a profile ``name`` + * attribute, which the renderer prints as plain text, so a marker left in there + * would show up as literal punctuation. The option text keeps its markers: + * ``makeKeywordsBold`` in 10th/Roster.jsx formats them into the bold uppercase + * keywords and bold emphasis the printed card uses, which is more than + * upper-casing here could manage. The intro profile keeps its original + * typeName, renders through the normal path, and was never touched either way. + * + * The Python original also stripped the option text, so this is the one place + * the port deliberately differs from it; transforms.test.js resolves markers on + * both sides when it compares against the fixtures. * * Unlike the Python original this needs no entity bookkeeping: the DOM hands us * resolved text, so there is no ``"`` here that upper-casing could turn * into an entity no parser would recognise. */ -const stripMarkup = (text) => +export const stripMarkup = (text) => text .replace(KEYWORD_RE, (_, keyword) => keyword.replaceAll("*", "").toUpperCase(), @@ -201,7 +209,6 @@ function buildProfile( */ export function convertChoiceAbilities(doc, config) { const wanted = new Set(config.abilitiesToConvert); - const overrides = config.groupTitleOverrides ?? {}; const root = doc.documentElement; if (!root) return []; @@ -234,7 +241,10 @@ export function convertChoiceAbilities(doc, config) { ); if (options.length === 0) continue; // Already converted, or a plain ability. - const groupTitle = overrides[ability] ?? ability; + // The group's title becomes the generated profileType's typeName, which the + // renderer prints verbatim as the table heading - and the datasheets set + // that heading in capitals. + const groupTitle = ability.toUpperCase(); const typeId = makeId("profileType", groupTitle); const charTypeId = makeId( "characteristicType", @@ -265,7 +275,7 @@ export function convertChoiceAbilities(doc, config) { typeId, typeName: groupTitle, charTypeId, - description: stripMarkup(option.text), + description: option.text, }), ); } diff --git a/src/transforms/integration.test.js b/src/transforms/integration.test.js index a251c06..09d774a 100644 --- a/src/transforms/integration.test.js +++ b/src/transforms/integration.test.js @@ -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(/\^\^|\*\*/); } }); }); diff --git a/src/transforms/transforms.test.js b/src/transforms/transforms.test.js index 40ed8dc..adf3da2 100644 --- a/src/transforms/transforms.test.js +++ b/src/transforms/transforms.test.js @@ -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) - .filter((node) => node.nodeType === 3 /* Text */) - .map((node) => node.data) - .join("") - .trim(); + const text = stripMarkup( + Array.from(element.childNodes) + .filter((node) => node.nodeType === 3 /* Text */) + .map((node) => node.data) + .join("") + .trim(), + ); lines.push( `${" ".repeat(depth)}<${element.localName} ${attributes}>` +