@heroiclands/package-build 22.16.0 → 22.17.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.
@@ -14,20 +14,27 @@
14
14
  /**
15
15
  * Parse authored note dates into records used by content checks and renderers.
16
16
  *
17
- * A neutral day is `<year>.<day>[:HHMMSS]`, where `day` is an ordinal within
18
- * the world's year. A named date is `datefrom <calendar> <date>`; its calendar
19
- * and era resolve the value on the neutral timeline. Either form accepts a
20
- * leading `~` for approximation. `unknown` is unordered.
17
+ * A neutral date is `<year>[.<day>[:HHMMSS]]`, where `day` is an ordinal
18
+ * within the world's year. A bare year spans that year; a day spans that day;
19
+ * a clock time identifies a second. A named date uses the `datefrom` form;
20
+ * its calendar and era resolve the value on the neutral timeline.
21
+ * Either form accepts `~` for uncertainty beyond its stated interval.
22
+ * `unknown` is unordered.
21
23
  *
22
24
  * `text` keeps the authored spelling. `canonicalYear` and `sort` are set for
23
- * neutral and resolved named dates. Resolved dates also carry `canonicalDay`,
24
- * the ordinal day within the neutral year.
25
+ * neutral and resolved named dates. A neutral year retains `precision: "year"`
26
+ * and has no `canonicalDay`; day one is used only to calculate its bounds.
25
27
  *
26
28
  * @module
27
29
  */
28
30
 
29
31
  import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
30
32
  import { addressedCalendarDate } from "./calendar-human.mjs";
33
+ import {
34
+ calendarFormatHasClock,
35
+ formatCalendarPattern,
36
+ selectCalendarFormat,
37
+ } from "./calendar-format.mjs";
31
38
  import {
32
39
  calendarStructure,
33
40
  canonicalDateFromOffset,
@@ -188,10 +195,7 @@ export function parseNoteDate(value, options) {
188
195
  };
189
196
 
190
197
  const resolveEra = (parsed, reckoning, label) => {
191
- if (!ignoreEraBounds && parsed.year < 0 && reckoning.firstEra === false)
192
- return refuse(
193
- `${subject} counts backward from ${label}, but only the first era of a calendar may do so`,
194
- );
198
+ if (parsed.year < 1) return refuse(`${subject} needs a positive year in ${label}`);
195
199
  if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1)
196
200
  return refuse(`${subject} needs the world's year length to resolve ${label}`);
197
201
  const { months } = calendarStructure(reckoning.calendar);
@@ -207,7 +211,7 @@ export function parseNoteDate(value, options) {
207
211
  );
208
212
  const ordinal = dayOfYear(months, parsed.month ?? 1, parsed.day ?? 1);
209
213
  const start = canonicalDayOffset(reckoning.epochYear, reckoning.epochDay, 1, daysPerYear);
210
- const yearOffset = parsed.year > 0 ? parsed.year - 1 : parsed.year;
214
+ const yearOffset = reckoning.beforeEra ? -parsed.year : parsed.year - 1;
211
215
  const offset = start + yearOffset * daysPerYear + ordinal - 1;
212
216
  if (!Number.isSafeInteger(offset))
213
217
  return refuse(`${subject} lies outside the supported canonical day range`);
@@ -283,10 +287,10 @@ export function parseNoteDate(value, options) {
283
287
  `${subject} writes year 0, and no reckoning has one — the years either ` +
284
288
  `side of an epoch are -1 and 1`,
285
289
  );
286
- if (field && !/^-?\d+\.\d+(?::\d{6})?$/.test(unmarked))
290
+ if (field && !/^-?\d+(?:\.\d+(?::\d{6})?)?$/.test(unmarked))
287
291
  return refuse(
288
292
  `${subject} is not a frontmatter date — write ` +
289
- "`<year>.<day>[:HHMMSS]` or `datefrom <calendar> <date>`",
293
+ "`<year>[.<day>[:HHMMSS]]` or `datefrom <calendar> <date>`",
290
294
  );
291
295
  if (unmarked.includes(".")) {
292
296
  const canonical = parseCanonicalDate(unmarked, daysPerYear ?? Number.MAX_SAFE_INTEGER);
@@ -301,13 +305,13 @@ export function parseNoteDate(value, options) {
301
305
  day: canonical.day,
302
306
  approximate: approximateInput,
303
307
  precision: "day",
304
- canonicalYear: canonical.year,
308
+ canonicalYear: canonicalYear(canonical.year),
305
309
  canonicalDay: canonical.day,
306
310
  spanDays: 1,
307
311
  ...(canonical.seconds === undefined ? {} : { seconds: canonical.seconds }),
308
312
  sort:
309
313
  Number.isSafeInteger(daysPerYear) ?
310
- canonical.year +
314
+ canonicalYear(canonical.year) +
311
315
  (canonical.day - 1 + (canonical.seconds ?? 0) / 86400) / daysPerYear
312
316
  : null,
313
317
  },
@@ -348,7 +352,7 @@ export function parseNoteDate(value, options) {
348
352
  }
349
353
  return refuse(
350
354
  `${subject} is not a date — write ` +
351
- `\`<year>.<day>[:HHMMSS]\` or ` +
355
+ `\`<year>[.<day>[:HHMMSS]]\` or ` +
352
356
  `\`datefrom <calendar> <date>\``,
353
357
  );
354
358
  }
@@ -515,17 +519,17 @@ export function calendarEras(reference, context) {
515
519
  }
516
520
 
517
521
  /** Print a resolved date using the era active in one addressed calendar. */
518
- export function formatDateInCalendar(date, reference, context) {
522
+ export function formatDateInCalendar(date, reference, context, formatName) {
519
523
  const eras = calendarEras(reference, context);
520
524
  const chosen = eraCovering(date, eras, context?.daysPerYear);
521
525
  const name = String(reference ?? "");
522
526
  if (context?.eras?.get(name)?.era && chosen === null)
523
527
  throw new RangeError(`date falls outside calendar era ${name}`);
524
- return formatNoteDate(date, chosen, context?.daysPerYear);
528
+ return formatNoteDate(date, chosen, context?.daysPerYear, formatName);
525
529
  }
526
530
 
527
531
  /** Print a resolved date in one era, retaining its authored precision. */
528
- export function formatNoteDate(date, era, daysPerYear) {
532
+ export function formatNoteDate(date, era, daysPerYear, formatName) {
529
533
  if (!date?.known || !Number.isSafeInteger(date.canonicalYear)) return null;
530
534
  if (!era) return { era: null, year: eraYear(date.canonicalYear), text: date.text, prose: null };
531
535
  if (!Number.isSafeInteger(daysPerYear) || !Number.isSafeInteger(date.spanDays)) return null;
@@ -538,7 +542,8 @@ export function formatNoteDate(date, era, daysPerYear) {
538
542
  canonicalDayOffset(target.epochYear, target.epochDay ?? 1, 1, daysPerYear);
539
543
  const delta = start - epoch;
540
544
  const cycle = Math.floor(delta / daysPerYear);
541
- const year = cycle >= 0 ? cycle + 1 : cycle;
545
+ const year = target.beforeEra ? -cycle : cycle + 1;
546
+ if (year < 1) return null;
542
547
  if (Math.floor((delta + date.spanDays - 1) / daysPerYear) !== cycle) return null;
543
548
 
544
549
  let month = null;
@@ -565,14 +570,37 @@ export function formatNoteDate(date, era, daysPerYear) {
565
570
  month === null ? null : calendarStructure(target.calendar).months?.[month - 1]?.name;
566
571
  const eraName =
567
572
  target.abbreviation || target.marker || target.name || target.era.split(".").at(-1);
568
- const text = `${date.approximate ? "~" : ""}${day === null ? "" : `${day} `}${monthName ? `${monthName} ` : ""}${year} ${eraName}${clock}`;
573
+ let text = `${date.approximate ? "~" : ""}${day === null ? "" : `${day} `}${monthName ? `${monthName} ` : ""}${year} ${eraName}${clock}`;
574
+ if (target.calendar?.formats) {
575
+ const { pattern } = selectCalendarFormat(target.calendar, formatName);
576
+ const hasClock = calendarFormatHasClock(pattern);
577
+ const rendered = formatCalendarPattern(
578
+ pattern,
579
+ {
580
+ calendar: target.calendar,
581
+ era: target,
582
+ eraYear: year,
583
+ calendarYear: target.beforeEra ? -year : target.startYear + year - 1,
584
+ month,
585
+ day,
586
+ dayOfYear: (((delta % daysPerYear) + daysPerYear) % daysPerYear) + 1,
587
+ seconds: date.seconds,
588
+ canonicalOffset: start,
589
+ calendarEpochOffset: target.calendarEpochOffset,
590
+ },
591
+ date.precision,
592
+ date.seconds !== undefined,
593
+ );
594
+ text = `${date.approximate ? "~" : ""}${rendered}${date.seconds !== undefined && !hasClock ? clock : ""}`;
595
+ }
569
596
  const digits = `${Math.abs(year)}${month === null ? "" : `/${month}`}${day === null ? "" : `/${day}${clock.replaceAll(":", "").replace(/^ /, ":")}`}`;
570
597
  const label =
571
598
  typeof target.label === "string" ?
572
599
  target.label
573
600
  : target.label?.[year < 0 ? "before" : "after"];
574
601
  const prose =
575
- typeof label === "string" && label.includes("{date}") ?
602
+ target.calendar?.formats ? null
603
+ : typeof label === "string" && label.includes("{date}") ?
576
604
  label.replace("{date}", `${date.approximate ? "~" : ""}${digits}`)
577
605
  : null;
578
606
  return { era, year, month, day, text, prose };
@@ -8,11 +8,18 @@ import path from "node:path";
8
8
  import { unified } from "unified";
9
9
  import remarkParse from "remark-parse";
10
10
  import remarkFrontmatter from "remark-frontmatter";
11
+ import remarkGfm from "remark-gfm";
11
12
  import remarkRetext from "remark-retext";
12
13
  import retextEnglish from "retext-english";
13
14
  import retextReadability from "retext-readability";
14
15
  import retextSimplify from "retext-simplify";
15
16
  import retextStringify from "retext-stringify";
17
+ import { flesch } from "flesch";
18
+ import { syllable } from "syllable";
19
+ import { daleChall } from "dale-chall";
20
+
21
+ const FAMILIAR_WORDS = new Set(daleChall);
22
+ const NOMINALIZATION = /(?:tion|sion|ment|ness|ity|ance|ence|ism)$/i;
16
23
 
17
24
  /** Files in a content tree, excluding configured directory names. */
18
25
  export function proseFiles(root, skipDirectories = []) {
@@ -31,28 +38,84 @@ export function proseFiles(root, skipDirectories = []) {
31
38
 
32
39
  /** Keep source offsets while excluding HeroicLands inline addresses and expressions. */
33
40
  function maskMarkup(source) {
34
- return source.replace(/!?\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (match) =>
35
- match.replace(/[^\n]/g, " "),
36
- );
41
+ return source
42
+ .replace(/!?\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (match) => match.replace(/[^\n]/g, " "))
43
+ .replace(/^:::sql[^\n]*\n[\s\S]*?^:::[ \t]*$/gm, (match) => match.replace(/[^\n]/g, " "))
44
+ .replace(/^:::[^\n]*$/gm, (match) => match.replace(/[^\n]/g, " "));
37
45
  }
38
46
 
39
- /** Source-aligned findings for one Markdown note. */
40
- export async function analyzeProse(file, source, { age = 21, threshold = 5, minWords = 8 } = {}) {
41
- const text = maskMarkup(source);
42
- const virtualFile = { value: text, path: file, messages: [] };
43
- const processor = unified()
47
+ /** Select complete paragraphs while keeping their original source positions. */
48
+ function proseTree(source) {
49
+ const processor = unified().use(remarkParse).use(remarkFrontmatter).use(remarkGfm);
50
+ const tree = processor.parse(maskMarkup(source));
51
+ const keepInline = (node) => {
52
+ if (
53
+ ["inlineCode", "image", "imageReference", "html", "footnoteReference"].includes(
54
+ node.type,
55
+ )
56
+ ) {
57
+ return false;
58
+ }
59
+ if (node.children) node.children = node.children.filter(keepInline);
60
+ return true;
61
+ };
62
+ const paragraphs = [];
63
+ const collect = (node) => {
64
+ if (node.type === "paragraph") paragraphs.push(node);
65
+ else if (node.type === "blockquote")
66
+ for (const child of node.children ?? []) collect(child);
67
+ };
68
+ for (const node of tree.children) collect(node);
69
+ tree.children = paragraphs;
70
+ for (const paragraph of tree.children)
71
+ paragraph.children = paragraph.children.filter(keepInline);
72
+ return tree;
73
+ }
74
+
75
+ /** Count readable body words, including headings and lists, for score coverage. */
76
+ function bodyWordCount(source) {
77
+ const tree = unified()
44
78
  .use(remarkParse)
45
79
  .use(remarkFrontmatter)
46
- .use(
47
- remarkRetext,
48
- unified()
49
- .use(retextEnglish)
50
- .use(retextReadability, { age, threshold: threshold / 7, minWords })
51
- .use(retextSimplify)
52
- .use(retextStringify),
53
- );
54
- await processor.run(processor.parse(virtualFile), virtualFile);
55
- return virtualFile.messages.map((message) => {
80
+ .use(remarkGfm)
81
+ .parse(maskMarkup(source));
82
+ const visit = (node) => {
83
+ if (
84
+ ["yaml", "code", "table", "html", "inlineCode", "image", "imageReference"].includes(
85
+ node.type,
86
+ )
87
+ ) {
88
+ return 0;
89
+ }
90
+ if (node.type === "text")
91
+ return (node.value.match(/[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu) ?? []).length;
92
+ return (node.children ?? []).reduce((sum, child) => sum + visit(child), 0);
93
+ };
94
+ return visit(tree);
95
+ }
96
+
97
+ /** Bridge selected Markdown paragraphs to retext for lint and scoring. */
98
+ async function retextProse(file, source, options = {}, inspect) {
99
+ const virtualFile = { value: maskMarkup(source), path: file, messages: [] };
100
+ const analysis = unified().use(retextEnglish);
101
+ if (options.rules !== "simplify" && options.rules !== "none") {
102
+ analysis.use(retextReadability, {
103
+ age: options.age ?? 21,
104
+ threshold: (options.threshold ?? 5) / 7,
105
+ minWords: options.minWords ?? 8,
106
+ });
107
+ }
108
+ if (options.rules === "simplify" || options.rules === "all") analysis.use(retextSimplify);
109
+ if (inspect) analysis.use(() => (tree) => inspect(tree));
110
+ analysis.use(retextStringify);
111
+ await unified().use(remarkRetext, analysis).run(proseTree(source), virtualFile);
112
+ return virtualFile.messages;
113
+ }
114
+
115
+ /** Source-aligned findings for one Markdown note. */
116
+ export async function analyzeProse(file, source, options = {}) {
117
+ const messages = await retextProse(file, source, options);
118
+ return messages.map((message) => {
56
119
  const position = message.place?.start;
57
120
  const sentence = message.ancestors?.find((node) => node.type === "SentenceNode");
58
121
  const span = sentence?.position;
@@ -84,3 +147,118 @@ export async function lintProse(root, options, skipDirectories = []) {
84
147
  }
85
148
  return findings;
86
149
  }
150
+
151
+ /** Concatenate the text leaves in a retext word. */
152
+ function wordText(node) {
153
+ return node.value ?? (node.children ?? []).map(wordText).join("");
154
+ }
155
+
156
+ /** Count the words and syllables in one retext sentence. */
157
+ function sentenceCounts(sentence) {
158
+ const words = [];
159
+ const visit = (node) => {
160
+ if (node.type === "WordNode") words.push(wordText(node));
161
+ else for (const child of node.children ?? []) visit(child);
162
+ };
163
+ visit(sentence);
164
+ return {
165
+ words: words.length,
166
+ syllables: words.reduce((sum, word) => sum + syllable(word), 0),
167
+ unfamiliar: words.filter((word) => !FAMILIAR_WORDS.has(word.toLowerCase())).length,
168
+ nominalizations: words.filter((word) => NOMINALIZATION.test(word)).length,
169
+ };
170
+ }
171
+
172
+ /** Score the complete prose paragraphs in one note. */
173
+ export async function scoreProse(file, source, { minWords = 80, bands = {} } = {}) {
174
+ const sentences = [];
175
+ await retextProse(file, source, { rules: "none" }, (tree) => {
176
+ const visit = (node) => {
177
+ if (node.type === "SentenceNode") sentences.push(sentenceCounts(node));
178
+ else for (const child of node.children ?? []) visit(child);
179
+ };
180
+ visit(tree);
181
+ });
182
+ const totals = sentences.reduce(
183
+ (sum, sentence) => ({
184
+ words: sum.words + sentence.words,
185
+ syllables: sum.syllables + sentence.syllables,
186
+ unfamiliar: sum.unfamiliar + sentence.unfamiliar,
187
+ nominalizations: sum.nominalizations + sentence.nominalizations,
188
+ }),
189
+ { words: 0, syllables: 0, unfamiliar: 0, nominalizations: 0 },
190
+ );
191
+ const longestSentenceWords = Math.max(0, ...sentences.map((sentence) => sentence.words));
192
+ const count = sentences.filter((sentence) => sentence.words > 0).length;
193
+ const metrics =
194
+ count && totals.words ?
195
+ {
196
+ flesch: flesch({ sentence: count, word: totals.words, syllable: totals.syllables }),
197
+ syllablesPerWord: totals.syllables / totals.words,
198
+ meanSentenceWords: totals.words / count,
199
+ longestSentenceWords,
200
+ unfamiliarWordPercent: (100 * totals.unfamiliar) / totals.words,
201
+ nominalizationsPer1000Words: (1000 * totals.nominalizations) / totals.words,
202
+ }
203
+ : null;
204
+ const insufficient = totals.words < minWords;
205
+ const violations = [];
206
+ if (!insufficient) {
207
+ for (const [metric, band] of Object.entries(bands)) {
208
+ if (band.min !== undefined && metrics[metric] < band.min) {
209
+ violations.push(`${metric} below ${band.min}`);
210
+ }
211
+ if (band.max !== undefined && metrics[metric] > band.max) {
212
+ violations.push(`${metric} above ${band.max}`);
213
+ }
214
+ }
215
+ }
216
+ const bodyWords = bodyWordCount(source);
217
+ return {
218
+ file,
219
+ ...totals,
220
+ bodyWords,
221
+ coveragePercent: bodyWords ? (100 * totals.words) / bodyWords : 0,
222
+ sentences: count,
223
+ longestSentenceWords,
224
+ metrics,
225
+ insufficient,
226
+ violations,
227
+ };
228
+ }
229
+
230
+ /** Score a note or content tree and pool counts for its aggregate. */
231
+ export async function scoreProseTree(root, options, skipDirectories = []) {
232
+ const notes = [];
233
+ for (const file of proseFiles(root, skipDirectories)) {
234
+ notes.push(await scoreProse(file, fs.readFileSync(file, "utf8"), options));
235
+ }
236
+ const scored = notes.filter((note) => !note.insufficient);
237
+ const totals = scored.reduce(
238
+ (sum, note) => ({
239
+ words: sum.words + note.words,
240
+ syllables: sum.syllables + note.syllables,
241
+ sentences: sum.sentences + note.sentences,
242
+ unfamiliar: sum.unfamiliar + note.unfamiliar,
243
+ nominalizations: sum.nominalizations + note.nominalizations,
244
+ }),
245
+ { words: 0, syllables: 0, sentences: 0, unfamiliar: 0, nominalizations: 0 },
246
+ );
247
+ return {
248
+ notes,
249
+ summary: {
250
+ ...totals,
251
+ scored: scored.length,
252
+ insufficient: notes.length - scored.length,
253
+ outside: scored.filter((note) => note.violations.length).length,
254
+ flesch:
255
+ totals.words && totals.sentences ?
256
+ flesch({
257
+ sentence: totals.sentences,
258
+ word: totals.words,
259
+ syllable: totals.syllables,
260
+ })
261
+ : null,
262
+ },
263
+ };
264
+ }