@heroiclands/package-build 20.2.0 → 20.3.0

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,81 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 20.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6be4a7e: An icon can now carry attributes, and `affiliation` joins the registry.
8
+
9
+ `:icon-affiliation:{size: 2x}` — `key: value` pairs in a trailing brace, the
10
+ shape `markdown-it-attrs` and remark-directive already use. Attributes rather
11
+ than a bare size because `size` is only the first one anybody needed: a
12
+ fixed-width flag, a rotation, a title override are the same shape of thing, and
13
+ a syntax that could only express size would have to be replaced to gain any of
14
+ them.
15
+
16
+ Size is a closed vocabulary — `lg`, `xl`, `2x`, `3x` — mapping to Font Awesome's
17
+ own classes on the web and the same multiples in a PDF. It exists because the
18
+ icon legend enlarges its glyphs so a reader can tell them apart, which makes the
19
+ size part of what that page says rather than styling applied to it. A free-form
20
+ `font-size` would have been CSS, which reaches two surfaces of three.
21
+
22
+ An attribute that cannot be honoured is reported rather than ignored, and the
23
+ icon still renders: hiding a good icon over a bad size would be the worse answer.
24
+
25
+ `affiliation` is `fa-certificate` — a charter under seal. It replaces a
26
+ `fa-duotone fa-handshake` that was reaching for Font Awesome Pro because the
27
+ interlocking hands washed out at small sizes; the fix for a silhouette problem
28
+ is a bolder silhouette, not a paid tier.
29
+ - e209110: Add `close` and `run` to the icon registry, and correct `delete`.
30
+
31
+ Converting the SoHL user guide turned up two controls with no name to convert
32
+ to. A `✕` there means three different things — "not applicable" in a Healing
33
+ Rate column, "remove" on a control, and **close** on a dialog's corner — and a
34
+ `▶` **runs** an action, where the nearest existing entry was `expand`, whose
35
+ accessible label would have told a screen reader the wrong thing. Both now have
36
+ their own name and their own label; that Font Awesome draws the three `xmark`
37
+ senses identically is this table's business rather than the reader's.
38
+
39
+ `delete` was written as `fa-trash-can` and is now `fa-trash`, which is what the
40
+ system's templates actually draw — twenty-five times, against no `fa-trash-can`
41
+ at all. A registry checked against the interface is the whole point of having
42
+ one; unchecked, it is just a second place to be wrong.
43
+
44
+ ### Patch Changes
45
+
46
+ - 1d8e289: The charset and icon checks now report warnings rather than errors, so neither
47
+ can fail a build.
48
+
49
+ Both emitted `severity: "error"`, and `reportFindings` fails a run on an error —
50
+ so adopting the charset check turned consumers' builds red for content that was
51
+ already correct. A character outside the charset does not make a note wrong: it
52
+ compiles to the same document and publishes the same page, and only a book that
53
+ does not exist yet cares. An undeclared icon name is the same shape of thing,
54
+ visible on the page as literal text.
55
+
56
+ The NFC rule is a warning too, despite having the best claim to being an error,
57
+ because a lint that fails a build for one of its rules and not the others is one
58
+ nobody can predict.
59
+
60
+ ## 20.2.1
61
+
62
+ ### Patch Changes
63
+
64
+ - f39c7e8: Restore the lockfile refresh to the release, and re-sync the lockfile 20.2.0
65
+ shipped without.
66
+
67
+ `changeset version` rewrites `package.json` and the CHANGELOG and never touches
68
+ `package-lock.json`, so the lockfile's root `version` keeps the previous
69
+ release's number. The `version-script` input is the only seam the changesets
70
+ action offers between versioning and committing, which is where the refresh has
71
+ to happen for it to land in the same commit.
72
+
73
+ That input was removed for one release on the theory that it was why `changeset
74
+ version` could not find its own binary. It was not: `node_modules` had been
75
+ committed as a symlink to an absolute path, and the action's `git reset --hard`
76
+ restored it over the install, so nothing resolved by any mechanism. With the
77
+ symlink gone the seam works again, and it is back.
78
+
3
79
  ## 20.2.0
4
80
 
5
81
  ### Minor 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,143 @@ import path from "node:path";
73
79
  */
74
80
  export const ICON_STYLES = Object.freeze(["solid", "regular", "brands"]);
75
81
 
82
+ /**
83
+ * The sizes a note may ask for, and what each means on a page.
84
+ *
85
+ * **A closed set, because size is content here rather than styling.** The icon
86
+ * legend exists to let a reader tell one glyph from another, and several of
87
+ * them genuinely cannot be told apart at the size of running text — so the
88
+ * enlargement is part of what that page *says*, not decoration applied to it.
89
+ * That is the one case worth a size at all.
90
+ *
91
+ * Closed rather than free-form for the usual reason: `font-size: 2em` in a note
92
+ * is CSS, which reaches two of the three surfaces and means nothing to the
93
+ * third. A name means the same thing everywhere, and the multiples below are
94
+ * Font Awesome's own, so the web class and the PDF scale cannot drift.
95
+ *
96
+ * @type {Readonly<Record<string, {class: string, scale: number}>>}
97
+ */
98
+ export const ICON_SIZES = Object.freeze({
99
+ lg: { class: "fa-lg", scale: 1.25 },
100
+ xl: { class: "fa-xl", scale: 1.5 },
101
+ "2x": { class: "fa-2x", scale: 2 },
102
+ "3x": { class: "fa-3x", scale: 3 },
103
+ });
104
+
105
+ /**
106
+ * The attribute names a note may write, and how each is validated.
107
+ *
108
+ * One entry today. It is a table rather than an `if (key === "size")` because
109
+ * the next attribute — a fixed-width flag, a rotation, a title override — should
110
+ * cost a line here and nothing else, and because an unknown key has to be
111
+ * *reported*: silently ignoring `{sixe: 2x}` would leave the author looking at a
112
+ * page that is not what they asked for, with nothing to say why.
113
+ *
114
+ * @type {Readonly<Record<string, {values: readonly string[], describe: string}>>}
115
+ */
116
+ export const ICON_ATTRIBUTES = Object.freeze({
117
+ size: {
118
+ values: Object.freeze(Object.keys(ICON_SIZES)),
119
+ describe: "how much larger than running text to draw the icon",
120
+ },
121
+ });
122
+
123
+ /**
124
+ * Read the brace of an icon token.
125
+ *
126
+ * Values are validated against {@link ICON_ATTRIBUTES} here rather than at the
127
+ * point of rendering, so a mistake is one finding with a position rather than a
128
+ * silently different page.
129
+ *
130
+ * @param {string} [raw] - The text between the braces, without them.
131
+ * @returns {{attrs: Record<string, string>, problems: string[]}} What was
132
+ * written, and what cannot be honoured.
133
+ */
134
+ export function parseIconAttributes(raw) {
135
+ /** @type {Record<string, string>} */
136
+ const attrs = {};
137
+ const problems = [];
138
+ if (!raw || !raw.trim()) return { attrs, problems };
139
+
140
+ // Split on commas, not whitespace: `size: 2x` is one pair with a space in
141
+ // it, and the space after the colon is the whole point of the spelling.
142
+ for (const part of raw
143
+ .split(",")
144
+ .map((s) => s.trim())
145
+ .filter(Boolean)) {
146
+ const colon = part.indexOf(":");
147
+ if (colon === -1) {
148
+ problems.push(
149
+ `\`${part}\` is not a \`key: value\` attribute — an icon takes ` +
150
+ `${Object.keys(ICON_ATTRIBUTES).join(", ")}`,
151
+ );
152
+ continue;
153
+ }
154
+ const key = part.slice(0, colon).trim();
155
+ const value = part.slice(colon + 1).trim();
156
+ const spec =
157
+ Object.prototype.hasOwnProperty.call(ICON_ATTRIBUTES, key) ?
158
+ ICON_ATTRIBUTES[key]
159
+ : undefined;
160
+ if (!spec) {
161
+ problems.push(
162
+ `\`${key}\` is not an icon attribute — the ones there are: ` +
163
+ `${Object.keys(ICON_ATTRIBUTES).join(", ")}`,
164
+ );
165
+ continue;
166
+ }
167
+ if (!spec.values.includes(value)) {
168
+ problems.push(
169
+ `\`${key}: ${value}\` is not one of ${spec.values.join(", ")} — ` +
170
+ `${key} says ${spec.describe}`,
171
+ );
172
+ continue;
173
+ }
174
+ attrs[key] = value;
175
+ }
176
+ return { attrs, problems };
177
+ }
178
+
76
179
  /**
77
180
  * The icons the user guide already depicts, under the names it should call them.
78
181
  *
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.
182
+ * Each entry is read off the interface it describes rather than invented, and
183
+ * that is meant literally — the table was checked against the system's own
184
+ * templates, which is how `delete` came to be `fa-trash` rather than the
185
+ * `fa-trash-can` first written here. The sheets draw `fa-trash` twenty-five
186
+ * times and `fa-trash-can` never, so the first spelling would have printed an
187
+ * icon the reader has never seen on screen. A registry that is not checked
188
+ * against the interface is just a second place to be wrong.
189
+ *
190
+ * The names are what a *writer* would reach for — `delete`, not `trash` —
191
+ * because the writer is the one typing them; the Font Awesome spelling is this
192
+ * table's business, not theirs.
193
+ *
194
+ * **Three entries share `xmark`, and that is the point of naming rather than
195
+ * drawing.** A `✕` in the guide means "not applicable" in a Healing Rate
196
+ * column, "remove this row" on a control, and "close" on a dialog's corner. One
197
+ * glyph, three sentences, three different things for a reader who cannot see
198
+ * it — so they are three names with three labels, and the fact that Font
199
+ * Awesome happens to draw them identically stays in this table.
200
+ *
201
+ * `run` and `expand` are likewise distinct: `▶` runs an action, and its label
202
+ * should say so. `fa-play` is what the sheets use for it.
85
203
  *
86
204
  * @type {Readonly<Record<string, {style: string, icon: string, label: string}>>}
87
205
  */
88
206
  export const DEFAULT_ICONS = Object.freeze({
207
+ affiliation: { style: "solid", icon: "certificate", label: "affiliation" },
89
208
  star: { style: "solid", icon: "star", label: "star" },
90
209
  "star-outline": { style: "regular", icon: "star", label: "hollow star" },
91
210
  diamond: { style: "solid", icon: "diamond", label: "diamond" },
92
211
  edit: { style: "solid", icon: "pen-to-square", label: "edit" },
93
- delete: { style: "solid", icon: "trash-can", label: "delete" },
212
+ delete: { style: "solid", icon: "trash", label: "delete" },
94
213
  add: { style: "solid", icon: "plus", label: "add" },
95
214
  remove: { style: "solid", icon: "xmark", label: "remove" },
96
215
  "not-applicable": { style: "solid", icon: "xmark", label: "not applicable" },
216
+ close: { style: "solid", icon: "xmark", label: "close" },
97
217
  menu: { style: "solid", icon: "ellipsis-vertical", label: "actions menu" },
218
+ run: { style: "solid", icon: "play", label: "run this action" },
98
219
  expand: { style: "solid", icon: "caret-right", label: "expand" },
99
220
  shield: { style: "solid", icon: "shield-halved", label: "armour" },
100
221
  compass: { style: "solid", icon: "compass", label: "guided tour" },
@@ -113,9 +234,24 @@ export const DEFAULT_ICONS = Object.freeze({
113
234
  * Not `:name[content]`. That is remark-directive syntax, and this toolchain
114
235
  * parses with markdown-it; a directive would render as its own literal text.
115
236
  *
237
+ * An optional trailing brace carries **attributes**:
238
+ *
239
+ * :icon-affiliation:{size: 2x}
240
+ *
241
+ * `key: value` pairs, comma-separated, in the shape `markdown-it-attrs` and
242
+ * remark-directive already use — so it is a convention a reader may recognise
243
+ * rather than one this module invented. Attributes rather than a bare value
244
+ * because `size` is merely the first one anybody needed: a fixed-width flag, a
245
+ * rotation, a title override are the same shape of thing, and a syntax that
246
+ * could only ever express size would have to be replaced to gain any of them.
247
+ *
248
+ * One inline rule consumes the token **and** its brace, so there is no state in
249
+ * which the icon resolves and the brace is left stranded on the page. An
250
+ * unhandled token degrades whole, exactly as the bare form does.
251
+ *
116
252
  * @type {RegExp}
117
253
  */
118
- export const ICON_PATTERN = /:icon-([a-z0-9]+(?:-[a-z0-9]+)*):/g;
254
+ export const ICON_PATTERN = /:icon-([a-z0-9]+(?:-[a-z0-9]+)*):(?:\{([^}]*)\})?/g;
119
255
 
120
256
  /**
121
257
  * Look one name up.
@@ -144,11 +280,11 @@ const attr = (value) =>
144
280
  * @param {{style: string, icon: string, label: string}} entry - A registry entry.
145
281
  * @returns {string} An `<i>` element.
146
282
  */
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
- );
283
+ export function iconHtml(entry, attrs = {}) {
284
+ const classes = [`fa-${attr(entry.style)}`, `fa-${attr(entry.icon)}`];
285
+ const sized = attrs.size ? ICON_SIZES[attrs.size] : undefined;
286
+ if (sized) classes.push(sized.class);
287
+ return `<i class="${classes.join(" ")}" role="img" aria-label="${attr(entry.label)}"></i>`;
152
288
  }
153
289
 
154
290
  /**
@@ -160,7 +296,8 @@ export function iconHtml(entry) {
160
296
  export function iconsIn(text) {
161
297
  const out = [];
162
298
  for (const m of text.matchAll(ICON_PATTERN)) {
163
- out.push({ name: m[1], index: m.index ?? 0, raw: m[0] });
299
+ const { attrs, problems } = parseIconAttributes(m[2]);
300
+ out.push({ name: m[1], index: m.index ?? 0, raw: m[0], attrs, problems });
164
301
  }
165
302
  return out;
166
303
  }
@@ -176,28 +313,38 @@ export function iconsIn(text) {
176
313
  * @param {string} file - Path to report.
177
314
  * @param {Record<string, object>} [registry] - Defaults to {@link DEFAULT_ICONS}.
178
315
  * @returns {Array<{file: string, line: number, column: number,
179
- * severity: "error", message: string}>} The unknown names.
316
+ * severity: "warning", message: string}>} The unknown names.
180
317
  */
181
318
  export function lintIcons(text, file, registry = DEFAULT_ICONS) {
182
319
  const findings = [];
183
- for (const { name, index, raw } of iconsIn(text)) {
184
- if (resolveIcon(name, registry)) continue;
320
+ for (const { name, index, raw, problems } of iconsIn(text)) {
185
321
  const before = text.slice(0, index);
186
322
  const line = before.split("\n").length;
187
323
  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
- });
324
+ const at = { file, line, column, severity: /** @type {const} */ ("warning") };
325
+
326
+ if (!resolveIcon(name, registry)) {
327
+ // Nearest declared name, when there is an obvious one: a typo is the
328
+ // common case and the registry is short enough to say what was meant.
329
+ const suggestion = nearestName(name, Object.keys(registry));
330
+ findings.push({
331
+ ...at,
332
+ message:
333
+ `\`${raw}\` names an icon the registry does not declare` +
334
+ (suggestion ? `; did you mean \`:icon-${suggestion}:\`?` : "") +
335
+ ` — an undeclared name renders as its own literal text`,
336
+ });
337
+ // The name is the bigger fault; reporting its attributes as well
338
+ // would be two findings for one token the author has to rewrite.
339
+ continue;
340
+ }
341
+
342
+ // A bad attribute on a good name is its own finding: the icon renders,
343
+ // and renders differently from what was asked for, which is the case
344
+ // nobody notices without being told.
345
+ for (const problem of problems) {
346
+ findings.push({ ...at, message: `\`${raw}\`: ${problem}` });
347
+ }
201
348
  }
202
349
  return findings;
203
350
  }
@@ -250,7 +397,7 @@ function editDistance(a, b) {
250
397
  *
251
398
  * @param {Record<string, object>} registry - A package's icon table.
252
399
  * @param {string} [where="icons"] - Where to say the fault is.
253
- * @returns {Array<{severity: "error", message: string}>} What is wrong with it.
400
+ * @returns {Array<{severity: "warning", message: string}>} What is wrong with it.
254
401
  */
255
402
  export function checkIconRegistry(registry, where = "icons") {
256
403
  const findings = [];
@@ -258,14 +405,14 @@ export function checkIconRegistry(registry, where = "icons") {
258
405
  const at = `\`${where}.${name}\``;
259
406
  if (!entry || typeof entry !== "object") {
260
407
  findings.push({
261
- severity: /** @type {const} */ ("error"),
408
+ severity: /** @type {const} */ ("warning"),
262
409
  message: `${at} is not an icon entry — it takes \`style\`, \`icon\` and \`label\``,
263
410
  });
264
411
  continue;
265
412
  }
266
413
  if (!ICON_STYLES.includes(entry.style)) {
267
414
  findings.push({
268
- severity: /** @type {const} */ ("error"),
415
+ severity: /** @type {const} */ ("warning"),
269
416
  message:
270
417
  `${at} names style \`${entry.style}\`, and Font Awesome Free ships ` +
271
418
  `only ${ICON_STYLES.join(", ")} — a glyph in any other style is ` +
@@ -274,13 +421,13 @@ export function checkIconRegistry(registry, where = "icons") {
274
421
  }
275
422
  if (typeof entry.icon !== "string" || !entry.icon) {
276
423
  findings.push({
277
- severity: /** @type {const} */ ("error"),
424
+ severity: /** @type {const} */ ("warning"),
278
425
  message: `${at} declares no \`icon\`, so nothing names the glyph to draw`,
279
426
  });
280
427
  }
281
428
  if (typeof entry.label !== "string" || !entry.label) {
282
429
  findings.push({
283
- severity: /** @type {const} */ ("error"),
430
+ severity: /** @type {const} */ ("warning"),
284
431
  message:
285
432
  `${at} declares no \`label\`, and an icon with no accessible name ` +
286
433
  `is read aloud as a gap in the sentence`,
@@ -302,7 +449,7 @@ export function checkIconRegistry(registry, where = "icons") {
302
449
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
303
450
  * @param {Record<string, object>} [opts.registry] - The package's icon table.
304
451
  * @returns {{findings: Array<{file: string, line: number, column: number,
305
- * severity: "error", message: string}>, files: number}} What it found.
452
+ * severity: "warning", message: string}>, files: number}} What it found.
306
453
  */
307
454
  export function lintContentIcons(contentBase, { skipDirectories = [], registry } = {}) {
308
455
  const skip = new Set(skipDirectories);
@@ -355,7 +502,7 @@ export function iconPlugin(registry = DEFAULT_ICONS) {
355
502
  return (md) => {
356
503
  /** @type {any} */ (md).inline.ruler.before("emphasis", "heroiclands_icon", iconRule);
357
504
  /** @type {any} */ (md).renderer.rules.heroiclands_icon = (tokens, idx) =>
358
- iconHtml(tokens[idx].meta.entry);
505
+ iconHtml(tokens[idx].meta.entry, tokens[idx].meta.attrs);
359
506
 
360
507
  /**
361
508
  * @param {any} state - markdown-it inline state.
@@ -367,7 +514,7 @@ export function iconPlugin(registry = DEFAULT_ICONS) {
367
514
  if (state.src.charCodeAt(start) !== 0x3a /* : */) return false;
368
515
  // Anchored at the cursor, so the scan is O(token) rather than a
369
516
  // search of the remaining source at every colon in the paragraph.
370
- const re = /^:icon-([a-z0-9]+(?:-[a-z0-9]+)*):/;
517
+ const re = /^:icon-([a-z0-9]+(?:-[a-z0-9]+)*):(?:\{([^}]*)\})?/;
371
518
  const m = re.exec(state.src.slice(start));
372
519
  if (!m) return false;
373
520
 
@@ -376,9 +523,14 @@ export function iconPlugin(registry = DEFAULT_ICONS) {
376
523
  // unrecognised name visible on the page instead of vanishing.
377
524
  if (!entry) return false;
378
525
 
526
+ // An attribute that cannot be honoured is reported by the lint, not
527
+ // enforced here: refusing to render would hide a good icon over a
528
+ // bad size, and the page is the place the author is looking.
529
+ const { attrs } = parseIconAttributes(m[2]);
530
+
379
531
  if (!silent) {
380
532
  const token = state.push("heroiclands_icon", "", 0);
381
- token.meta = { name: m[1], entry };
533
+ token.meta = { name: m[1], entry, attrs };
382
534
  token.markup = m[0];
383
535
  }
384
536
  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.0",
3
+ "version": "20.3.0",
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,18 @@
1
+ /**
2
+ * Read the brace of an icon token.
3
+ *
4
+ * Values are validated against {@link ICON_ATTRIBUTES} here rather than at the
5
+ * point of rendering, so a mistake is one finding with a position rather than a
6
+ * silently different page.
7
+ *
8
+ * @param {string} [raw] - The text between the braces, without them.
9
+ * @returns {{attrs: Record<string, string>, problems: string[]}} What was
10
+ * written, and what cannot be honoured.
11
+ */
12
+ export function parseIconAttributes(raw?: string): {
13
+ attrs: Record<string, string>;
14
+ problems: string[];
15
+ };
1
16
  /**
2
17
  * Look one name up.
3
18
  *
@@ -26,7 +41,7 @@ export function iconHtml(entry: {
26
41
  style: string;
27
42
  icon: string;
28
43
  label: string;
29
- }): string;
44
+ }, attrs?: {}): string;
30
45
  /**
31
46
  * Every icon a string names, in the order written.
32
47
  *
@@ -49,13 +64,13 @@ export function iconsIn(text: string): Array<{
49
64
  * @param {string} file - Path to report.
50
65
  * @param {Record<string, object>} [registry] - Defaults to {@link DEFAULT_ICONS}.
51
66
  * @returns {Array<{file: string, line: number, column: number,
52
- * severity: "error", message: string}>} The unknown names.
67
+ * severity: "warning", message: string}>} The unknown names.
53
68
  */
54
69
  export function lintIcons(text: string, file: string, registry?: Record<string, object>): Array<{
55
70
  file: string;
56
71
  line: number;
57
72
  column: number;
58
- severity: "error";
73
+ severity: "warning";
59
74
  message: string;
60
75
  }>;
61
76
  /**
@@ -63,10 +78,10 @@ export function lintIcons(text: string, file: string, registry?: Record<string,
63
78
  *
64
79
  * @param {Record<string, object>} registry - A package's icon table.
65
80
  * @param {string} [where="icons"] - Where to say the fault is.
66
- * @returns {Array<{severity: "error", message: string}>} What is wrong with it.
81
+ * @returns {Array<{severity: "warning", message: string}>} What is wrong with it.
67
82
  */
68
83
  export function checkIconRegistry(registry: Record<string, object>, where?: string): Array<{
69
- severity: "error";
84
+ severity: "warning";
70
85
  message: string;
71
86
  }>;
72
87
  /**
@@ -81,7 +96,7 @@ export function checkIconRegistry(registry: Record<string, object>, where?: stri
81
96
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
82
97
  * @param {Record<string, object>} [opts.registry] - The package's icon table.
83
98
  * @returns {{findings: Array<{file: string, line: number, column: number,
84
- * severity: "error", message: string}>, files: number}} What it found.
99
+ * severity: "warning", message: string}>, files: number}} What it found.
85
100
  */
86
101
  export function lintContentIcons(contentBase: string, { skipDirectories, registry }?: {
87
102
  skipDirectories?: readonly string[] | undefined;
@@ -91,7 +106,7 @@ export function lintContentIcons(contentBase: string, { skipDirectories, registr
91
106
  file: string;
92
107
  line: number;
93
108
  column: number;
94
- severity: "error";
109
+ severity: "warning";
95
110
  message: string;
96
111
  }>;
97
112
  files: number;
@@ -117,15 +132,65 @@ export function iconPlugin(registry?: Record<string, object>): (md: object) => v
117
132
  * @type {readonly string[]}
118
133
  */
119
134
  export const ICON_STYLES: readonly string[];
135
+ /**
136
+ * The sizes a note may ask for, and what each means on a page.
137
+ *
138
+ * **A closed set, because size is content here rather than styling.** The icon
139
+ * legend exists to let a reader tell one glyph from another, and several of
140
+ * them genuinely cannot be told apart at the size of running text — so the
141
+ * enlargement is part of what that page *says*, not decoration applied to it.
142
+ * That is the one case worth a size at all.
143
+ *
144
+ * Closed rather than free-form for the usual reason: `font-size: 2em` in a note
145
+ * is CSS, which reaches two of the three surfaces and means nothing to the
146
+ * third. A name means the same thing everywhere, and the multiples below are
147
+ * Font Awesome's own, so the web class and the PDF scale cannot drift.
148
+ *
149
+ * @type {Readonly<Record<string, {class: string, scale: number}>>}
150
+ */
151
+ export const ICON_SIZES: Readonly<Record<string, {
152
+ class: string;
153
+ scale: number;
154
+ }>>;
155
+ /**
156
+ * The attribute names a note may write, and how each is validated.
157
+ *
158
+ * One entry today. It is a table rather than an `if (key === "size")` because
159
+ * the next attribute — a fixed-width flag, a rotation, a title override — should
160
+ * cost a line here and nothing else, and because an unknown key has to be
161
+ * *reported*: silently ignoring `{sixe: 2x}` would leave the author looking at a
162
+ * page that is not what they asked for, with nothing to say why.
163
+ *
164
+ * @type {Readonly<Record<string, {values: readonly string[], describe: string}>>}
165
+ */
166
+ export const ICON_ATTRIBUTES: Readonly<Record<string, {
167
+ values: readonly string[];
168
+ describe: string;
169
+ }>>;
120
170
  /**
121
171
  * The icons the user guide already depicts, under the names it should call them.
122
172
  *
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.
173
+ * Each entry is read off the interface it describes rather than invented, and
174
+ * that is meant literally — the table was checked against the system's own
175
+ * templates, which is how `delete` came to be `fa-trash` rather than the
176
+ * `fa-trash-can` first written here. The sheets draw `fa-trash` twenty-five
177
+ * times and `fa-trash-can` never, so the first spelling would have printed an
178
+ * icon the reader has never seen on screen. A registry that is not checked
179
+ * against the interface is just a second place to be wrong.
180
+ *
181
+ * The names are what a *writer* would reach for — `delete`, not `trash` —
182
+ * because the writer is the one typing them; the Font Awesome spelling is this
183
+ * table's business, not theirs.
184
+ *
185
+ * **Three entries share `xmark`, and that is the point of naming rather than
186
+ * drawing.** A `✕` in the guide means "not applicable" in a Healing Rate
187
+ * column, "remove this row" on a control, and "close" on a dialog's corner. One
188
+ * glyph, three sentences, three different things for a reader who cannot see
189
+ * it — so they are three names with three labels, and the fact that Font
190
+ * Awesome happens to draw them identically stays in this table.
191
+ *
192
+ * `run` and `expand` are likewise distinct: `▶` runs an action, and its label
193
+ * should say so. `fa-play` is what the sheets use for it.
129
194
  *
130
195
  * @type {Readonly<Record<string, {style: string, icon: string, label: string}>>}
131
196
  */
@@ -146,6 +211,21 @@ export const DEFAULT_ICONS: Readonly<Record<string, {
146
211
  * Not `:name[content]`. That is remark-directive syntax, and this toolchain
147
212
  * parses with markdown-it; a directive would render as its own literal text.
148
213
  *
214
+ * An optional trailing brace carries **attributes**:
215
+ *
216
+ * :icon-affiliation:{size: 2x}
217
+ *
218
+ * `key: value` pairs, comma-separated, in the shape `markdown-it-attrs` and
219
+ * remark-directive already use — so it is a convention a reader may recognise
220
+ * rather than one this module invented. Attributes rather than a bare value
221
+ * because `size` is merely the first one anybody needed: a fixed-width flag, a
222
+ * rotation, a title override are the same shape of thing, and a syntax that
223
+ * could only ever express size would have to be replaced to gain any of them.
224
+ *
225
+ * One inline rule consumes the token **and** its brace, so there is no state in
226
+ * which the icon resolves and the brace is left stranded on the page. An
227
+ * unhandled token degrades whole, exactly as the bare form does.
228
+ *
149
229
  * @type {RegExp}
150
230
  */
151
231
  export const ICON_PATTERN: RegExp;