@heroiclands/package-build 22.20.0 → 22.22.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.
Files changed (118) hide show
  1. package/CHANGELOG-content-build.md +0 -8
  2. package/CHANGELOG.md +204 -0
  3. package/assets/styles/content-base.css +162 -0
  4. package/bin/content-commands.mjs +26 -3
  5. package/bin/package-build.mjs +15 -2
  6. package/ci/content-format-reference.mjs +68 -1
  7. package/config.mjs +26 -0
  8. package/content-config.mjs +73 -4
  9. package/docs/api.md +46 -28
  10. package/docs/authoring/dates-and-calendars.md +4 -2
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +237 -28
  13. package/docs/configuration.md +27 -1
  14. package/docs/content-format.md +3 -1
  15. package/docs/guides/reference-pages.md +15 -0
  16. package/docs/reference/format-details.md +265 -42
  17. package/docs/reference/note-types.md +45 -28
  18. package/engine/actor-compiler.mjs +23 -0
  19. package/engine/anchored-sections.mjs +35 -4
  20. package/engine/anchors.mjs +10 -20
  21. package/engine/base-compiler.mjs +86 -21
  22. package/engine/base-styles.mjs +149 -0
  23. package/engine/bundles.mjs +0 -1
  24. package/engine/compendiums.mjs +8 -1
  25. package/engine/content-blocks.mjs +410 -0
  26. package/engine/content-captions.mjs +57 -8
  27. package/engine/content-footnotes.mjs +189 -0
  28. package/engine/content-format-check.mjs +15 -12
  29. package/engine/content-index.mjs +2 -2
  30. package/engine/content-links.mjs +14 -6
  31. package/engine/content-tables.mjs +45 -4
  32. package/engine/content-tasklists.mjs +189 -0
  33. package/engine/docs-checks.mjs +4 -4
  34. package/engine/extension-attributes.mjs +48 -2
  35. package/engine/frontmatter-lint.mjs +62 -66
  36. package/engine/frontmatter.mjs +3 -3
  37. package/engine/heading-attributes.mjs +308 -0
  38. package/engine/helpers.mjs +19 -13
  39. package/engine/homepage.mjs +8 -8
  40. package/engine/index.mjs +3 -0
  41. package/engine/infobox-render.mjs +21 -4
  42. package/engine/infobox.mjs +167 -7
  43. package/engine/journals.mjs +36 -17
  44. package/engine/macros.mjs +1 -1
  45. package/engine/markdown-expressions.mjs +178 -53
  46. package/engine/note-addresses.mjs +17 -8
  47. package/engine/note-dates.mjs +49 -0
  48. package/engine/note-frontmatter.mjs +7 -16
  49. package/engine/note-vocabulary.mjs +216 -42
  50. package/engine/page-lists.mjs +358 -0
  51. package/engine/pdf-build.mjs +76 -42
  52. package/engine/pdf-render.mjs +422 -49
  53. package/engine/rank-ladder.mjs +198 -0
  54. package/engine/reckoning-markers.mjs +1 -1
  55. package/engine/reference-targets.mjs +13 -1
  56. package/engine/retired-fields.mjs +8 -13
  57. package/engine/scenes.mjs +10 -3
  58. package/engine/site-build.mjs +41 -12
  59. package/engine/site-config.mjs +10 -1
  60. package/engine/site-index.mjs +39 -4
  61. package/engine/site-preview.mjs +18 -7
  62. package/engine/social-ties.mjs +52 -14
  63. package/engine/sql-tables.mjs +66 -9
  64. package/engine/standing-terms.mjs +19 -0
  65. package/engine/standings.mjs +538 -0
  66. package/engine/svg-theme.mjs +15 -4
  67. package/engine/system-block.mjs +12 -14
  68. package/engine/web-wikilinks.mjs +48 -1
  69. package/engine/wikilinks.mjs +3 -1
  70. package/hm3/actors.mjs +6 -3
  71. package/manifest.mjs +13 -0
  72. package/package.json +1 -1
  73. package/sohl/actors.mjs +82 -103
  74. package/sohl/item-fields.mjs +4 -5
  75. package/sohl/items.mjs +1 -1
  76. package/sohl/note-schemas.mjs +11 -21
  77. package/types/config.d.mts +9 -0
  78. package/types/engine/actor-compiler.d.mts +11 -0
  79. package/types/engine/anchored-sections.d.mts +24 -0
  80. package/types/engine/base-compiler.d.mts +19 -0
  81. package/types/engine/base-styles.d.mts +93 -0
  82. package/types/engine/content-blocks.d.mts +87 -0
  83. package/types/engine/content-captions.d.mts +14 -1
  84. package/types/engine/content-footnotes.d.mts +73 -0
  85. package/types/engine/content-format-check.d.mts +8 -6
  86. package/types/engine/content-tables.d.mts +12 -4
  87. package/types/engine/content-tasklists.d.mts +72 -0
  88. package/types/engine/extension-attributes.d.mts +22 -1
  89. package/types/engine/frontmatter.d.mts +1 -1
  90. package/types/engine/heading-attributes.d.mts +174 -0
  91. package/types/engine/helpers.d.mts +7 -4
  92. package/types/engine/homepage.d.mts +7 -7
  93. package/types/engine/index.d.mts +1 -0
  94. package/types/engine/infobox-render.d.mts +6 -1
  95. package/types/engine/journals.d.mts +14 -7
  96. package/types/engine/markdown-expressions.d.mts +23 -0
  97. package/types/engine/note-frontmatter.d.mts +1 -4
  98. package/types/engine/note-vocabulary.d.mts +98 -8
  99. package/types/engine/page-lists.d.mts +126 -0
  100. package/types/engine/pdf-render.d.mts +72 -2
  101. package/types/engine/rank-ladder.d.mts +32 -0
  102. package/types/engine/retired-fields.d.mts +4 -6
  103. package/types/engine/site-build.d.mts +13 -1
  104. package/types/engine/site-config.d.mts +9 -0
  105. package/types/engine/site-index.d.mts +13 -1
  106. package/types/engine/site-preview.d.mts +1 -1
  107. package/types/engine/sql-tables.d.mts +31 -7
  108. package/types/engine/standing-terms.d.mts +16 -0
  109. package/types/engine/standings.d.mts +147 -0
  110. package/types/engine/system-block.d.mts +11 -12
  111. package/types/engine/web-wikilinks.d.mts +3 -1
  112. package/types/engine/wikilinks.d.mts +2 -1
  113. package/types/hm3/actors.d.mts +3 -3
  114. package/types/sohl/actors.d.mts +25 -7
  115. package/engine/content-admonitions.mjs +0 -114
  116. package/engine/content-secrets.mjs +0 -112
  117. package/types/engine/content-admonitions.d.mts +0 -24
  118. package/types/engine/content-secrets.d.mts +0 -17
@@ -7,8 +7,182 @@ import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
7
7
  import { formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
8
8
  import { numberWords, numberDigits } from "./number-words.mjs";
9
9
 
10
- // Hugo shortcodes begin `{{<` or `{{%` and stay in the source for Hugo.
11
- const EXPRESSION = /(?<![\\{])\{\{(?![{<%])([^{}\n]+)\}\}(?!\})/g;
10
+ /**
11
+ * An inline expression, as an author writes one.
12
+ *
13
+ * Hugo shortcodes begin `{{<` or `{{%` and stay in the source for Hugo.
14
+ *
15
+ * Exported because a renderer that meets one has met a body this pass never ran
16
+ * over, and has to be able to say so.
17
+ *
18
+ * @type {RegExp}
19
+ */
20
+ export const EXPRESSION = /(?<![\\{])\{\{(?![{<%])([^{}\n]+)\}\}(?!\})/g;
21
+
22
+ /**
23
+ * Every helper an expression may call, with the parameters it takes.
24
+ *
25
+ * **This is the one list.** The engine registers from it and the authoring
26
+ * document is checked against it, so a helper added here is a helper the
27
+ * document must describe, and a helper the document describes but the engine
28
+ * does not register fails the same test.
29
+ *
30
+ * `make` receives the render's context — the prepared SQL results and the date
31
+ * rules — and returns the function Handlebars calls. A helper that needs
32
+ * neither ignores it.
33
+ */
34
+ const HELPERS = Object.freeze([
35
+ Object.freeze({
36
+ name: "eq",
37
+ params: "a b",
38
+ summary: 'True when both are the same value. Compares exactly, so 1 and "1" differ.',
39
+ make: () => (left, right) => left === right,
40
+ }),
41
+ Object.freeze({
42
+ name: "gt",
43
+ params: "a b",
44
+ summary: "True when a is greater than b. Both must be finite numbers.",
45
+ make: () => numericCompare("gt", (a, b) => a > b),
46
+ }),
47
+ Object.freeze({
48
+ name: "gte",
49
+ params: "a b",
50
+ summary: "True when a is greater than or equal to b. Both must be finite numbers.",
51
+ make: () => numericCompare("gte", (a, b) => a >= b),
52
+ }),
53
+ Object.freeze({
54
+ name: "lt",
55
+ params: "a b",
56
+ summary: "True when a is less than b. Both must be finite numbers.",
57
+ make: () => numericCompare("lt", (a, b) => a < b),
58
+ }),
59
+ Object.freeze({
60
+ name: "lte",
61
+ params: "a b",
62
+ summary: "True when a is less than or equal to b. Both must be finite numbers.",
63
+ make: () => numericCompare("lte", (a, b) => a <= b),
64
+ }),
65
+ Object.freeze({
66
+ name: "not",
67
+ params: "value",
68
+ summary: "True when the value is false, zero, empty or absent.",
69
+ make: () => (value) => !value,
70
+ }),
71
+ Object.freeze({
72
+ name: "and",
73
+ params: "value …",
74
+ summary: "True when every value given is true. Takes two or more.",
75
+ make:
76
+ () =>
77
+ (...args) =>
78
+ args.slice(0, -1).every(Boolean),
79
+ }),
80
+ Object.freeze({
81
+ name: "or",
82
+ params: "value …",
83
+ summary: "True when any value given is true. Takes two or more.",
84
+ make:
85
+ () =>
86
+ (...args) =>
87
+ args.slice(0, -1).some(Boolean),
88
+ }),
89
+ Object.freeze({
90
+ name: "words",
91
+ params: "number",
92
+ summary:
93
+ "The number spelled out for running prose — `1200` becomes one thousand two hundred. " +
94
+ "Whole numbers only; a decimal is a finding, and `digits` takes one.",
95
+ make: () => (value) => numberWords(value),
96
+ }),
97
+ Object.freeze({
98
+ name: "digits",
99
+ params: "number",
100
+ summary:
101
+ "The number as a grouped numeral — `1200` becomes 1,200, and a fractional part is kept.",
102
+ make: () => (value) => numberDigits(value),
103
+ }),
104
+ Object.freeze({
105
+ name: "sql",
106
+ params: '"query"',
107
+ summary:
108
+ "The single value a query returns, for use inside a sentence. The query is a quoted " +
109
+ "string, the same SQL a table fence takes.",
110
+ make:
111
+ ({ sqlResults }) =>
112
+ (query) => {
113
+ if (typeof query !== "string" || !query.trim())
114
+ throw new TypeError("sql needs a nonempty query string");
115
+ const result = sqlResults?.get(query);
116
+ if (!result)
117
+ throw new RangeError(`SQL expression has no prepared result: ${query}`);
118
+ if (result.error) throw new RangeError(`SQL expression failed: ${result.error}`);
119
+ return result.value;
120
+ },
121
+ }),
122
+ Object.freeze({
123
+ name: "dateformat",
124
+ params: 'calendar date ["format"]',
125
+ summary:
126
+ "A date written in a calendar. The calendar is an Address or its shortcode, the date " +
127
+ "is the value a note carries, and the optional third argument names an output pattern.",
128
+ make:
129
+ ({ dates }) =>
130
+ (calendar, authored, formatOrOptions) => {
131
+ if (
132
+ !(typeof calendar === "string" || isAddressTuple(calendar)) ||
133
+ !["string", "number"].includes(typeof authored)
134
+ )
135
+ throw new TypeError("dateformat needs a calendar Address and a date");
136
+ const parsed = parseNoteDate(authored, { ...dates, allowUnknown: false });
137
+ const error = parsed.findings.find((item) => item.severity === "error");
138
+ if (error || !parsed.date)
139
+ throw new RangeError(error?.message ?? "date does not resolve");
140
+ const printable = formatDateInCalendar(
141
+ parsed.date,
142
+ isAddressTuple(calendar) ? renderAddress(calendar) : calendar,
143
+ dates,
144
+ typeof formatOrOptions === "string" ? formatOrOptions : undefined,
145
+ );
146
+ if (!printable)
147
+ throw new RangeError(`date cannot be printed in calendar ${calendar}`);
148
+ return printable.prose ?? printable.text;
149
+ },
150
+ }),
151
+ ]);
152
+
153
+ /**
154
+ * A comparison of two numbers, which refuses anything else.
155
+ *
156
+ * Four helpers differ only in the comparison, and a value that is not a finite
157
+ * number is a finding rather than a silent `false`.
158
+ *
159
+ * @param {string} name - The helper's name, for the message.
160
+ * @param {(a: number, b: number) => boolean} compare - The comparison.
161
+ * @returns {(left: unknown, right: unknown) => boolean} The helper.
162
+ */
163
+ function numericCompare(name, compare) {
164
+ return (left, right) => {
165
+ const finite = (value) =>
166
+ typeof value === "bigint" || (typeof value === "number" && Number.isFinite(value));
167
+ if (!finite(left) || !finite(right))
168
+ throw new TypeError(`${name} needs two finite numbers`);
169
+ return compare(left, right);
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Each helper's parameters and what it does, keyed by name.
175
+ *
176
+ * The authoring document describes this surface, and a test holds the two
177
+ * together. A consumer reads it to list what an expression may call.
178
+ *
179
+ * @type {Readonly<Record<string, Readonly<{params: string, summary: string}>>>}
180
+ */
181
+ export const EXPRESSION_HELPERS = Object.freeze(
182
+ Object.fromEntries(
183
+ HELPERS.map(({ name, params, summary }) => [name, Object.freeze({ params, summary })]),
184
+ ),
185
+ );
12
186
 
13
187
  /** Find SQL helper calls, including calls nested inside Boolean expressions. */
14
188
  export function sqlQueriesInMarkdown(body, fm = {}) {
@@ -47,57 +221,8 @@ export function renderMarkdownExpressions(
47
221
  ) {
48
222
  const findings = [];
49
223
  const engine = Handlebars.create();
50
- const numeric = (name, left, right) => {
51
- if (
52
- !["number", "bigint"].includes(typeof left) ||
53
- !["number", "bigint"].includes(typeof right) ||
54
- (typeof left === "number" && !Number.isFinite(left)) ||
55
- (typeof right === "number" && !Number.isFinite(right))
56
- )
57
- throw new TypeError(`${name} needs two finite numbers`);
58
- };
59
- for (const [name, compare] of Object.entries({
60
- gt: (a, b) => a > b,
61
- gte: (a, b) => a >= b,
62
- lt: (a, b) => a < b,
63
- lte: (a, b) => a <= b,
64
- }))
65
- engine.registerHelper(name, (left, right) => {
66
- numeric(name, left, right);
67
- return compare(left, right);
68
- });
69
- engine.registerHelper("eq", (left, right) => left === right);
70
- engine.registerHelper("not", (value) => !value);
71
- engine.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
72
- engine.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
73
- engine.registerHelper("words", (value) => numberWords(value));
74
- engine.registerHelper("digits", (value) => numberDigits(value));
75
- engine.registerHelper("sql", (query) => {
76
- if (typeof query !== "string" || !query.trim())
77
- throw new TypeError("sql needs a nonempty query string");
78
- const result = sqlResults?.get(query);
79
- if (!result) throw new RangeError(`SQL expression has no prepared result: ${query}`);
80
- if (result.error) throw new RangeError(`SQL expression failed: ${result.error}`);
81
- return result.value;
82
- });
83
- engine.registerHelper("dateformat", (calendar, authored, formatOrOptions) => {
84
- if (
85
- !(typeof calendar === "string" || isAddressTuple(calendar)) ||
86
- !["string", "number"].includes(typeof authored)
87
- )
88
- throw new TypeError("dateformat needs a calendar Address and a date");
89
- const parsed = parseNoteDate(authored, { ...dates, allowUnknown: false });
90
- const error = parsed.findings.find((item) => item.severity === "error");
91
- if (error || !parsed.date) throw new RangeError(error?.message ?? "date does not resolve");
92
- const printable = formatDateInCalendar(
93
- parsed.date,
94
- isAddressTuple(calendar) ? renderAddress(calendar) : calendar,
95
- dates,
96
- typeof formatOrOptions === "string" ? formatOrOptions : undefined,
97
- );
98
- if (!printable) throw new RangeError(`date cannot be printed in calendar ${calendar}`);
99
- return printable.prose ?? printable.text;
100
- });
224
+ for (const helper of HELPERS)
225
+ engine.registerHelper(helper.name, helper.make({ sqlResults, dates }));
101
226
  const markdown = replaceOutsideCode(body, EXPRESSION, (source, _inside, offset) => {
102
227
  try {
103
228
  return engine.compile(source, { strict: true })(fm ?? {});
@@ -62,8 +62,12 @@ export const STRUCTURED_ADDRESSES = Object.freeze([
62
62
  export function addressPositions(fm, context = {}) {
63
63
  const out = [];
64
64
  for (const field of dataFields(fm.type) ?? []) {
65
+ // A field that admits both a list and a map declares both halves, and
66
+ // which one a note wrote is not knowable from the declaration — so the
67
+ // position says "either" and the conversion reads the value's shape.
65
68
  const shape =
66
69
  field.kind === "address" ? "value"
70
+ : field.kind === "list-or-map" ? "keys-or-list"
67
71
  : field.keyKind === "address" ? "keys"
68
72
  : field.entryKind === "address" ? field.kind
69
73
  : undefined;
@@ -197,7 +201,7 @@ export function decodeNoteAddresses(fm, context) {
197
201
  `\`${path.join(".")}\` ${description}: ${JSON.stringify(value)}`,
198
202
  );
199
203
  error.keyPath = path;
200
- error.addressKey = position.shape === "keys";
204
+ error.addressKey = position.shape === "keys" || position.shape === "keys-or-list";
201
205
  throw error;
202
206
  };
203
207
  const read = (value, path) => {
@@ -217,7 +221,16 @@ export function decodeNoteAddresses(fm, context) {
217
221
  };
218
222
  const convert = (value, path) => {
219
223
  if (value == null || value === "") return value;
220
- if (position.shape === "keys") {
224
+ // Whichever of the two shapes the note wrote, read as that one. An
225
+ // empty list is the property editor's emptied map and converts to
226
+ // nothing either way.
227
+ const shape =
228
+ position.shape === "keys-or-list" ?
229
+ Array.isArray(value) && value.length ?
230
+ "list"
231
+ : "keys"
232
+ : position.shape;
233
+ if (shape === "keys") {
221
234
  if (value instanceof AddressEntries) return value;
222
235
  if (Array.isArray(value) && value.length === 0) return value;
223
236
  if (typeof value !== "object" || Array.isArray(value))
@@ -230,15 +243,11 @@ export function decodeNoteAddresses(fm, context) {
230
243
  })),
231
244
  );
232
245
  }
233
- if (position.shape === "list") {
246
+ if (shape === "list") {
234
247
  if (!Array.isArray(value)) fail(value, path, "must be a list of Addresses");
235
248
  return value.map((v, i) => read(v, [...path, i]));
236
249
  }
237
- if (
238
- position.shape === "scalar-or-map" &&
239
- typeof value === "object" &&
240
- !isAddressTuple(value)
241
- ) {
250
+ if (shape === "scalar-or-map" && typeof value === "object" && !isAddressTuple(value)) {
242
251
  if (Array.isArray(value)) {
243
252
  if (!value.length) return value;
244
253
  fail(value, path, "must be an Address or pack map");
@@ -117,6 +117,39 @@ function unknownRecord() {
117
117
  };
118
118
  }
119
119
 
120
+ /**
121
+ * Whether a number-valued date may have lost a trailing zero off its day.
122
+ *
123
+ * YAML reads an unquoted `<year>.<day>` as a float, and a float carries no
124
+ * trailing zero: `born: 667.130` and `born: 667.13` both arrive as `667.13`.
125
+ * The authored digits are unrecoverable by then, so the question a reader needs
126
+ * answered is whether a longer day was *possible* — and it was exactly when the
127
+ * value with one more zero on the end still names a day the world's year holds.
128
+ *
129
+ * One zero settles it. A second only makes the day ten times longer again, so a
130
+ * value whose single-zero expansion already falls outside the year has no
131
+ * reading but the one it was handed.
132
+ *
133
+ * **The world's year length is what makes the question answerable**, so a tree
134
+ * whose notes declare none gets no claim either way — the same rule the upper
135
+ * bound on a day follows. Refusing every float there would red a whole corpus
136
+ * over a test nothing could evaluate.
137
+ *
138
+ * @param {number} value - The authored value, as YAML parsed it.
139
+ * @param {number|undefined} daysPerYear - The world's year length.
140
+ * @returns {boolean} True when the authored day is ambiguous.
141
+ */
142
+ function dayCouldHaveLostAZero(value, daysPerYear) {
143
+ if (Number.isInteger(value)) return false;
144
+ const fraction = String(Math.abs(value)).split(".")[1] ?? "";
145
+ // An exponent-form string carries no day at all.
146
+ if (!/^\d+$/.test(fraction)) return false;
147
+ const tenfold = Number(`${fraction}0`);
148
+ if (!Number.isSafeInteger(tenfold)) return false;
149
+ if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1) return false;
150
+ return tenfold <= daysPerYear;
151
+ }
152
+
120
153
  /**
121
154
  * Parse one authored date.
122
155
  *
@@ -194,6 +227,22 @@ export function parseNoteDate(value, options) {
194
227
  return { date: null, findings };
195
228
  };
196
229
 
230
+ // An unquoted `<year>.<day>` reaches here as a float, and YAML has already
231
+ // dropped a trailing zero off the day: `667.13` and `667.130` arrive as one
232
+ // value, so nothing downstream can tell day 13 from day 130. Refused where
233
+ // the lost zero would still name a day inside the world's year, and read as
234
+ // written where no such day exists — `675.281` cannot have been `675.2810`.
235
+ if (typeof value === "number" && dayCouldHaveLostAZero(value, daysPerYear)) {
236
+ const grown = `${text}0`;
237
+ return refuse(
238
+ `${subject} is a number, and a day's trailing zero is lost before a date ` +
239
+ `is read — \`${text}\` and \`${grown}\` reach this check as one value. ` +
240
+ `Quote a canonical date that states a day, \`"${text}"\` or ` +
241
+ `\`"${grown}"\`, so the digits the note states are the digits the ` +
242
+ `build reads`,
243
+ );
244
+ }
245
+
197
246
  const resolveEra = (parsed, reckoning, label) => {
198
247
  if (parsed.year < 1) return refuse(`${subject} needs a positive year in ${label}`);
199
248
  if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1)
@@ -7,22 +7,13 @@
7
7
 
8
8
  import YAML from "yaml";
9
9
 
10
- /** The complete ordered top-level vocabulary for addressed content notes. */
11
- export const NOTE_TOP_LEVEL_KEYS = Object.freeze([
12
- "shortcode",
13
- "name",
14
- "type",
15
- "subType",
16
- "description",
17
- "tags",
18
- "data",
19
- "hm3",
20
- "sohl",
21
- "dnd5e",
22
- ]);
23
-
24
- /** Membership test for the top-level vocabulary. */
25
- export const NOTE_TOP_LEVEL_KEY_SET = new Set(NOTE_TOP_LEVEL_KEYS);
10
+ // The top-level vocabulary itself is declared beside the other two closed
11
+ // regions, so the lint, the formatter and the reference read one list.
12
+ export {
13
+ NOTE_TOP_LEVEL_FIELDS,
14
+ NOTE_TOP_LEVEL_KEYS,
15
+ NOTE_TOP_LEVEL_KEY_SET,
16
+ } from "./note-vocabulary.mjs";
26
17
 
27
18
  /** The name properties every note accepts, in display order. */
28
19
  export const NOTE_NAME_KEYS = Object.freeze(["full", "aliases"]);