@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.17.1
4
+
5
+ ### Patch Changes
6
+
7
+ **Prose review**
8
+
9
+ - Prose lint focuses on difficult sentences by default and skips tables and code; simpler-word suggestions remain available on request.
10
+ - Prose score reports each note's readability and lets a content package define an advisory range for its writing.
11
+
12
+ ## 22.17.0
13
+
14
+ ### Minor Changes
15
+
16
+ **Calendar dates**
17
+
18
+ - Calendar notes anchor their first year to a canonical day, count eras from calendar years, and include an era for dates before year one.
19
+ - Named calendar formats control how dates print; the standard format also controls how authored calendar dates are read.
20
+ - Calendars can name special days and divide the year into seasons, with those names available in formatted dates.
21
+
22
+ **Content dates**
23
+
24
+ - A date written as a year covers that whole year; a written day covers that day, and a clock time identifies a moment.
25
+ - Calendar starting days and moon phase references require a day so their dates cannot silently shift to the first day of a year.
26
+
3
27
  ## 22.16.0
4
28
 
5
29
  ### Minor Changes
@@ -97,7 +97,7 @@ import { ENGINE_NOTE_SCHEMAS } from "../engine/note-schemas.mjs";
97
97
  import { schemaSubtypeOf } from "../engine/subtype-registry.mjs";
98
98
  import { NOTE_VOCABULARY } from "../engine/note-vocabulary.mjs";
99
99
  import { checkFormatting, checkPrettierConventions, lintMarkdown } from "../engine/prose-lint.mjs";
100
- import { lintProse } from "../engine/readability-lint.mjs";
100
+ import { lintProse, scoreProseTree } from "../engine/readability-lint.mjs";
101
101
  import { loadPackageBuildConfig } from "../config.mjs";
102
102
  import {
103
103
  authoredFrontmatter,
@@ -240,14 +240,14 @@ export function registerContentCommands(cli) {
240
240
  .command(addressesCommand());
241
241
  }
242
242
 
243
- /** Opt-in readability and plain-language suggestions for Markdown notes. */
243
+ /** Optional sentence findings and note-level readability reports. */
244
244
  function proseCommand() {
245
245
  return {
246
246
  command: "prose <action> [path]",
247
247
  describe: "Analyze note prose on demand",
248
248
  builder: (yargs) =>
249
249
  yargs
250
- .positional("action", { choices: ["lint"], describe: "Analyze prose" })
250
+ .positional("action", { choices: ["lint", "score"], describe: "Analyze prose" })
251
251
  .positional("path", {
252
252
  type: "string",
253
253
  describe: "One Markdown file or a content tree",
@@ -257,17 +257,97 @@ function proseCommand() {
257
257
  type: "number",
258
258
  describe: "Readability algorithms required (1–7)",
259
259
  })
260
- .option("min-words", { type: "number", describe: "Minimum words per sentence" }),
260
+ .option("min-words", {
261
+ type: "number",
262
+ describe: "Minimum words per sentence or note",
263
+ })
264
+ .option("rules", {
265
+ choices: ["readability", "simplify", "all"],
266
+ describe: "Lint rule set",
267
+ })
268
+ .option("fail-on-warning", {
269
+ type: "boolean",
270
+ default: false,
271
+ describe: "Exit unsuccessfully when lint reports suggestions",
272
+ })
273
+ .option("fail-outside", {
274
+ type: "boolean",
275
+ default: false,
276
+ describe: "Exit unsuccessfully when a scored note is outside a configured band",
277
+ }),
261
278
  handler: async (argv) => {
262
279
  try {
263
280
  const config = loadPackConfig();
264
- const defaults = loadPackageBuildConfig().proseLint;
281
+ const defaults = loadPackageBuildConfig();
282
+ const root = argv.path ?? config.paths.content;
283
+ const displayFile = (file) => path.relative(process.cwd(), path.resolve(file));
284
+ if (argv.action === "score") {
285
+ if (
286
+ argv.age !== undefined ||
287
+ argv.threshold !== undefined ||
288
+ argv.rules !== undefined ||
289
+ argv.failOnWarning
290
+ ) {
291
+ throw new Error(
292
+ "--age, --threshold, --rules, and --fail-on-warning apply only to prose lint",
293
+ );
294
+ }
295
+ const minWords = argv.minWords ?? defaults.proseScore.minWords;
296
+ if (!Number.isInteger(minWords) || minWords < 1) {
297
+ throw new Error("minWords must be a positive integer");
298
+ }
299
+ const result = await scoreProseTree(
300
+ root,
301
+ { ...defaults.proseScore, minWords },
302
+ config.skipDirectories,
303
+ );
304
+ const fixed = (number, places = 1) => number.toFixed(places);
305
+ for (const note of result.notes) {
306
+ if (note.insufficient) {
307
+ console.log(
308
+ `${displayFile(note.file)}: insufficient prose (${note.words}/${minWords} words; coverage=${fixed(note.coveragePercent)}%)`,
309
+ );
310
+ continue;
311
+ }
312
+ const metrics = note.metrics;
313
+ const detail =
314
+ `flesch=${fixed(metrics.flesch)} syl/word=${fixed(metrics.syllablesPerWord, 2)} ` +
315
+ `mean=${fixed(metrics.meanSentenceWords)}w longest=${note.longestSentenceWords}w ` +
316
+ `unfamiliar=${fixed(metrics.unfamiliarWordPercent)}% ` +
317
+ `nominalizations/1k=${fixed(metrics.nominalizationsPer1000Words)} ` +
318
+ `words=${note.words} coverage=${fixed(note.coveragePercent)}%`;
319
+ if (note.violations.length) {
320
+ emitDiagnostic({
321
+ file: displayFile(note.file),
322
+ severity: "warning",
323
+ message: `prose-score/band: ${detail}; ${note.violations.join(", ")}`,
324
+ });
325
+ } else {
326
+ console.log(`${displayFile(note.file)}: ${detail}`);
327
+ }
328
+ }
329
+ const aggregate = result.summary;
330
+ console.log(
331
+ `Total: ${aggregate.scored} scored, ${aggregate.insufficient} insufficient, ` +
332
+ `${aggregate.outside} outside band; ${aggregate.words} words; ` +
333
+ `pooled flesch=${aggregate.flesch === null ? "n/a" : fixed(aggregate.flesch)} ` +
334
+ `syl/word=${aggregate.words ? fixed(aggregate.syllables / aggregate.words, 2) : "n/a"} ` +
335
+ `mean=${aggregate.sentences ? fixed(aggregate.words / aggregate.sentences) : "n/a"}w ` +
336
+ `unfamiliar=${aggregate.words ? fixed((100 * aggregate.unfamiliar) / aggregate.words) : "n/a"}% ` +
337
+ `nominalizations/1k=${aggregate.words ? fixed((1000 * aggregate.nominalizations) / aggregate.words) : "n/a"}`,
338
+ );
339
+ if (argv.failOutside && aggregate.outside) process.exitCode = 1;
340
+ return;
341
+ }
342
+ if (argv.failOutside) throw new Error("--fail-outside applies only to prose score");
265
343
  const options = {
266
- age: argv.age ?? defaults.age,
267
- threshold: argv.threshold ?? defaults.threshold,
268
- minWords: argv.minWords ?? defaults.minWords,
344
+ age: argv.age ?? defaults.proseLint.age,
345
+ threshold: argv.threshold ?? defaults.proseLint.threshold,
346
+ minWords: argv.minWords ?? defaults.proseLint.minWords,
347
+ rules: argv.rules ?? defaults.proseLint.rules,
269
348
  };
270
349
  for (const [key, value] of Object.entries(options)) {
350
+ if (key === "rules") continue;
271
351
  if (
272
352
  !Number.isInteger(value) ||
273
353
  value < 1 ||
@@ -278,13 +358,12 @@ function proseCommand() {
278
358
  );
279
359
  }
280
360
  }
281
- const findings = await lintProse(
282
- argv.path ?? config.paths.content,
283
- options,
284
- config.skipDirectories,
285
- );
286
- for (const finding of findings) emitDiagnostic(finding);
361
+ const findings = await lintProse(root, options, config.skipDirectories);
362
+ for (const finding of findings) {
363
+ emitDiagnostic({ ...finding, file: displayFile(finding.file) });
364
+ }
287
365
  log.info(`${findings.length} prose suggestion(s).`);
366
+ if (argv.failOnWarning && findings.length) process.exitCode = 1;
288
367
  } catch (err) {
289
368
  reportFailure(err);
290
369
  process.exitCode = 1;
@@ -540,7 +540,10 @@ function datefromCommand() {
540
540
  type: "string",
541
541
  describe: "Calendar Address or shortcode",
542
542
  })
543
- .positional("date", { type: "string", describe: "Day in that calendar" }),
543
+ .positional("date", {
544
+ type: "string",
545
+ describe: "Day written in that calendar's std format (or first named format)",
546
+ }),
544
547
  handler: handler((args) => {
545
548
  console.log(dateFromCalendar(args.calendar, args.date, calendarConversionContext()));
546
549
  }),
@@ -561,10 +564,19 @@ function datetoCommand() {
561
564
  .positional("canonical-date", {
562
565
  type: "string",
563
566
  describe: "Canonical <year>.<day>[:HHMMSS]",
567
+ })
568
+ .option("format", {
569
+ type: "string",
570
+ describe: "Named calendar format; defaults to std or the first declared format",
564
571
  }),
565
572
  handler: handler((args) => {
566
573
  console.log(
567
- dateToCalendar(args.calendar, args.canonicalDate, calendarConversionContext()),
574
+ dateToCalendar(
575
+ args.calendar,
576
+ args.canonicalDate,
577
+ calendarConversionContext(),
578
+ args.format,
579
+ ),
568
580
  );
569
581
  }),
570
582
  };
package/config.mjs CHANGED
@@ -76,6 +76,7 @@ const SECTION_KEYS = [
76
76
  "container",
77
77
  "e2e",
78
78
  "proseLint",
79
+ "proseScore",
79
80
  ];
80
81
 
81
82
  /**
@@ -528,7 +529,8 @@ function normalizeExceptions(value, field, where) {
528
529
  *
529
530
  * @typedef {object} PackageBuildConfig
530
531
  * @property {string} rootDir The repository root, from package-build.
531
- * @property {{age: number, threshold: number, minWords: number}} proseLint Optional prose analysis settings.
532
+ * @property {{age: number, threshold: number, minWords: number, rules: string}} proseLint Optional prose lint settings.
533
+ * @property {{minWords: number, bands: Record<string, {min?: number, max?: number}>}} proseScore Optional note score settings.
532
534
  * @property {string} packageKind `systems` or `modules`.
533
535
  * @property {string} packageId The Foundry package id.
534
536
  * @property {string} artifact Derived: `system` or `module`.
@@ -693,9 +695,20 @@ export function resolvePackageBuildConfig(shared) {
693
695
 
694
696
  const proseLint = section.proseLint ?? {};
695
697
  if (!isMapping(proseLint)) fail("packageBuild.proseLint", "must be a mapping");
696
- rejectUnknownKeys(proseLint, ["age", "threshold", "minWords"], "packageBuild.proseLint.");
697
- const proseOptions = { age: 21, threshold: 5, minWords: 8 };
698
+ rejectUnknownKeys(
699
+ proseLint,
700
+ ["age", "threshold", "minWords", "rules"],
701
+ "packageBuild.proseLint.",
702
+ );
703
+ const proseOptions = { age: 21, threshold: 5, minWords: 8, rules: "readability" };
698
704
  for (const [key, value] of Object.entries(proseLint)) {
705
+ if (key === "rules") {
706
+ if (!["readability", "simplify", "all"].includes(value)) {
707
+ fail("packageBuild.proseLint.rules", "must be readability, simplify, or all");
708
+ }
709
+ proseOptions.rules = value;
710
+ continue;
711
+ }
699
712
  const upper = key === "threshold" ? 7 : Infinity;
700
713
  if (!Number.isInteger(value) || value < 1 || value > upper) {
701
714
  fail(
@@ -708,6 +721,43 @@ export function resolvePackageBuildConfig(shared) {
708
721
  proseOptions[key] = value;
709
722
  }
710
723
 
724
+ const proseScore = section.proseScore ?? {};
725
+ if (!isMapping(proseScore)) fail("packageBuild.proseScore", "must be a mapping");
726
+ rejectUnknownKeys(proseScore, ["minWords", "bands"], "packageBuild.proseScore.");
727
+ const scoreMinWords = proseScore.minWords ?? 80;
728
+ if (!Number.isInteger(scoreMinWords) || scoreMinWords < 1) {
729
+ fail("packageBuild.proseScore.minWords", "must be a positive integer");
730
+ }
731
+ const bands = proseScore.bands ?? {};
732
+ if (!isMapping(bands)) fail("packageBuild.proseScore.bands", "must be a mapping");
733
+ const scoreMetrics = [
734
+ "flesch",
735
+ "syllablesPerWord",
736
+ "meanSentenceWords",
737
+ "longestSentenceWords",
738
+ "unfamiliarWordPercent",
739
+ "nominalizationsPer1000Words",
740
+ ];
741
+ rejectUnknownKeys(bands, scoreMetrics, "packageBuild.proseScore.bands.");
742
+ const scoreBands = {};
743
+ for (const [metric, band] of Object.entries(bands)) {
744
+ const where = `packageBuild.proseScore.bands.${metric}`;
745
+ if (!isMapping(band)) fail(where, "must be a mapping");
746
+ rejectUnknownKeys(band, ["min", "max"], `${where}.`);
747
+ if (band.min === undefined && band.max === undefined) {
748
+ fail(where, "must specify min, max, or both");
749
+ }
750
+ for (const [limit, value] of Object.entries(band)) {
751
+ if (typeof value !== "number" || !Number.isFinite(value)) {
752
+ fail(`${where}.${limit}`, "must be a finite number");
753
+ }
754
+ }
755
+ if (band.min !== undefined && band.max !== undefined && band.min > band.max) {
756
+ fail(where, "min must not exceed max");
757
+ }
758
+ scoreBands[metric] = Object.freeze({ ...band });
759
+ }
760
+
711
761
  if (section.assets !== undefined && !Array.isArray(section.assets)) {
712
762
  fail("packageBuild.assets", "must be a list");
713
763
  }
@@ -809,6 +859,7 @@ export function resolvePackageBuildConfig(shared) {
809
859
  return Object.freeze({
810
860
  rootDir: shared.rootDir,
811
861
  proseLint: Object.freeze(proseOptions),
862
+ proseScore: Object.freeze({ minWords: scoreMinWords, bands: Object.freeze(scoreBands) }),
812
863
  // Where the package is assembled before it is zipped or deployed. Every
813
864
  // asset destination is relative to it, so a repository's table says
814
865
  // `lang`, not `build/stage/lang` — the latter is what each consumer's
package/docs/api.md CHANGED
@@ -623,7 +623,7 @@ The closed registry of system ids, and the `none` that stands for no system at a
623
623
 
624
624
  ### `engine.calendars`
625
625
 
626
- The axis every date normalises onto, the arithmetic over a calendar's month list, and the moon. A reckoning is an **era** declared on the note about the calendar that counts those years, and its epoch is the `start` written on that row — nothing is registered in configuration. The year conversion is piecewise on the sign, because the authored numbering has no year zero while the normalised value does: authored `-1` is `0`, and `-1` and `1` are adjacent. `lunarPhase` takes a floored modulo for the same reason the conversion branches: a truncated one is right on one side of an epoch and wrong on the other.
626
+ The axis every date normalises onto, the arithmetic over a calendar's month list, and the moon. A calendar's `data.epoch` anchors its year 1, day 1 on that axis; its eras declare in-calendar start years. The year conversion is piecewise on the sign, because the authored numbering has no year zero while the normalised value does: authored `-1` is `0`, and `-1` and `1` are adjacent. `lunarPhase` uses a floored modulo on either side of the epoch.
627
627
 
628
628
  The neutral day spelling is `<year>.<day>[:HHMMSS]`. The pure conversion functions take the world's year length and an explicit origin year, so their arithmetic names no month or era. A caller supplies those values from its content; no setting year is baked into the toolchain.
629
629
 
@@ -647,29 +647,29 @@ The neutral day spelling is `<year>.<day>[:HHMMSS]`. The pure conversion functio
647
647
 
648
648
  ### `engine.noteDates`
649
649
 
650
- A date on a note is one authored string. `<year>.<day>[:HHMMSS]` names a calendar-neutral day; `datefrom vrcal 23 Taranis 326 VR` names a calendar, a named month, and an era. Either form may begin with `~` for an approximate date. A resolved calendar date carries a canonical year, day, span and sort key. Print `prose ?? text`, order on `sort`, and do arithmetic on `canonicalYear`. The literal `unknown` has no sort key or canonical year. `resolvedDateFields` supplies identical derived records to JSONL and generated page frontmatter.
651
-
652
- | Export | Signature | Returns | Use it when |
653
- | ----------------------- | ------------------------------------------------ | -------------------------------------------------- | -------------------------------------------------------------- |
654
- | `UNKNOWN_DATE` | `const UNKNOWN_DATE` | — | naming the literal that says a date is not recorded |
655
- | `NOTE_DATE_PATTERN` | `const NOTE_DATE_PATTERN` | — | matching the date grammar directly |
656
- | `DATEFROM_PATTERN` | `const DATEFROM_PATTERN` | — | recognizing a named calendar conversion in frontmatter |
657
- | `ERA_QUALIFIER_PATTERN` | `const ERA_QUALIFIER_PATTERN` | — | checking that a string is an era qualifier a date could carry |
658
- | `parseNoteDate` | `parseNoteDate(value, options)` | `{date, findings}` — `date` is `null` when refused | reading a note's authored date, with every finding it earned |
659
- | `eraCovering` | `eraCovering(date, eras, daysPerYear)` | one era or `null`; throws on overlap | selecting the era covering a resolved date's full precision |
660
- | `formatNoteDate` | `formatNoteDate(date, era, daysPerYear)` | `{era, year, month, day, text, prose}` or `null` | printing a resolved date in a chosen era or its authored form |
661
- | `formatDateInCalendar` | `formatDateInCalendar(date, reference, context)` | printable record or `null` | selecting a calendar's era and printing a resolved date in it |
662
- | `calendarEras` | `calendarEras(reference, context)` | ordered era records | resolving an addressed calendar or one of its eras |
663
- | `resolvedDateFields` | `resolvedDateFields(fm, context)` | map of normalized dates | deriving being and lore event date records for index and pages |
650
+ A date on a note is one authored scalar. `<year>[.<day>[:HHMMSS]]` names a calendar-neutral year, day, or second; `datefrom vrcal 23 Taranis 326 VR` names a calendar, a named month, and an era. Year and day values span their written interval; a clock time identifies a second. Either form may begin with `~` for uncertainty beyond that interval. The `approximate` field records that explicit marker, while `precision` and `seconds` describe the interval or time. A resolved date carries a canonical year, span, and sort key; a neutral year has no `canonicalDay`, while day-precision dates have one. Print `prose ?? text`, order on `sort`, and do arithmetic on `canonicalYear`. The literal `unknown` has no sort key or canonical year. `resolvedDateFields` supplies identical derived records to JSONL and generated page frontmatter.
651
+
652
+ | Export | Signature | Returns | Use it when |
653
+ | ----------------------- | ------------------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------- |
654
+ | `UNKNOWN_DATE` | `const UNKNOWN_DATE` | — | naming the literal that says a date is not recorded |
655
+ | `NOTE_DATE_PATTERN` | `const NOTE_DATE_PATTERN` | — | matching the date grammar directly |
656
+ | `DATEFROM_PATTERN` | `const DATEFROM_PATTERN` | — | recognizing a named calendar conversion in frontmatter |
657
+ | `ERA_QUALIFIER_PATTERN` | `const ERA_QUALIFIER_PATTERN` | — | checking that a string is an era qualifier a date could carry |
658
+ | `parseNoteDate` | `parseNoteDate(value, options)` | `{date, findings}` — `date` is `null` when refused | reading a note's authored date, with every finding it earned |
659
+ | `eraCovering` | `eraCovering(date, eras, daysPerYear)` | one era or `null`; throws on overlap | selecting the era covering a resolved date's full precision |
660
+ | `formatNoteDate` | `formatNoteDate(date, era, daysPerYear, formatName?)` | `{era, year, month, day, text, prose}` or `null` | printing a resolved date in a chosen era or its authored form |
661
+ | `formatDateInCalendar` | `formatDateInCalendar(date, reference, context, formatName?)` | printable record or `null` | selecting a calendar's era and printing a resolved date in it |
662
+ | `calendarEras` | `calendarEras(reference, context)` | ordered era records | resolving an addressed calendar or one of its eras |
663
+ | `resolvedDateFields` | `resolvedDateFields(fm, context)` | map of normalized dates | deriving being and lore event date records for index and pages |
664
664
 
665
665
  ### `engine.dateConversion`
666
666
 
667
- The exact-day conversion functions use the corpus reckoning context. A calendar with several eras requires an era in the named date. They reject dates outside the selected calendar and gaps between its eras. Approximate input keeps its `~` prefix in the result.
667
+ The exact-day conversion functions use the corpus reckoning context. `dateFromCalendar` reads the `std` pattern, or the first named pattern. An era-relative year needs its era label. They reject dates outside the selected era; approximate input keeps its `~` prefix.
668
668
 
669
- | Export | Signature | Returns | Use it when |
670
- | ------------------ | --------------------------------------------------- | --------------------- | --------------------------------------------- |
671
- | `dateFromCalendar` | `dateFromCalendar(reference, value, context)` | canonical date string | converting one calendar day to the world axis |
672
- | `dateToCalendar` | `dateToCalendar(reference, canonicalDate, context)` | calendar date string | expressing one canonical day in a calendar |
669
+ | Export | Signature | Returns | Use it when |
670
+ | ------------------ | ---------------------------------------------------------------- | --------------------- | --------------------------------------------- |
671
+ | `dateFromCalendar` | `dateFromCalendar(reference, value, context)` | canonical date string | converting one calendar day to the world axis |
672
+ | `dateToCalendar` | `dateToCalendar(reference, canonicalDate, context, formatName?)` | calendar date string | expressing one canonical day in a calendar |
673
673
 
674
674
  ### `engine.calendarChoice`
675
675
 
@@ -700,7 +700,7 @@ invokes the check during frontmatter lint.
700
700
 
701
701
  ### `engine.reckoningMarkers`
702
702
 
703
- The corpus registry maps every declared era to its calendar and each marker to exactly one era. Era starts may use the canonical axis or another declared era. Within one calendar, starts increase; the next start bounds the preceding era unless an explicit end leaves a gap. The first era also covers dates before its start and counts them with negative years. Later eras accept only forward year counts. Unknown eras, duplicate markers, invalid bounds, overlapping eras and dependency cycles are findings. Short qualifiers are ambiguous when they name eras in several packages; a full address selects one. This registry governs note dates; calendar definitions emitted for Foundry retain their consumer format.
703
+ The corpus registry anchors each calendar's year 1, day 1 to its canonical `data.epoch` and maps every declared era to that calendar. Exactly one `start: null` era covers earlier years and counts backwards; exactly one `start: 1` era begins the forward count. Other starts are distinct positive calendar years. Numeric order determines bounds, irrespective of array order. Duplicate starts and markers, missing required eras, and invalid epochs are findings. Short qualifiers are ambiguous when they name eras in several packages; a full Address selects one.
704
704
 
705
705
  | Export | Signature | Returns | Use it when |
706
706
  | ------------------------- | --------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------- |
@@ -720,7 +720,7 @@ A calendar is a `lore` note with `subType: calendar`, declaring the months it ke
720
720
  | `CALENDAR_FORMAT_TOKENS` | `const CALENDAR_FORMAT_TOKENS` | — | checking bare date tokens in a format string |
721
721
  | `INVARIANT_FIELDS` | `const INVARIANT_FIELDS` | — | reading the closed list of what a body may declare about itself |
722
722
  | `checkCalendarNote` | `checkCalendarNote(note, opts)` | `object[]` — findings | holding a `lore` note to the family and to the year the world keeps |
723
- | `checkCalendarDateFormats` | `checkCalendarDateFormats(note)` | `object[]` — findings | checking display slots and escaped literal words |
723
+ | `checkCalendarDateFormats` | `checkCalendarDateFormats(note)` | `object[]` — findings | checking named patterns and the readable default |
724
724
  | `checkWorldFacts` | `checkWorldFacts(note, opts)` | `object[]` — findings | holding a `place` note's world facts to one body, one year and one moon |
725
725
  | `worldInvariants` | `worldInvariants(index)` | `{year, present, moon, body, moonName, …}` | reading the year, the present and the moon a package's own notes state |
726
726
  | `quarterDays` | `quarterDays(daysPerYear)` | `number[]` — four days of the year | asking which days the year turns on |
@@ -7,27 +7,79 @@ subType: howto
7
7
 
8
8
  # Dates, calendars, and eras
9
9
 
10
- A canonical date names a day without assuming how a culture divides a year. Write `<year>.<day>[:HHMMSS]`: `720.136` is day 136 of year 720, and `720.136:143005` adds a 24-hour time. A negative year is valid. The day must fit the world's declared year length. The dot separates year from **day of year**, not month from day.
10
+ A canonical date uses the world's year and day count without choosing a culture's calendar. Write `<year>[.<day>[:HHMMSS]]`: `720` covers year 720, `720.136` covers its day 136, and `720.136:143005` identifies 14:30:05 on that day. The dot separates the year from the **day of year**, not a month. Negative years are valid, and the day must fit the world's declared year length. Prefix a date with `~` when its uncertainty extends outside the stated year, day, or second. `unknown` records an occurrence whose date is not known, in fields that permit it.
11
11
 
12
- Authors can instead write a named calendar date in frontmatter:
12
+ ## Define a calendar
13
+
14
+ A `lore` note with `subType: calendar` names months, weekdays, seasons, named days, eras, and date formats. Its `data.epoch` is the **canonical day that equals calendar year 1, day 1**. For a calendar beginning on canonical day `1.91`, write `epoch: 1.91`: calendar year 1 still starts at its own day 1. A calendar does not state the length of the world year: its months must sum to `data.year.days` on the world's `place` note.
13
15
 
14
16
  ```yaml
17
+ shortcode: commoncal
18
+ name: { full: The Common Calendar }
19
+ type: lore
20
+ subType: calendar
15
21
  data:
16
- born: datefrom vrcal 23 Taranis 326 VR
17
- died: ~326.114
22
+ epoch: 1.1
23
+ months:
24
+ - { name: Floralis, abbreviation: Flor, days: 30 }
25
+ - { name: Lusenar, abbreviation: Luse, days: 31 }
26
+ - { name: Murkas, abbreviation: Murk, days: 30 }
27
+ - { name: Taranis, abbreviation: Tara, days: 31 }
28
+ - { name: Vulcar, abbreviation: Vulc, days: 30 }
29
+ - { name: Menaris, abbreviation: Mena, days: 31 }
30
+ - { name: Venuris, abbreviation: Venu, days: 30 }
31
+ - { name: Karnavar, abbreviation: Karn, days: 30 }
32
+ - { name: Morveth, abbreviation: Morv, days: 31 }
33
+ - { name: Thanaris, abbreviation: Than, days: 30 }
34
+ - { name: Aetheris, abbreviation: Aeth, days: 31 }
35
+ - { name: Janar, abbreviation: Jana, days: 30 }
36
+ weekdays:
37
+ - { name: Newday, abbreviation: New }
38
+ - { name: Tillday, abbreviation: Til }
39
+ - { name: Growday, abbreviation: Gro }
40
+ - { name: Harvestday, abbreviation: Hrv }
41
+ - { name: Reapday, abbreviation: Rep }
42
+ - { name: Slowday, abbreviation: Slo }
43
+ - { name: Setday, abbreviation: Set }
44
+ seasons:
45
+ - { name: Spring, abbreviation: Spr, start: 1 }
46
+ - { name: Summer, abbreviation: Sum, start: 92 }
47
+ - { name: Fall, abbreviation: Fal, start: 183 }
48
+ - { name: Winter, abbreviation: Win, start: 274 }
49
+ namedDays:
50
+ - { name: New Years Day, abbreviation: NY, day: 1 }
51
+ formats:
52
+ std: "MM/DD/Y GGG"
53
+ long: "D MMMM Y GGG"
54
+ eras:
55
+ - { shortcode: bvr, name: Before Vylarian Reckoning, abbreviation: BVR, start: null }
56
+ - { shortcode: vr, name: Vylarian Reckoning, abbreviation: VR, start: 1 }
18
57
  ```
19
58
 
20
- `datefrom` takes a calendar shortcode or Address, then the date in that calendar. The calendar defines month names and eras. A date can have day, month, or year precision. Prefix either spelling with `~` for an approximate date. `unknown` means an occurrence is known but its date is not recorded, in fields that accept it. These are the frontmatter date forms; do not put a Handlebars expression there.
59
+ The `weekdays` array gives the names in order, starting at index 0. Weeks continue across month and year boundaries. Leave `weekdays` out when the calendar has no week. `seasons[].start` is the one-based day of year on which a season begins. Starts increase in array order. A season continues through the day before the next, and the last wraps through the beginning of the next year when the first season starts after day 1. `namedDays[].day` is also a one-based day of year. Its name appears through `[namedDay]`; `[namedDayAbbr]` prints the abbreviation.
60
+
61
+ Every calendar has exactly one era with `start: null` and one with `start: 1`. The null era covers the years before calendar year 1 and counts them backwards: under `Y GGG`, package-build prints canonical `-50.2` as `50 BVR` when the epoch is `1.1`. This null-start era is an authoring extension and is omitted from the Calendaria definition; Foundry's calendar represents current time. Each other `start` is a distinct, positive **calendar year**, never a day-precise date: `start: 701` begins on day 1 of calendar year 701. `start: 1` begins on the canonical day given by `epoch`, even when that day is `1.91` or another date. Eras end where the next numeric start begins. Array order does not determine chronology. The optional `marker` identifies an era in authored dates and must be unique across the corpus.
62
+
63
+ `formats` contains any number of named Calendaria patterns. `std` is the default for `dateto`, `datefrom`, and `dateformat`; if absent, the first named pattern is the default. The default pattern must contain enough information to read a day back: a complete year (`Y`, `YYYY`, or `[yearInEra]`), a day of year (`DDD`) or month and day, and an era label when using `[yearInEra]`. `Y` means the calendar year in Calendaria; `[yearInEra]` means the year counted from the era's start. In package-build's null-start era, `Y`, `YY`, and `YYYY` print the positive historical year. Other named patterns can use display-only Calendaria tokens. Use `package-build dateto <calendar> <canonical-date> --format <name>` or `{{dateformat "commoncal" data.born "long"}}` to select another output format. The [format token reference](../reference/format-details.md#calendar-format-tokens) lists every Calendaria token and the input constraints.
64
+
65
+ ## Write and convert dates
21
66
 
22
- A calendar defines the order and length of months. An era defines where a year count begins. One calendar can have several eras; a multi-era date names its era. Era starts increase on the canonical timeline, with gaps allowed. Dates before the first era use a negative year in that first era; later eras count forward from their own year 1. There is no year zero. A date in a gap has no named era in that calendar.
67
+ A frontmatter date can use the canonical form or `datefrom <calendar> <date in the default format>`. The calendar may be a shortcode or Address. A named date keeps the precision it states: with the sample `std` pattern, `326 VR` covers a year, `04/326 VR` covers Taranis, and `04/23/326 VR` covers its twenty-third day. A date with an era-relative year names its era.
68
+
69
+ ```yaml
70
+ data:
71
+ born: datefrom commoncal 04/23/326 VR
72
+ died: ~326.114
73
+ ```
23
74
 
24
- Use the CLI to inspect a conversion:
75
+ The conversion commands take the same forms:
25
76
 
26
77
  ```bash
27
- package-build datefrom vrcal '23 Taranis 326 VR'
28
- package-build dateto vrcal 326.114
78
+ package-build datefrom commoncal '04/23/326 VR'
79
+ package-build dateto commoncal 326.114
80
+ package-build dateto commoncal 326.114 --format long
29
81
  ```
30
82
 
31
- `datefrom` prints the canonical value; `dateto` prints a named date in the era covering it. The [detailed calendar rules](../reference/format-details.md#dates-and-calendars) explain precision, era markers, bounds, and resolution errors.
83
+ The CLI `datefrom` command requires a day and reads the default (`std`, or first) pattern. `dateto` prints the era covering the canonical day. A clock time makes a date exact; a year or day without one spans the stated interval. `data.epoch` and `data.moon.newOn` require a day. The [detailed date rules](../reference/format-details.md#dates-and-calendars) describe precision and bounds.
32
84
 
33
- In prose, `{{dateformat "vrcal" data.born}}` renders a date in the chosen calendar. `{{dateformat data.calendar data.born}}` uses the note's own calendar Address. A being or place can set `data.calendar`; a being's infobox displays its dates in that calendar, and a place's infobox displays its `present` there.
85
+ In prose, `{{dateformat "commoncal" data.born}}` prints a date in the chosen calendar. `{{dateformat data.calendar data.born}}` uses the note's calendar Address. A third argument chooses another named pattern. A being or place can set `data.calendar`; its infobox uses that calendar for dates.
@@ -134,4 +134,4 @@ The index contains {{digits (sql "SELECT COUNT(*) FROM entries")}} entries.
134
134
  {{and (gt 3 5) (lt 4 2)}}
135
135
  ```
136
136
 
137
- Use `{{dateformat data.calendar data.born}}` when the note supplies a calendar Address. SQL fences produce tables; the `sql` helper returns one value for an inline expression. Wrap a numeric result in `words` for running prose or `digits` for a grouped numeral. See [date rules](dates-and-calendars.md) and [SQL details](../reference/format-details.md#content-tables) for query options.
137
+ Use `{{dateformat data.calendar data.born}}` when the note supplies a calendar Address. A third argument selects a named output pattern, as in `{{dateformat data.calendar data.born "long"}}`. SQL fences produce tables; the `sql` helper returns one value for an inline expression. Wrap a numeric result in `words` for running prose or `digits` for a grouped numeral. See [date rules](dates-and-calendars.md) and [SQL details](../reference/format-details.md#content-tables) for query options.