@heroiclands/package-build 22.12.1 → 22.12.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 +15 -0
- package/README.md +3 -0
- package/bin/content-build.mjs +4 -0
- package/bin/heroiclands-content-language-server.mjs +8 -0
- package/docs/api.md +1 -0
- package/docs/commands.md +5 -2
- package/docs/content-format.md +7 -3
- package/docs/content-language-server.md +25 -0
- package/docs/getting-started.md +4 -3
- package/engine/anchored-sections.mjs +2 -2
- package/engine/base-compiler.mjs +13 -0
- package/engine/bundles.mjs +2 -2
- package/engine/content-language-server.mjs +439 -0
- package/engine/content-secrets.mjs +110 -0
- package/engine/helpers.mjs +8 -1
- package/engine/journals.mjs +14 -3
- package/engine/pdf-build.mjs +13 -2
- package/engine/site-build.mjs +28 -4
- package/package.json +3 -2
- package/types/engine/content-language-server.d.mts +64 -0
- package/types/engine/content-secrets.d.mts +17 -0
- package/types/engine/helpers.d.mts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @heroiclands/package-build
|
|
2
2
|
|
|
3
|
+
## 22.12.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Content authors can find notes by name, alias, tag, shortcode, or Address and follow references from an LSP-capable editor.
|
|
8
|
+
- Editor reference search works without ripgrep installed.
|
|
9
|
+
|
|
10
|
+
## 22.12.2
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
**Content**
|
|
15
|
+
|
|
16
|
+
- GM passages in notes appear as secret sections in Foundry journals, expandable spoilers on the web, and labelled notes in books.
|
|
17
|
+
|
|
3
18
|
## 22.12.1
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -14,6 +14,9 @@ The content half is documented separately in **[CONTENT.md](CONTENT.md)** — th
|
|
|
14
14
|
note format, the pack pipeline, and the configuration contract a content tree
|
|
15
15
|
declares itself with.
|
|
16
16
|
|
|
17
|
+
Editor navigation through the content index is described in the
|
|
18
|
+
[content language server guide](docs/content-language-server.md).
|
|
19
|
+
|
|
17
20
|
## Install
|
|
18
21
|
|
|
19
22
|
```
|
package/bin/content-build.mjs
CHANGED
|
@@ -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. |
|
package/docs/commands.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# Command reference
|
|
2
2
|
|
|
3
|
-
`@heroiclands/package-build` ships two binaries. `package-build` is the
|
|
3
|
+
`@heroiclands/package-build` ships two build binaries. `package-build` is the
|
|
4
4
|
packaging half — clean, stage, check, package, deploy, run a Foundry
|
|
5
5
|
container, drive the end-to-end suite. `content-build` is the content half —
|
|
6
6
|
compile a note tree into compendium packs, check it, publish it as a site or a
|
|
7
7
|
book, and manage the caches a build resolves other packages through.
|
|
8
8
|
|
|
9
|
+
The package also ships `heroiclands-content-language-server`, a stdio editor
|
|
10
|
+
service described in the [content language server guide](content-language-server.md).
|
|
11
|
+
|
|
9
12
|
Both read `package-build.config.yaml` from the repository root — see
|
|
10
13
|
[Configuration](configuration.md) for every key. Neither reads it for
|
|
11
14
|
`--version` or `--help`: those two answer in a directory with no
|
|
@@ -21,7 +24,7 @@ says so under **OPTIONS** rather than omitting the section.
|
|
|
21
24
|
|
|
22
25
|
**Every finding is `file:line:column: severity: message`**, the path starting
|
|
23
26
|
the line — the diagnostic contract this package's own build tooling emits and
|
|
24
|
-
that both binaries' content-side commands use for anything found in a
|
|
27
|
+
that both build binaries' content-side commands use for anything found in a
|
|
25
28
|
specific file. A field that cannot be known is dropped rather than guessed:
|
|
26
29
|
`file:line:` when the column means nothing, `file:` when only the file is
|
|
27
30
|
known. See [Diagnostics](diagnostics.md) for the full contract.
|
package/docs/content-format.md
CHANGED
|
@@ -2418,15 +2418,17 @@ This is secret text
|
|
|
2418
2418
|
:::
|
|
2419
2419
|
```
|
|
2420
2420
|
|
|
2421
|
-
|
|
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,
|
|
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.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Content language server
|
|
2
|
+
|
|
3
|
+
`heroiclands-content-language-server` provides editor navigation for Markdown notes in a HeroicLands content project. It is a stdio Language Server Protocol process; an editor starts it from the project root. The command uses the same `package-build.config.yaml` and Address rules as `content-build`.
|
|
4
|
+
|
|
5
|
+
Build the content index in the project before starting navigation:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx content-build content-index
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The generated JSONL file under the configured `paths.contentIndex` is the source for note names, aliases, tags, shortcodes, Addresses, and destinations. Search covers the current project. Search and destinations reflect the saved index, so run the index command after changing note metadata. The language server reads a completed replacement index on its next request. An editor's unsaved buffer text is used to identify the Address at the cursor, but it does not add search results.
|
|
12
|
+
|
|
13
|
+
| LSP request | Behavior |
|
|
14
|
+
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| `textDocument/definition` | Follows an Address or wikilink to the indexed note. An anchor lands on its indexed line. |
|
|
16
|
+
| `workspace/symbol` | Finds notes by name, alias, ASCII name, shortcode, or Address. `tag:myth` searches tags. One result appears per source note. |
|
|
17
|
+
| `textDocument/references` | Finds authored wikilinks, embeds, and declared frontmatter Address values or keys. Ordinary prose is excluded. |
|
|
18
|
+
|
|
19
|
+
The server reports a missing index with the `content-build content-index` command. Reference search scans Markdown notes in the configured content tree. The server writes only LSP messages to stdout, and it uses UTF-16 positions as required by the default LSP position encoding.
|
|
20
|
+
|
|
21
|
+
## Editor integration
|
|
22
|
+
|
|
23
|
+
The [HeroicLands Emacs package](https://github.com/HeroicLands/heroiclands-emacs) connects this command through Eglot in content Markdown buffers. Eglot maps `M-.` to definition, `C-M-.` to workspace search, `M-?` to references, and `M-,` to the Xref location stack.
|
|
24
|
+
|
|
25
|
+
An editor other than Emacs starts the executable from the package root and associates it with Markdown notes under the configured content directory. The process handles `initialize`, `shutdown`, `exit`, full and incremental document synchronization, definition, references, and workspace symbols. It does not advertise completion, diagnostics, rename, or document symbols.
|
package/docs/getting-started.md
CHANGED
|
@@ -132,9 +132,10 @@ repository.
|
|
|
132
132
|
npm install --save-dev @heroiclands/package-build
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
That installs
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
That installs `package-build`, the packaging half, and `content-build`, the
|
|
136
|
+
content half, into `node_modules/.bin`. It also installs the optional
|
|
137
|
+
`heroiclands-content-language-server` for editor navigation. Every build command
|
|
138
|
+
below uses one of the first two binaries. Run them through `npx`, or wire them into npm scripts as
|
|
138
139
|
[`project-setup.md`](project-setup.md) describes.
|
|
139
140
|
|
|
140
141
|
**How it worked:** `npx package-build --version` prints the installed version.
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* @module
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import {
|
|
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 ?
|
|
82
|
+
return slice ? renderFoundryMarkdown(slice) : "";
|
|
83
83
|
}
|
package/engine/base-compiler.mjs
CHANGED
|
@@ -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 ?? {};
|
package/engine/bundles.mjs
CHANGED
|
@@ -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,
|
|
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() ?
|
|
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,439 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
import YAML from "yaml";
|
|
9
|
+
import { parseAddress, renderAddress } from "./address.mjs";
|
|
10
|
+
import { addressPositions } from "./note-addresses.mjs";
|
|
11
|
+
import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
|
|
12
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
13
|
+
import { noteFile } from "./index-records.mjs";
|
|
14
|
+
import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
|
|
15
|
+
import { metadataFileName } from "./packages.mjs";
|
|
16
|
+
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
17
|
+
import { embedsIn, EMBED_DEFAULT_TYPE } from "./content-embeds.mjs";
|
|
18
|
+
|
|
19
|
+
const EMPTY_RANGE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
|
|
20
|
+
|
|
21
|
+
/** Return an LSP position for a UTF-16 offset in TEXT. */
|
|
22
|
+
function positionAt(text, offset) {
|
|
23
|
+
const before = text.slice(0, offset);
|
|
24
|
+
const line = before.split("\n").length - 1;
|
|
25
|
+
const lastNewline = before.lastIndexOf("\n");
|
|
26
|
+
return { line, character: offset - lastNewline - 1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Return a UTF-16 offset for an LSP position in TEXT. */
|
|
30
|
+
function offsetAt(text, position) {
|
|
31
|
+
const lines = text.split("\n");
|
|
32
|
+
if (position.line < 0 || position.line >= lines.length) return -1;
|
|
33
|
+
let offset = 0;
|
|
34
|
+
for (let i = 0; i < position.line; i++) offset += lines[i].length + 1;
|
|
35
|
+
return offset + Math.min(position.character, lines[position.line].length);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function location(file, text, start, end) {
|
|
39
|
+
return {
|
|
40
|
+
uri: pathToFileURL(file).href,
|
|
41
|
+
range: { start: positionAt(text, start), end: positionAt(text, end) },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function noteName(record) {
|
|
46
|
+
return typeof record.name === "string" ? record.name : (record.name?.full ?? record.shortcode);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An index read from the package's configured content tree. */
|
|
50
|
+
export class ContentWorkspace {
|
|
51
|
+
constructor(config = loadPackConfig()) {
|
|
52
|
+
this.config = config;
|
|
53
|
+
this.contentRoot = config.paths.content;
|
|
54
|
+
this.indexFile = path.join(
|
|
55
|
+
config.paths.contentIndex,
|
|
56
|
+
metadataFileName(config.contentPackage),
|
|
57
|
+
);
|
|
58
|
+
this.indexState = "";
|
|
59
|
+
this.records = [];
|
|
60
|
+
this.byAddress = new Map();
|
|
61
|
+
this.byFile = new Map();
|
|
62
|
+
this.types = new Set(Object.keys(NOTE_VOCABULARY));
|
|
63
|
+
this.documents = new Map();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
refresh() {
|
|
67
|
+
let stat;
|
|
68
|
+
try {
|
|
69
|
+
stat = fs.statSync(this.indexFile);
|
|
70
|
+
} catch {
|
|
71
|
+
this.indexState = "";
|
|
72
|
+
this.records = [];
|
|
73
|
+
this.byAddress.clear();
|
|
74
|
+
this.byFile.clear();
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const state = `${stat.mtimeMs}:${stat.size}`;
|
|
78
|
+
if (state === this.indexState) return true;
|
|
79
|
+
const records = fs
|
|
80
|
+
.readFileSync(this.indexFile, "utf8")
|
|
81
|
+
.split("\n")
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map((line) => JSON.parse(line));
|
|
84
|
+
const byAddress = new Map();
|
|
85
|
+
const byFile = new Map();
|
|
86
|
+
const types = new Set(Object.keys(NOTE_VOCABULARY));
|
|
87
|
+
for (const record of records) {
|
|
88
|
+
if (record.type) types.add(record.type);
|
|
89
|
+
if (record.address?.canonical)
|
|
90
|
+
byAddress.set(record.address.canonical.toLowerCase(), record);
|
|
91
|
+
if (record.file?.path) {
|
|
92
|
+
const file = noteFile(this.contentRoot, record);
|
|
93
|
+
if (!byFile.has(file)) byFile.set(file, record);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this.records = records;
|
|
97
|
+
this.byAddress = byAddress;
|
|
98
|
+
this.byFile = byFile;
|
|
99
|
+
this.types = types;
|
|
100
|
+
this.indexState = state;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
requireIndex() {
|
|
105
|
+
if (!this.refresh())
|
|
106
|
+
throw new Error(
|
|
107
|
+
`No content index at ${this.indexFile}; run content-build content-index`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fileFor(record) {
|
|
112
|
+
if (record.file?.path) return noteFile(this.contentRoot, record);
|
|
113
|
+
if (record.asset?.path)
|
|
114
|
+
return path.join(this.config.paths.assets, ...record.asset.path.split("/"));
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
text(uri) {
|
|
119
|
+
if (this.documents.has(uri)) return this.documents.get(uri);
|
|
120
|
+
try {
|
|
121
|
+
return fs.readFileSync(fileURLToPath(uri), "utf8");
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Resolve a written Address using the same tuple grammar as the build. */
|
|
128
|
+
resolve(value, defaults = {}) {
|
|
129
|
+
const tuple = parseAddress(value, {
|
|
130
|
+
package: this.config.contentPackage,
|
|
131
|
+
system: "none",
|
|
132
|
+
types: this.types,
|
|
133
|
+
packages: new Set([this.config.contentPackage]),
|
|
134
|
+
...defaults,
|
|
135
|
+
});
|
|
136
|
+
if (tuple.reason) return null;
|
|
137
|
+
return this.byAddress.get(renderAddress(tuple)) ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Link and declared frontmatter targets, with exact source ranges. */
|
|
141
|
+
referencesInText(text, file) {
|
|
142
|
+
const found = [];
|
|
143
|
+
const bodyStart = text.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0].length ?? 0;
|
|
144
|
+
const linkText = text.slice(bodyStart);
|
|
145
|
+
for (const match of matchAllOutsideCode(linkText, new RegExp(WIKILINK.source, "g"))) {
|
|
146
|
+
const parsed = parseWikilink(match[1]);
|
|
147
|
+
const record = parsed.target ? this.resolve(parsed.target) : this.byFile.get(file);
|
|
148
|
+
if (!record) continue;
|
|
149
|
+
const start = bodyStart + match.index + (parsed.target ? 2 : 3);
|
|
150
|
+
found.push({
|
|
151
|
+
record,
|
|
152
|
+
anchor: parsed.anchor,
|
|
153
|
+
location: location(
|
|
154
|
+
file,
|
|
155
|
+
text,
|
|
156
|
+
start,
|
|
157
|
+
start + (parsed.target || parsed.anchor).length,
|
|
158
|
+
),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
for (const embed of embedsIn(linkText)) {
|
|
162
|
+
const record = this.resolve(embed.written, { type: EMBED_DEFAULT_TYPE });
|
|
163
|
+
if (!record) continue;
|
|
164
|
+
const start = bodyStart + embed.index + 3;
|
|
165
|
+
found.push({
|
|
166
|
+
record,
|
|
167
|
+
anchor: "",
|
|
168
|
+
location: location(file, text, start, start + embed.written.length),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (!bodyStart) return found;
|
|
172
|
+
const yamlStart = text.indexOf("\n") + 1;
|
|
173
|
+
const yamlText = text.slice(yamlStart, bodyStart).replace(/\r?\n---(?:\r?\n)?$/, "");
|
|
174
|
+
let document;
|
|
175
|
+
try {
|
|
176
|
+
document = YAML.parseDocument(yamlText);
|
|
177
|
+
} catch {
|
|
178
|
+
return found;
|
|
179
|
+
}
|
|
180
|
+
if (document.errors.length) return found;
|
|
181
|
+
const frontmatter = document.toJS();
|
|
182
|
+
if (!frontmatter || typeof frontmatter !== "object") return found;
|
|
183
|
+
const visit = (node, segments, position) => {
|
|
184
|
+
if (!node) return;
|
|
185
|
+
if (segments.length) {
|
|
186
|
+
const [head, ...tail] = segments;
|
|
187
|
+
if (YAML.isMap(node)) {
|
|
188
|
+
for (const pair of node.items) {
|
|
189
|
+
if (head === "*" || String(pair.key?.value) === String(head))
|
|
190
|
+
visit(pair.value, tail, position);
|
|
191
|
+
}
|
|
192
|
+
} else if (YAML.isSeq(node)) {
|
|
193
|
+
node.items.forEach((child, index) => {
|
|
194
|
+
if (head === "*" || String(index) === String(head))
|
|
195
|
+
visit(child, tail, position);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const scalar = (part, value) => {
|
|
201
|
+
if (!YAML.isScalar(part) || typeof value !== "string" || !part.range) return;
|
|
202
|
+
const record = this.resolve(value, {
|
|
203
|
+
system: position.system ?? "none",
|
|
204
|
+
type: position.type,
|
|
205
|
+
});
|
|
206
|
+
if (!record) return;
|
|
207
|
+
const start = yamlStart + part.range[0];
|
|
208
|
+
found.push({
|
|
209
|
+
record,
|
|
210
|
+
anchor: "",
|
|
211
|
+
location: location(file, text, start, yamlStart + part.range[1]),
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
if (position.shape === "keys" && YAML.isMap(node)) {
|
|
215
|
+
for (const pair of node.items) scalar(pair.key, pair.key?.value);
|
|
216
|
+
} else if (position.shape === "list" && YAML.isSeq(node)) {
|
|
217
|
+
for (const child of node.items) scalar(child, child?.value);
|
|
218
|
+
} else if (position.shape === "scalar-or-map" && YAML.isMap(node)) {
|
|
219
|
+
for (const pair of node.items) scalar(pair.value, pair.value?.value);
|
|
220
|
+
} else {
|
|
221
|
+
scalar(node, node.value);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
for (const position of addressPositions(frontmatter))
|
|
225
|
+
visit(document.contents, position.path, position);
|
|
226
|
+
return found;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
targetAt(uri, position) {
|
|
230
|
+
const text = this.text(uri);
|
|
231
|
+
if (text == null) return null;
|
|
232
|
+
const offset = offsetAt(text, position);
|
|
233
|
+
if (offset < 0) return null;
|
|
234
|
+
const file = fileURLToPath(uri);
|
|
235
|
+
const reference = this.referencesInText(text, file).find(({ location: source }) => {
|
|
236
|
+
const start = offsetAt(text, source.range.start);
|
|
237
|
+
const end = offsetAt(text, source.range.end);
|
|
238
|
+
return start <= offset && offset <= end;
|
|
239
|
+
});
|
|
240
|
+
if (reference) return { record: reference.record, anchor: reference.anchor };
|
|
241
|
+
const own = this.byFile.get(file);
|
|
242
|
+
if (own && /^shortcode:\s*/.test(text.split("\n")[position.line] ?? ""))
|
|
243
|
+
return { record: own, anchor: "" };
|
|
244
|
+
const start = text.slice(0, offset).search(/[A-Za-z0-9./]+$/);
|
|
245
|
+
if (start < 0) return null;
|
|
246
|
+
const end = offset + (text.slice(offset).match(/^[A-Za-z0-9./]+/)?.[0].length ?? 0);
|
|
247
|
+
const record = this.resolve(text.slice(start, end));
|
|
248
|
+
return record ? { record, anchor: "" } : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
definition(uri, position) {
|
|
252
|
+
this.requireIndex();
|
|
253
|
+
const target = this.targetAt(uri, position);
|
|
254
|
+
if (!target) return null;
|
|
255
|
+
const { record, anchor } = target;
|
|
256
|
+
const file = this.fileFor(record);
|
|
257
|
+
if (!file) return null;
|
|
258
|
+
if (record.asset) return { uri: pathToFileURL(file).href, range: EMPTY_RANGE };
|
|
259
|
+
const line =
|
|
260
|
+
anchor ?
|
|
261
|
+
record.anchors?.find((entry) => entry.slug.toLowerCase() === anchor.toLowerCase())
|
|
262
|
+
?.line
|
|
263
|
+
: null;
|
|
264
|
+
if (anchor && !line) return null;
|
|
265
|
+
const targetText = fs.readFileSync(file, "utf8");
|
|
266
|
+
const offset = line ? offsetAt(targetText, { line: line - 1, character: 0 }) : 0;
|
|
267
|
+
return location(file, targetText, offset, offset);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
symbols(query) {
|
|
271
|
+
this.requireIndex();
|
|
272
|
+
const tag = /^tag:(.*)$/i.exec(query);
|
|
273
|
+
const needle = (tag ? tag[1] : query).trim().toLowerCase();
|
|
274
|
+
const found = new Map();
|
|
275
|
+
for (const record of this.records) {
|
|
276
|
+
if (!record.file?.path) continue;
|
|
277
|
+
const values =
|
|
278
|
+
tag ?
|
|
279
|
+
(record.tags ?? [])
|
|
280
|
+
: [
|
|
281
|
+
record.name?.full,
|
|
282
|
+
record.nameAscii,
|
|
283
|
+
...(record.name?.aliases ?? []),
|
|
284
|
+
...(record.aliasesAscii ?? []),
|
|
285
|
+
record.shortcode,
|
|
286
|
+
record.address?.slug,
|
|
287
|
+
record.address?.canonical,
|
|
288
|
+
];
|
|
289
|
+
const match = values.find(
|
|
290
|
+
(value) => typeof value === "string" && value.toLowerCase().includes(needle),
|
|
291
|
+
);
|
|
292
|
+
if (!match) continue;
|
|
293
|
+
const file = noteFile(this.contentRoot, record);
|
|
294
|
+
if (found.has(file)) continue;
|
|
295
|
+
found.set(file, {
|
|
296
|
+
name: String(noteName(record)),
|
|
297
|
+
kind: 1,
|
|
298
|
+
containerName: `${record.address?.slug ?? [record.type, record.shortcode].filter(Boolean).join(" ")} · ${tag ? `tag: ${match}` : match}`,
|
|
299
|
+
location: { uri: pathToFileURL(file).href, range: EMPTY_RANGE },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return [...found.values()];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
references(uri, position) {
|
|
306
|
+
this.requireIndex();
|
|
307
|
+
const target = this.targetAt(uri, position)?.record;
|
|
308
|
+
if (!target?.address?.canonical) return [];
|
|
309
|
+
const locations = [];
|
|
310
|
+
const needle = target.shortcode.toLowerCase();
|
|
311
|
+
const visit = (directory) => {
|
|
312
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
313
|
+
if (entry.name.startsWith(".")) continue;
|
|
314
|
+
const file = path.join(directory, entry.name);
|
|
315
|
+
if (entry.isDirectory()) {
|
|
316
|
+
visit(file);
|
|
317
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
|
|
318
|
+
const savedText = fs.readFileSync(file, "utf8");
|
|
319
|
+
if (!savedText.toLowerCase().includes(needle)) continue;
|
|
320
|
+
const text = this.documents.get(pathToFileURL(file).href) ?? savedText;
|
|
321
|
+
for (const reference of this.referencesInText(text, file)) {
|
|
322
|
+
if (reference.record.address?.canonical === target.address.canonical)
|
|
323
|
+
locations.push(reference.location);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
visit(this.contentRoot);
|
|
329
|
+
return locations;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Process one JSON-RPC request without writing protocol bytes. */
|
|
334
|
+
export function respond(workspace, message) {
|
|
335
|
+
const { method, params = {} } = message;
|
|
336
|
+
switch (method) {
|
|
337
|
+
case "initialize":
|
|
338
|
+
return {
|
|
339
|
+
capabilities: {
|
|
340
|
+
positionEncoding: "utf-16",
|
|
341
|
+
textDocumentSync: 2,
|
|
342
|
+
definitionProvider: true,
|
|
343
|
+
referencesProvider: true,
|
|
344
|
+
workspaceSymbolProvider: true,
|
|
345
|
+
},
|
|
346
|
+
serverInfo: { name: "heroiclands-content" },
|
|
347
|
+
};
|
|
348
|
+
case "shutdown":
|
|
349
|
+
return null;
|
|
350
|
+
case "textDocument/didOpen":
|
|
351
|
+
workspace.documents.set(params.textDocument.uri, params.textDocument.text);
|
|
352
|
+
return undefined;
|
|
353
|
+
case "textDocument/didChange": {
|
|
354
|
+
const uri = params.textDocument.uri;
|
|
355
|
+
let text = workspace.text(uri) ?? "";
|
|
356
|
+
for (const change of params.contentChanges ?? []) {
|
|
357
|
+
if (!change.range) text = change.text;
|
|
358
|
+
else {
|
|
359
|
+
const start = offsetAt(text, change.range.start);
|
|
360
|
+
const end = offsetAt(text, change.range.end);
|
|
361
|
+
text = text.slice(0, start) + change.text + text.slice(end);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
workspace.documents.set(uri, text);
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
case "textDocument/didClose":
|
|
368
|
+
workspace.documents.delete(params.textDocument.uri);
|
|
369
|
+
return undefined;
|
|
370
|
+
case "textDocument/definition":
|
|
371
|
+
return workspace.definition(params.textDocument.uri, params.position);
|
|
372
|
+
case "textDocument/references":
|
|
373
|
+
return workspace.references(params.textDocument.uri, params.position);
|
|
374
|
+
case "workspace/symbol":
|
|
375
|
+
return workspace.symbols(params.query ?? "");
|
|
376
|
+
default:
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Run the stdio language server. */
|
|
382
|
+
export function runLanguageServer(
|
|
383
|
+
input = process.stdin,
|
|
384
|
+
output = process.stdout,
|
|
385
|
+
workspace = new ContentWorkspace(),
|
|
386
|
+
) {
|
|
387
|
+
let pending = Buffer.alloc(0);
|
|
388
|
+
const send = (message) => {
|
|
389
|
+
const body = Buffer.from(JSON.stringify(message));
|
|
390
|
+
output.write(`Content-Length: ${body.length}\r\n\r\n`);
|
|
391
|
+
output.write(body);
|
|
392
|
+
};
|
|
393
|
+
input.on("data", (chunk) => {
|
|
394
|
+
pending = Buffer.concat([pending, chunk]);
|
|
395
|
+
while (true) {
|
|
396
|
+
const separator = pending.indexOf("\r\n\r\n");
|
|
397
|
+
if (separator < 0) break;
|
|
398
|
+
const header = pending.subarray(0, separator).toString("ascii");
|
|
399
|
+
const length = /^content-length:\s*(\d+)\s*$/im.exec(header)?.[1];
|
|
400
|
+
if (!length) throw new Error("LSP message has no Content-Length");
|
|
401
|
+
const end = separator + 4 + Number(length);
|
|
402
|
+
if (pending.length < end) break;
|
|
403
|
+
const body = pending.subarray(separator + 4, end);
|
|
404
|
+
pending = pending.subarray(end);
|
|
405
|
+
let message;
|
|
406
|
+
try {
|
|
407
|
+
message = JSON.parse(body.toString("utf8"));
|
|
408
|
+
} catch (error) {
|
|
409
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: String(error) } });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (message.method === "exit") {
|
|
413
|
+
process.exitCode = 0;
|
|
414
|
+
input.pause();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
const result = respond(workspace, message);
|
|
419
|
+
if (message.id !== undefined)
|
|
420
|
+
send(
|
|
421
|
+
result === undefined ?
|
|
422
|
+
{
|
|
423
|
+
jsonrpc: "2.0",
|
|
424
|
+
id: message.id,
|
|
425
|
+
error: { code: -32601, message: "Method not found" },
|
|
426
|
+
}
|
|
427
|
+
: { jsonrpc: "2.0", id: message.id, result },
|
|
428
|
+
);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (message.id !== undefined)
|
|
431
|
+
send({
|
|
432
|
+
jsonrpc: "2.0",
|
|
433
|
+
id: message.id,
|
|
434
|
+
error: { code: -32603, message: String(error) },
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
}
|
|
@@ -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
|
+
}
|
package/engine/helpers.mjs
CHANGED
|
@@ -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 ?
|
|
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
|
package/engine/journals.mjs
CHANGED
|
@@ -51,7 +51,14 @@
|
|
|
51
51
|
|
|
52
52
|
import log from "loglevel";
|
|
53
53
|
|
|
54
|
-
import {
|
|
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 =
|
|
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 ?
|
|
269
|
+
content: page.markdown ? renderFoundryMarkdown(page.markdown) : "",
|
|
259
270
|
},
|
|
260
271
|
_key: `!journal.pages!${entryId}.${pageId}`,
|
|
261
272
|
};
|
package/engine/pdf-build.mjs
CHANGED
|
@@ -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
|
-
|
|
527
|
-
const
|
|
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,
|
package/engine/site-build.mjs
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "22.12.3",
|
|
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",
|
|
@@ -96,7 +96,8 @@
|
|
|
96
96
|
},
|
|
97
97
|
"bin": {
|
|
98
98
|
"package-build": "./bin/package-build.mjs",
|
|
99
|
-
"content-build": "./bin/content-build.mjs"
|
|
99
|
+
"content-build": "./bin/content-build.mjs",
|
|
100
|
+
"heroiclands-content-language-server": "./bin/heroiclands-content-language-server.mjs"
|
|
100
101
|
},
|
|
101
102
|
"files": [
|
|
102
103
|
"CHANGELOG-content-build.md",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Process one JSON-RPC request without writing protocol bytes. */
|
|
2
|
+
export function respond(workspace: any, message: any): any;
|
|
3
|
+
/** Run the stdio language server. */
|
|
4
|
+
export function runLanguageServer(input?: NodeJS.ReadStream & {
|
|
5
|
+
fd: 0;
|
|
6
|
+
}, output?: NodeJS.WriteStream & {
|
|
7
|
+
fd: 1;
|
|
8
|
+
}, workspace?: ContentWorkspace): void;
|
|
9
|
+
/** An index read from the package's configured content tree. */
|
|
10
|
+
export class ContentWorkspace {
|
|
11
|
+
constructor(config?: import("../content-config.mjs").ContentBuildConfig);
|
|
12
|
+
config: import("../content-config.mjs").ContentBuildConfig;
|
|
13
|
+
contentRoot: string;
|
|
14
|
+
indexFile: string;
|
|
15
|
+
indexState: string;
|
|
16
|
+
records: any[];
|
|
17
|
+
byAddress: Map<any, any>;
|
|
18
|
+
byFile: Map<any, any>;
|
|
19
|
+
types: Set<string>;
|
|
20
|
+
documents: Map<any, any>;
|
|
21
|
+
refresh(): boolean;
|
|
22
|
+
requireIndex(): void;
|
|
23
|
+
fileFor(record: any): string | null;
|
|
24
|
+
text(uri: any): any;
|
|
25
|
+
/** Resolve a written Address using the same tuple grammar as the build. */
|
|
26
|
+
resolve(value: any, defaults?: {}): any;
|
|
27
|
+
/** Link and declared frontmatter targets, with exact source ranges. */
|
|
28
|
+
referencesInText(text: any, file: any): {
|
|
29
|
+
record: any;
|
|
30
|
+
anchor: string;
|
|
31
|
+
location: {
|
|
32
|
+
uri: string;
|
|
33
|
+
range: {
|
|
34
|
+
start: {
|
|
35
|
+
line: number;
|
|
36
|
+
character: number;
|
|
37
|
+
};
|
|
38
|
+
end: {
|
|
39
|
+
line: number;
|
|
40
|
+
character: number;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
}[];
|
|
45
|
+
targetAt(uri: any, position: any): {
|
|
46
|
+
record: any;
|
|
47
|
+
anchor: string;
|
|
48
|
+
} | null;
|
|
49
|
+
definition(uri: any, position: any): {
|
|
50
|
+
uri: string;
|
|
51
|
+
range: {
|
|
52
|
+
start: {
|
|
53
|
+
line: number;
|
|
54
|
+
character: number;
|
|
55
|
+
};
|
|
56
|
+
end: {
|
|
57
|
+
line: number;
|
|
58
|
+
character: number;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
} | null;
|
|
62
|
+
symbols(query: any): any[];
|
|
63
|
+
references(uri: any, position: any): any[];
|
|
64
|
+
}
|
|
@@ -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
|
+
};
|