@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.
- package/CHANGELOG-content-build.md +0 -8
- package/CHANGELOG.md +204 -0
- package/assets/styles/content-base.css +162 -0
- package/bin/content-commands.mjs +26 -3
- package/bin/package-build.mjs +15 -2
- package/ci/content-format-reference.mjs +68 -1
- package/config.mjs +26 -0
- package/content-config.mjs +73 -4
- package/docs/api.md +46 -28
- package/docs/authoring/dates-and-calendars.md +4 -2
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +237 -28
- package/docs/configuration.md +27 -1
- package/docs/content-format.md +3 -1
- package/docs/guides/reference-pages.md +15 -0
- package/docs/reference/format-details.md +265 -42
- package/docs/reference/note-types.md +45 -28
- package/engine/actor-compiler.mjs +23 -0
- package/engine/anchored-sections.mjs +35 -4
- package/engine/anchors.mjs +10 -20
- package/engine/base-compiler.mjs +86 -21
- package/engine/base-styles.mjs +149 -0
- package/engine/bundles.mjs +0 -1
- package/engine/compendiums.mjs +8 -1
- package/engine/content-blocks.mjs +410 -0
- package/engine/content-captions.mjs +57 -8
- package/engine/content-footnotes.mjs +189 -0
- package/engine/content-format-check.mjs +15 -12
- package/engine/content-index.mjs +2 -2
- package/engine/content-links.mjs +14 -6
- package/engine/content-tables.mjs +45 -4
- package/engine/content-tasklists.mjs +189 -0
- package/engine/docs-checks.mjs +4 -4
- package/engine/extension-attributes.mjs +48 -2
- package/engine/frontmatter-lint.mjs +62 -66
- package/engine/frontmatter.mjs +3 -3
- package/engine/heading-attributes.mjs +308 -0
- package/engine/helpers.mjs +19 -13
- package/engine/homepage.mjs +8 -8
- package/engine/index.mjs +3 -0
- package/engine/infobox-render.mjs +21 -4
- package/engine/infobox.mjs +167 -7
- package/engine/journals.mjs +36 -17
- package/engine/macros.mjs +1 -1
- package/engine/markdown-expressions.mjs +178 -53
- package/engine/note-addresses.mjs +17 -8
- package/engine/note-dates.mjs +49 -0
- package/engine/note-frontmatter.mjs +7 -16
- package/engine/note-vocabulary.mjs +216 -42
- package/engine/page-lists.mjs +358 -0
- package/engine/pdf-build.mjs +76 -42
- package/engine/pdf-render.mjs +422 -49
- package/engine/rank-ladder.mjs +198 -0
- package/engine/reckoning-markers.mjs +1 -1
- package/engine/reference-targets.mjs +13 -1
- package/engine/retired-fields.mjs +8 -13
- package/engine/scenes.mjs +10 -3
- package/engine/site-build.mjs +41 -12
- package/engine/site-config.mjs +10 -1
- package/engine/site-index.mjs +39 -4
- package/engine/site-preview.mjs +18 -7
- package/engine/social-ties.mjs +52 -14
- package/engine/sql-tables.mjs +66 -9
- package/engine/standing-terms.mjs +19 -0
- package/engine/standings.mjs +538 -0
- package/engine/svg-theme.mjs +15 -4
- package/engine/system-block.mjs +12 -14
- package/engine/web-wikilinks.mjs +48 -1
- package/engine/wikilinks.mjs +3 -1
- package/hm3/actors.mjs +6 -3
- package/manifest.mjs +13 -0
- package/package.json +1 -1
- package/sohl/actors.mjs +82 -103
- package/sohl/item-fields.mjs +4 -5
- package/sohl/items.mjs +1 -1
- package/sohl/note-schemas.mjs +11 -21
- package/types/config.d.mts +9 -0
- package/types/engine/actor-compiler.d.mts +11 -0
- package/types/engine/anchored-sections.d.mts +24 -0
- package/types/engine/base-compiler.d.mts +19 -0
- package/types/engine/base-styles.d.mts +93 -0
- package/types/engine/content-blocks.d.mts +87 -0
- package/types/engine/content-captions.d.mts +14 -1
- package/types/engine/content-footnotes.d.mts +73 -0
- package/types/engine/content-format-check.d.mts +8 -6
- package/types/engine/content-tables.d.mts +12 -4
- package/types/engine/content-tasklists.d.mts +72 -0
- package/types/engine/extension-attributes.d.mts +22 -1
- package/types/engine/frontmatter.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +174 -0
- package/types/engine/helpers.d.mts +7 -4
- package/types/engine/homepage.d.mts +7 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox-render.d.mts +6 -1
- package/types/engine/journals.d.mts +14 -7
- package/types/engine/markdown-expressions.d.mts +23 -0
- package/types/engine/note-frontmatter.d.mts +1 -4
- package/types/engine/note-vocabulary.d.mts +98 -8
- package/types/engine/page-lists.d.mts +126 -0
- package/types/engine/pdf-render.d.mts +72 -2
- package/types/engine/rank-ladder.d.mts +32 -0
- package/types/engine/retired-fields.d.mts +4 -6
- package/types/engine/site-build.d.mts +13 -1
- package/types/engine/site-config.d.mts +9 -0
- package/types/engine/site-index.d.mts +13 -1
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +31 -7
- package/types/engine/standing-terms.d.mts +16 -0
- package/types/engine/standings.d.mts +147 -0
- package/types/engine/system-block.d.mts +11 -12
- package/types/engine/web-wikilinks.d.mts +3 -1
- package/types/engine/wikilinks.d.mts +2 -1
- package/types/hm3/actors.d.mts +3 -3
- package/types/sohl/actors.d.mts +25 -7
- package/engine/content-admonitions.mjs +0 -114
- package/engine/content-secrets.mjs +0 -112
- package/types/engine/content-admonitions.d.mts +0 -24
- 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
|
-
|
|
11
|
-
|
|
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
|
|
51
|
-
|
|
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
|
-
|
|
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 (
|
|
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");
|
package/engine/note-dates.mjs
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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"]);
|