@heroiclands/package-build 22.12.1 → 22.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.12.2
4
+
5
+ ### Patch Changes
6
+
7
+ **Content**
8
+
9
+ - GM passages in notes appear as secret sections in Foundry journals, expandable spoilers on the web, and labelled notes in books.
10
+
3
11
  ## 22.12.1
4
12
 
5
13
  ### Patch Changes
@@ -1627,6 +1627,9 @@ function siteCommand() {
1627
1627
  message: e.reason,
1628
1628
  });
1629
1629
  }
1630
+ for (const e of result.secretErrors) {
1631
+ emitDiagnostic({ ...e, severity: "error" });
1632
+ }
1630
1633
  // Reported the way the pack build reports the very same
1631
1634
  // finding: `file:line:column: error: message`, path first, and
1632
1635
  // the message from the shared table, not a
@@ -1663,6 +1666,7 @@ function siteCommand() {
1663
1666
  }
1664
1667
  if (
1665
1668
  result.tableErrors.length ||
1669
+ result.secretErrors.length ||
1666
1670
  result.wikiErrors.length ||
1667
1671
  result.imageErrors.length
1668
1672
  ) {
package/docs/api.md CHANGED
@@ -1012,6 +1012,7 @@ Shared helpers for the pack compilers in `packages/content-build/`. The HeroicLa
1012
1012
  | Export | Signature | Returns | Use it when |
1013
1013
  | ------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1014
1014
  | `md` | `const md` | — | The markdown renderer every surface shares. |
1015
+ | `renderFoundryMarkdown` | `function renderFoundryMarkdown(body)` | `string` — HTML with secret sections for GM passages. | Rendering a note body for a Foundry document. |
1015
1016
  | `parseMarkdownFile` | `function parseMarkdownFile(filePath)` | — | Parses a markdown file with YAML frontmatter. |
1016
1017
  | `assertStatedScope` | `function assertStatedScope(skipDirectories, who)` | {void} | Refuse a corpus read whose scope its caller did not state. |
1017
1018
  | `assertSuppliedCorpus` | `function assertSuppliedCorpus(records, who)` | {void} | Refuse a corpus read whose records its caller did not supply. |
@@ -2418,15 +2418,17 @@ This is secret text
2418
2418
  :::
2419
2419
  ```
2420
2420
 
2421
- A fenced off area of text may be marked as secret, indicating that the text will be treated as secret text. In FoundryVTT, this will be achieved with
2421
+ Use a whole-line `:::secret` opener and a whole-line `:::` closer to mark a GM passage. Markdown inside the block, including links and emphasis, renders normally. Keep the fences outside code blocks and close each one before starting another. An unclosed or nested block is a build error at the source line.
2422
+
2423
+ In FoundryVTT, the passage is a secret section visible to the GM and owners of the journal:
2422
2424
 
2423
2425
  ```html
2424
- <section class="secret">
2426
+ <section class="secret" id="secret-example">
2425
2427
  <p>This is secret text</p>
2426
2428
  </section>
2427
2429
  ```
2428
2430
 
2429
- On webpages, this will be achieved with the use of
2431
+ On webpages, the passage appears as a collapsed spoiler that any reader can open:
2430
2432
 
2431
2433
  ```html
2432
2434
  <details>
@@ -2435,6 +2437,8 @@ On webpages, this will be achieved with the use of
2435
2437
  </details>
2436
2438
  ```
2437
2439
 
2440
+ In books, the passage is visible under a **GM note** label. A printed page has no reader permissions or interactive disclosure control.
2441
+
2438
2442
  #### Actors
2439
2443
 
2440
2444
  All actor types, including being and vehicle, have their entire markdown section processed as normal.
@@ -29,7 +29,7 @@
29
29
  * @module
30
30
  */
31
31
 
32
- import { md } from "./helpers.mjs";
32
+ import { renderFoundryMarkdown } from "./helpers.mjs";
33
33
 
34
34
  /**
35
35
  * Extract the body of an H1 section whose heading carries the explicit
@@ -79,5 +79,5 @@ export function extractAnchorSection(body, anchorId) {
79
79
  */
80
80
  export function renderSection(body, anchorId) {
81
81
  const slice = extractAnchorSection(body, anchorId);
82
- return slice ? md.render(slice) : "";
82
+ return slice ? renderFoundryMarkdown(slice) : "";
83
83
  }
@@ -62,6 +62,7 @@
62
62
  */
63
63
 
64
64
  import { authoredFrontmatter } from "./index-records.mjs";
65
+ import { renderSecretBlocks } from "./content-secrets.mjs";
65
66
  import { cloneAddressState } from "./address-values.mjs";
66
67
  import fs from "fs";
67
68
  import path from "path";
@@ -620,6 +621,18 @@ export class BasePackCompiler {
620
621
  * that does not convert.
621
622
  */
622
623
  convertBody(fm, body) {
624
+ const secretError = renderSecretBlocks(body, "book").errors[0];
625
+ if (secretError) {
626
+ const error = new Error(secretError.message);
627
+ error.position = {
628
+ line: (this.currentNote?.bodyLine ?? 1) + secretError.line - 1,
629
+ column:
630
+ secretError.line === 1 ?
631
+ (this.currentNote?.bodyColumn ?? 1)
632
+ : secretError.column,
633
+ };
634
+ throw error;
635
+ }
623
636
  if (!this.constructor.convertsWikilinks) return body;
624
637
  const name = resolveName(fm);
625
638
  const { absPath, bodyLine, bodyColumn } = this.currentNote ?? {};
@@ -72,7 +72,7 @@ import { contentPackage } from "./content-package.mjs";
72
72
  import { FOLDER_TYPE } from "./folder-notes.mjs";
73
73
  import { HOMEPAGE_TYPE } from "./homepage.mjs";
74
74
  import { itemDocEntryId } from "./item-docs.mjs";
75
- import { folderField, md, resolveName } from "./helpers.mjs";
75
+ import { folderField, renderFoundryMarkdown, resolveName } from "./helpers.mjs";
76
76
  import { packForType } from "./ids.mjs";
77
77
  import { readQualifier } from "./wikilinks.mjs";
78
78
 
@@ -322,7 +322,7 @@ export class Bundles extends BasePackCompiler {
322
322
  // Foundry renders on the import card. That is why a bundle earns no
323
323
  // separate documentation journal the way an item does: the document
324
324
  // it compiles into already has somewhere to put the prose.
325
- description: markdown.trim() ? md.render(markdown) : "",
325
+ description: markdown.trim() ? renderFoundryMarkdown(markdown) : "",
326
326
  folder: this.folderResolver(authoredFolder, { isAddress: folderIsAddress }),
327
327
  flags: fm.flags,
328
328
  stats: this.stats,
@@ -0,0 +1,110 @@
1
+ /*
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ */
4
+
5
+ import crypto from "node:crypto";
6
+ import markdownit from "markdown-it";
7
+
8
+ const webMarkdown = markdownit({ html: true });
9
+
10
+ /**
11
+ * Render whole-line `:::secret` blocks for one publishing surface.
12
+ * The source line of each syntax error is relative to the supplied body.
13
+ *
14
+ * @param {string} source - Markdown containing secret blocks.
15
+ * @param {"foundry"|"web"|"book"} target - Publishing surface.
16
+ * @param {(markdown: string) => string} [renderMarkdown] - Foundry's configured renderer.
17
+ * @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>}}
18
+ */
19
+ export function renderSecretBlocks(
20
+ source,
21
+ target,
22
+ renderMarkdown = webMarkdown.render.bind(webMarkdown),
23
+ ) {
24
+ const lines = String(source ?? "").split("\n");
25
+ const result = [];
26
+ const errors = [];
27
+ let opening = -1;
28
+ let body = [];
29
+ let codeFence = null;
30
+
31
+ for (let index = 0; index < lines.length; index++) {
32
+ const line = lines[index];
33
+ const fence = line.match(/^ {0,3}(`{3,}|~{3,})/);
34
+ if (codeFence) {
35
+ if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
36
+ codeFence = null;
37
+ }
38
+ if (opening >= 0) body.push(line);
39
+ else result.push(line);
40
+ continue;
41
+ }
42
+ if (fence) {
43
+ codeFence = fence[1];
44
+ if (opening >= 0) body.push(line);
45
+ else result.push(line);
46
+ continue;
47
+ }
48
+ if (/^:::secret[ \t]*$/.test(line)) {
49
+ if (opening >= 0) {
50
+ errors.push({
51
+ line: index + 1,
52
+ column: 1,
53
+ message: "nested secret blocks are not supported",
54
+ });
55
+ body.push(line);
56
+ } else {
57
+ opening = index;
58
+ body = [];
59
+ }
60
+ continue;
61
+ }
62
+ if (/^:::[ \t]*$/.test(line)) {
63
+ if (opening < 0) {
64
+ errors.push({
65
+ line: index + 1,
66
+ column: 1,
67
+ message: "secret block has no opening :::secret line",
68
+ });
69
+ result.push(line);
70
+ continue;
71
+ }
72
+ const inner = body.join("\n").trim();
73
+ if (target === "book") {
74
+ result.push("", "**GM note**", "", inner, "");
75
+ } else {
76
+ const html = renderMarkdown(inner).trim();
77
+ if (target === "foundry") {
78
+ const id = crypto
79
+ .createHash("sha256")
80
+ .update(`${opening}:${inner}`)
81
+ .digest("hex")
82
+ .slice(0, 12);
83
+ result.push(
84
+ "",
85
+ `<section class="secret" id="secret-${id}">`,
86
+ html,
87
+ "</section>",
88
+ "",
89
+ );
90
+ } else {
91
+ result.push("", "<details><summary>Spoiler</summary>", html, "</details>", "");
92
+ }
93
+ }
94
+ opening = -1;
95
+ body = [];
96
+ continue;
97
+ }
98
+ if (opening >= 0) body.push(line);
99
+ else result.push(line);
100
+ }
101
+ if (opening >= 0) {
102
+ errors.push({
103
+ line: opening + 1,
104
+ column: 1,
105
+ message: "secret block needs a closing ::: line",
106
+ });
107
+ result.push(":::secret", ...body);
108
+ }
109
+ return { markdown: result.join("\n"), errors };
110
+ }
@@ -62,6 +62,7 @@ import { linkFindingMessage } from "./wikilink-syntax.mjs";
62
62
  // from there rather than respelt, so the tag and its one reader cannot drift.
63
63
  import { isDraftNote } from "./note-vocabulary.mjs";
64
64
  import { expandContentTables } from "./content-tables.mjs";
65
+ import { renderSecretBlocks } from "./content-secrets.mjs";
65
66
  import { positionInBody } from "./diagnostics.mjs";
66
67
  // The pure `sohl:` frontmatter readers live in a leaf module so the item-type
67
68
  // registry can import them without reaching back through this one.
@@ -125,6 +126,12 @@ export const md = markdownit({ html: true })
125
126
  }),
126
127
  );
127
128
 
129
+ /** Render a note body with Foundry's secret section markup. */
130
+ export function renderFoundryMarkdown(body) {
131
+ const { markdown } = renderSecretBlocks(body, "foundry", (inner) => md.render(inner));
132
+ return md.render(markdown);
133
+ }
134
+
128
135
  /**
129
136
  * Parses a markdown file with YAML frontmatter.
130
137
  *
@@ -165,7 +172,7 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
165
172
  }
166
173
  const raw = fmMatch[2];
167
174
  const body = raw.trim();
168
- const description = body ? md.render(body) : "";
175
+ const description = body ? renderFoundryMarkdown(body) : "";
169
176
  // Where the trimmed body starts in the *file*, so an offset within it can
170
177
  // be reported as a file position. The frontmatter's lines and the
171
178
  // blank lines `trim()` removes both sit in between, and the trim can take
@@ -51,7 +51,14 @@
51
51
 
52
52
  import log from "loglevel";
53
53
 
54
- import { sohlField, makeId, resolveName, defaultStats, md, folderField } from "./helpers.mjs";
54
+ import {
55
+ sohlField,
56
+ makeId,
57
+ resolveName,
58
+ defaultStats,
59
+ renderFoundryMarkdown,
60
+ folderField,
61
+ } from "./helpers.mjs";
55
62
  import { BasePackCompiler } from "./base-compiler.mjs";
56
63
  import { anchorPageId, resolveReference } from "./wikilinks.mjs";
57
64
  import { infoboxesToHtml, linkToUuid } from "./infobox-render.mjs";
@@ -83,6 +90,7 @@ export function splitPages(body, leadName = "Introduction") {
83
90
  const beforeFirstH1 = [];
84
91
  let current = null;
85
92
  let inCodeBlock = false;
93
+ let inSecret = false;
86
94
 
87
95
  const closeCurrent = () => {
88
96
  if (!current) return;
@@ -99,11 +107,14 @@ export function splitPages(body, leadName = "Introduction") {
99
107
  if (line.trim().startsWith("```")) {
100
108
  inCodeBlock = !inCodeBlock;
101
109
  }
110
+ if (!inCodeBlock && /^:::secret[ \t]*$/.test(line)) inSecret = true;
111
+ else if (!inCodeBlock && /^:::[ \t]*$/.test(line)) inSecret = false;
102
112
 
103
113
  // An H1 starts a page, as does any heading carrying an `{#slug}`
104
114
  // anchor: a Foundry UUID can only address a page, so a linkable
105
115
  // section has to be one.
106
- const headingMatch = !inCodeBlock ? line.match(/^\s*(#{1,6})\s+(.+?)\s*#*\s*$/) : null;
116
+ const headingMatch =
117
+ !inCodeBlock && !inSecret ? line.match(/^\s*(#{1,6})\s+(.+?)\s*#*\s*$/) : null;
107
118
  const rawHeading = headingMatch?.[2]?.trim();
108
119
  const anchorMatch = rawHeading?.match(/^(.*?)\s*\{#([^}]+)\}\s*$/);
109
120
  const startsPage = headingMatch && (headingMatch[1].length === 1 || anchorMatch);
@@ -255,7 +266,7 @@ export function buildPages(rawPages, entryId, noteName) {
255
266
  title: { show: true, level: page.level ?? 1 },
256
267
  text: {
257
268
  format: 1,
258
- content: page.markdown ? md.render(page.markdown) : "",
269
+ content: page.markdown ? renderFoundryMarkdown(page.markdown) : "",
259
270
  },
260
271
  _key: `!journal.pages!${entryId}.${pageId}`,
261
272
  };
@@ -102,6 +102,7 @@ import { resolveWebWikilinks } from "./web-wikilinks.mjs";
102
102
  import { linkFindingMessage } from "./wikilink-syntax.mjs";
103
103
  import { assetAddressIndex } from "./art-fields.mjs";
104
104
  import { expandContentTables } from "./content-tables.mjs";
105
+ import { renderSecretBlocks } from "./content-secrets.mjs";
105
106
  import { protectCode } from "./code-fences.mjs";
106
107
  import { imageSourcesIn } from "./content-images.mjs";
107
108
  import { pathnameProblem, resolvePathname } from "./pathnames.mjs";
@@ -523,8 +524,18 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
523
524
  message: err.message ?? (err.reason ? linkFindingMessage(err) : String(err)),
524
525
  });
525
526
  }
526
- stageImages(resolvedBody, page.file);
527
- const prose = markdownToTypst(resolvedBody, {
527
+ const secrets = renderSecretBlocks(resolvedBody, "book");
528
+ for (const error of renderSecretBlocks(page.body, "book").errors) {
529
+ findings.push({
530
+ file: page.file,
531
+ line: (page.bodyLine ?? 1) + error.line - 1,
532
+ column: error.column,
533
+ severity: "error",
534
+ message: error.message,
535
+ });
536
+ }
537
+ stageImages(secrets.markdown, page.file);
538
+ const prose = markdownToTypst(secrets.markdown, {
528
539
  md,
529
540
  links: plan.links,
530
541
  glyphs,
@@ -61,6 +61,7 @@ import matter from "gray-matter";
61
61
  import { addressSlug } from "./content-address.mjs";
62
62
  import { protectCode } from "./code-fences.mjs";
63
63
  import { expandContentTables } from "./content-tables.mjs";
64
+ import { renderSecretBlocks } from "./content-secrets.mjs";
64
65
  import { renderImageFigures } from "./content-images.mjs";
65
66
  import { pathnameProblem, resolvePathname } from "./pathnames.mjs";
66
67
  import { buildSiteIndex, resolveInfoboxRef, wikiContext } from "./site-index.mjs";
@@ -140,8 +141,10 @@ function siteCorpusFiles(contentBase, ctx) {
140
141
  */
141
142
  function readNote(file, ctx) {
142
143
  let parsed;
144
+ let source;
143
145
  try {
144
- parsed = matter(fs.readFileSync(file, "utf8"));
146
+ source = fs.readFileSync(file, "utf8");
147
+ parsed = matter(source);
145
148
  } catch {
146
149
  return null;
147
150
  }
@@ -150,7 +153,11 @@ function readNote(file, ctx) {
150
153
  package: ctx.contentPackage ?? ctx.config?.contentPackage,
151
154
  };
152
155
  try {
153
- return { fm: decodeNoteAddresses(parsed.data, context), body: parsed.content };
156
+ return {
157
+ fm: decodeNoteAddresses(parsed.data, context),
158
+ body: parsed.content,
159
+ bodyLine: source.slice(0, source.length - parsed.content.length).split("\n").length,
160
+ };
154
161
  } catch (error) {
155
162
  const position = positionOfYamlPath(parsed.matter, error.keyPath ?? [], {
156
163
  key: error.addressKey,
@@ -202,7 +209,7 @@ export function collectContentPages(contentBase, ctx) {
202
209
  const note = readNote(file, ctx);
203
210
  if (!note) continue;
204
211
  applyComputedBeingAge(note.fm, present);
205
- const { fm, body } = note;
212
+ const { fm, body, bodyLine } = note;
206
213
  // The configuration's, never a note's: `package:` is retired, so every
207
214
  // note in the tree belongs to the package this repository compiles.
208
215
  const pkg = ctx.contentPackage;
@@ -249,6 +256,7 @@ export function collectContentPages(contentBase, ctx) {
249
256
  // — reads one configured value and never frontmatter.
250
257
  pkg,
251
258
  body,
259
+ bodyLine,
252
260
  name,
253
261
  slug,
254
262
  base,
@@ -734,6 +742,7 @@ export function renderPages(pages, options) {
734
742
  });
735
743
 
736
744
  const tableErrors = [];
745
+ const secretErrors = [];
737
746
  const wikiErrors = [];
738
747
  const imageErrors = [];
739
748
  const byKind = {};
@@ -847,7 +856,17 @@ export function renderPages(pages, options) {
847
856
  tableErrors.push(...errors);
848
857
 
849
858
  const data = pageFrontmatter(page, { decorate, webSrc, artSrc });
850
- rendered.push({ page, body: protectCode(body, resolve), data });
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 });
851
870
  // A resolved target with no URL is a pack-only package's address:
852
871
  // real, and nowhere on the web, so not an edge.
853
872
  for (const hit of resolved) {
@@ -885,6 +904,7 @@ export function renderPages(pages, options) {
885
904
  written: pages.length,
886
905
  byKind,
887
906
  tableErrors,
907
+ secretErrors,
888
908
  wikiErrors,
889
909
  imageErrors,
890
910
  related,
@@ -1044,6 +1064,7 @@ export function buildSite({ config, sqlTables, locate } = {}) {
1044
1064
  gates: { ...emptyGates(), homepages: homepageFindings },
1045
1065
  manifests: null,
1046
1066
  tableErrors: [],
1067
+ secretErrors: [],
1047
1068
  wikiErrors: [],
1048
1069
  imageErrors: [],
1049
1070
  mapFindings: [],
@@ -1063,6 +1084,7 @@ export function buildSite({ config, sqlTables, locate } = {}) {
1063
1084
  gates: emptyGates(),
1064
1085
  manifests: null,
1065
1086
  tableErrors: [],
1087
+ secretErrors: [],
1066
1088
  wikiErrors: [],
1067
1089
  imageErrors: [],
1068
1090
  mapFindings: [],
@@ -1108,6 +1130,7 @@ export function buildSite({ config, sqlTables, locate } = {}) {
1108
1130
  gates,
1109
1131
  stats: null,
1110
1132
  tableErrors: [],
1133
+ secretErrors: [],
1111
1134
  wikiErrors: [],
1112
1135
  imageErrors: [],
1113
1136
  mapFindings: [],
@@ -1175,6 +1198,7 @@ export function buildSite({ config, sqlTables, locate } = {}) {
1175
1198
  return {
1176
1199
  gates,
1177
1200
  tableErrors: rendered.tableErrors,
1201
+ secretErrors: rendered.secretErrors,
1178
1202
  wikiErrors: rendered.wikiErrors,
1179
1203
  imageErrors: rendered.imageErrors,
1180
1204
  mapFindings: drawn.findings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.12.1",
3
+ "version": "22.12.2",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Render whole-line `:::secret` blocks for one publishing surface.
3
+ * The source line of each syntax error is relative to the supplied body.
4
+ *
5
+ * @param {string} source - Markdown containing secret blocks.
6
+ * @param {"foundry"|"web"|"book"} target - Publishing surface.
7
+ * @param {(markdown: string) => string} [renderMarkdown] - Foundry's configured renderer.
8
+ * @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>}}
9
+ */
10
+ export function renderSecretBlocks(source: string, target: "foundry" | "web" | "book", renderMarkdown?: (markdown: string) => string): {
11
+ markdown: string;
12
+ errors: Array<{
13
+ line: number;
14
+ column: number;
15
+ message: string;
16
+ }>;
17
+ };
@@ -1,3 +1,5 @@
1
+ /** Render a note body with Foundry's secret section markup. */
2
+ export function renderFoundryMarkdown(body: any): string;
1
3
  /**
2
4
  * Parses a markdown file with YAML frontmatter.
3
5
  *