@heroiclands/package-build 23.8.0 → 23.10.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 (114) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +2 -0
  3. package/bin/content-commands.mjs +207 -70
  4. package/docs/api.md +646 -78
  5. package/docs/authoring/first-note.md +1 -1
  6. package/docs/authoring/links-and-markup.md +253 -102
  7. package/docs/commands.md +111 -13
  8. package/docs/content-format.md +7 -2
  9. package/docs/diagnostics.md +1 -1
  10. package/docs/guides/packs.md +2 -1
  11. package/docs/guides/reference-pages.md +86 -63
  12. package/docs/reference/format-details.md +398 -137
  13. package/docs/reference/note-types.md +188 -174
  14. package/engine/anchors.mjs +15 -26
  15. package/engine/base-compiler.mjs +6 -4
  16. package/engine/changelog-group.mjs +58 -34
  17. package/engine/changelog-lint.mjs +6 -2
  18. package/engine/compendiums.mjs +15 -1
  19. package/engine/compile-corpus.mjs +17 -3
  20. package/engine/content-charset.mjs +13 -1
  21. package/engine/content-format.yaml +1 -0
  22. package/engine/content-html.mjs +18 -4
  23. package/engine/content-icons.mjs +15 -1
  24. package/engine/content-images.mjs +28 -9
  25. package/engine/content-index.mjs +496 -16
  26. package/engine/content-links.mjs +70 -27
  27. package/engine/content-session.mjs +247 -0
  28. package/engine/content-sources.mjs +110 -0
  29. package/engine/content-spans.mjs +87 -8
  30. package/engine/content-tables.mjs +9 -4
  31. package/engine/content-tasklists.mjs +20 -4
  32. package/engine/culture-reach.mjs +61 -34
  33. package/engine/data-keys.mjs +3 -1
  34. package/engine/diagnostics.mjs +17 -6
  35. package/engine/dictionary-layout.mjs +151 -19
  36. package/engine/dictionary-rows.mjs +129 -79
  37. package/engine/extension-attributes.mjs +28 -5
  38. package/engine/first-use.mjs +371 -0
  39. package/engine/foundry-entries.mjs +29 -8
  40. package/engine/generate.mjs +13 -4
  41. package/engine/glossary-entries.mjs +11 -11
  42. package/engine/heading-attributes.mjs +21 -28
  43. package/engine/helpers.mjs +54 -19
  44. package/engine/index-records.mjs +1 -0
  45. package/engine/index.mjs +3 -0
  46. package/engine/journals.mjs +13 -6
  47. package/engine/language-data.mjs +55 -2
  48. package/engine/language-rows.mjs +4 -4
  49. package/engine/language-terms.mjs +90 -4
  50. package/engine/language-words.mjs +2 -2
  51. package/engine/lexical-sections.mjs +247 -0
  52. package/engine/markup-validation.mjs +7 -2
  53. package/engine/metadata-index.mjs +1 -0
  54. package/engine/name-markup.mjs +114 -0
  55. package/engine/name-rows.mjs +70 -0
  56. package/engine/names.mjs +460 -0
  57. package/engine/note-addresses.mjs +25 -4
  58. package/engine/note-claims.mjs +2 -0
  59. package/engine/note-state.mjs +9 -3
  60. package/engine/note-vocabulary.mjs +7 -6
  61. package/engine/path-charset.mjs +24 -1
  62. package/engine/pdf-build.mjs +9 -9
  63. package/engine/pdf-render.mjs +15 -1
  64. package/engine/site-build.mjs +16 -2
  65. package/engine/site-index.mjs +5 -1
  66. package/engine/sql-tables.mjs +324 -44
  67. package/engine/stub-lint.mjs +10 -5
  68. package/engine/web-wikilinks.mjs +8 -6
  69. package/engine/word-rows.mjs +20 -12
  70. package/engine/word-terms.mjs +15 -6
  71. package/engine/yaml-lint.mjs +3 -5
  72. package/hugo-theme/static/css/style.css +4 -0
  73. package/package.json +1 -1
  74. package/types/engine/anchors.d.mts +4 -2
  75. package/types/engine/compendiums.d.mts +5 -1
  76. package/types/engine/compile-corpus.d.mts +5 -2
  77. package/types/engine/content-charset.d.mts +5 -1
  78. package/types/engine/content-html.d.mts +5 -1
  79. package/types/engine/content-icons.d.mts +5 -1
  80. package/types/engine/content-images.d.mts +5 -1
  81. package/types/engine/content-index.d.mts +102 -18
  82. package/types/engine/content-links.d.mts +22 -8
  83. package/types/engine/content-session.d.mts +108 -0
  84. package/types/engine/content-sources.d.mts +32 -0
  85. package/types/engine/content-spans.d.mts +3 -5
  86. package/types/engine/content-tables.d.mts +2 -1
  87. package/types/engine/content-tasklists.d.mts +5 -1
  88. package/types/engine/culture-reach.d.mts +22 -2
  89. package/types/engine/data-keys.d.mts +7 -0
  90. package/types/engine/diagnostics.d.mts +10 -0
  91. package/types/engine/dictionary-layout.d.mts +35 -4
  92. package/types/engine/dictionary-rows.d.mts +9 -0
  93. package/types/engine/extension-attributes.d.mts +16 -2
  94. package/types/engine/first-use.d.mts +31 -0
  95. package/types/engine/foundry-entries.d.mts +14 -5
  96. package/types/engine/generate.d.mts +5 -1
  97. package/types/engine/heading-attributes.d.mts +2 -2
  98. package/types/engine/helpers.d.mts +33 -7
  99. package/types/engine/index.d.mts +1 -0
  100. package/types/engine/journals.d.mts +5 -2
  101. package/types/engine/language-terms.d.mts +7 -0
  102. package/types/engine/lexical-sections.d.mts +96 -0
  103. package/types/engine/markup-validation.d.mts +2 -1
  104. package/types/engine/name-markup.d.mts +176 -0
  105. package/types/engine/name-rows.d.mts +35 -0
  106. package/types/engine/names.d.mts +356 -0
  107. package/types/engine/note-addresses.d.mts +3 -3
  108. package/types/engine/note-state.d.mts +2 -1
  109. package/types/engine/path-charset.d.mts +16 -1
  110. package/types/engine/site-build.d.mts +6 -3
  111. package/types/engine/site-index.d.mts +3 -1
  112. package/types/engine/sql-tables.d.mts +39 -4
  113. package/types/engine/stub-lint.d.mts +3 -1
  114. package/types/engine/word-rows.d.mts +9 -7
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ **Notes**
8
+
9
+ - Notes with CRLF frontmatter retain their identity, body and links in compiled content and published indexes.
10
+ - Correct malformed YAML in CRLF frontmatter before building; YAML errors in these notes prevent publication.
11
+
12
+ **Language notes**
13
+
14
+ - Language notes write a name element's joining form as `joining`; `genitive` is refused.
15
+ - Compiled-skill references in culture, literature and affiliation language fields are refused instead of silently dropping glossary entries; write `skill-provnzlng` or `<pkg>-note-skill-provnzlng`.
16
+
17
+ ### Patch Changes
18
+
19
+ **Names**
20
+
21
+ - Mark names without notes as `[Name]{.name}` and link indexed names; lint checks introductions and repeats, and marked names appear bold on sites and in books.
22
+ - Mark unwritten names with `[Name]{.name data-type="place"}`; query indexed uses and review grouped lint advice about unwritten names and conflicting types.
23
+
24
+ **Commands and release notes**
25
+
26
+ - Selected content checks and compendium builds can run together in one command.
27
+ - Release notes retain every summary when paragraphs and bullets are combined.
28
+
29
+ ## 23.9.0
30
+
31
+ ### Minor Changes
32
+
33
+ **Dictionaries and glossaries**
34
+
35
+ - Language dictionaries and declared culture or affiliation glossaries appear automatically in journals, sites and books; remove SQL fences for these defaults.
36
+ - Dictionaries use one full alphabetical list with field tags and one key; existing anchored Dictionary, Lexicon and Glossary headings keep their introductions and receive the lists.
37
+ - Language notes with nonempty `data.words`, and cultures or affiliations with nonempty `data.glossary`, need no prose body; glossary sections show only their owner's declared entries.
38
+ - Glossary entries naming dictionary words show their class and word link, with dictionary senses or the entry's context definition; sole-owner sense labels are omitted on that owner's page, while other attribution remains.
39
+ - Common marks belong only to glossary terms; select or filter `terms.common`, because queries using `dictionary.common` fail.
40
+ - Custom culture views select the culture's own entries and reached ranks or offices, applying common marks only to entries the culture owns.
41
+ - Glossary entries without definitions must name a word in their resolved language or a reachable rank or office; specify the correct language or supply a definition when a name cannot resolve.
42
+
43
+ **Links and publishing**
44
+
45
+ - Correct links to headings inside HTML comments or raw HTML, which publish no section; builds also refuse section links absent from a dependency's published notes, including pages without prose anchors or journal documents.
46
+ - Rebuild and fetch package indexes so dependent packages can link to generated sections and SQL-expression anchors; readable item documentation anchors target their documentation journals.
47
+ - Engine publishers with dynamic content must await `prepareContentIndex` and pass its publication to `emitContentIndex` before writing metadata.
48
+
49
+ ### Patch Changes
50
+
51
+ - Language dictionaries can show optional noun plurals and one verb pattern beside existing gender labels; languages without these details retain their output.
52
+ - Dictionary fragments without a key omit the verb table; key-only fragments print abbreviations alone.
53
+ - Built words show element senses in dictionary etymologies and pass the built-piece check, including declared variants, inherited elements and front-vowel affix forms.
54
+ - Public dictionaries omit GM-only citations, preventing false link errors from hidden notes.
55
+
3
56
  ## 23.8.0
4
57
 
5
58
  ### Minor Changes
package/README.md CHANGED
@@ -16,6 +16,8 @@ npm install -D @heroiclands/package-build
16
16
 
17
17
  The generated project includes a homepage, website and book targets, npm scripts, and a local `.gitignore`. [Getting started](docs/getting-started.md) routes you to a [system](docs/guides/system-project.md), [module](docs/guides/module-project.md), or [content authoring](docs/authoring/first-note.md) path. The [content-language-server](https://github.com/HeroicLands/content-language-server) provides editor navigation and completion.
18
18
 
19
+ Run selected content operations together with `package-build content run index lint links compile`. The [shared content command](docs/commands.md#package-build-content-run-targets) captures one source tree for the selected checks and outputs; standalone commands remain available.
20
+
19
21
  ## Working on this repository
20
22
 
21
23
  Run `npm ci` in each fresh clone or worktree. Its `prepare` step installs the
@@ -105,14 +105,22 @@ import { markupFindings } from "../engine/markup-validation.mjs";
105
105
  import { checkFormatting, checkPrettierConventions, lintMarkdown } from "../engine/prose-lint.mjs";
106
106
  import { lintProse, scoreProseTree } from "../engine/readability-lint.mjs";
107
107
  import { loadPackageBuildConfig } from "../config.mjs";
108
- import { emitContentIndex, indexRecordsFor, isAssetRecord } from "../engine/content-index.mjs";
108
+ import {
109
+ emitContentIndex,
110
+ prepareContentIndex,
111
+ indexRecordsFor,
112
+ isAssetRecord,
113
+ } from "../engine/content-index.mjs";
109
114
  import {
110
115
  buildSite,
111
116
  gatesFailed,
112
117
  formatUnaddressableFinding as formatUnaddressable,
113
118
  } from "../engine/site-build.mjs";
114
119
  import { auditLinks, buildLinkIndex, walkReachability } from "../engine/content-links.mjs";
115
- import { prepareTreeSqlTables } from "../engine/sql-tables.mjs";
120
+ import { prepareLinkSqlTables, prepareTreeSqlTables } from "../engine/sql-tables.mjs";
121
+ import { createContentSession, CONTENT_TARGETS } from "../engine/content-session.mjs";
122
+ import { createFirstUseChecker } from "../engine/first-use.mjs";
123
+ import { createNameCatalog, createNamesReport, namesReportFinding } from "../engine/names.mjs";
116
124
  // The one place a link finding is worded, shared with both builds.
117
125
  import { linkFindingMessage } from "../engine/wikilink-syntax.mjs";
118
126
  import {
@@ -178,6 +186,11 @@ prefix.apply(log, {
178
186
  },
179
187
  });
180
188
 
189
+ const CONTENT_SESSION = Symbol("content session");
190
+ const CONTENT_CONFIG = Symbol("content configuration");
191
+ const PREPARATION_REPORTED = Symbol("preparation reported");
192
+ const REPORTED_FAILURES = Symbol("reported preparation failures");
193
+
181
194
  /**
182
195
  * Report a command's failure.
183
196
  *
@@ -198,6 +211,29 @@ function reportFailure(err) {
198
211
  else log.error(message);
199
212
  }
200
213
 
214
+ /** Report one shared preparation failure once in a combined invocation. */
215
+ function reportContentFailure(err, argv) {
216
+ const reported = argv[REPORTED_FAILURES];
217
+ if (reported?.has(err)) return;
218
+ reported?.add(err);
219
+ reportFailure(err);
220
+ }
221
+
222
+ /** Apply the package command's existing eligibility rule before a selected writer.
223
+ * @param {object} config - Resolved configuration.
224
+ * @param {string} action - The requested package action.
225
+ */
226
+ function assertCompendiumOperation(config, action) {
227
+ if (!compilesFoundryDocuments(config)) {
228
+ throw new Error(
229
+ `\`packageKind: ${config.packageKind}\` compiles no ` +
230
+ `compendium, so there is nothing to ${action}. Build ` +
231
+ `its site with \`package-build site\` and its book ` +
232
+ `with \`package-build pdf\`.`,
233
+ );
234
+ }
235
+ }
236
+
201
237
  /**
202
238
  * A note's source text, or `""` when it cannot be read.
203
239
  *
@@ -230,7 +266,12 @@ export function registerContentCommands(cli) {
230
266
  return cli
231
267
  .command(
232
268
  withIndexPreflight(
233
- withBodyPreflight(packageCommand(), (_config, argv) => argv.action === "compile"),
269
+ withBodyPreflight(
270
+ packageCommand(),
271
+ (_config, argv) => argv.action === "compile",
272
+ undefined,
273
+ true,
274
+ ),
234
275
  (_config, argv) => argv.action === "compile",
235
276
  ),
236
277
  )
@@ -240,6 +281,7 @@ export function registerContentCommands(cli) {
240
281
  .command(proseCommand())
241
282
  .command(contentFormatCommand())
242
283
  .command(withIndexPreflight(linksCommand()))
284
+ .command(withIndexPreflight(contentCommand()))
243
285
  .command(formatCommand())
244
286
  .command(markdownCommand())
245
287
  .command(
@@ -247,6 +289,7 @@ export function registerContentCommands(cli) {
247
289
  contentIndexCommand(),
248
290
  () => true,
249
291
  (config, argv) => argv.root || config.paths.content,
292
+ true,
250
293
  ),
251
294
  )
252
295
  .command(withIndexPreflight(withBodyPreflight(siteCommand()), publishesContentPages))
@@ -399,19 +442,33 @@ function withBodyPreflight(
399
442
  command,
400
443
  shouldCheck = () => true,
401
444
  contentRoot = (config) => config.paths.content,
445
+ shareCapture = false,
402
446
  ) {
403
447
  const handler = command.handler;
404
448
  return {
405
449
  ...command,
406
450
  handler: async (argv) => {
451
+ let owned;
407
452
  try {
408
- const config = loadPackConfig();
453
+ const config = argv[CONTENT_CONFIG] ?? loadPackConfig();
454
+ let next = argv;
409
455
  if (shouldCheck(config, argv)) {
410
456
  const contentBase = contentRoot(config, argv);
411
457
  if (!fs.existsSync(contentBase)) return handler(argv);
412
- const records = indexRecordsFor({ config, contentBase });
458
+ const session =
459
+ shareCapture ?
460
+ (argv[CONTENT_SESSION] ??
461
+ (owned = await createContentSession({ config, contentBase })))
462
+ : undefined;
463
+ const records = session?.records ?? indexRecordsFor({ config, contentBase });
464
+ if (session?.problems.length) {
465
+ emitDiagnostic(session.problems[0]);
466
+ process.exitCode = 1;
467
+ return;
468
+ }
413
469
  const { findings } = lintNoteStates(contentBase, {
414
470
  records,
471
+ parsed: session?.parsed,
415
472
  contentPackage: config.contentPackage,
416
473
  });
417
474
  const errors = findings.filter((finding) => finding.severity === "error");
@@ -420,13 +477,16 @@ function withBodyPreflight(
420
477
  process.exitCode = 1;
421
478
  return;
422
479
  }
480
+ if (session) next = { ...argv, [CONTENT_SESSION]: session };
423
481
  }
482
+ return await handler(next);
424
483
  } catch (err) {
425
484
  reportFailure(err);
426
485
  process.exitCode = 1;
427
486
  return;
487
+ } finally {
488
+ owned?.dispose();
428
489
  }
429
- return handler(argv);
430
490
  },
431
491
  };
432
492
  }
@@ -452,7 +512,7 @@ function withIndexPreflight(command, shouldCheck = () => true) {
452
512
  process.exitCode = 1;
453
513
  return;
454
514
  }
455
- return handler(argv);
515
+ return handler({ ...argv, [CONTENT_CONFIG]: config });
456
516
  },
457
517
  };
458
518
  }
@@ -901,8 +961,10 @@ function lintCommand() {
901
961
  });
902
962
  },
903
963
  handler: async (argv) => {
964
+ let session;
904
965
  try {
905
- const config = loadPackConfig();
966
+ const config =
967
+ argv[CONTENT_SESSION]?.config ?? argv[CONTENT_CONFIG] ?? loadPackConfig();
906
968
  const root = argv.root ?? config.paths.content;
907
969
 
908
970
  // The corpus, enumerated once for this command and handed to
@@ -916,30 +978,22 @@ function lintCommand() {
916
978
  // finding rather than thrown: one malformed note must
917
979
  // not take every other finding in the tree with it, and the
918
980
  // reader needs a line to open, not a stack.
919
- const corpusProblems = [];
920
- const parsed = new Map();
921
981
  const indexStarted = performance.now();
922
- const records = indexRecordsFor({
923
- contentBase: root,
924
- config,
925
- skipDirectories: config.skipDirectories,
926
- problems: corpusProblems,
927
- parsed,
928
- });
982
+ session =
983
+ argv[CONTENT_SESSION] ??
984
+ (await createContentSession({ config, contentBase: root }));
985
+ session.assertContext({ config, contentBase: root });
986
+ const { problems: corpusProblems, parsed, records } = session;
929
987
  const indexMs = performance.now() - indexStarted;
930
- for (const problem of corpusProblems) emitDiagnostic(problem);
988
+ if (!argv[PREPARATION_REPORTED])
989
+ for (const problem of corpusProblems) emitDiagnostic(problem);
931
990
  // An error whatever the command's own strictness: the note is
932
991
  // absent from every answer below, so reporting it and exiting 0
933
992
  // would call the tree clean while silently omitting a note.
934
993
  if (corpusProblems.length) process.exitCode = 1;
935
994
 
936
995
  const sqlStarted = performance.now();
937
- const sqlTables = await prepareTreeSqlTables(root, {
938
- config,
939
- records,
940
- parsed,
941
- skipDirectories: config.skipDirectories,
942
- });
996
+ const sqlTables = await session.sqlTables("all");
943
997
  const sqlMs = performance.now() - sqlStarted;
944
998
  const validationStarted = performance.now();
945
999
 
@@ -954,12 +1008,19 @@ function lintCommand() {
954
1008
  // One index, built once, for the reference check. It is the
955
1009
  // same resolver the wikilink audit uses, so a frontmatter
956
1010
  // reference and a body link answer the same way.
957
- const index = buildLinkIndex(root, {
958
- config,
1011
+ const index = await session.audit("lint");
1012
+ const catalog = createNameCatalog({ records, index });
1013
+ const firstUseChecker = createFirstUseChecker({
959
1014
  records,
960
- skipDirectories: config.skipDirectories,
961
- sqlTables,
1015
+ index,
1016
+ parsed: session.parsed,
1017
+ syntax: session.syntax,
1018
+ catalog,
962
1019
  });
1020
+ const names = namesReportFinding(
1021
+ createNamesReport({ package: config.contentPackage, records, catalog }),
1022
+ { contentBase: root },
1023
+ );
963
1024
  // Which system blocks this tree carries, derived from what it
964
1025
  // declares it ships for. Read before the lint so the
965
1026
  // systems it will *not* check can be said out loud below.
@@ -1099,6 +1160,7 @@ function lintCommand() {
1099
1160
  // the subject is every character authored, including the ones
1100
1161
  // that stopped a note parsing at all.
1101
1162
  const charset = lintContentCharset(root, {
1163
+ sources: session.sources,
1102
1164
  skipDirectories: config.skipDirectories,
1103
1165
  });
1104
1166
 
@@ -1107,6 +1169,7 @@ function lintCommand() {
1107
1169
  // than the parsed notes, so a picture, which has no note,
1108
1170
  // is covered by the same rule.
1109
1171
  const pathCharset = lintPathCharset({
1172
+ inventory: session.inventory,
1110
1173
  contentBase: root,
1111
1174
  assetsBase: config.paths.assets,
1112
1175
  skipDirectories: config.skipDirectories,
@@ -1121,6 +1184,7 @@ function lintCommand() {
1121
1184
  // package's own entries merged over it, which is where a
1122
1185
  // Game-Icons glyph becomes a name a note may write.
1123
1186
  const icons = lintContentIcons(root, {
1187
+ sources: session.sources,
1124
1188
  skipDirectories: config.skipDirectories,
1125
1189
  registry: config.icons,
1126
1190
  });
@@ -1130,6 +1194,7 @@ function lintCommand() {
1130
1194
  // still unrenderable in a book — the character rule one level
1131
1195
  // up.
1132
1196
  const html = lintContentHtml(root, {
1197
+ sources: session.sources,
1133
1198
  skipDirectories: config.skipDirectories,
1134
1199
  });
1135
1200
 
@@ -1138,6 +1203,7 @@ function lintCommand() {
1138
1203
  // ordinary width looks exactly like a directive that worked —
1139
1204
  // so these are errors and they fail the run.
1140
1205
  const images = lintContentImages(root, {
1206
+ sources: session.sources,
1141
1207
  skipDirectories: config.skipDirectories,
1142
1208
  // Which packages this build can reach is configuration, and
1143
1209
  // an address naming one it cannot reaches a journal as a
@@ -1149,6 +1215,7 @@ function lintCommand() {
1149
1215
  // miscompiled, since every surface sets the marker as the
1150
1216
  // item's own text and all three agree, so this is advisory.
1151
1217
  const taskLists = lintContentTaskLists(root, {
1218
+ sources: session.sources,
1152
1219
  skipDirectories: config.skipDirectories,
1153
1220
  });
1154
1221
 
@@ -1158,6 +1225,7 @@ function lintCommand() {
1158
1225
  // wrong, and 464 warnings a reader cannot clear is how a report
1159
1226
  // teaches its reader to stop looking at it.
1160
1227
  const states = lintNoteStates(root, {
1228
+ parsed,
1161
1229
  records,
1162
1230
  contentPackage: config.contentPackage,
1163
1231
  });
@@ -1190,7 +1258,11 @@ function lintCommand() {
1190
1258
  });
1191
1259
 
1192
1260
  const findings = [
1193
- ...index.notes.flatMap((note) => markupFindings(note.raw, note.file)),
1261
+ ...index.notes.flatMap((note) =>
1262
+ markupFindings(note.raw, note.file, session.syntax),
1263
+ ),
1264
+ ...index.notes.flatMap(firstUseChecker),
1265
+ ...(names ? [names] : []),
1194
1266
  ...addresses.findings,
1195
1267
  ...frontmatter.findings,
1196
1268
  ...schemaFindings,
@@ -1229,8 +1301,10 @@ function lintCommand() {
1229
1301
  `SQL ${sqlMs.toFixed(1)} ms; validation ${validationMs.toFixed(1)} ms.`,
1230
1302
  );
1231
1303
  } catch (err) {
1232
- reportFailure(err);
1304
+ reportContentFailure(err, argv);
1233
1305
  process.exitCode = 1;
1306
+ } finally {
1307
+ if (!argv[CONTENT_SESSION]) session?.dispose();
1234
1308
  }
1235
1309
  },
1236
1310
  };
@@ -1402,6 +1476,73 @@ function markdownCommand() {
1402
1476
  * @returns {object} The yargs command module.
1403
1477
  */
1404
1478
  // eslint-disable-next-line
1479
+ function contentCommand() {
1480
+ return {
1481
+ command: "content <action> <targets..>",
1482
+ describe: "Run selected content operations with one in-memory source capture",
1483
+ builder: (yargs) =>
1484
+ yargs
1485
+ .positional("action", { type: "string", choices: ["run"] })
1486
+ .positional("targets", { type: "string", array: true, choices: CONTENT_TARGETS })
1487
+ .option("references", {
1488
+ type: "boolean",
1489
+ default: true,
1490
+ describe: "Check frontmatter references during lint",
1491
+ })
1492
+ .option("pack", {
1493
+ type: "string",
1494
+ describe: "Select one pack for the compile target",
1495
+ })
1496
+ .option("out", {
1497
+ type: "string",
1498
+ describe: "Output directory for the index target",
1499
+ }),
1500
+ handler: async (argv) => {
1501
+ let session;
1502
+ const failures = new Set();
1503
+ const problems = new Set();
1504
+ const shared = { ...argv, [PREPARATION_REPORTED]: true, [REPORTED_FAILURES]: failures };
1505
+ try {
1506
+ const config = argv[CONTENT_CONFIG] ?? loadPackConfig();
1507
+ session = await createContentSession({ config });
1508
+ shared[CONTENT_SESSION] = session;
1509
+ const targets = CONTENT_TARGETS.filter((target) => argv.targets.includes(target));
1510
+ let preparationFailed = false;
1511
+ try {
1512
+ if (targets.includes("compile")) assertCompendiumOperation(config, "compile");
1513
+ await session.prepare(targets);
1514
+ } catch (error) {
1515
+ preparationFailed = true;
1516
+ process.exitCode = 1;
1517
+ if (error.findings) {
1518
+ for (const finding of error.findings) {
1519
+ emitDiagnostic(finding);
1520
+ problems.add(finding);
1521
+ }
1522
+ failures.add(error);
1523
+ } else reportContentFailure(error, shared);
1524
+ }
1525
+ for (const problem of session.problems) {
1526
+ if (!problems.has(problem)) emitDiagnostic(problem);
1527
+ process.exitCode = 1;
1528
+ }
1529
+ for (const target of targets) {
1530
+ if (preparationFailed && (target === "index" || target === "compile")) continue;
1531
+ if (target === "index") await contentIndexCommand().handler(shared);
1532
+ else if (target === "lint") await lintCommand().handler(shared);
1533
+ else if (target === "links") await linksCommand().handler(shared);
1534
+ else await packageCommand().handler({ ...shared, action: "compile" });
1535
+ }
1536
+ } catch (error) {
1537
+ reportContentFailure(error, shared);
1538
+ process.exitCode = 1;
1539
+ } finally {
1540
+ session?.dispose();
1541
+ }
1542
+ },
1543
+ };
1544
+ }
1545
+
1405
1546
  function linksCommand() {
1406
1547
  return {
1407
1548
  command: "links [root]",
@@ -1413,38 +1554,23 @@ function linksCommand() {
1413
1554
  });
1414
1555
  },
1415
1556
  handler: async (argv) => {
1557
+ let session;
1416
1558
  try {
1417
- const config = loadPackConfig();
1559
+ const config =
1560
+ argv[CONTENT_SESSION]?.config ?? argv[CONTENT_CONFIG] ?? loadPackConfig();
1418
1561
  const contentBase = argv.root ?? config.paths.content;
1419
1562
 
1420
1563
  const scope = { skipDirectories: config.skipDirectories };
1421
1564
  // Enumerated once and shared, as in `lint`, and a note
1422
1565
  // it cannot record is reported rather than thrown.
1423
- const corpusProblems = [];
1424
- const parsed = new Map();
1425
- const records = indexRecordsFor({
1426
- contentBase,
1427
- config,
1428
- ...scope,
1429
- problems: corpusProblems,
1430
- parsed,
1431
- });
1432
- for (const problem of corpusProblems) emitDiagnostic(problem);
1433
- // An error whatever the command's own strictness: the note is
1434
- // absent from every answer below, so reporting it and exiting 0
1435
- // would call the tree clean while silently omitting a note.
1566
+ session =
1567
+ argv[CONTENT_SESSION] ?? (await createContentSession({ config, contentBase }));
1568
+ session.assertContext({ config, contentBase });
1569
+ const { problems: corpusProblems } = session;
1570
+ if (!argv[PREPARATION_REPORTED])
1571
+ for (const problem of corpusProblems) emitDiagnostic(problem);
1436
1572
  if (corpusProblems.length) process.exitCode = 1;
1437
- const index = buildLinkIndex(contentBase, {
1438
- config,
1439
- records,
1440
- ...scope,
1441
- sqlTables: await prepareTreeSqlTables(contentBase, {
1442
- config,
1443
- records,
1444
- parsed,
1445
- ...scope,
1446
- }),
1447
- });
1573
+ const index = await session.audit("links");
1448
1574
 
1449
1575
  // An unusable manifest would otherwise surface as a pile of
1450
1576
  // dead addresses pointing at the notes that cite it, rather
@@ -1571,8 +1697,10 @@ function linksCommand() {
1571
1697
  );
1572
1698
  }
1573
1699
  } catch (err) {
1574
- reportFailure(err);
1700
+ reportContentFailure(err, argv);
1575
1701
  process.exitCode = 1;
1702
+ } finally {
1703
+ if (!argv[CONTENT_SESSION]) session?.dispose();
1576
1704
  }
1577
1705
  },
1578
1706
  };
@@ -1616,12 +1744,20 @@ function contentIndexCommand() {
1616
1744
  type: "string",
1617
1745
  });
1618
1746
  },
1619
- handler: (argv) => {
1747
+ handler: async (argv) => {
1748
+ let session;
1620
1749
  try {
1621
- const config = loadPackConfig();
1750
+ const config =
1751
+ argv[CONTENT_SESSION]?.config ?? argv[CONTENT_CONFIG] ?? loadPackConfig();
1752
+ const contentBase = argv.root ?? config.paths.content;
1753
+ session =
1754
+ argv[CONTENT_SESSION] ?? (await createContentSession({ config, contentBase }));
1755
+ session.assertContext({ config, contentBase });
1756
+ const publication = await session.publication();
1622
1757
  const { file, notes, bytes, full, draft, stub, dictionary, terms } =
1623
1758
  emitContentIndex({
1624
1759
  config,
1760
+ publication,
1625
1761
  ...(argv.root ? { contentBase: argv.root } : {}),
1626
1762
  ...(argv.out ? { outDir: argv.out } : {}),
1627
1763
  });
@@ -1636,8 +1772,10 @@ function contentIndexCommand() {
1636
1772
  `(${written.rows} ${relation} rows)`,
1637
1773
  );
1638
1774
  } catch (err) {
1639
- reportFailure(err);
1775
+ reportContentFailure(err, argv);
1640
1776
  process.exitCode = 1;
1777
+ } finally {
1778
+ if (!argv[CONTENT_SESSION]) session?.dispose();
1641
1779
  }
1642
1780
  },
1643
1781
  };
@@ -2530,19 +2668,13 @@ function packageCommand() {
2530
2668
  // an unhandled-rejection stack trace. Report the message and set a
2531
2669
  // failing exit code, so a build guard reads as a build failure.
2532
2670
  try {
2533
- const config = loadPackConfig();
2671
+ const config =
2672
+ argv[CONTENT_SESSION]?.config ?? argv[CONTENT_CONFIG] ?? loadPackConfig();
2534
2673
  // The compile passes do not run for a package that declares no
2535
2674
  // packs by rule. Refused rather than exiting 0 having compiled
2536
2675
  // nothing: a build that succeeds and produces no documents is
2537
2676
  // the quiet failure this toolchain reports everywhere else.
2538
- if (!compilesFoundryDocuments(config)) {
2539
- throw new Error(
2540
- `\`packageKind: ${config.packageKind}\` compiles no ` +
2541
- `compendium, so there is nothing to ${action}. Build ` +
2542
- `its site with \`package-build site\` and its book ` +
2543
- `with \`package-build pdf\`.`,
2544
- );
2545
- }
2677
+ assertCompendiumOperation(config, action);
2546
2678
  // The one directory the pipeline creates rather than expects:
2547
2679
  // `unpack` writes the extracted JSON there and `compile` reads
2548
2680
  // it back. Created here rather than at module scope so that
@@ -2555,7 +2687,12 @@ function packageCommand() {
2555
2687
  // from the resolved configuration, so nothing is restated
2556
2688
  // here.
2557
2689
  case "compile":
2558
- return await compilePacks({ packName: pack });
2690
+ return await compilePacks({
2691
+ packName: pack,
2692
+ config,
2693
+ session: argv[CONTENT_SESSION],
2694
+ reportPreparation: !argv[PREPARATION_REPORTED],
2695
+ });
2559
2696
  case "clean":
2560
2697
  return await cleanPacks({
2561
2698
  packName: pack,
@@ -2569,7 +2706,7 @@ function packageCommand() {
2569
2706
  });
2570
2707
  }
2571
2708
  } catch (err) {
2572
- reportFailure(err);
2709
+ reportContentFailure(err, argv);
2573
2710
  process.exitCode = 1;
2574
2711
  }
2575
2712
  },