@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.
- package/CHANGELOG.md +26 -0
- package/bin/content-build.mjs +6 -6
- package/bin/package-build.mjs +10 -0
- package/config.mjs +18 -1
- package/content-config.mjs +2 -2
- package/docs/api.md +63 -35
- package/docs/configuration.md +16 -7
- package/docs/content-format.md +157 -181
- package/docs/getting-started.md +42 -11
- package/docs/project-setup.md +5 -6
- package/engine/address.mjs +3 -5
- package/engine/asset-bindings.mjs +66 -0
- package/engine/calendar-notes.mjs +74 -0
- package/engine/changelog-lint.mjs +2 -2
- package/engine/content-index.mjs +63 -36
- package/engine/content-lint.mjs +1 -2
- package/engine/content-package.mjs +2 -2
- package/engine/content-tables.mjs +4 -8
- package/engine/folder-notes.mjs +1 -2
- package/engine/foundry-entries.mjs +21 -6
- package/engine/frontmatter-lint.mjs +3 -4
- package/engine/frontmatter.mjs +5 -8
- package/engine/helpers.mjs +3 -4
- package/engine/index.mjs +3 -0
- package/engine/infobox.mjs +67 -2
- package/engine/item-registry.mjs +2 -2
- package/engine/note-vocabulary.mjs +92 -6
- package/engine/pack-config.mjs +2 -2
- package/engine/pack-router.mjs +2 -2
- package/engine/pdf-render.mjs +1 -1
- package/engine/pdf-toc.mjs +3 -3
- package/engine/retired-fields.mjs +1 -1
- package/engine/scene-levels.mjs +4 -4
- package/engine/schema-check.mjs +1 -1
- package/engine/site-build.mjs +134 -121
- package/engine/site-preview.mjs +293 -0
- package/engine/site-root.mjs +20 -2
- package/engine/social-tie-terms.mjs +11 -0
- package/engine/social-ties.mjs +84 -0
- package/engine/sql-tables.mjs +4 -9
- package/hm3/actors.mjs +1 -1
- package/package.json +3 -3
- package/sohl/item-fields.mjs +2 -2
- package/types/config.d.mts +8 -0
- package/types/content-config.d.mts +1 -1
- package/types/engine/address.d.mts +3 -5
- package/types/engine/asset-bindings.d.mts +6 -0
- package/types/engine/calendar-notes.d.mts +12 -0
- package/types/engine/content-index.d.mts +11 -0
- package/types/engine/content-package.d.mts +2 -2
- package/types/engine/folder-notes.d.mts +1 -2
- package/types/engine/frontmatter-lint.d.mts +1 -2
- package/types/engine/frontmatter.d.mts +2 -4
- package/types/engine/helpers.d.mts +3 -4
- package/types/engine/index.d.mts +1 -0
- package/types/engine/item-registry.d.mts +2 -2
- package/types/engine/note-vocabulary.d.mts +5 -6
- package/types/engine/pack-config.d.mts +1 -1
- package/types/engine/pack-router.d.mts +1 -1
- package/types/engine/pdf-render.d.mts +1 -1
- package/types/engine/pdf-toc.d.mts +1 -1
- package/types/engine/site-build.d.mts +36 -3
- package/types/engine/site-preview.d.mts +34 -0
- package/types/engine/social-tie-terms.d.mts +5 -0
- package/types/engine/social-ties.d.mts +10 -0
package/engine/site-build.mjs
CHANGED
|
@@ -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
|
|
137
|
-
*
|
|
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
|
|
514
|
-
*
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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(...
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
for (const
|
|
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
|
-
|
|
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: (
|
|
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
|
+
}
|
package/engine/site-root.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
]);
|