@heroiclands/package-build 22.14.1 → 22.14.3

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 (65) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/content-build.mjs +6 -6
  3. package/bin/package-build.mjs +10 -0
  4. package/config.mjs +18 -1
  5. package/content-config.mjs +2 -2
  6. package/docs/api.md +63 -35
  7. package/docs/configuration.md +16 -7
  8. package/docs/content-format.md +157 -181
  9. package/docs/getting-started.md +42 -11
  10. package/docs/project-setup.md +5 -6
  11. package/engine/address.mjs +3 -5
  12. package/engine/asset-bindings.mjs +66 -0
  13. package/engine/calendar-notes.mjs +74 -0
  14. package/engine/changelog-lint.mjs +2 -2
  15. package/engine/content-index.mjs +63 -36
  16. package/engine/content-lint.mjs +1 -2
  17. package/engine/content-package.mjs +2 -2
  18. package/engine/content-tables.mjs +4 -8
  19. package/engine/folder-notes.mjs +1 -2
  20. package/engine/foundry-entries.mjs +21 -6
  21. package/engine/frontmatter-lint.mjs +3 -4
  22. package/engine/frontmatter.mjs +5 -8
  23. package/engine/helpers.mjs +3 -4
  24. package/engine/index.mjs +3 -0
  25. package/engine/infobox.mjs +67 -2
  26. package/engine/item-registry.mjs +2 -2
  27. package/engine/note-vocabulary.mjs +92 -6
  28. package/engine/pack-config.mjs +2 -2
  29. package/engine/pack-router.mjs +2 -2
  30. package/engine/pdf-render.mjs +1 -1
  31. package/engine/pdf-toc.mjs +3 -3
  32. package/engine/retired-fields.mjs +1 -1
  33. package/engine/scene-levels.mjs +4 -4
  34. package/engine/schema-check.mjs +1 -1
  35. package/engine/site-build.mjs +134 -121
  36. package/engine/site-preview.mjs +293 -0
  37. package/engine/site-root.mjs +20 -2
  38. package/engine/social-tie-terms.mjs +11 -0
  39. package/engine/social-ties.mjs +84 -0
  40. package/engine/sql-tables.mjs +4 -9
  41. package/hm3/actors.mjs +1 -1
  42. package/package.json +3 -3
  43. package/sohl/item-fields.mjs +2 -2
  44. package/types/config.d.mts +8 -0
  45. package/types/content-config.d.mts +1 -1
  46. package/types/engine/address.d.mts +3 -5
  47. package/types/engine/asset-bindings.d.mts +6 -0
  48. package/types/engine/calendar-notes.d.mts +12 -0
  49. package/types/engine/content-index.d.mts +11 -0
  50. package/types/engine/content-package.d.mts +2 -2
  51. package/types/engine/folder-notes.d.mts +1 -2
  52. package/types/engine/frontmatter-lint.d.mts +1 -2
  53. package/types/engine/frontmatter.d.mts +2 -4
  54. package/types/engine/helpers.d.mts +3 -4
  55. package/types/engine/index.d.mts +1 -0
  56. package/types/engine/item-registry.d.mts +2 -2
  57. package/types/engine/note-vocabulary.d.mts +5 -6
  58. package/types/engine/pack-config.d.mts +1 -1
  59. package/types/engine/pack-router.d.mts +1 -1
  60. package/types/engine/pdf-render.d.mts +1 -1
  61. package/types/engine/pdf-toc.d.mts +1 -1
  62. package/types/engine/site-build.d.mts +36 -3
  63. package/types/engine/site-preview.d.mts +34 -0
  64. package/types/engine/social-tie-terms.d.mts +5 -0
  65. package/types/engine/social-ties.d.mts +10 -0
@@ -133,8 +133,8 @@ function siteCorpusFiles(contentBase, ctx) {
133
133
  * Reads a note, returning `null` for one that cannot be parsed.
134
134
  *
135
135
  * A file that is not front-mattered markdown is skipped rather than reported: a
136
- * content tree is edited in Obsidian, which leaves files of its own about, and
137
- * failing on one would make the build hostage to the editor.
136
+ * content tree may contain editor or working files, and failing on one would
137
+ * make the build depend on unrelated local files.
138
138
  *
139
139
  * @param {string} file - Absolute path.
140
140
  * @returns {{fm: object, body: string}|null}
@@ -510,9 +510,8 @@ export function tableUniverse(pages) {
510
510
  * The frontmatter a page publishes with.
511
511
  *
512
512
  * An authored `aliases` is retired and refused before a build reaches
513
- * here, which makes this a guard rather than a working path. It was Obsidian's
514
- * — a list of *names* a reader might call
515
- * the note, which is vault addressing and stays in the vault. Hugo reads
513
+ * here, which makes this a guard rather than a working path. It is a list of
514
+ * names a reader might call the note. Hugo reads
516
515
  * `aliases` as **URL redirects**, so passing them through would publish a
517
516
  * redirect stub at each name. They are dropped, and this build emits no
518
517
  * redirects of its own.
@@ -567,6 +566,7 @@ export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
567
566
  const { fm, slug } = page;
568
567
  const data = {
569
568
  ...fm,
569
+ ...(isPlainObject(fm.data) ? { data: { ...fm.data } } : {}),
570
570
  // Spread after the note's own frontmatter. Guarded because
571
571
  // `package: undefined` is not a value YAML can carry.
572
572
  ...(page.pkg ? { package: page.pkg } : {}),
@@ -675,6 +675,96 @@ export function pageDestination(page, { bundle = false } = {}) {
675
675
  return bundle ? `${page.slug}/index.md` : `${page.slug}.md`;
676
676
  }
677
677
 
678
+ /** Transform one content page without writing the site mount. */
679
+ export function renderSitePage(
680
+ page,
681
+ {
682
+ index,
683
+ foreign,
684
+ universe,
685
+ pass = {},
686
+ decorate,
687
+ linkable = (d) => Boolean(d.fm.shortcode),
688
+ sqlTables,
689
+ config,
690
+ artIndex,
691
+ },
692
+ ) {
693
+ const tableErrors = [];
694
+ const secretErrors = [];
695
+ const wikiErrors = [];
696
+ const imageErrors = [];
697
+ const resolved = [];
698
+ const src = page.relPath ?? page.base;
699
+ const ctx = wikiContext(index, {
700
+ src,
701
+ file: page.file,
702
+ type: page.fm.type ?? null,
703
+ errors: wikiErrors,
704
+ foreignIndex: foreign.index,
705
+ assets: artIndex,
706
+ resolved,
707
+ });
708
+
709
+ const seenImages = new Map();
710
+ const webSrc = (src) => {
711
+ const occurrence = (seenImages.get(src) ?? 0) + 1;
712
+ seenImages.set(src, occurrence);
713
+ const problem = pathnameProblem(src);
714
+ if (problem) {
715
+ imageErrors.push({ file: page.file, src, occurrence, message: problem });
716
+ return src;
717
+ }
718
+ const forms = resolvePathname(src, config);
719
+ if (!forms || forms.web !== null) return forms?.web ?? src;
720
+ imageErrors.push({
721
+ file: page.file,
722
+ src,
723
+ occurrence,
724
+ message:
725
+ `\`${src}\` names a file the \`${forms.package}\` package ships, and ` +
726
+ "no asset host is configured to serve it from — set `site.assets` in " +
727
+ "package-build.config.yaml. Emitted as authored, the address resolves " +
728
+ "against the page's own URL, which is nowhere",
729
+ });
730
+ return src;
731
+ };
732
+ const artSrc = (value, type, accepts) => artPathname(artIndex, value, type, accepts).pathname;
733
+ const resolve = (text) => {
734
+ let transformed = pass.beforeLinks ? pass.beforeLinks(text, page) : text;
735
+ transformed = resolveWebWikilinks(transformed, ctx);
736
+ return renderImageFigures(transformed, webSrc);
737
+ };
738
+
739
+ const { markdown, errors } = expandContentTables(page.body, {
740
+ docs: universe.get(page.pkg) ?? [],
741
+ linkable,
742
+ source: src,
743
+ sqlTables: sqlTables?.get(page.file),
744
+ self: { fm: searchableFrontmatter(page.fm, page.pkg), path: page.relPath },
745
+ });
746
+ tableErrors.push(...errors);
747
+ const data = pageFrontmatter(page, { decorate, webSrc, artSrc });
748
+ const secrets = renderSecretBlocks(protectCode(markdown, resolve), "web");
749
+ for (const error of renderSecretBlocks(page.body, "book").errors)
750
+ secretErrors.push({
751
+ file: page.file,
752
+ line: (page.bodyLine ?? 1) + error.line - 1,
753
+ column: error.column,
754
+ message: error.message,
755
+ });
756
+ return {
757
+ page,
758
+ body: secrets.markdown,
759
+ data,
760
+ resolved,
761
+ tableErrors,
762
+ secretErrors,
763
+ wikiErrors,
764
+ imageErrors,
765
+ };
766
+ }
767
+
678
768
  /**
679
769
  * Renders and writes every page.
680
770
  *
@@ -731,6 +821,8 @@ export function renderPages(pages, options) {
731
821
  records = [],
732
822
  homepages = [],
733
823
  maps = new Map(),
824
+ write = true,
825
+ capture = false,
734
826
  } = options;
735
827
 
736
828
  // The address space the art slots and the body's embeds resolve against:
@@ -770,108 +862,24 @@ export function renderPages(pages, options) {
770
862
  /** @type {Array<{page: object, body: string, data: object}>} */
771
863
  const rendered = [];
772
864
 
773
- /**
774
- * The address the website serves for one authored pathname.
775
- *
776
- * A pathname the rule refuses, and a package-owned one with no asset host
777
- * to resolve against, are both reported and emitted as authored: a page
778
- * still publishes, with a picture the reader can see is missing, and the
779
- * build exits non-zero. The occurrence count is what lets the command find
780
- * the literal in the note and report a line and a column, the way a
781
- * wikilink finding is located.
782
- *
783
- * @param {string} file - The note, for the finding.
784
- * @returns {(src: string) => string} The resolver for that note's images.
785
- */
786
- const webAddresses = (file) => {
787
- /** @type {Map<string, number>} */
788
- const seen = new Map();
789
- return (src) => {
790
- const occurrence = (seen.get(src) ?? 0) + 1;
791
- seen.set(src, occurrence);
792
- const problem = pathnameProblem(src);
793
- if (problem) {
794
- imageErrors.push({ file, src, occurrence, message: problem });
795
- return src;
796
- }
797
- const forms = resolvePathname(src, config);
798
- if (!forms || forms.web !== null) return forms?.web ?? src;
799
- imageErrors.push({
800
- file,
801
- src,
802
- occurrence,
803
- message:
804
- `\`${src}\` names a file the \`${forms.package}\` package ships, and ` +
805
- "no asset host is configured to serve it from — set `site.assets` in " +
806
- "package-build.config.yaml. Emitted as authored, the address resolves " +
807
- "against the page's own URL, which is nowhere",
808
- });
809
- return src;
810
- };
811
- };
812
-
813
865
  for (const page of pages) {
814
- // The page's path in the tree an author edits, below the content
815
- // root.
816
- const src = page.relPath ?? page.base;
817
- // Every index entry a link on this page resolved to.
818
- const resolved = [];
819
- const ctx = wikiContext(index, {
820
- src,
821
- file: page.file,
822
- type: page.fm.type ?? null,
823
- errors: wikiErrors,
824
- foreignIndex: foreign.index,
825
- assets: artIndex,
826
- resolved,
827
- });
828
-
829
- const webSrc = webAddresses(page.file);
830
- const artSrc = (value, type, accepts) =>
831
- artPathname(artIndex, value, type, accepts).pathname;
832
- const resolve = (text) => {
833
- let t = text;
834
- if (pass.beforeLinks) t = pass.beforeLinks(t, page);
835
- t = resolveWebWikilinks(t, ctx);
836
- // Last, so a consumer's own rewrites see the image as the note
837
- // wrote it rather than as a figure. Hugo is handed markdown, not a
838
- // rendered page, so a `{…}` directive left in the body would reach
839
- // the reader as its own literal braces.
840
- return renderImageFigures(t, webSrc);
841
- };
842
-
843
- const { markdown: body, errors } = expandContentTables(page.body, {
844
- docs: universe.get(page.pkg) ?? [],
866
+ const result = renderSitePage(page, {
867
+ index,
868
+ foreign,
869
+ universe,
870
+ pass,
871
+ decorate,
845
872
  linkable,
846
- source: src,
847
- // Prepared before this render began — DuckDB is async and this
848
- // is not. Keyed by the note's own file, absolute here as in
849
- // every other pass, so the three cannot disagree about a note.
850
- sqlTables: sqlTables?.get(page.file),
851
- self: {
852
- fm: searchableFrontmatter(page.fm, page.pkg),
853
- path: page.relPath,
854
- },
873
+ sqlTables,
874
+ config,
875
+ artIndex,
855
876
  });
856
- tableErrors.push(...errors);
857
-
858
- const data = pageFrontmatter(page, { decorate, webSrc, artSrc });
859
- const resolvedBody = protectCode(body, resolve);
860
- const secrets = renderSecretBlocks(resolvedBody, "web");
861
- for (const error of renderSecretBlocks(page.body, "book").errors) {
862
- secretErrors.push({
863
- file: page.file,
864
- line: (page.bodyLine ?? 1) + error.line - 1,
865
- column: error.column,
866
- message: error.message,
867
- });
868
- }
869
- rendered.push({ page, body: secrets.markdown, data });
870
- // A resolved target with no URL is a pack-only package's address:
871
- // real, and nowhere on the web, so not an edge.
872
- for (const hit of resolved) {
873
- if (hit.url) edges.push([page.url, hit.url]);
874
- }
877
+ tableErrors.push(...result.tableErrors);
878
+ secretErrors.push(...result.secretErrors);
879
+ wikiErrors.push(...result.wikiErrors);
880
+ imageErrors.push(...result.imageErrors);
881
+ rendered.push({ page, body: result.body, data: result.data });
882
+ for (const hit of result.resolved) if (hit.url) edges.push([page.url, hit.url]);
875
883
  }
876
884
 
877
885
  const related = relatedPages(edges, entries);
@@ -884,19 +892,23 @@ export function renderPages(pages, options) {
884
892
  ]);
885
893
 
886
894
  let withMap = 0;
895
+ const outputs = capture === true ? new Map() : null;
887
896
  for (const { page, body, data } of rendered) {
888
897
  const block = related.get(page.url);
889
898
  if (block) data.related = block;
890
899
  Object.assign(data, holdings.get(page.url));
891
900
  const map = maps.get(page.url);
892
- const dest = path.join(outRoot, pageDestination(page, { bundle: Boolean(map) }));
893
- fs.mkdirSync(path.dirname(dest), { recursive: true });
894
901
  if (map) {
895
- fs.copyFileSync(map.file, path.join(path.dirname(dest), map.name));
896
902
  data.map = map.name;
897
903
  withMap += 1;
898
904
  }
899
- fs.writeFileSync(dest, matter.stringify(body, encodeAddresses(data)));
905
+ if (write) {
906
+ const dest = path.join(outRoot, pageDestination(page, { bundle: Boolean(map) }));
907
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
908
+ if (map) fs.copyFileSync(map.file, path.join(path.dirname(dest), map.name));
909
+ fs.writeFileSync(dest, matter.stringify(body, encodeAddresses(data)));
910
+ }
911
+ if (outputs) outputs.set(page.file, { markdown: body, frontmatter: data });
900
912
  byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
901
913
  }
902
914
 
@@ -909,6 +921,7 @@ export function renderPages(pages, options) {
909
921
  imageErrors,
910
922
  related,
911
923
  maps: withMap,
924
+ ...(capture ? { ...(outputs ? { outputs } : {}), edges, entries } : {}),
912
925
  };
913
926
  }
914
927
 
@@ -946,6 +959,18 @@ export function resolveSitePass(name, options) {
946
959
  return factory()(options);
947
960
  }
948
961
 
962
+ /** The site frontmatter decoration shared by the site writer and page preview. */
963
+ export function sitePageDecorator(config, index) {
964
+ const router = routerFor(config);
965
+ return (data, page) => {
966
+ if (isBeing(page.fm)) data.sohl = deriveBeingInfo(page.fm.sohl, index.refIndex);
967
+ data.infoboxes = noteInfoboxes(page.fm, {
968
+ resolve: (ref, hint) => resolveInfoboxRef(index, ref, hint),
969
+ router,
970
+ });
971
+ };
972
+ }
973
+
949
974
  /**
950
975
  * Builds a Hugo content tree from a content tree, and reports what it found.
951
976
  *
@@ -1173,19 +1198,7 @@ export function buildSite({ config, sqlTables, locate } = {}) {
1173
1198
  // Asking in a consumer's script is how one came to still be checking
1174
1199
  // `character` and `creature` months after they were retired, and to
1175
1200
  // publish 95 pages with empty sidebars for months without noticing.
1176
- decorate: (data, page) => {
1177
- if (isBeing(page.fm)) {
1178
- data.sohl = deriveBeingInfo(page.fm.sohl, gates.index.refIndex);
1179
- }
1180
- // The declared infobox, travelling in the page's own front matter.
1181
- // The site theme draws it; what it holds is settled here, so the
1182
- // same panel reaches the website, a compendium journal and the
1183
- // book from one definition.
1184
- data.infoboxes = noteInfoboxes(page.fm, {
1185
- resolve: (ref, hint) => resolveInfoboxRef(gates.index, ref, hint),
1186
- router: routerFor(resolved),
1187
- });
1188
- },
1201
+ decorate: sitePageDecorator(resolved, gates.index),
1189
1202
  });
1190
1203
 
1191
1204
  // Last, and outside the mount: the package's front page is not part of the
@@ -0,0 +1,293 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * SPDX-License-Identifier: GPL-3.0-or-later
6
+ */
7
+
8
+ /** A private, reusable site renderer for an editor's active note. @module */
9
+
10
+ import path from "node:path";
11
+ import matter from "gray-matter";
12
+ import { indexRecordsFor, indexRecordsForNote } from "./content-index.mjs";
13
+ import { noteAddressContext, decodeNoteAddresses, encodeAddresses } from "./note-addresses.mjs";
14
+ import { noteFile } from "./index-records.mjs";
15
+ import { foundryIdentities } from "./foundry-entries.mjs";
16
+ import { applyComputedBeingAge, presentAmongRecords } from "./being-age.mjs";
17
+ import { cachedMetadataIndexes, noContentIndexPackages } from "./metadata-index.mjs";
18
+ import { buildSiteIndex } from "./site-index.mjs";
19
+ import { openNotesDatabase, prepareSqlTables, findSqlBlocks } from "./sql-tables.mjs";
20
+ import { relatedPages } from "./related-pages.mjs";
21
+ import { holdingsNode, holdingsPages, foreignHoldingsNodes } from "./holdings.mjs";
22
+ import { assetAddressIndex } from "./art-fields.mjs";
23
+ import { frontmatterWikilinks } from "./web-wikilinks.mjs";
24
+ import { positionOfLiteral } from "./diagnostics.mjs";
25
+ import { addressSlug } from "./content-address.mjs";
26
+ import { homepageTitle } from "./homepage.mjs";
27
+ import { loadPackConfig } from "./pack-config.mjs";
28
+ import {
29
+ collectContentPages,
30
+ collectHomepages,
31
+ gatesFailed,
32
+ renderPages,
33
+ renderSitePage,
34
+ resolveSitePass,
35
+ siteGates,
36
+ sitePageDecorator,
37
+ tableUniverse,
38
+ } from "./site-build.mjs";
39
+
40
+ /** Prepare saved project state once, then render live text without writing a site. */
41
+ export async function prepareSitePreview({ config = loadPackConfig() } = {}) {
42
+ const contentBase = config.paths.content;
43
+ const base = config.site.base || `/${config.contentPackage}/`;
44
+ const dependencies = cachedMetadataIndexes(config);
45
+ const pass = resolveSitePass(config.site.pass, {
46
+ ...config.site.passOptions,
47
+ repoRoot: config.rootDir,
48
+ });
49
+ const context = {
50
+ config,
51
+ packages: new Set(
52
+ config.site.packages.length ? config.site.packages : [config.contentPackage],
53
+ ),
54
+ contentPackage: config.contentPackage,
55
+ skipDirectories: config.skipDirectories,
56
+ base,
57
+ mount: `${base}${config.publish.address.prefix}`,
58
+ scheme: config.publish.address,
59
+ };
60
+ let snapshot;
61
+
62
+ async function load() {
63
+ const records = indexRecordsFor({ config });
64
+ const ctx = { ...context, records };
65
+ const { pages, ...findings } = collectContentPages(contentBase, ctx);
66
+ const homepages = collectHomepages(contentBase, ctx).pages;
67
+ const homeEntries = homepages.map((home) => ({
68
+ kind: "content",
69
+ fm: home.fm,
70
+ body: home.body,
71
+ pkg: config.contentPackage,
72
+ name: home.fm.name?.full ?? homepageTitle(home.fm, config),
73
+ title: homepageTitle(home.fm, config),
74
+ slug: addressSlug(home.fm),
75
+ base: path.basename(home.file),
76
+ url: base,
77
+ }));
78
+ const gates = siteGates([...pages, ...homeEntries], findings, { config, records });
79
+ if (gatesFailed(gates))
80
+ throw new Error("site preview cannot prepare: site integrity gates failed");
81
+ const db = await openNotesDatabase(records, {
82
+ dependencies,
83
+ addressContext: noteAddressContext(config),
84
+ });
85
+ try {
86
+ const sources = pages
87
+ .filter((page) => findSqlBlocks(page.body).length)
88
+ .map((page) => ({ source: page.file, markdown: page.body }));
89
+ const sqlTables = await prepareSqlTables(db, sources);
90
+ const rendered = renderPages(pages, {
91
+ index: gates.index,
92
+ foreign: gates.foreign,
93
+ universe: tableUniverse(pages),
94
+ pass,
95
+ decorate: sitePageDecorator(config, gates.index),
96
+ sqlTables,
97
+ config,
98
+ records,
99
+ homepages: homeEntries,
100
+ write: false,
101
+ capture: "graph",
102
+ });
103
+ return { records, pages, homeEntries, gates, db, rendered };
104
+ } catch (error) {
105
+ await db.close();
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ snapshot = await load();
111
+
112
+ return {
113
+ /** Render the active buffer. Other local and foreign notes remain saved. */
114
+ async render(file, text) {
115
+ const absolute = path.resolve(file);
116
+ const original = snapshot.pages.find((page) => page.file === absolute);
117
+ if (!original)
118
+ return {
119
+ ok: false,
120
+ findings: [
121
+ {
122
+ file: absolute,
123
+ severity: "error",
124
+ message: "note is not a published site page",
125
+ },
126
+ ],
127
+ };
128
+ let fm;
129
+ let parsed;
130
+ try {
131
+ parsed = matter(text);
132
+ fm = decodeNoteAddresses(parsed.data, {
133
+ ...noteAddressContext(config),
134
+ package: config.contentPackage,
135
+ });
136
+ applyComputedBeingAge(fm, presentAmongRecords(snapshot.records));
137
+ } catch (error) {
138
+ return {
139
+ ok: false,
140
+ findings: [
141
+ { file: absolute, severity: "error", message: String(error.message) },
142
+ ],
143
+ };
144
+ }
145
+ const body = parsed.content;
146
+ const bodyLine = text.slice(0, text.length - body.length).split("\n").length;
147
+ const findings = frontmatterWikilinks(fm).map((hit) => ({
148
+ file: absolute,
149
+ severity: "error",
150
+ ...hit,
151
+ }));
152
+ let slug;
153
+ try {
154
+ slug = addressSlug(fm);
155
+ } catch (error) {
156
+ findings.push({ file: absolute, severity: "error", message: error.message });
157
+ }
158
+ if (findings.length) return { ok: false, findings };
159
+ const page = {
160
+ ...original,
161
+ fm,
162
+ body,
163
+ bodyLine,
164
+ name: fm.name?.full ?? path.basename(absolute, ".md"),
165
+ slug,
166
+ url: `${base}${slug}/`,
167
+ };
168
+ const changedMetadata = JSON.stringify(fm) !== JSON.stringify(original.fm);
169
+ let records = snapshot.records;
170
+ let db = snapshot.db;
171
+ let index = snapshot.gates.index;
172
+ try {
173
+ if (changedMetadata) {
174
+ const overlayRecords = indexRecordsForNote({
175
+ frontmatter: fm,
176
+ relPath: original.relPath,
177
+ absPath: absolute,
178
+ contentPackage: config.contentPackage,
179
+ body,
180
+ bodyLine,
181
+ manifest: foundryIdentities(config),
182
+ addressContext: noteAddressContext(config),
183
+ });
184
+ records = snapshot.records.filter(
185
+ (entry) => noteFile(contentBase, entry) !== absolute,
186
+ );
187
+ records.push(...overlayRecords);
188
+ const allPages = snapshot.pages.map((item) =>
189
+ item.file === absolute ? page : item,
190
+ );
191
+ index = buildSiteIndex([...allPages, ...snapshot.homeEntries], {
192
+ package: config.contentPackage,
193
+ foreignIndex: snapshot.gates.foreign.index,
194
+ foreignReferences: snapshot.gates.foreign.references,
195
+ records,
196
+ noIndexPackages: noContentIndexPackages(config),
197
+ });
198
+ db = await openNotesDatabase(records, {
199
+ dependencies,
200
+ addressContext: noteAddressContext(config),
201
+ });
202
+ }
203
+ const sqlTables = await prepareSqlTables(db, [
204
+ { source: absolute, markdown: body },
205
+ ]);
206
+ const allPages = snapshot.pages.map((item) =>
207
+ item.file === absolute ? page : item,
208
+ );
209
+ const result = renderSitePage(page, {
210
+ index,
211
+ foreign: snapshot.gates.foreign,
212
+ universe: tableUniverse(allPages),
213
+ pass,
214
+ decorate: sitePageDecorator(config, index),
215
+ sqlTables,
216
+ config,
217
+ artIndex: assetAddressIndex(records, {
218
+ config,
219
+ foreign: snapshot.gates.foreign,
220
+ types: index.contentTypes,
221
+ }),
222
+ });
223
+ for (const error of result.tableErrors)
224
+ findings.push({
225
+ file: absolute,
226
+ line: bodyLine + error.line,
227
+ column: 1,
228
+ severity: "error",
229
+ message: error.reason ?? error.message,
230
+ });
231
+ for (const error of result.secretErrors)
232
+ findings.push({ ...error, severity: "error" });
233
+ for (const error of result.wikiErrors) {
234
+ const pos = positionOfLiteral(text, error.link, error.occurrence);
235
+ findings.push({
236
+ ...error,
237
+ ...pos,
238
+ severity: "error",
239
+ message: error.message ?? error.reason ?? "unresolved wikilink",
240
+ });
241
+ }
242
+ for (const error of result.imageErrors) {
243
+ const pos = positionOfLiteral(text, error.src, error.occurrence);
244
+ findings.push({ ...error, ...pos, severity: "error" });
245
+ }
246
+ if (findings.length) return { ok: false, findings };
247
+ const edges = snapshot.rendered.edges.filter(([source]) => source !== original.url);
248
+ for (const hit of result.resolved) if (hit.url) edges.push([page.url, hit.url]);
249
+ const entries = new Map(snapshot.rendered.entries);
250
+ entries.delete(original.url);
251
+ entries.set(page.url, {
252
+ title: fm.title ?? page.name,
253
+ url: page.url,
254
+ type: String(fm.type),
255
+ });
256
+ const related = relatedPages(edges, entries).get(page.url);
257
+ if (related) result.data.related = related;
258
+ const holdings = holdingsPages([
259
+ ...allPages.map((item) =>
260
+ holdingsNode(item.fm, { title: item.fm.title ?? item.name, url: item.url }),
261
+ ),
262
+ ...foreignHoldingsNodes(snapshot.gates.foreign.index),
263
+ ]);
264
+ Object.assign(result.data, holdings.get(page.url));
265
+ return {
266
+ ok: true,
267
+ markdown: result.body,
268
+ frontmatter: encodeAddresses(result.data),
269
+ findings: [],
270
+ };
271
+ } catch (error) {
272
+ return {
273
+ ok: false,
274
+ findings: [
275
+ { file: absolute, severity: "error", message: String(error.message) },
276
+ ],
277
+ };
278
+ } finally {
279
+ if (db !== snapshot.db) await db.close();
280
+ }
281
+ },
282
+ /** Re-read saved notes and dependency indexes after a save. */
283
+ async refresh() {
284
+ const next = await load();
285
+ const previous = snapshot;
286
+ snapshot = next;
287
+ await previous.db.close();
288
+ },
289
+ async close() {
290
+ await snapshot.db.close();
291
+ },
292
+ };
293
+ }
@@ -44,6 +44,7 @@
44
44
  import fs from "node:fs";
45
45
  import path from "node:path";
46
46
  import * as pagefind from "pagefind";
47
+ import { gunzipSync } from "node:zlib";
47
48
 
48
49
  import { formatDiagnostic } from "./diagnostics.mjs";
49
50
 
@@ -174,19 +175,36 @@ export async function indexSite({ site, output }) {
174
175
  }
175
176
  throw err;
176
177
  }
178
+ let pages;
177
179
  try {
178
180
  const added = await index.addDirectory({ path: site });
179
181
  if (added.errors.length) throw new Error(added.errors.join("; "));
180
- const pages = added.page_count;
182
+ pages = added.page_count;
181
183
  if (!pages) {
182
184
  throw new Error(`no page under ${site} was indexed — the site holds no HTML`);
183
185
  }
184
186
  const written = await index.writeFiles({ outputPath: output });
185
187
  if (written.errors.length) throw new Error(written.errors.join("; "));
186
- return { pages };
187
188
  } finally {
188
189
  await pagefind.close();
189
190
  }
191
+ // Pagefind may finish its API call while fragment writes are still visible
192
+ // as incomplete gzip streams under heavy parallel load. A successful site
193
+ // build must hand deployment complete fragments.
194
+ const fragmentDir = path.join(output, "fragment");
195
+ const deadline = Date.now() + 5000;
196
+ for (;;) {
197
+ try {
198
+ const fragments = fs.readdirSync(fragmentDir);
199
+ if (!fragments.length) throw new Error("no search fragments were written");
200
+ for (const name of fragments) gunzipSync(fs.readFileSync(path.join(fragmentDir, name)));
201
+ break;
202
+ } catch (error) {
203
+ if (Date.now() >= deadline) throw error;
204
+ await new Promise((resolve) => setTimeout(resolve, 25));
205
+ }
206
+ }
207
+ return { pages };
190
208
  }
191
209
 
192
210
  /**
@@ -0,0 +1,11 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ /** Relationships a being states from its own point of view. */
4
+ export const SOCIAL_TIES = Object.freeze([
5
+ { term: "patron", meaning: "Supports the subject from greater power, wealth, or status." },
6
+ { term: "friend", meaning: "Supports the subject out of goodwill." },
7
+ { term: "dependent", meaning: "Relies on the subject's support or protection." },
8
+ { term: "acquaintance", meaning: "Knows the subject without a strong disposition." },
9
+ { term: "rival", meaning: "Opposes the subject without implacable hostility." },
10
+ { term: "nemesis", meaning: "Opposes the subject personally and implacably." },
11
+ ]);