@heroiclands/package-build 20.2.1 → 20.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,84 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 20.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 43188f2: A registry entry can now name an icon family, so a package that draws from two
8
+ of them can say so.
9
+
10
+ The SoHL icon legend states in its own prose that it uses Font Awesome plus
11
+ Game-Icons.net "for the arms, gear, and condition glyphs that Font Awesome does
12
+ not cover" — eighteen `ginf-*` classes the registry had no way to express. They
13
+ differ in more than a prefix: Game-Icons has no weights, so a `style` on such an
14
+ entry names something that does not exist, and it resolves to a different font,
15
+ which a PDF has to embed separately.
16
+
17
+ `family` defaults to `fontawesome`, so every existing entry is unchanged. A
18
+ style on an unstyled family is reported rather than silently ignored, because it
19
+ means the author expected a filled and hollow pair the family cannot spell.
20
+
21
+ Also adds `gem` and `gem-outline`, and repoints `diamond` at them. `fa-diamond`
22
+ is Font Awesome's playing-card suit and ships in solid only, so it could not
23
+ spell the hollow half of the Success Value scale it was being used for.
24
+
25
+ ## 20.3.0
26
+
27
+ ### Minor Changes
28
+
29
+ - 6be4a7e: An icon can now carry attributes, and `affiliation` joins the registry.
30
+
31
+ `:icon-affiliation:{size: 2x}` — `key: value` pairs in a trailing brace, the
32
+ shape `markdown-it-attrs` and remark-directive already use. Attributes rather
33
+ than a bare size because `size` is only the first one anybody needed: a
34
+ fixed-width flag, a rotation, a title override are the same shape of thing, and
35
+ a syntax that could only express size would have to be replaced to gain any of
36
+ them.
37
+
38
+ Size is a closed vocabulary — `lg`, `xl`, `2x`, `3x` — mapping to Font Awesome's
39
+ own classes on the web and the same multiples in a PDF. It exists because the
40
+ icon legend enlarges its glyphs so a reader can tell them apart, which makes the
41
+ size part of what that page says rather than styling applied to it. A free-form
42
+ `font-size` would have been CSS, which reaches two surfaces of three.
43
+
44
+ An attribute that cannot be honoured is reported rather than ignored, and the
45
+ icon still renders: hiding a good icon over a bad size would be the worse answer.
46
+
47
+ `affiliation` is `fa-certificate` — a charter under seal. It replaces a
48
+ `fa-duotone fa-handshake` that was reaching for Font Awesome Pro because the
49
+ interlocking hands washed out at small sizes; the fix for a silhouette problem
50
+ is a bolder silhouette, not a paid tier.
51
+ - e209110: Add `close` and `run` to the icon registry, and correct `delete`.
52
+
53
+ Converting the SoHL user guide turned up two controls with no name to convert
54
+ to. A `✕` there means three different things — "not applicable" in a Healing
55
+ Rate column, "remove" on a control, and **close** on a dialog's corner — and a
56
+ `▶` **runs** an action, where the nearest existing entry was `expand`, whose
57
+ accessible label would have told a screen reader the wrong thing. Both now have
58
+ their own name and their own label; that Font Awesome draws the three `xmark`
59
+ senses identically is this table's business rather than the reader's.
60
+
61
+ `delete` was written as `fa-trash-can` and is now `fa-trash`, which is what the
62
+ system's templates actually draw — twenty-five times, against no `fa-trash-can`
63
+ at all. A registry checked against the interface is the whole point of having
64
+ one; unchecked, it is just a second place to be wrong.
65
+
66
+ ### Patch Changes
67
+
68
+ - 1d8e289: The charset and icon checks now report warnings rather than errors, so neither
69
+ can fail a build.
70
+
71
+ Both emitted `severity: "error"`, and `reportFindings` fails a run on an error —
72
+ so adopting the charset check turned consumers' builds red for content that was
73
+ already correct. A character outside the charset does not make a note wrong: it
74
+ compiles to the same document and publishes the same page, and only a book that
75
+ does not exist yet cares. An undeclared icon name is the same shape of thing,
76
+ visible on the page as literal text.
77
+
78
+ The NFC rule is a warning too, despite having the best claim to being an error,
79
+ because a lint that fails a build for one of its rules and not the others is one
80
+ nobody can predict.
81
+
3
82
  ## 20.2.1
4
83
 
5
84
  ### Patch Changes
@@ -64,8 +64,21 @@
64
64
  * embeds: it carries the box-drawing, geometric and arrow repertoire that no
65
65
  * candidate serif reliably has.
66
66
  *
67
- * **This module reports and does not exit**, as every gate in this engine does.
68
- * The command decides what a finding is worth.
67
+ * **Every finding here is a `warning`, and that is deliberate.** `reportFindings`
68
+ * fails a run on an error and not on a warning, so nothing this module says can
69
+ * break a build.
70
+ *
71
+ * A character outside the charset does not make a note wrong. It compiles to
72
+ * the same document, publishes the same page, and reads correctly everywhere
73
+ * except a book that does not exist yet — so failing a consumer's build over it
74
+ * would stop work that is already correct in order to serve a renderer that is
75
+ * still being written. The trees this shipped against had 112 findings between
76
+ * them on the day it landed, all of them real and none of them urgent.
77
+ *
78
+ * The NFC rule is the one with a claim to being an error, since a decomposed
79
+ * name is silently invisible to an exact-match filter. It is a warning too,
80
+ * because a lint that fails a build for one of its rules and not the others is
81
+ * a lint nobody can predict.
69
82
  *
70
83
  * @module
71
84
  */
@@ -295,7 +308,7 @@ export function decomposedRuns(text) {
295
308
  * @param {string} text - The file's contents.
296
309
  * @param {string} file - Path to report, relative to the tree.
297
310
  * @returns {Array<{file: string, line: number, column: number,
298
- * severity: "error", message: string}>} What is wrong, in file order.
311
+ * severity: "warning", message: string}>} What is wrong, in file order.
299
312
  */
300
313
  export function checkText(text, file) {
301
314
  const findings = [];
@@ -347,7 +360,7 @@ export function checkText(text, file) {
347
360
  file,
348
361
  line: i + 1,
349
362
  column,
350
- severity: /** @type {const} */ ("error"),
363
+ severity: /** @type {const} */ ("warning"),
351
364
  message: `\`${ch}\` ${hex(cp)} is ${refusalFor(cp)}`,
352
365
  });
353
366
  }
@@ -364,7 +377,7 @@ export function checkText(text, file) {
364
377
  file,
365
378
  line,
366
379
  column,
367
- severity: /** @type {const} */ ("error"),
380
+ severity: /** @type {const} */ ("warning"),
368
381
  message:
369
382
  `\`${run.sequence}\` is written decomposed as ${points}; write the ` +
370
383
  `precomposed \`${run.composed}\` (${[...run.composed]
@@ -391,13 +404,13 @@ export function checkText(text, file) {
391
404
  * in addition to the dot-directories always skipped.
392
405
  * @param {readonly string[]} [opts.extensions] - File extensions to read.
393
406
  * @returns {{findings: Array<{file: string, line: number, column: number,
394
- * severity: "error", message: string}>, files: number}} The findings, and how
407
+ * severity: "warning", message: string}>, files: number}} The findings, and how
395
408
  * many files produced them.
396
409
  */
397
410
  export function lintContentCharset(contentBase, { skipDirectories = [], extensions } = {}) {
398
411
  const exts = new Set(extensions ?? [".md", ".markdown", ".yaml", ".yml", ".json"]);
399
412
  const skip = new Set(skipDirectories);
400
- /** @type {Array<{file: string, line: number, column: number, severity: "error", message: string}>} */
413
+ /** @type {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} */
401
414
  const findings = [];
402
415
  let files = 0;
403
416
 
@@ -56,6 +56,12 @@
56
56
  * 1.1, so a distributed PDF may embed the subset it uses. Attribution belongs
57
57
  * in the book's colophon, not in every note.
58
58
  *
59
+ * **Every finding here is a `warning`**, so nothing this module says can fail a
60
+ * build — `reportFindings` fails on an error and not on a warning. An
61
+ * undeclared name renders as its own literal text, which is visible on the page
62
+ * and wrong in a way a reader will notice; that deserves to be reported and
63
+ * does not deserve to stop a build that is otherwise correct.
64
+ *
59
65
  * @module
60
66
  */
61
67
 
@@ -73,28 +79,201 @@ import path from "node:path";
73
79
  */
74
80
  export const ICON_STYLES = Object.freeze(["solid", "regular", "brands"]);
75
81
 
82
+ /**
83
+ * The icon families a registry entry may draw from.
84
+ *
85
+ * **Two, because the interface uses two.** The SoHL icon legend says so in its
86
+ * own prose: Font Awesome for most things, and Game-Icons.net *"for the arms,
87
+ * gear, and condition glyphs that Font Awesome does not cover"* — eighteen of
88
+ * them, `ginf-broadsword` and its kin (#391).
89
+ *
90
+ * They differ in more than a class prefix, which is why this is a family rather
91
+ * than a naming convention:
92
+ *
93
+ * - Font Awesome has **weights**, so an entry names a `style` and a filled and
94
+ * hollow pair is one icon twice. Game-Icons has none, so a `style` on such an
95
+ * entry names something that does not exist.
96
+ * - They resolve to **different fonts**. A PDF has to embed both, and find each
97
+ * codepoint in its own table — Font Awesome's from the file it ships,
98
+ * Game-Icons' from the `game-icons-codepoints.json` the consumer's own
99
+ * `build-icon-font.mjs` writes.
100
+ *
101
+ * `class` is the prefix the web surfaces use. `styled` says whether a `style`
102
+ * belongs on the entry at all, which is what lets a mistake be reported rather
103
+ * than rendered as a class nobody defined.
104
+ *
105
+ * @type {Readonly<Record<string, {class: string, styled: boolean, describe: string}>>}
106
+ */
107
+ export const ICON_FAMILIES = Object.freeze({
108
+ fontawesome: {
109
+ class: "fa",
110
+ styled: true,
111
+ describe: "Font Awesome Free",
112
+ },
113
+ "game-icons": {
114
+ class: "ginf",
115
+ styled: false,
116
+ describe: "the Game-Icons.net webfont a package builds for itself",
117
+ },
118
+ });
119
+
120
+ /** The family an entry that does not name one belongs to. */
121
+ export const DEFAULT_ICON_FAMILY = "fontawesome";
122
+
123
+ /**
124
+ * The family an entry draws from, named or defaulted.
125
+ *
126
+ * @param {{family?: string}} entry - A registry entry.
127
+ * @returns {string} The family name.
128
+ */
129
+ export function familyOf(entry) {
130
+ return entry?.family ?? DEFAULT_ICON_FAMILY;
131
+ }
132
+
133
+ /**
134
+ * The sizes a note may ask for, and what each means on a page.
135
+ *
136
+ * **A closed set, because size is content here rather than styling.** The icon
137
+ * legend exists to let a reader tell one glyph from another, and several of
138
+ * them genuinely cannot be told apart at the size of running text — so the
139
+ * enlargement is part of what that page *says*, not decoration applied to it.
140
+ * That is the one case worth a size at all.
141
+ *
142
+ * Closed rather than free-form for the usual reason: `font-size: 2em` in a note
143
+ * is CSS, which reaches two of the three surfaces and means nothing to the
144
+ * third. A name means the same thing everywhere, and the multiples below are
145
+ * Font Awesome's own, so the web class and the PDF scale cannot drift.
146
+ *
147
+ * @type {Readonly<Record<string, {class: string, scale: number}>>}
148
+ */
149
+ export const ICON_SIZES = Object.freeze({
150
+ lg: { class: "fa-lg", scale: 1.25 },
151
+ xl: { class: "fa-xl", scale: 1.5 },
152
+ "2x": { class: "fa-2x", scale: 2 },
153
+ "3x": { class: "fa-3x", scale: 3 },
154
+ });
155
+
156
+ /**
157
+ * The attribute names a note may write, and how each is validated.
158
+ *
159
+ * One entry today. It is a table rather than an `if (key === "size")` because
160
+ * the next attribute — a fixed-width flag, a rotation, a title override — should
161
+ * cost a line here and nothing else, and because an unknown key has to be
162
+ * *reported*: silently ignoring `{sixe: 2x}` would leave the author looking at a
163
+ * page that is not what they asked for, with nothing to say why.
164
+ *
165
+ * @type {Readonly<Record<string, {values: readonly string[], describe: string}>>}
166
+ */
167
+ export const ICON_ATTRIBUTES = Object.freeze({
168
+ size: {
169
+ values: Object.freeze(Object.keys(ICON_SIZES)),
170
+ describe: "how much larger than running text to draw the icon",
171
+ },
172
+ });
173
+
174
+ /**
175
+ * Read the brace of an icon token.
176
+ *
177
+ * Values are validated against {@link ICON_ATTRIBUTES} here rather than at the
178
+ * point of rendering, so a mistake is one finding with a position rather than a
179
+ * silently different page.
180
+ *
181
+ * @param {string} [raw] - The text between the braces, without them.
182
+ * @returns {{attrs: Record<string, string>, problems: string[]}} What was
183
+ * written, and what cannot be honoured.
184
+ */
185
+ export function parseIconAttributes(raw) {
186
+ /** @type {Record<string, string>} */
187
+ const attrs = {};
188
+ const problems = [];
189
+ if (!raw || !raw.trim()) return { attrs, problems };
190
+
191
+ // Split on commas, not whitespace: `size: 2x` is one pair with a space in
192
+ // it, and the space after the colon is the whole point of the spelling.
193
+ for (const part of raw
194
+ .split(",")
195
+ .map((s) => s.trim())
196
+ .filter(Boolean)) {
197
+ const colon = part.indexOf(":");
198
+ if (colon === -1) {
199
+ problems.push(
200
+ `\`${part}\` is not a \`key: value\` attribute — an icon takes ` +
201
+ `${Object.keys(ICON_ATTRIBUTES).join(", ")}`,
202
+ );
203
+ continue;
204
+ }
205
+ const key = part.slice(0, colon).trim();
206
+ const value = part.slice(colon + 1).trim();
207
+ const spec =
208
+ Object.prototype.hasOwnProperty.call(ICON_ATTRIBUTES, key) ?
209
+ ICON_ATTRIBUTES[key]
210
+ : undefined;
211
+ if (!spec) {
212
+ problems.push(
213
+ `\`${key}\` is not an icon attribute — the ones there are: ` +
214
+ `${Object.keys(ICON_ATTRIBUTES).join(", ")}`,
215
+ );
216
+ continue;
217
+ }
218
+ if (!spec.values.includes(value)) {
219
+ problems.push(
220
+ `\`${key}: ${value}\` is not one of ${spec.values.join(", ")} — ` +
221
+ `${key} says ${spec.describe}`,
222
+ );
223
+ continue;
224
+ }
225
+ attrs[key] = value;
226
+ }
227
+ return { attrs, problems };
228
+ }
229
+
76
230
  /**
77
231
  * The icons the user guide already depicts, under the names it should call them.
78
232
  *
79
- * Each entry was read off the interface it describes rather than invented: the
80
- * `star`/`star-outline` pair is the filled and hollow star the mastery row and
81
- * the improve flag draw, and `edit` is the pencil the formula editor opens
82
- * from. The names are what a *writer* would reach for — `delete`, not
83
- * `trash-can` because the writer is the one typing them; the Font Awesome
84
- * spelling is this table's business, not theirs.
233
+ * Each entry is read off the interface it describes rather than invented, and
234
+ * that is meant literally — the table was checked against the system's own
235
+ * templates, which is how `delete` came to be `fa-trash` rather than the
236
+ * `fa-trash-can` first written here. The sheets draw `fa-trash` twenty-five
237
+ * times and `fa-trash-can` never, so the first spelling would have printed an
238
+ * icon the reader has never seen on screen. A registry that is not checked
239
+ * against the interface is just a second place to be wrong.
240
+ *
241
+ * The names are what a *writer* would reach for — `delete`, not `trash` —
242
+ * because the writer is the one typing them; the Font Awesome spelling is this
243
+ * table's business, not theirs.
244
+ *
245
+ * **Three entries share `xmark`, and that is the point of naming rather than
246
+ * drawing.** A `✕` in the guide means "not applicable" in a Healing Rate
247
+ * column, "remove this row" on a control, and "close" on a dialog's corner. One
248
+ * glyph, three sentences, three different things for a reader who cannot see
249
+ * it — so they are three names with three labels, and the fact that Font
250
+ * Awesome happens to draw them identically stays in this table.
251
+ *
252
+ * `run` and `expand` are likewise distinct: `▶` runs an action, and its label
253
+ * should say so. `fa-play` is what the sheets use for it.
85
254
  *
86
255
  * @type {Readonly<Record<string, {style: string, icon: string, label: string}>>}
87
256
  */
88
257
  export const DEFAULT_ICONS = Object.freeze({
258
+ affiliation: { style: "solid", icon: "certificate", label: "affiliation" },
89
259
  star: { style: "solid", icon: "star", label: "star" },
90
260
  "star-outline": { style: "regular", icon: "star", label: "hollow star" },
91
- diamond: { style: "solid", icon: "diamond", label: "diamond" },
261
+ // The Success Value scale. `fa-diamond` is Font Awesome's playing-card
262
+ // suit and ships in solid only, so it can spell no hollow half of a
263
+ // filled/hollow pair; `fa-gem` is a gemstone, has both weights, and is what
264
+ // a quality scale actually means. `diamond` stays as an alias of it so a
265
+ // note that already says `:icon-diamond:` keeps working.
266
+ gem: { style: "solid", icon: "gem", label: "value gem" },
267
+ "gem-outline": { style: "regular", icon: "gem", label: "unearned value gem" },
268
+ diamond: { style: "solid", icon: "gem", label: "value gem" },
92
269
  edit: { style: "solid", icon: "pen-to-square", label: "edit" },
93
- delete: { style: "solid", icon: "trash-can", label: "delete" },
270
+ delete: { style: "solid", icon: "trash", label: "delete" },
94
271
  add: { style: "solid", icon: "plus", label: "add" },
95
272
  remove: { style: "solid", icon: "xmark", label: "remove" },
96
273
  "not-applicable": { style: "solid", icon: "xmark", label: "not applicable" },
274
+ close: { style: "solid", icon: "xmark", label: "close" },
97
275
  menu: { style: "solid", icon: "ellipsis-vertical", label: "actions menu" },
276
+ run: { style: "solid", icon: "play", label: "run this action" },
98
277
  expand: { style: "solid", icon: "caret-right", label: "expand" },
99
278
  shield: { style: "solid", icon: "shield-halved", label: "armour" },
100
279
  compass: { style: "solid", icon: "compass", label: "guided tour" },
@@ -113,9 +292,24 @@ export const DEFAULT_ICONS = Object.freeze({
113
292
  * Not `:name[content]`. That is remark-directive syntax, and this toolchain
114
293
  * parses with markdown-it; a directive would render as its own literal text.
115
294
  *
295
+ * An optional trailing brace carries **attributes**:
296
+ *
297
+ * :icon-affiliation:{size: 2x}
298
+ *
299
+ * `key: value` pairs, comma-separated, in the shape `markdown-it-attrs` and
300
+ * remark-directive already use — so it is a convention a reader may recognise
301
+ * rather than one this module invented. Attributes rather than a bare value
302
+ * because `size` is merely the first one anybody needed: a fixed-width flag, a
303
+ * rotation, a title override are the same shape of thing, and a syntax that
304
+ * could only ever express size would have to be replaced to gain any of them.
305
+ *
306
+ * One inline rule consumes the token **and** its brace, so there is no state in
307
+ * which the icon resolves and the brace is left stranded on the page. An
308
+ * unhandled token degrades whole, exactly as the bare form does.
309
+ *
116
310
  * @type {RegExp}
117
311
  */
118
- export const ICON_PATTERN = /:icon-([a-z0-9]+(?:-[a-z0-9]+)*):/g;
312
+ export const ICON_PATTERN = /:icon-([a-z0-9]+(?:-[a-z0-9]+)*):(?:\{([^}]*)\})?/g;
119
313
 
120
314
  /**
121
315
  * Look one name up.
@@ -144,11 +338,24 @@ const attr = (value) =>
144
338
  * @param {{style: string, icon: string, label: string}} entry - A registry entry.
145
339
  * @returns {string} An `<i>` element.
146
340
  */
147
- export function iconHtml(entry) {
148
- return (
149
- `<i class="fa-${attr(entry.style)} fa-${attr(entry.icon)}" ` +
150
- `role="img" aria-label="${attr(entry.label)}"></i>`
151
- );
341
+ export function iconHtml(entry, attrs = {}) {
342
+ const family = ICON_FAMILIES[familyOf(entry)] ?? ICON_FAMILIES[DEFAULT_ICON_FAMILY];
343
+ const prefix = family.class;
344
+
345
+ // A styled family spells the weight and the name as two classes; an
346
+ // unstyled one has a single class and no weight to spell.
347
+ const classes =
348
+ family.styled ?
349
+ [`${prefix}-${attr(entry.style)}`, `${prefix}-${attr(entry.icon)}`]
350
+ : [`${prefix}-${attr(entry.icon)}`];
351
+
352
+ // The size classes are Font Awesome's, and the Game-Icons stylesheet this
353
+ // toolchain's consumers generate mirrors its box metrics deliberately, so
354
+ // they apply to both families.
355
+ const sized = attrs.size ? ICON_SIZES[attrs.size] : undefined;
356
+ if (sized) classes.push(sized.class);
357
+
358
+ return `<i class="${classes.join(" ")}" role="img" aria-label="${attr(entry.label)}"></i>`;
152
359
  }
153
360
 
154
361
  /**
@@ -160,7 +367,8 @@ export function iconHtml(entry) {
160
367
  export function iconsIn(text) {
161
368
  const out = [];
162
369
  for (const m of text.matchAll(ICON_PATTERN)) {
163
- out.push({ name: m[1], index: m.index ?? 0, raw: m[0] });
370
+ const { attrs, problems } = parseIconAttributes(m[2]);
371
+ out.push({ name: m[1], index: m.index ?? 0, raw: m[0], attrs, problems });
164
372
  }
165
373
  return out;
166
374
  }
@@ -176,28 +384,38 @@ export function iconsIn(text) {
176
384
  * @param {string} file - Path to report.
177
385
  * @param {Record<string, object>} [registry] - Defaults to {@link DEFAULT_ICONS}.
178
386
  * @returns {Array<{file: string, line: number, column: number,
179
- * severity: "error", message: string}>} The unknown names.
387
+ * severity: "warning", message: string}>} The unknown names.
180
388
  */
181
389
  export function lintIcons(text, file, registry = DEFAULT_ICONS) {
182
390
  const findings = [];
183
- for (const { name, index, raw } of iconsIn(text)) {
184
- if (resolveIcon(name, registry)) continue;
391
+ for (const { name, index, raw, problems } of iconsIn(text)) {
185
392
  const before = text.slice(0, index);
186
393
  const line = before.split("\n").length;
187
394
  const column = index - (before.lastIndexOf("\n") + 1) + 1;
188
- // Nearest declared name, when there is an obvious one: a typo is the
189
- // common case and the registry is short enough to say what was meant.
190
- const suggestion = nearestName(name, Object.keys(registry));
191
- findings.push({
192
- file,
193
- line,
194
- column,
195
- severity: /** @type {const} */ ("error"),
196
- message:
197
- `\`${raw}\` names an icon the registry does not declare` +
198
- (suggestion ? `; did you mean \`:icon-${suggestion}:\`?` : "") +
199
- ` — an undeclared name renders as its own literal text`,
200
- });
395
+ const at = { file, line, column, severity: /** @type {const} */ ("warning") };
396
+
397
+ if (!resolveIcon(name, registry)) {
398
+ // Nearest declared name, when there is an obvious one: a typo is the
399
+ // common case and the registry is short enough to say what was meant.
400
+ const suggestion = nearestName(name, Object.keys(registry));
401
+ findings.push({
402
+ ...at,
403
+ message:
404
+ `\`${raw}\` names an icon the registry does not declare` +
405
+ (suggestion ? `; did you mean \`:icon-${suggestion}:\`?` : "") +
406
+ ` — an undeclared name renders as its own literal text`,
407
+ });
408
+ // The name is the bigger fault; reporting its attributes as well
409
+ // would be two findings for one token the author has to rewrite.
410
+ continue;
411
+ }
412
+
413
+ // A bad attribute on a good name is its own finding: the icon renders,
414
+ // and renders differently from what was asked for, which is the case
415
+ // nobody notices without being told.
416
+ for (const problem of problems) {
417
+ findings.push({ ...at, message: `\`${raw}\`: ${problem}` });
418
+ }
201
419
  }
202
420
  return findings;
203
421
  }
@@ -250,7 +468,7 @@ function editDistance(a, b) {
250
468
  *
251
469
  * @param {Record<string, object>} registry - A package's icon table.
252
470
  * @param {string} [where="icons"] - Where to say the fault is.
253
- * @returns {Array<{severity: "error", message: string}>} What is wrong with it.
471
+ * @returns {Array<{severity: "warning", message: string}>} What is wrong with it.
254
472
  */
255
473
  export function checkIconRegistry(registry, where = "icons") {
256
474
  const findings = [];
@@ -258,29 +476,54 @@ export function checkIconRegistry(registry, where = "icons") {
258
476
  const at = `\`${where}.${name}\``;
259
477
  if (!entry || typeof entry !== "object") {
260
478
  findings.push({
261
- severity: /** @type {const} */ ("error"),
479
+ severity: /** @type {const} */ ("warning"),
262
480
  message: `${at} is not an icon entry — it takes \`style\`, \`icon\` and \`label\``,
263
481
  });
264
482
  continue;
265
483
  }
266
- if (!ICON_STYLES.includes(entry.style)) {
484
+ const familyName = familyOf(entry);
485
+ const family = ICON_FAMILIES[familyName];
486
+ if (!family) {
267
487
  findings.push({
268
- severity: /** @type {const} */ ("error"),
488
+ severity: /** @type {const} */ ("warning"),
489
+ message:
490
+ `${at} names family \`${familyName}\`, and the families there ` +
491
+ `are: ${Object.keys(ICON_FAMILIES).join(", ")}`,
492
+ });
493
+ continue;
494
+ }
495
+
496
+ if (family.styled && !ICON_STYLES.includes(entry.style)) {
497
+ findings.push({
498
+ severity: /** @type {const} */ ("warning"),
269
499
  message:
270
500
  `${at} names style \`${entry.style}\`, and Font Awesome Free ships ` +
271
501
  `only ${ICON_STYLES.join(", ")} — a glyph in any other style is ` +
272
502
  `absent from the font a book would embed`,
273
503
  });
274
504
  }
505
+
506
+ // A style on an unstyled family is not a harmless extra key: it says
507
+ // the author expected a weight, and the family has none, so what they
508
+ // get is not what they asked for.
509
+ if (!family.styled && entry.style !== undefined) {
510
+ findings.push({
511
+ severity: /** @type {const} */ ("warning"),
512
+ message:
513
+ `${at} names style \`${entry.style}\`, and ${family.describe} has ` +
514
+ `no weights — the style is ignored, so a filled and hollow pair ` +
515
+ `cannot be spelled this way`,
516
+ });
517
+ }
275
518
  if (typeof entry.icon !== "string" || !entry.icon) {
276
519
  findings.push({
277
- severity: /** @type {const} */ ("error"),
520
+ severity: /** @type {const} */ ("warning"),
278
521
  message: `${at} declares no \`icon\`, so nothing names the glyph to draw`,
279
522
  });
280
523
  }
281
524
  if (typeof entry.label !== "string" || !entry.label) {
282
525
  findings.push({
283
- severity: /** @type {const} */ ("error"),
526
+ severity: /** @type {const} */ ("warning"),
284
527
  message:
285
528
  `${at} declares no \`label\`, and an icon with no accessible name ` +
286
529
  `is read aloud as a gap in the sentence`,
@@ -302,7 +545,7 @@ export function checkIconRegistry(registry, where = "icons") {
302
545
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
303
546
  * @param {Record<string, object>} [opts.registry] - The package's icon table.
304
547
  * @returns {{findings: Array<{file: string, line: number, column: number,
305
- * severity: "error", message: string}>, files: number}} What it found.
548
+ * severity: "warning", message: string}>, files: number}} What it found.
306
549
  */
307
550
  export function lintContentIcons(contentBase, { skipDirectories = [], registry } = {}) {
308
551
  const skip = new Set(skipDirectories);
@@ -355,7 +598,7 @@ export function iconPlugin(registry = DEFAULT_ICONS) {
355
598
  return (md) => {
356
599
  /** @type {any} */ (md).inline.ruler.before("emphasis", "heroiclands_icon", iconRule);
357
600
  /** @type {any} */ (md).renderer.rules.heroiclands_icon = (tokens, idx) =>
358
- iconHtml(tokens[idx].meta.entry);
601
+ iconHtml(tokens[idx].meta.entry, tokens[idx].meta.attrs);
359
602
 
360
603
  /**
361
604
  * @param {any} state - markdown-it inline state.
@@ -367,7 +610,7 @@ export function iconPlugin(registry = DEFAULT_ICONS) {
367
610
  if (state.src.charCodeAt(start) !== 0x3a /* : */) return false;
368
611
  // Anchored at the cursor, so the scan is O(token) rather than a
369
612
  // search of the remaining source at every colon in the paragraph.
370
- const re = /^:icon-([a-z0-9]+(?:-[a-z0-9]+)*):/;
613
+ const re = /^:icon-([a-z0-9]+(?:-[a-z0-9]+)*):(?:\{([^}]*)\})?/;
371
614
  const m = re.exec(state.src.slice(start));
372
615
  if (!m) return false;
373
616
 
@@ -376,9 +619,14 @@ export function iconPlugin(registry = DEFAULT_ICONS) {
376
619
  // unrecognised name visible on the page instead of vanishing.
377
620
  if (!entry) return false;
378
621
 
622
+ // An attribute that cannot be honoured is reported by the lint, not
623
+ // enforced here: refusing to render would hide a good icon over a
624
+ // bad size, and the page is the place the author is looking.
625
+ const { attrs } = parseIconAttributes(m[2]);
626
+
379
627
  if (!silent) {
380
628
  const token = state.push("heroiclands_icon", "", 0);
381
- token.meta = { name: m[1], entry };
629
+ token.meta = { name: m[1], entry, attrs };
382
630
  token.markup = m[0];
383
631
  }
384
632
  state.pos += m[0].length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "20.2.1",
3
+ "version": "20.3.1",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -61,13 +61,13 @@ export function decomposedRuns(text: string): Array<{
61
61
  * @param {string} text - The file's contents.
62
62
  * @param {string} file - Path to report, relative to the tree.
63
63
  * @returns {Array<{file: string, line: number, column: number,
64
- * severity: "error", message: string}>} What is wrong, in file order.
64
+ * severity: "warning", message: string}>} What is wrong, in file order.
65
65
  */
66
66
  export function checkText(text: string, file: string): Array<{
67
67
  file: string;
68
68
  line: number;
69
69
  column: number;
70
- severity: "error";
70
+ severity: "warning";
71
71
  message: string;
72
72
  }>;
73
73
  /**
@@ -84,7 +84,7 @@ export function checkText(text: string, file: string): Array<{
84
84
  * in addition to the dot-directories always skipped.
85
85
  * @param {readonly string[]} [opts.extensions] - File extensions to read.
86
86
  * @returns {{findings: Array<{file: string, line: number, column: number,
87
- * severity: "error", message: string}>, files: number}} The findings, and how
87
+ * severity: "warning", message: string}>, files: number}} The findings, and how
88
88
  * many files produced them.
89
89
  */
90
90
  export function lintContentCharset(contentBase: string, { skipDirectories, extensions }?: {
@@ -95,7 +95,7 @@ export function lintContentCharset(contentBase: string, { skipDirectories, exten
95
95
  file: string;
96
96
  line: number;
97
97
  column: number;
98
- severity: "error";
98
+ severity: "warning";
99
99
  message: string;
100
100
  }>;
101
101
  files: number;
@@ -1,3 +1,27 @@
1
+ /**
2
+ * The family an entry draws from, named or defaulted.
3
+ *
4
+ * @param {{family?: string}} entry - A registry entry.
5
+ * @returns {string} The family name.
6
+ */
7
+ export function familyOf(entry: {
8
+ family?: string;
9
+ }): string;
10
+ /**
11
+ * Read the brace of an icon token.
12
+ *
13
+ * Values are validated against {@link ICON_ATTRIBUTES} here rather than at the
14
+ * point of rendering, so a mistake is one finding with a position rather than a
15
+ * silently different page.
16
+ *
17
+ * @param {string} [raw] - The text between the braces, without them.
18
+ * @returns {{attrs: Record<string, string>, problems: string[]}} What was
19
+ * written, and what cannot be honoured.
20
+ */
21
+ export function parseIconAttributes(raw?: string): {
22
+ attrs: Record<string, string>;
23
+ problems: string[];
24
+ };
1
25
  /**
2
26
  * Look one name up.
3
27
  *
@@ -26,7 +50,7 @@ export function iconHtml(entry: {
26
50
  style: string;
27
51
  icon: string;
28
52
  label: string;
29
- }): string;
53
+ }, attrs?: {}): string;
30
54
  /**
31
55
  * Every icon a string names, in the order written.
32
56
  *
@@ -49,13 +73,13 @@ export function iconsIn(text: string): Array<{
49
73
  * @param {string} file - Path to report.
50
74
  * @param {Record<string, object>} [registry] - Defaults to {@link DEFAULT_ICONS}.
51
75
  * @returns {Array<{file: string, line: number, column: number,
52
- * severity: "error", message: string}>} The unknown names.
76
+ * severity: "warning", message: string}>} The unknown names.
53
77
  */
54
78
  export function lintIcons(text: string, file: string, registry?: Record<string, object>): Array<{
55
79
  file: string;
56
80
  line: number;
57
81
  column: number;
58
- severity: "error";
82
+ severity: "warning";
59
83
  message: string;
60
84
  }>;
61
85
  /**
@@ -63,10 +87,10 @@ export function lintIcons(text: string, file: string, registry?: Record<string,
63
87
  *
64
88
  * @param {Record<string, object>} registry - A package's icon table.
65
89
  * @param {string} [where="icons"] - Where to say the fault is.
66
- * @returns {Array<{severity: "error", message: string}>} What is wrong with it.
90
+ * @returns {Array<{severity: "warning", message: string}>} What is wrong with it.
67
91
  */
68
92
  export function checkIconRegistry(registry: Record<string, object>, where?: string): Array<{
69
- severity: "error";
93
+ severity: "warning";
70
94
  message: string;
71
95
  }>;
72
96
  /**
@@ -81,7 +105,7 @@ export function checkIconRegistry(registry: Record<string, object>, where?: stri
81
105
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
82
106
  * @param {Record<string, object>} [opts.registry] - The package's icon table.
83
107
  * @returns {{findings: Array<{file: string, line: number, column: number,
84
- * severity: "error", message: string}>, files: number}} What it found.
108
+ * severity: "warning", message: string}>, files: number}} What it found.
85
109
  */
86
110
  export function lintContentIcons(contentBase: string, { skipDirectories, registry }?: {
87
111
  skipDirectories?: readonly string[] | undefined;
@@ -91,7 +115,7 @@ export function lintContentIcons(contentBase: string, { skipDirectories, registr
91
115
  file: string;
92
116
  line: number;
93
117
  column: number;
94
- severity: "error";
118
+ severity: "warning";
95
119
  message: string;
96
120
  }>;
97
121
  files: number;
@@ -117,15 +141,97 @@ export function iconPlugin(registry?: Record<string, object>): (md: object) => v
117
141
  * @type {readonly string[]}
118
142
  */
119
143
  export const ICON_STYLES: readonly string[];
144
+ /**
145
+ * The icon families a registry entry may draw from.
146
+ *
147
+ * **Two, because the interface uses two.** The SoHL icon legend says so in its
148
+ * own prose: Font Awesome for most things, and Game-Icons.net *"for the arms,
149
+ * gear, and condition glyphs that Font Awesome does not cover"* — eighteen of
150
+ * them, `ginf-broadsword` and its kin (#391).
151
+ *
152
+ * They differ in more than a class prefix, which is why this is a family rather
153
+ * than a naming convention:
154
+ *
155
+ * - Font Awesome has **weights**, so an entry names a `style` and a filled and
156
+ * hollow pair is one icon twice. Game-Icons has none, so a `style` on such an
157
+ * entry names something that does not exist.
158
+ * - They resolve to **different fonts**. A PDF has to embed both, and find each
159
+ * codepoint in its own table — Font Awesome's from the file it ships,
160
+ * Game-Icons' from the `game-icons-codepoints.json` the consumer's own
161
+ * `build-icon-font.mjs` writes.
162
+ *
163
+ * `class` is the prefix the web surfaces use. `styled` says whether a `style`
164
+ * belongs on the entry at all, which is what lets a mistake be reported rather
165
+ * than rendered as a class nobody defined.
166
+ *
167
+ * @type {Readonly<Record<string, {class: string, styled: boolean, describe: string}>>}
168
+ */
169
+ export const ICON_FAMILIES: Readonly<Record<string, {
170
+ class: string;
171
+ styled: boolean;
172
+ describe: string;
173
+ }>>;
174
+ /** The family an entry that does not name one belongs to. */
175
+ export const DEFAULT_ICON_FAMILY: "fontawesome";
176
+ /**
177
+ * The sizes a note may ask for, and what each means on a page.
178
+ *
179
+ * **A closed set, because size is content here rather than styling.** The icon
180
+ * legend exists to let a reader tell one glyph from another, and several of
181
+ * them genuinely cannot be told apart at the size of running text — so the
182
+ * enlargement is part of what that page *says*, not decoration applied to it.
183
+ * That is the one case worth a size at all.
184
+ *
185
+ * Closed rather than free-form for the usual reason: `font-size: 2em` in a note
186
+ * is CSS, which reaches two of the three surfaces and means nothing to the
187
+ * third. A name means the same thing everywhere, and the multiples below are
188
+ * Font Awesome's own, so the web class and the PDF scale cannot drift.
189
+ *
190
+ * @type {Readonly<Record<string, {class: string, scale: number}>>}
191
+ */
192
+ export const ICON_SIZES: Readonly<Record<string, {
193
+ class: string;
194
+ scale: number;
195
+ }>>;
196
+ /**
197
+ * The attribute names a note may write, and how each is validated.
198
+ *
199
+ * One entry today. It is a table rather than an `if (key === "size")` because
200
+ * the next attribute — a fixed-width flag, a rotation, a title override — should
201
+ * cost a line here and nothing else, and because an unknown key has to be
202
+ * *reported*: silently ignoring `{sixe: 2x}` would leave the author looking at a
203
+ * page that is not what they asked for, with nothing to say why.
204
+ *
205
+ * @type {Readonly<Record<string, {values: readonly string[], describe: string}>>}
206
+ */
207
+ export const ICON_ATTRIBUTES: Readonly<Record<string, {
208
+ values: readonly string[];
209
+ describe: string;
210
+ }>>;
120
211
  /**
121
212
  * The icons the user guide already depicts, under the names it should call them.
122
213
  *
123
- * Each entry was read off the interface it describes rather than invented: the
124
- * `star`/`star-outline` pair is the filled and hollow star the mastery row and
125
- * the improve flag draw, and `edit` is the pencil the formula editor opens
126
- * from. The names are what a *writer* would reach for — `delete`, not
127
- * `trash-can` because the writer is the one typing them; the Font Awesome
128
- * spelling is this table's business, not theirs.
214
+ * Each entry is read off the interface it describes rather than invented, and
215
+ * that is meant literally — the table was checked against the system's own
216
+ * templates, which is how `delete` came to be `fa-trash` rather than the
217
+ * `fa-trash-can` first written here. The sheets draw `fa-trash` twenty-five
218
+ * times and `fa-trash-can` never, so the first spelling would have printed an
219
+ * icon the reader has never seen on screen. A registry that is not checked
220
+ * against the interface is just a second place to be wrong.
221
+ *
222
+ * The names are what a *writer* would reach for — `delete`, not `trash` —
223
+ * because the writer is the one typing them; the Font Awesome spelling is this
224
+ * table's business, not theirs.
225
+ *
226
+ * **Three entries share `xmark`, and that is the point of naming rather than
227
+ * drawing.** A `✕` in the guide means "not applicable" in a Healing Rate
228
+ * column, "remove this row" on a control, and "close" on a dialog's corner. One
229
+ * glyph, three sentences, three different things for a reader who cannot see
230
+ * it — so they are three names with three labels, and the fact that Font
231
+ * Awesome happens to draw them identically stays in this table.
232
+ *
233
+ * `run` and `expand` are likewise distinct: `▶` runs an action, and its label
234
+ * should say so. `fa-play` is what the sheets use for it.
129
235
  *
130
236
  * @type {Readonly<Record<string, {style: string, icon: string, label: string}>>}
131
237
  */
@@ -146,6 +252,21 @@ export const DEFAULT_ICONS: Readonly<Record<string, {
146
252
  * Not `:name[content]`. That is remark-directive syntax, and this toolchain
147
253
  * parses with markdown-it; a directive would render as its own literal text.
148
254
  *
255
+ * An optional trailing brace carries **attributes**:
256
+ *
257
+ * :icon-affiliation:{size: 2x}
258
+ *
259
+ * `key: value` pairs, comma-separated, in the shape `markdown-it-attrs` and
260
+ * remark-directive already use — so it is a convention a reader may recognise
261
+ * rather than one this module invented. Attributes rather than a bare value
262
+ * because `size` is merely the first one anybody needed: a fixed-width flag, a
263
+ * rotation, a title override are the same shape of thing, and a syntax that
264
+ * could only ever express size would have to be replaced to gain any of them.
265
+ *
266
+ * One inline rule consumes the token **and** its brace, so there is no state in
267
+ * which the icon resolves and the brace is left stranded on the page. An
268
+ * unhandled token degrades whole, exactly as the bare form does.
269
+ *
149
270
  * @type {RegExp}
150
271
  */
151
272
  export const ICON_PATTERN: RegExp;