@heroiclands/package-build 23.10.0 → 23.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/docs/api.md +29 -2
- package/docs/authoring/first-note.md +10 -0
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +74 -4
- package/docs/content-format.md +4 -2
- package/docs/design.md +18 -0
- package/docs/guides/book.md +7 -0
- package/docs/guides/site.md +7 -0
- package/docs/reference/format-details.md +59 -3
- package/engine/anchors.mjs +36 -4
- package/engine/content-links.mjs +41 -18
- package/engine/content-spans.mjs +32 -7
- package/engine/helpers.mjs +4 -2
- package/engine/pdf-render.mjs +1 -1
- package/engine/site-index.mjs +23 -4
- package/engine/web-wikilinks.mjs +33 -12
- package/engine/wikilink-syntax.mjs +65 -8
- package/engine/wikilinks.mjs +29 -11
- package/hugo-theme/layouts/partials/footer.html +2 -0
- package/hugo-theme/static/css/style.css +16 -0
- package/hugo-theme/static/js/inline-spoilers.js +23 -0
- package/package.json +1 -1
- package/types/engine/anchors.d.mts +2 -1
- package/types/engine/content-spans.d.mts +5 -1
- package/types/engine/wikilink-syntax.d.mts +40 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @heroiclands/package-build
|
|
2
2
|
|
|
3
|
+
## 23.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- References to private notes and secret sections now conceal their labels on the web; authors can keep selected references visible.
|
|
8
|
+
- Inline spoilers preserve formatted text across websites, Foundry journals and books.
|
|
9
|
+
|
|
3
10
|
## 23.10.0
|
|
4
11
|
|
|
5
12
|
### Minor Changes
|
package/docs/api.md
CHANGED
|
@@ -1474,6 +1474,15 @@ Wikilink resolution for the knowledgebase build. The same authored links the pac
|
|
|
1474
1474
|
| `frontmatterWikilinks` | `function frontmatterWikilinks(fm)` | {Array<{path: string, link: string}>} In reading order; `path` is the dotted key path of the offending value (`government.summary`, `aliases.1`). | Every wikilink authored inside a frontmatter value. |
|
|
1475
1475
|
| `resolveWebWikilinks` | `function resolveWebWikilinks(body, ctx)` | {string} The body with wikilinks rewritten. | Rewrites the wikilinks in a markdown body as KB-local markdown links. |
|
|
1476
1476
|
|
|
1477
|
+
Both `resolveWebWikilinks` and Foundry's `convertWikilinks` derive reference
|
|
1478
|
+
spoilers from the resolved note audience and secret-anchor metadata. A trailing
|
|
1479
|
+
`{.show}` bypasses automatic spoiler wrapping, without changing destination
|
|
1480
|
+
access. A web destination excluded by audience retains its label without an
|
|
1481
|
+
href. Same-note secret anchors follow the same rule; a whole-note link to a
|
|
1482
|
+
public note does not inherit individual secret sections. See the
|
|
1483
|
+
[authoring contract](authoring/links-and-markup.md#references-to-secret-subjects)
|
|
1484
|
+
for surface presentation.
|
|
1485
|
+
|
|
1477
1486
|
### `engine.contentTables`
|
|
1478
1487
|
|
|
1479
1488
|
Prepared SQL content tables are expanded into Markdown before link resolution.
|
|
@@ -1602,8 +1611,8 @@ What a `[[…]]` **is**, before anything decides where it points. One authored l
|
|
|
1602
1611
|
| Export | Signature | Returns | Use it when |
|
|
1603
1612
|
| -------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
1604
1613
|
| `WIKILINK` | `const WIKILINK` | — | A wikilink, as authored. |
|
|
1605
|
-
| `parseWikilink` | `function parseWikilink(rawInner)`
|
|
1606
|
-
| `readWikilink` | `function readWikilink(inner, context)`
|
|
1614
|
+
| `parseWikilink` | `function parseWikilink(rawInner, literal?)` | {ParsedWikilink} The parts, each trimmed. | Split a wikilink's interior into its parts. |
|
|
1615
|
+
| `readWikilink` | `function readWikilink(inner, context, literal?)` | Typed target or literal target with diagnostic source. | Parse a link target using complete builder Address context. |
|
|
1607
1616
|
| `authoredLabel` | `authoredLabel({ display })` | {string\|null} The label, or `null` when there is none to show. | The label an author actually supplied, or `null` when they supplied none. |
|
|
1608
1617
|
| `unlabelledLinkMessage` | `function unlabelledLinkMessage(target)` | {string} The message, unpunctuated at the end as a finding is. | What an author writing an unlabelled link is told, in one place. |
|
|
1609
1618
|
| `LINK_FINDING_REASONS` | `const LINK_FINDING_REASONS` | — | Every way a link can fail, named once for all three resolvers. |
|
|
@@ -1614,6 +1623,15 @@ What a `[[…]]` **is**, before anything decides where it points. One authored l
|
|
|
1614
1623
|
| `isEventAnchor` | `isEventAnchor(hit, anchor)` | {boolean} Whether it is an event. | Whether the anchor a wikilink names is one of its target's events, which a wikilink may not name. |
|
|
1615
1624
|
| `isSamePage` | `isSamePage({ target, anchor })` | {boolean} True when the link is same-page. | Whether a parsed link addresses a section of the page it is written on. |
|
|
1616
1625
|
|
|
1626
|
+
`parseWikilink` and `readWikilink` accept the complete matched `literal` to
|
|
1627
|
+
read a trailing attribute block into `attributes`; its `classes` include the
|
|
1628
|
+
explicit `.show` override.
|
|
1629
|
+
|
|
1630
|
+
| Export | Signature | Returns | Use it when |
|
|
1631
|
+
| ------------------- | ------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------- |
|
|
1632
|
+
| `secretDestination` | `secretDestination(destination, anchor?)` | `boolean` | Read the resolved note's GM audience or the referenced anchor's secret metadata. |
|
|
1633
|
+
| `presentWikilink` | `presentWikilink(reference, parsed, destination)` | `string` | Wrap a rendered reference as an inline spoiler unless the parsed link explicitly carries `.show`. |
|
|
1634
|
+
|
|
1617
1635
|
### `engine.siteIndex`
|
|
1618
1636
|
|
|
1619
1637
|
**The address index a site build resolves its wikilinks against.**
|
|
@@ -2371,6 +2389,15 @@ The engine wildcard subpath also exposes the body parsers below. Their figure ex
|
|
|
2371
2389
|
| `engine/content-poetry.mjs` | `verseLines`, `poetryStanzas`, `inlineVerse`, `poetryMarkdown`, `scanPoetry`, `renderPoetry` | Read `poetry` code fences, validate authored metadata and tabs, and preserve relative indent levels and stanzas. |
|
|
2372
2390
|
| `engine/content-spans.mjs` | `spanMarkdownPlugin`, `scanSpans`, `renderSpans` | Parse bracket spans after recognized links. The scanner returns spans, the exact `.name` inventory and located errors, retaining visible text and attribute positions; rendering preserves inline Markdown and permitted attributes. |
|
|
2373
2391
|
|
|
2392
|
+
Inline `.secret` spans preserve their inline content, name classes and
|
|
2393
|
+
attributes. Site rendering uses a separate show/hide button and content span,
|
|
2394
|
+
so a link inside the content does not become a nested control. Foundry renders
|
|
2395
|
+
visible inline text with a **Secret:** label, without native secret-section
|
|
2396
|
+
concealment; book rendering uses static secret styling and the same label.
|
|
2397
|
+
`collectAnchors` includes `secret: true` on secret anchors for destination
|
|
2398
|
+
presentation. Block and heading secrecy retain their separate scope and
|
|
2399
|
+
Foundry permission handling.
|
|
2400
|
+
|
|
2374
2401
|
`scanFigures` retains `line` (1-based caption line), `bodyStart`/`bodyEnd` (0-based, exclusive-end target range), and `close` (last consumed target line), plus caption attributes, kind, label and number. Unnumbered captions have an empty label and consume no number. `collectAnchors` discovers heading, caption, div, poem and span identifiers; `markupAnchorFindings(body)` reports collisions involving non-page identifiers; Foundry maps non-page anchors to the actual containing page UUID. An unnumbered `ref` requires `form="title"` or `form="full"`.
|
|
2375
2402
|
|
|
2376
2403
|
`engine/markup-validation.mjs` provides shared syntax validation:
|
|
@@ -41,6 +41,16 @@ For optional writing suggestions, run `npx package-build prose lint assets/conte
|
|
|
41
41
|
|
|
42
42
|
The full Address is `<package>-note-lore-harbor`; `<package>` comes from `contentPackage` in `package-build.config.yaml`. In another note, `[[lore-harbor|Harbor]]` links to it. `[[lore-harbor|]]` asks the renderer to use the target's current name. The pipe is required.
|
|
43
43
|
|
|
44
|
+
Use another subject's full canonical name at its first prose mention, then
|
|
45
|
+
plain text for later mentions and aliases. Keep a note's own subject in plain
|
|
46
|
+
text. Link indexed subjects even when they are private: the destination's
|
|
47
|
+
audience automatically makes the whole reference a spoiler. Add `.show` only
|
|
48
|
+
to intentionally reveal the reference; it grants no access to the destination.
|
|
49
|
+
For an unwritten subject, use a typed span such as
|
|
50
|
+
`[Aldric Venn]{.name data-type="being/npc"}`. The
|
|
51
|
+
[links and markup guide](links-and-markup.md#marking-names-in-prose) covers
|
|
52
|
+
these rules and separate `[text]{.secret}` spoilers.
|
|
53
|
+
|
|
44
54
|
## Grow the note only when needed
|
|
45
55
|
|
|
46
56
|
Add `description` for a short page summary, `tags: [draft]` to mark unfinished prose, or `data` for fields the note's type permits. A system block such as `sohl:` or `hm3:` adds mechanics and can produce a Foundry Actor or Item. It does not replace the shared prose or make another JournalEntry. [Frontmatter](frontmatter.md) explains the allowed keys, and the [note-type reference](../reference/note-types.md) lists every type-specific field.
|
|
@@ -25,7 +25,7 @@ data: {}
|
|
|
25
25
|
|
|
26
26
|
`description` is the short page summary. `tags` hold draft state and descriptive labels. The note type's classification belongs in `subType` where it is declared. `data` holds facts about the subject shared by systems. A YAML comment is the place for an author-only note: commented values remain valid YAML but do not enter the content index or generated documents.
|
|
27
27
|
|
|
28
|
-
Use `tags: [gm]` for a note intended only for the GM. It is absent from the public website and book. Foundry includes it only when its document routes to a pack with `private: true`. A note with `gm` that also creates a prose JournalEntry needs a private JournalEntry pack.
|
|
28
|
+
Use `tags: [gm]` for a note intended only for the GM. It is absent from the public website and book. Foundry includes it only when its document routes to a pack with `private: true`. A note with `gm` that also creates a prose JournalEntry needs a private JournalEntry pack. Any note can reference a GM note with an ordinary wikilink. The whole reference is automatically a spoiler; `.show` reveals only that reference, without changing the target's audience or permissions. See [references to secret subjects](links-and-markup.md#references-to-secret-subjects).
|
|
29
29
|
|
|
30
30
|
The formatter keeps a collection on one line when the full line is under 100 characters. Longer collections use full YAML block form. Run `package-build format --write` to apply key order and collection formatting.
|
|
31
31
|
|
|
@@ -23,10 +23,48 @@ A link to a note tagged `draft` retains its target and carries a visible draft c
|
|
|
23
23
|
|
|
24
24
|
An HTML comment is an aside to whoever reads the note next, and no surface shows one — the packs and the website emit it into the HTML, where it stays a comment, and a book leaves it out. Write a `markdownlint` pragma or a note about a quoted passage freely. A comment inside a fence or a code span is an example and is printed as written, and a comment that is never closed is an error.
|
|
25
25
|
|
|
26
|
+
## References to secret subjects
|
|
27
|
+
|
|
28
|
+
Use an ordinary wikilink for an indexed subject, including a GM-only note:
|
|
29
|
+
|
|
30
|
+
```markdown
|
|
31
|
+
[[doc-divineactn|The Truth of Divine Action]]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The renderer reads the destination's audience and conceals the entire reference,
|
|
35
|
+
including its label, as a spoiler. Authors do not repeat that audience at each
|
|
36
|
+
reference. A reference to a public subject stays visible. A link to an anchor inside secret content
|
|
37
|
+
conceals its reference too, including a same-note `[[#anchor|label]]`.
|
|
38
|
+
Linking to the public note as a whole does not inherit a section's secrecy.
|
|
39
|
+
|
|
40
|
+
| Surface | Reference to a secret destination |
|
|
41
|
+
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
42
|
+
| Website | An inline show/hide spoiler conceals its label. An omitted GM-only destination produces text without an unusable link. A published secret heading can still be linked. |
|
|
43
|
+
| Foundry | A visible inline spoiler labelled **Secret:** contains the reference. Private pack and page permissions govern whether its destination can be opened. |
|
|
44
|
+
| Book | The reference prints with secret styling and a **Secret:** label. An omitted GM-only destination produces text without a link. |
|
|
45
|
+
|
|
46
|
+
`.show` removes that automatic spoiler presentation on every surface. Where
|
|
47
|
+
the target is unavailable it still produces text without a link.
|
|
48
|
+
|
|
49
|
+
To intentionally reveal this reference, add `.show` immediately after the link:
|
|
50
|
+
|
|
51
|
+
```markdown
|
|
52
|
+
[[doc-divineactn|The Truth of Divine Action]]{.show}
|
|
53
|
+
[[doc-magictruth|The Truth of Magic]]{.show}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`.show` reveals only this reference. It does not publish the destination, change
|
|
57
|
+
its audience or grant Foundry access. It is redundant for a public target.
|
|
58
|
+
Use `[text]{.secret}` when the text itself is a spoiler, independently of any
|
|
59
|
+
destination; see [inline spoilers](#inline-spoilers).
|
|
60
|
+
|
|
26
61
|
## Marking names in prose
|
|
27
62
|
|
|
28
63
|
Use `[Name]{.name}` for a name that has no indexed note. Link a name that has
|
|
29
|
-
one; an empty link label uses the indexed full
|
|
64
|
+
one, including a private subject; an empty link label uses the indexed full
|
|
65
|
+
name. Introduce another subject with its full canonical name at its first prose
|
|
66
|
+
use. Keep the note's own subject in plain text, and use plain text for later
|
|
67
|
+
mentions and aliases. Bold is emphasis.
|
|
30
68
|
Plain text, including capitalized words, is not searched for names.
|
|
31
69
|
|
|
32
70
|
```markdown
|
|
@@ -39,7 +77,7 @@ creating a placeholder note:
|
|
|
39
77
|
|
|
40
78
|
```markdown
|
|
41
79
|
[Ravenmoor]{.name data-type="place/settlement"}
|
|
42
|
-
[Aldric Venn]{.name data-type="being"}
|
|
80
|
+
[Aldric Venn]{.name data-type="being/npc"}
|
|
43
81
|
```
|
|
44
82
|
|
|
45
83
|
`data-type` is optional. It names a declared [note type and
|
|
@@ -196,6 +234,38 @@ optional declared type; see [marking names](#marking-names-in-prose).
|
|
|
196
234
|
|
|
197
235
|
Inline Markdown remains active inside the span. Bracket spans have lower priority than URL links, image links, reference links, footnotes and wikilinks. A bracketed form immediately preceded by `!`, followed by `(` or another `[`, or beginning with `[[` is not a span, even when the link is unresolved. Ordinary links retain their existing syntax. Escape a bracket when it should be literal, or put examples inside a code span or fence. A span identifier addresses that point in the note; it does not start a journal page.
|
|
198
236
|
|
|
237
|
+
### Inline spoilers
|
|
238
|
+
|
|
239
|
+
Mark text itself as a spoiler with `.secret`. It can contain inline Markdown or
|
|
240
|
+
combine with a typed name for an unwritten subject:
|
|
241
|
+
|
|
242
|
+
```markdown
|
|
243
|
+
The key is [under the **third** stair]{.secret}.
|
|
244
|
+
The informant is [Aldric Venn]{.name .secret data-type="being/npc"}.
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
This marks only the enclosed text; it does not declare a target's audience.
|
|
248
|
+
For indexed subjects, use the ordinary wikilink and let the destination decide
|
|
249
|
+
whether the reference needs a spoiler. `.show` on a wikilink bypasses automatic
|
|
250
|
+
reference concealment; an enclosing authored `.secret` still marks its text.
|
|
251
|
+
|
|
252
|
+
On the website an inline spoiler has a button to show or hide its content.
|
|
253
|
+
It stays within the surrounding sentence, and links inside it can be followed
|
|
254
|
+
after revealing it. Foundry prints a visible inline `secret` span labelled
|
|
255
|
+
**Secret:**; this span does not withhold its text from players. The book prints
|
|
256
|
+
the text with secret styling and a **Secret:** label; printed text cannot be
|
|
257
|
+
interactively concealed.
|
|
258
|
+
These presentations preserve inline emphasis and the `.name` classification.
|
|
259
|
+
|
|
260
|
+
Published web HTML contains the spoiler's text. Revealing the spoiler or reading
|
|
261
|
+
the page source exposes it; this is presentation, not access control. GM-only
|
|
262
|
+
destination notes remain absent from public site and book output.
|
|
263
|
+
|
|
264
|
+
A `.secret` span does not start a journal page or change surrounding text.
|
|
265
|
+
Use a [secret heading](#a-section-players-must-not-read) to withhold a whole
|
|
266
|
+
Foundry page or a [secret block](#secret-blocks) for a passage in a labelled
|
|
267
|
+
section; those retain their own scope and presentation.
|
|
268
|
+
|
|
199
269
|
### Alerts
|
|
200
270
|
|
|
201
271
|
Alerts replace the former `:::info` and `:::warn` fences. Start with a special blockquote marker, optionally followed by an attribute block, then write the alert body as a standard blockquote:
|
|
@@ -997,8 +1067,8 @@ common.
|
|
|
997
1067
|
|
|
998
1068
|
Public dictionaries omit citations to GM-only notes. `links` checks generated
|
|
999
1069
|
tables in the source note's audience: public notes use public rows, and GM
|
|
1000
|
-
notes retain GM citations. An authored
|
|
1001
|
-
note is
|
|
1070
|
+
notes retain GM citations. An authored wikilink from a public note to a GM-only
|
|
1071
|
+
note is valid and follows the [secret-reference rules](#references-to-secret-subjects).
|
|
1002
1072
|
|
|
1003
1073
|
#### Custom views
|
|
1004
1074
|
|
package/docs/content-format.md
CHANGED
|
@@ -52,8 +52,10 @@ for examples and placement in journals, web pages, and books.
|
|
|
52
52
|
An inline `{{sql "SELECT COUNT(*) FROM notes"}}` expression inserts one scalar query result. Use `{{words (sql "SELECT COUNT(*) FROM notes")}}` for a count in running prose, or `{{digits (sql "SELECT COUNT(*) FROM entries")}}` for grouped numerals. The query must return exactly one row and one column with a nonempty value; a mismatch is a located build error. Prefix `\{{` to show the syntax literally. The [expression reference](reference/format-details.md#dates-and-calendars) describes the helpers and the available SQL relations.
|
|
53
53
|
|
|
54
54
|
Add `gm` to `tags` for a GM-only note. Public site and book builds omit it;
|
|
55
|
-
Foundry compiles it only into private compendiums.
|
|
56
|
-
|
|
55
|
+
Foundry compiles it only into private compendiums. Ordinary wikilinks to it
|
|
56
|
+
conceal the whole reference as a spoiler; `.show` reveals just the reference.
|
|
57
|
+
`[text]{.secret}` marks text itself as a spoiler. Neither changes the target's
|
|
58
|
+
access permissions. The [frontmatter guide](authoring/frontmatter.md#frontmatter-and-system-blocks)
|
|
57
59
|
explains routing for documents and their prose journals.
|
|
58
60
|
|
|
59
61
|
A note can also produce a system document: an Actor, Item, Scene, or Macro according to its type and system blocks. The `sohl:` and `hm3:` blocks supply game-specific mechanics and overrides; they do not create separate prose. The JournalEntry is shared between systems.
|
package/docs/design.md
CHANGED
|
@@ -21,6 +21,24 @@ An Address identifies a subject by package, system, type, and shortcode. It is i
|
|
|
21
21
|
|
|
22
22
|
The content index uses JSON Lines. A record can retain the note's full frontmatter, anchors, and outgoing links, and streams without loading one giant JSON array. It is derived, disposable, and stable for a fixed source tree and toolchain version. SQL queries and editor navigation read that index rather than guessing from filenames.
|
|
23
23
|
|
|
24
|
+
## Reference presentation and access
|
|
25
|
+
|
|
26
|
+
An indexed subject has one ordinary wikilink syntax regardless of its audience.
|
|
27
|
+
The renderer derives spoiler presentation from the actual destination, so an
|
|
28
|
+
author does not duplicate a privacy declaration on every mention. The `.show`
|
|
29
|
+
override reveals a particular reference without changing the destination's
|
|
30
|
+
audience. A separate inline `.secret` span marks authored text as a spoiler.
|
|
31
|
+
|
|
32
|
+
Concealment and access remain separate decisions. Public site and book builds
|
|
33
|
+
omit GM-only notes; a reference to an omitted destination carries no unusable
|
|
34
|
+
link. Foundry's private packs and page permissions govern destination access.
|
|
35
|
+
Web inline spoilers use a separate show/hide control beside their content, so
|
|
36
|
+
a revealed link is independently usable. Foundry and book inline spoilers are
|
|
37
|
+
visible labelled text; an inline Foundry span has no native secret-section
|
|
38
|
+
permission behavior. A spoiler in published HTML remains readable in the source. See the
|
|
39
|
+
[authoring contract](authoring/links-and-markup.md#references-to-secret-subjects)
|
|
40
|
+
for syntax and surface presentation.
|
|
41
|
+
|
|
24
42
|
## Build boundaries
|
|
25
43
|
|
|
26
44
|
The content pass reads `assets/content/` and addressable assets. It emits the content index, page source, and Foundry documents. Site rendering and book typesetting consume those outputs; their layout decisions do not become fields of a Foundry DataModel. Packaging stages only the files a Foundry manifest declares. The build checks emitted fields against the target system schema because Foundry can otherwise discard an unknown field without an error.
|
package/docs/guides/book.md
CHANGED
|
@@ -522,6 +522,13 @@ Anchors on headings are scoped to their entries, so two entries can use the
|
|
|
522
522
|
same heading anchor. The build reports an unresolved destination while
|
|
523
523
|
keeping the source available for inspection.
|
|
524
524
|
|
|
525
|
+
A reference to a GM-only note or secret heading prints with secret styling
|
|
526
|
+
and a **Secret:** label. GM-only notes remain omitted, and references to them
|
|
527
|
+
print as text without a link. `.show` removes the automatic spoiler presentation
|
|
528
|
+
of that reference, without adding the destination to the book. An authored
|
|
529
|
+
`[text]{.secret}` uses the same static inline styling. Printed text remains
|
|
530
|
+
visible; see [secret references](../authoring/links-and-markup.md#references-to-secret-subjects).
|
|
531
|
+
|
|
525
532
|
The [generated sections](../reference/format-details.md#generated-sections)
|
|
526
533
|
an entry carries — **Within**, **Governed by**, **In song and story**, the
|
|
527
534
|
event views and the rest — name notes the build chose rather than the author,
|
package/docs/guides/site.md
CHANGED
|
@@ -118,6 +118,13 @@ Wikilinks resolve through the address index. Each emitted page receives
|
|
|
118
118
|
derived `related` entries for the links its author wrote and the backlinks
|
|
119
119
|
other authors wrote to it.
|
|
120
120
|
|
|
121
|
+
A wikilink to a GM-only note or secret heading conceals its whole reference in
|
|
122
|
+
an inline show/hide spoiler. `.show` reveals the reference without changing the
|
|
123
|
+
destination's audience. GM-only pages remain omitted, so references to them
|
|
124
|
+
have no link; published secret sections retain theirs. Spoiler text remains in
|
|
125
|
+
the generated HTML and is readable in page source. See
|
|
126
|
+
[secret references](../authoring/links-and-markup.md#references-to-secret-subjects).
|
|
127
|
+
|
|
121
128
|
What lies within a place, who governs it, what an affiliation governs, the
|
|
122
129
|
works naming a note and the map from a place reach the page as
|
|
123
130
|
[generated sections](../reference/format-details.md#generated-sections): H1
|
|
@@ -515,7 +515,10 @@ is declared, so a near miss is a finding that names what you probably meant.
|
|
|
515
515
|
|
|
516
516
|
`gm` marks a note for GM access. It is excluded from public site pages, SQL
|
|
517
517
|
tables on the public site, and the book. Foundry compiles it only to private
|
|
518
|
-
compendiums.
|
|
518
|
+
compendiums. Any note may reference it with an ordinary wikilink; the
|
|
519
|
+
reference is automatically a spoiler. The `.show` override reveals that
|
|
520
|
+
reference without changing the destination's audience or access permissions;
|
|
521
|
+
see [references to secret subjects](#references-to-secret-subjects).
|
|
519
522
|
|
|
520
523
|
**`draft` marks unfinished content.** It does two things and nothing
|
|
521
524
|
else: a link _into_ a draft note renders marked, and the note itself states that
|
|
@@ -1808,6 +1811,37 @@ name at every citation with no link edited. `[[x|]]` is labelled; `[[x]]` is not
|
|
|
1808
1811
|
It is the label that is required, not a target: the anchor-only form above
|
|
1809
1812
|
carries its pipe like every other link.
|
|
1810
1813
|
|
|
1814
|
+
#### References to secret subjects
|
|
1815
|
+
|
|
1816
|
+
Use `[[doc-divineactn|The Truth of Divine Action]]` for an indexed subject,
|
|
1817
|
+
including one with a GM-only audience. The renderer derives spoiler presentation
|
|
1818
|
+
from the resolved destination and conceals the whole reference, including its
|
|
1819
|
+
label. A public destination has no automatic concealment. An anchor inside secret
|
|
1820
|
+
content is a secret destination, including a same-note anchor reference. A
|
|
1821
|
+
link to the whole public note does not inherit a section's secrecy.
|
|
1822
|
+
|
|
1823
|
+
The website renders an inline show/hide spoiler; an omitted GM-only target
|
|
1824
|
+
retains its reference as text without a dead link. A published secret section
|
|
1825
|
+
retains its link. Foundry renders a visible inline spoiler with a **Secret:**
|
|
1826
|
+
label, with access to its destination controlled by private packs and page
|
|
1827
|
+
permissions. Books print secret styling and a **Secret:** label, retaining text
|
|
1828
|
+
without a link when the GM-only destination is omitted. `.show` bypasses the
|
|
1829
|
+
automatic spoiler presentation on each surface; an unavailable destination
|
|
1830
|
+
still has no link.
|
|
1831
|
+
|
|
1832
|
+
An immediately following `.show` attribute explicitly reveals the reference:
|
|
1833
|
+
|
|
1834
|
+
```markdown
|
|
1835
|
+
[[doc-divineactn|The Truth of Divine Action]]{.show}
|
|
1836
|
+
[[doc-magictruth|The Truth of Magic]]{.show}
|
|
1837
|
+
```
|
|
1838
|
+
|
|
1839
|
+
This override changes the reference's presentation only. It does not publish a
|
|
1840
|
+
GM-only note, alter Foundry permissions or reveal the destination's content.
|
|
1841
|
+
It is redundant for public destinations. `[text]{.secret}` separately marks
|
|
1842
|
+
text itself as a spoiler; it declares no destination audience. See
|
|
1843
|
+
[inline spans](#inline-spans) for inline spoiler syntax and surface behavior.
|
|
1844
|
+
|
|
1811
1845
|
#### Marking names in prose
|
|
1812
1846
|
|
|
1813
1847
|
`package-build lint` reports name-marking warnings in authored body prose.
|
|
@@ -1815,7 +1849,10 @@ The warnings leave a valid package passing. Headings, frontmatter, tables,
|
|
|
1815
1849
|
infoboxes, code, comments and embeds are outside this check.
|
|
1816
1850
|
|
|
1817
1851
|
A name is explicit: a `[Name]{.name}` bracket span or an authored link label.
|
|
1818
|
-
Use `.name` for
|
|
1852
|
+
Use typed `.name` spans for unwritten subjects and ordinary wikilinks for
|
|
1853
|
+
indexed subjects, including private ones. Introduce another subject with its
|
|
1854
|
+
full canonical name at its first prose use; use plain text for the note's own
|
|
1855
|
+
subject and for later mentions and aliases.
|
|
1819
1856
|
Plain prose is not searched for names. Wikilinks, ordinary Markdown links and
|
|
1820
1857
|
reference links all supply names; a resolved empty wikilink label uses the
|
|
1821
1858
|
note's full name. Images, code, footnote markers and bare autolinks without
|
|
@@ -1856,7 +1893,7 @@ An optional `data-type` on a `.name` span states what the named thing is:
|
|
|
1856
1893
|
|
|
1857
1894
|
```markdown
|
|
1858
1895
|
[Ravenmoor]{.name data-type="place/settlement"}
|
|
1859
|
-
[Aldric Venn]{.name data-type="being"}
|
|
1896
|
+
[Aldric Venn]{.name data-type="being/npc"}
|
|
1860
1897
|
```
|
|
1861
1898
|
|
|
1862
1899
|
Use a declared [note type](note-types.md), optionally followed by `/` and one
|
|
@@ -3483,6 +3520,25 @@ The watch wears [a _blue_ sash]{#watch-sash .uniform lang=en}.
|
|
|
3483
3520
|
|
|
3484
3521
|
The exact `.name` class marks a name, with optional `data-type="type/subType"`;
|
|
3485
3522
|
see [marking names in prose](#marking-names-in-prose) for the vocabulary and index.
|
|
3523
|
+
The exact `.secret` class marks the enclosed text as an inline spoiler:
|
|
3524
|
+
|
|
3525
|
+
```markdown
|
|
3526
|
+
[The hidden **passage**]{.secret}
|
|
3527
|
+
[Aldric Venn]{.name .secret data-type="being/npc"}
|
|
3528
|
+
```
|
|
3529
|
+
|
|
3530
|
+
The website provides an inline show/hide button and preserves interactive links
|
|
3531
|
+
inside the revealed content. Foundry prints a visible inline `secret` span
|
|
3532
|
+
with a **Secret:** label; the span does not withhold its text from players.
|
|
3533
|
+
The book prints the text with secret styling and a **Secret:** label.
|
|
3534
|
+
Inline emphasis and combined `.name` classification survive rendering. Published HTML retains the text, so opening
|
|
3535
|
+
the spoiler or inspecting the source reveals it.
|
|
3536
|
+
|
|
3537
|
+
This span has no effect on a destination's audience. Ordinary wikilinks derive
|
|
3538
|
+
spoilers from their resolved destinations; `.show` bypasses that automatic
|
|
3539
|
+
concealment without cancelling an enclosing authored `.secret`. An inline span
|
|
3540
|
+
does not change the scope of `.secret` headings or `:::secret` blocks, and it
|
|
3541
|
+
does not start or withhold a JournalEntryPage.
|
|
3486
3542
|
|
|
3487
3543
|
Inline Markdown remains active inside the span. Bracket spans have lower priority than URL links, image links, reference links, footnotes and wikilinks. A bracketed form immediately preceded by `!`, followed by `(` or another `[`, or beginning with `[[` is not a span, even when the link is unresolved. Ordinary links retain their existing syntax. Escape a bracket when it should be literal, or put examples inside a code span or fence. A span identifier addresses that point in the note; it does not start a journal page.
|
|
3488
3544
|
|
package/engine/anchors.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import { scanPoetry } from "./content-poetry.mjs";
|
|
|
41
41
|
import { scanSpans } from "./content-spans.mjs";
|
|
42
42
|
import { slugify } from "./content-slug.mjs";
|
|
43
43
|
import { scanFigures } from "./content-figures.mjs";
|
|
44
|
-
import { scanHeadingAttributes } from "./heading-attributes.mjs";
|
|
44
|
+
import { withheldSections, scanHeadingAttributes } from "./heading-attributes.mjs";
|
|
45
45
|
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
46
46
|
|
|
47
47
|
/**
|
|
@@ -67,11 +67,12 @@ export const ANCHOR_KINDS = Object.freeze(["prose", "event"]);
|
|
|
67
67
|
* {@link module:engine/content-figures.scanFigures}.
|
|
68
68
|
* @param {import("./content-session.mjs").ContentSyntax} [syntax] - Exact-text scanner projections.
|
|
69
69
|
* @returns {Array<{slug: string, name: string, level: number, line: number,
|
|
70
|
-
* kind: string}>} In document order.
|
|
70
|
+
* kind: string, secret?: boolean}>} In document order.
|
|
71
71
|
*/
|
|
72
72
|
export function collectAnchors(body, bodyLine = 1, resolveRole, syntax) {
|
|
73
73
|
const anchors = [];
|
|
74
|
-
|
|
74
|
+
const headings = scanHeadingAttributes(body, bodyLine).headings;
|
|
75
|
+
for (const heading of headings) {
|
|
75
76
|
if (!heading.id) continue;
|
|
76
77
|
anchors.push({
|
|
77
78
|
slug: heading.id,
|
|
@@ -91,9 +92,12 @@ export function collectAnchors(body, bodyLine = 1, resolveRole, syntax) {
|
|
|
91
92
|
kind: "prose",
|
|
92
93
|
});
|
|
93
94
|
}
|
|
95
|
+
const secretBlocks = [];
|
|
94
96
|
const blockAnchors = (source, firstLine) => {
|
|
95
97
|
const sourceLines = source.split("\n");
|
|
96
98
|
for (const block of (syntax ? syntax(source).blocks : scanBlocks(source)).blocks) {
|
|
99
|
+
if (block.name === "secret")
|
|
100
|
+
secretBlocks.push({ start: firstLine + block.start, end: firstLine + block.end });
|
|
97
101
|
if (block.id)
|
|
98
102
|
anchors.push({
|
|
99
103
|
slug: slugify(block.id),
|
|
@@ -129,7 +133,8 @@ export function collectAnchors(body, bodyLine = 1, resolveRole, syntax) {
|
|
|
129
133
|
kind: "prose",
|
|
130
134
|
});
|
|
131
135
|
}
|
|
132
|
-
|
|
136
|
+
const spans = (syntax ? syntax(body).spans : scanSpans(body)).spans;
|
|
137
|
+
for (const span of spans) {
|
|
133
138
|
if (span.id)
|
|
134
139
|
anchors.push({
|
|
135
140
|
slug: slugify(span.id),
|
|
@@ -139,6 +144,33 @@ export function collectAnchors(body, bodyLine = 1, resolveRole, syntax) {
|
|
|
139
144
|
kind: "prose",
|
|
140
145
|
});
|
|
141
146
|
}
|
|
147
|
+
const sections =
|
|
148
|
+
headings.some((heading) => heading.classes.includes("secret")) ?
|
|
149
|
+
withheldSections(body).sections
|
|
150
|
+
: [];
|
|
151
|
+
const secretSpans = spans.filter((span) =>
|
|
152
|
+
span.attrs.some(([key, value]) => key === "class" && value.split(/\s+/).includes("secret")),
|
|
153
|
+
);
|
|
154
|
+
const secretSpanAnchors = new Set(
|
|
155
|
+
spans
|
|
156
|
+
.filter(
|
|
157
|
+
(target) =>
|
|
158
|
+
target.id &&
|
|
159
|
+
secretSpans.some(
|
|
160
|
+
(parent) => parent.start <= target.start && parent.end >= target.end,
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
.map((span) => span.id),
|
|
164
|
+
);
|
|
165
|
+
for (const anchor of anchors) {
|
|
166
|
+
const line = anchor.line - bodyLine;
|
|
167
|
+
if (
|
|
168
|
+
sections.some((section) => line >= section.start && line < section.end) ||
|
|
169
|
+
secretBlocks.some((block) => anchor.line >= block.start && anchor.line <= block.end) ||
|
|
170
|
+
secretSpanAnchors.has(anchor.slug)
|
|
171
|
+
)
|
|
172
|
+
anchor.secret = true;
|
|
173
|
+
}
|
|
142
174
|
return anchors.sort((a, b) => a.line - b.line);
|
|
143
175
|
}
|
|
144
176
|
|
package/engine/content-links.mjs
CHANGED
|
@@ -444,13 +444,17 @@ export function buildLinkIndex(
|
|
|
444
444
|
// Code is verbatim, so a `[[…]]` inside a fence, an indented block or an
|
|
445
445
|
// inline span is not a link — the compilers make none of it either.
|
|
446
446
|
for (const [all, rawInner] of matchAllOutsideCode(body, new RegExp(WIKILINK.source, "g"))) {
|
|
447
|
-
const parsed = readWikilink(
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
447
|
+
const parsed = readWikilink(
|
|
448
|
+
rawInner,
|
|
449
|
+
{
|
|
450
|
+
package: pkg,
|
|
451
|
+
system: "note",
|
|
452
|
+
types,
|
|
453
|
+
packages,
|
|
454
|
+
noIndexPackages,
|
|
455
|
+
},
|
|
456
|
+
all,
|
|
457
|
+
);
|
|
454
458
|
const target = parsed.target.kind ? parsed.rawTarget : parsed.target;
|
|
455
459
|
const { anchor, rawTarget } = parsed;
|
|
456
460
|
const occurrence = (seen.get(all) ?? 0) + 1;
|
|
@@ -459,6 +463,7 @@ export function buildLinkIndex(
|
|
|
459
463
|
// in the file. A link a table generated is not in the file at all,
|
|
460
464
|
// so the search simply fails and a finding names the file.
|
|
461
465
|
out.push({
|
|
466
|
+
attributeProblems: parsed.attributes?.problems ?? [],
|
|
462
467
|
target,
|
|
463
468
|
rawTarget,
|
|
464
469
|
anchor,
|
|
@@ -962,15 +967,23 @@ export function auditLinks(index) {
|
|
|
962
967
|
|
|
963
968
|
const deadAnchors = [];
|
|
964
969
|
for (const note of notes) {
|
|
965
|
-
for (const {
|
|
970
|
+
for (const {
|
|
971
|
+
target,
|
|
972
|
+
rawTarget,
|
|
973
|
+
anchor,
|
|
974
|
+
text,
|
|
975
|
+
occurrence,
|
|
976
|
+
labelled,
|
|
977
|
+
attributeProblems,
|
|
978
|
+
} of linksOf(note)) {
|
|
966
979
|
if (!anchor || !labelled) continue;
|
|
967
980
|
const local = target ? resolve(target) : note;
|
|
968
981
|
const foreign = !local && target ? manifestHit(target) : null;
|
|
969
982
|
// An unresolved address is reported below. A foreign sheet opens
|
|
970
983
|
// its system document, so only the readable note view names prose
|
|
971
|
-
// sections
|
|
984
|
+
// sections, including private destinations.
|
|
972
985
|
if (!local) {
|
|
973
|
-
if (!foreign
|
|
986
|
+
if (!foreign) continue;
|
|
974
987
|
const qualified = readQualifier(
|
|
975
988
|
target,
|
|
976
989
|
index.types,
|
|
@@ -1016,7 +1029,24 @@ export function auditLinks(index) {
|
|
|
1016
1029
|
const unlabelledLinks = [];
|
|
1017
1030
|
const usedManifest = new Set();
|
|
1018
1031
|
for (const note of notes) {
|
|
1019
|
-
for (const {
|
|
1032
|
+
for (const {
|
|
1033
|
+
target,
|
|
1034
|
+
rawTarget,
|
|
1035
|
+
anchor,
|
|
1036
|
+
text,
|
|
1037
|
+
occurrence,
|
|
1038
|
+
labelled,
|
|
1039
|
+
attributeProblems,
|
|
1040
|
+
} of linksOf(note)) {
|
|
1041
|
+
if (attributeProblems.length)
|
|
1042
|
+
deadAddresses.push({
|
|
1043
|
+
note,
|
|
1044
|
+
target: rawTarget,
|
|
1045
|
+
text,
|
|
1046
|
+
occurrence,
|
|
1047
|
+
reason: "invalid-attributes",
|
|
1048
|
+
problems: attributeProblems,
|
|
1049
|
+
});
|
|
1020
1050
|
// The label is required whatever the link part is, an anchor
|
|
1021
1051
|
// included — so this is tested before the same-page form.
|
|
1022
1052
|
if (!labelled) {
|
|
@@ -1063,9 +1093,6 @@ export function auditLinks(index) {
|
|
|
1063
1093
|
}
|
|
1064
1094
|
const resolved = index.resolveAddress(target);
|
|
1065
1095
|
if (resolved) {
|
|
1066
|
-
if ((isGmNote(resolved.fm) || resolved.gm) && !isGmNote(note.fm)) {
|
|
1067
|
-
deadAddresses.push({ ...at, reason: "gm" });
|
|
1068
|
-
}
|
|
1069
1096
|
continue;
|
|
1070
1097
|
}
|
|
1071
1098
|
// A stub is in the index, so the refusal can name it. Asked before
|
|
@@ -1084,10 +1111,6 @@ export function auditLinks(index) {
|
|
|
1084
1111
|
// report.
|
|
1085
1112
|
const manifest = manifestHit(target);
|
|
1086
1113
|
if (manifest) {
|
|
1087
|
-
if (manifest.gm && !isGmNote(note.fm)) {
|
|
1088
|
-
deadAddresses.push({ ...at, reason: "gm" });
|
|
1089
|
-
continue;
|
|
1090
|
-
}
|
|
1091
1114
|
usedManifest.add(encodeAddresses(target).toLowerCase());
|
|
1092
1115
|
continue;
|
|
1093
1116
|
}
|
package/engine/content-spans.mjs
CHANGED
|
@@ -21,21 +21,41 @@ import { inlineText } from "./name-markup.mjs";
|
|
|
21
21
|
|
|
22
22
|
/** Add bracket spans after recognized Markdown links and footnotes.
|
|
23
23
|
* @param {import("markdown-it")} md - Markdown parser to extend.
|
|
24
|
+
* @param {object} [options] - Surface presentation.
|
|
25
|
+
* @param {boolean} [options.spoilers] - Add visible static Secret labels (Foundry).
|
|
24
26
|
*/
|
|
25
|
-
export function spanMarkdownPlugin(md) {
|
|
27
|
+
export function spanMarkdownPlugin(md, { spoilers = false } = {}) {
|
|
28
|
+
if (spoilers) {
|
|
29
|
+
md.renderer.rules.span_open = (tokens, index, options, env, renderer) => {
|
|
30
|
+
const token = tokens[index];
|
|
31
|
+
const secret = (token.attrGet("class") ?? "").split(/\s+/).includes("secret");
|
|
32
|
+
return (
|
|
33
|
+
renderer.renderToken(tokens, index, options) +
|
|
34
|
+
(secret ? '<strong class="inline-secret-label">Secret:</strong> ' : "")
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
26
38
|
md.inline.ruler.after("link", "bracket_span", (state, silent) => {
|
|
27
39
|
const start = state.pos;
|
|
28
40
|
if (
|
|
29
41
|
state.src[start] !== "[" ||
|
|
42
|
+
// Raw wikilinks are handled before spans render. Refuse their
|
|
43
|
+
// adjacent closing brackets without parsing every label twice.
|
|
44
|
+
(state.src[start + 1] === "[" &&
|
|
45
|
+
state.src[state.src.indexOf("]", start + 2) + 1] === "]") ||
|
|
30
46
|
/@[A-Za-z]+$/.test(state.src.slice(0, start)) ||
|
|
31
|
-
state.src[start + 1] === "[" ||
|
|
32
47
|
state.src[start + 1] === "^" ||
|
|
33
48
|
state.src[start + 1] === "!" ||
|
|
34
49
|
["!", "[", "]"].includes(state.src[start - 1])
|
|
35
50
|
)
|
|
36
51
|
return false;
|
|
37
52
|
const end = state.md.helpers.parseLinkLabel(state, start, false);
|
|
38
|
-
if (
|
|
53
|
+
if (
|
|
54
|
+
end < 0 ||
|
|
55
|
+
["(", "["].includes(state.src[end + 1]) ||
|
|
56
|
+
(state.src[start + 1] === "[" && state.src[end + 1] !== "{")
|
|
57
|
+
)
|
|
58
|
+
return false;
|
|
39
59
|
let finish = end + 1;
|
|
40
60
|
let parsed = { id: "", classes: [], values: {}, problems: [] };
|
|
41
61
|
if (state.src[finish] === "{") {
|
|
@@ -259,10 +279,15 @@ export function renderSpans(source) {
|
|
|
259
279
|
)
|
|
260
280
|
.sort((a, b) => b.start - a.start)) {
|
|
261
281
|
const attrs = span.attrs.map(([key, value]) => ` ${key}="${escape(value)}"`).join("");
|
|
262
|
-
markdown
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
282
|
+
const inner = renderSpans(markdown.slice(span.start + 1, span.contentEnd)).markdown;
|
|
283
|
+
const secret = span.attrs.some(
|
|
284
|
+
([key, value]) => key === "class" && value.split(/\s+/).includes("secret"),
|
|
285
|
+
);
|
|
286
|
+
const content =
|
|
287
|
+
secret ?
|
|
288
|
+
`<span${attrs} data-inline-secret><button type="button" class="inline-secret-toggle" aria-label="Show spoiler" aria-expanded="false">Show spoiler</button><span class="inline-secret-content" hidden>${inner}</span></span>`
|
|
289
|
+
: `<span${attrs}>${inner}</span>`;
|
|
290
|
+
markdown = markdown.slice(0, span.start) + content + markdown.slice(span.end);
|
|
266
291
|
}
|
|
267
292
|
return { markdown, errors };
|
|
268
293
|
}
|
package/engine/helpers.mjs
CHANGED
|
@@ -70,7 +70,7 @@ import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
|
|
|
70
70
|
import { linkFindingMessage } from "./wikilink-syntax.mjs";
|
|
71
71
|
// The declared tag vocabulary, which is where `draft` is stated. Read
|
|
72
72
|
// from there rather than respelt, so the tag and its one reader cannot drift.
|
|
73
|
-
import { isDraftNote } from "./note-vocabulary.mjs";
|
|
73
|
+
import { isGmNote, isDraftNote } from "./note-vocabulary.mjs";
|
|
74
74
|
import { DERIVED_PACKED_TYPES, NEVER_PACKED_TYPES } from "./note-claims.mjs";
|
|
75
75
|
import { expandContentTables } from "./content-tables.mjs";
|
|
76
76
|
import { spanMarkdownPlugin } from "./content-spans.mjs";
|
|
@@ -108,7 +108,7 @@ let localAssetImageInfoCache;
|
|
|
108
108
|
export const md = markdownit({ html: true })
|
|
109
109
|
.use(footnotePlugin)
|
|
110
110
|
.use(deflistPlugin)
|
|
111
|
-
.use(spanMarkdownPlugin)
|
|
111
|
+
.use(spanMarkdownPlugin, { spoilers: true })
|
|
112
112
|
.use(dictionaryEntryPlugin)
|
|
113
113
|
// A heading's attribute block, written onto the heading element rather than
|
|
114
114
|
// typeset into it — the reading the website's renderer does for itself.
|
|
@@ -888,6 +888,8 @@ export function buildContentLinkIndex(
|
|
|
888
888
|
// *into* this note renders marked. It takes no part in resolution,
|
|
889
889
|
// so the note is indexed, compiled and published as any other.
|
|
890
890
|
draft: isDraftNote(fm),
|
|
891
|
+
gm: isGmNote(fm),
|
|
892
|
+
noteAnchors: [...(record.anchors ?? []), ...(prepared?.anchors ?? [])],
|
|
891
893
|
// The anchors this note declares, carried so the *builds* can check
|
|
892
894
|
// a `#section` link and not only the checker. A foreign
|
|
893
895
|
// anchor has always been checked, because a fetched index
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -1630,7 +1630,7 @@ export function bookTypstPreamble() {
|
|
|
1630
1630
|
// weight so it remains bold inside an ordinary strong phrase.
|
|
1631
1631
|
"#let book-span(body, classes: ()) = {\n" +
|
|
1632
1632
|
' let styled = if classes.contains("name") { text(weight: "bold", body) } else { body }\n' +
|
|
1633
|
-
"
|
|
1633
|
+
' if classes.contains("secret") { box(fill: rgb("#f2eefb"), inset: (x: 3pt, y: 1pt), text(fill: rgb("#5c4b8a"))[#strong[Secret:] #styled]) } else { box(styled) }\n' +
|
|
1634
1634
|
"}",
|
|
1635
1635
|
// Use the file's natural measure for auto. Every requested width is
|
|
1636
1636
|
// bounded by the available measure and by the height of a page.
|
package/engine/site-index.mjs
CHANGED
|
@@ -70,7 +70,7 @@ import { contentPackage } from "./content-package.mjs";
|
|
|
70
70
|
import { reckoningContext } from "./reckoning-markers.mjs";
|
|
71
71
|
import { presentAmongRecords } from "./being-age.mjs";
|
|
72
72
|
// The declared tag vocabulary, which is where `draft` is stated.
|
|
73
|
-
import { isDraftNote } from "./note-vocabulary.mjs";
|
|
73
|
+
import { isGmNote, isDraftNote } from "./note-vocabulary.mjs";
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* One page the site will publish, as the index needs to see it.
|
|
@@ -219,6 +219,24 @@ export function buildSiteIndex(
|
|
|
219
219
|
// a stale vendored manifest and a shadowed local page.
|
|
220
220
|
const { ambiguous } = mergeForeign(index, foreignIndex);
|
|
221
221
|
|
|
222
|
+
// Private notes have no public page, but still resolve references safely.
|
|
223
|
+
for (const record of records) {
|
|
224
|
+
if (!isGmNote(record) || !record.address || !record.shortcode) continue;
|
|
225
|
+
contentTypes.add(record.type);
|
|
226
|
+
const value = {
|
|
227
|
+
name: record.name?.full ?? record.name,
|
|
228
|
+
gm: true,
|
|
229
|
+
noteAnchors: record.anchors ?? [],
|
|
230
|
+
};
|
|
231
|
+
index.set(`${record.type}/${record.shortcode}`.toLowerCase(), value);
|
|
232
|
+
const pkg = record.package ?? ownPackage;
|
|
233
|
+
index.set(
|
|
234
|
+
canonicalKey(pkg, ownDocumentSystem(record.type), record.type, record.shortcode),
|
|
235
|
+
value,
|
|
236
|
+
);
|
|
237
|
+
index.set(canonicalKey(pkg, "note", record.type, record.shortcode), value);
|
|
238
|
+
}
|
|
239
|
+
|
|
222
240
|
for (const e of entries) {
|
|
223
241
|
// Only a content note is addressable; anything else is carried
|
|
224
242
|
// through and takes no part in type-scoped indexing.
|
|
@@ -233,19 +251,20 @@ export function buildSiteIndex(
|
|
|
233
251
|
// `subType` is: a being states a rank as a number, and what that
|
|
234
252
|
// number is called is declared on the body.
|
|
235
253
|
const standings = standingsDigest(e.fm);
|
|
254
|
+
const noteAnchors = collectAnchors(e.body ?? "", 1, undefined, syntax);
|
|
236
255
|
const value = {
|
|
237
256
|
url: e.url,
|
|
238
257
|
name: e.name,
|
|
239
258
|
draft: isDraftNote(e.fm),
|
|
259
|
+
gm: isGmNote(e.fm),
|
|
260
|
+
noteAnchors,
|
|
240
261
|
...(e.fm.subType ? { subType: e.fm.subType } : {}),
|
|
241
262
|
...(standings ? { standings } : {}),
|
|
242
263
|
// The `{#anchor}` slugs this page declares, so a `#section`
|
|
243
264
|
// written against it can be checked rather than joined onto the
|
|
244
265
|
// URL unverified. A Set even when empty: a page that declares no
|
|
245
266
|
// anchor still has a definite answer for one an author names.
|
|
246
|
-
anchors: new Set(
|
|
247
|
-
collectAnchors(e.body ?? "", 1, undefined, syntax).map((anchor) => anchor.slug),
|
|
248
|
-
),
|
|
267
|
+
anchors: new Set(noteAnchors.map((anchor) => anchor.slug)),
|
|
249
268
|
// The note's event ids, which a wikilink may not name.
|
|
250
269
|
eventAnchors: new Set(eventAnchors(e.fm).map((anchor) => anchor.slug)),
|
|
251
270
|
};
|
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
// cannot drift apart on it: they disagreed once over the unlabelled hyphen
|
|
42
42
|
// form, which the packs showed as a raw shortcode and the knowledgebase as a
|
|
43
43
|
// name.
|
|
44
|
-
import {
|
|
44
|
+
import { collectAnchors } from "./anchors.mjs";
|
|
45
|
+
import { presentWikilink, readWikilink } from "./wikilink-syntax.mjs";
|
|
45
46
|
import { readQualifier } from "./wikilinks.mjs";
|
|
46
47
|
import { replaceOutsideCode } from "./code-fences.mjs";
|
|
47
48
|
// The canonical `package-system-type-shortcode` key, so a package-qualified
|
|
@@ -354,6 +355,7 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
354
355
|
// same counting the checker does, and what turns a finding into a
|
|
355
356
|
// `file:line:column:` diagnostic rather than a note-wide one.
|
|
356
357
|
const seen = new Map();
|
|
358
|
+
let ownAnchors = ctx.noteAnchors;
|
|
357
359
|
/**
|
|
358
360
|
* Records a finding against one authored literal.
|
|
359
361
|
*
|
|
@@ -404,18 +406,28 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
404
406
|
// Code is verbatim: a `[[…]]` inside a code fence, an indented block or an
|
|
405
407
|
// inline span is source text, not a link.
|
|
406
408
|
return replaceOutsideCode(embedded.markdown, WIKILINK, (all, rawInner) => {
|
|
407
|
-
const parsed = readWikilink(
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
409
|
+
const parsed = readWikilink(
|
|
410
|
+
rawInner,
|
|
411
|
+
{
|
|
412
|
+
package: ctx.contentPackage,
|
|
413
|
+
system: "note",
|
|
414
|
+
types: ctx.contentTypes ?? new Set(),
|
|
415
|
+
packages: ctx.packages,
|
|
416
|
+
noIndexPackages: ctx.noIndexPackages,
|
|
417
|
+
},
|
|
418
|
+
all,
|
|
419
|
+
);
|
|
414
420
|
const { rawTarget: target, anchor, display } = parsed;
|
|
415
421
|
// An empty label is not a label: `[[x|]]` addresses the target and
|
|
416
422
|
// shows its name, so `""` falls through to the same place `null` does.
|
|
417
423
|
// One reading, from {@link authoredLabel}.
|
|
418
424
|
const label = authoredLabel({ display });
|
|
425
|
+
if (parsed.attributes?.problems.length)
|
|
426
|
+
return report(
|
|
427
|
+
all,
|
|
428
|
+
{ target, reason: "invalid-attributes", problems: parsed.attributes.problems },
|
|
429
|
+
label ?? target,
|
|
430
|
+
);
|
|
419
431
|
|
|
420
432
|
// **Every link carries a label**. Without one there is nothing
|
|
421
433
|
// to resolve against, and nothing to show either, a shortcode being an
|
|
@@ -445,7 +457,11 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
445
457
|
label ?? anchor,
|
|
446
458
|
);
|
|
447
459
|
}
|
|
448
|
-
return
|
|
460
|
+
return presentWikilink(
|
|
461
|
+
`[${label ?? ctx.captionLabels?.get(anchor) ?? anchor}](#${slugify(anchor)})`,
|
|
462
|
+
parsed,
|
|
463
|
+
{ noteAnchors: (ownAnchors ??= collectAnchors(body)) },
|
|
464
|
+
);
|
|
449
465
|
}
|
|
450
466
|
|
|
451
467
|
// The canonical separator has to be resolved, not merely
|
|
@@ -480,7 +496,7 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
480
496
|
label ?? hit.name ?? target,
|
|
481
497
|
);
|
|
482
498
|
}
|
|
483
|
-
if (anchor &&
|
|
499
|
+
if (anchor && !hasAnchor(hit.noteAnchors ?? hit.anchors, anchor)) {
|
|
484
500
|
return report(
|
|
485
501
|
all,
|
|
486
502
|
{ target, reason: "unknown-anchor", anchor, addressed: true },
|
|
@@ -506,8 +522,13 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
506
522
|
// Presentation only — the href above is unchanged, and a
|
|
507
523
|
// `[[#anchor]]` self-link is not marked because the reader is
|
|
508
524
|
// already on the page it would be telling them about.
|
|
509
|
-
|
|
510
|
-
|
|
525
|
+
return presentWikilink(
|
|
526
|
+
!hit.draft ? link
|
|
527
|
+
: ctx.output === "book" ? `${link} (draft)`
|
|
528
|
+
: draftLink(link),
|
|
529
|
+
parsed,
|
|
530
|
+
hit,
|
|
531
|
+
);
|
|
511
532
|
}
|
|
512
533
|
|
|
513
534
|
// **An address resolving nowhere is a failure, unconditionally**.
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
15
|
+
import { isGmNote } from "./note-vocabulary.mjs";
|
|
16
|
+
|
|
14
17
|
import { parseAddress, isAddressTuple, splitAnchor } from "./address.mjs";
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -67,7 +70,7 @@ import { parseAddress, isAddressTuple, splitAnchor } from "./address.mjs";
|
|
|
67
70
|
* copies of the exclusion is three places for it to be forgotten. An embed's
|
|
68
71
|
* own grammar is {@link module:engine/content-embeds}.
|
|
69
72
|
*/
|
|
70
|
-
export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\]
|
|
73
|
+
export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\](?:\{(?:[^{}"\n]|"(?:\\.|[^"\\\n])*")*\})?/g;
|
|
71
74
|
|
|
72
75
|
/**
|
|
73
76
|
* The parts of a wikilink's interior.
|
|
@@ -80,6 +83,7 @@ export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\]/g;
|
|
|
80
83
|
* @property {string} anchor - The `#section` slug, `""` when there is none.
|
|
81
84
|
* @property {string|null} display - The text after `|`, or `null` when the link
|
|
82
85
|
* is unlabelled. `null` and `""` differ: an author may write `[[x|]]`.
|
|
86
|
+
* @property {ReturnType<typeof parseExtensionAttributes>} [attributes] - Trailing extension attributes.
|
|
83
87
|
* @property {boolean} labelled - Whether a `|` was present at all. A link
|
|
84
88
|
* without one addresses nothing and is a finding — see
|
|
85
89
|
* {@link unlabelledLinkMessage} — so every reader has to be able to ask.
|
|
@@ -92,9 +96,10 @@ export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\]/g;
|
|
|
92
96
|
* not the whole link, so a caller that has already matched does not re-match.
|
|
93
97
|
*
|
|
94
98
|
* @param {string} rawInner - The text between `[[` and `]]`.
|
|
99
|
+
* @param {string} [literal] - Whole matched link, including trailing attributes.
|
|
95
100
|
* @returns {ParsedWikilink} The parts, each trimmed.
|
|
96
101
|
*/
|
|
97
|
-
export function parseWikilink(rawInner) {
|
|
102
|
+
export function parseWikilink(rawInner, literal = "") {
|
|
98
103
|
// A pipe inside a markdown table cell is escaped as `\|`; undo that before
|
|
99
104
|
// looking for the label separator, or the cell's own escape reads as one.
|
|
100
105
|
const inner = String(rawInner ?? "").replace(/\\\|/g, "|");
|
|
@@ -108,7 +113,18 @@ export function parseWikilink(rawInner) {
|
|
|
108
113
|
// link and a field cannot disagree about where the anchor starts.
|
|
109
114
|
const { address: target, anchor = "" } = splitAnchor(linkPart);
|
|
110
115
|
|
|
111
|
-
|
|
116
|
+
const suffix = literal.slice(literal.indexOf("]]") + 2);
|
|
117
|
+
const attributes =
|
|
118
|
+
suffix.startsWith("{") ? parseExtensionAttributes(suffix.slice(1, -1)) : null;
|
|
119
|
+
if (attributes) attributes.problems.push(...refusedAttributes(attributes.values));
|
|
120
|
+
return {
|
|
121
|
+
inner: inner.trim(),
|
|
122
|
+
target,
|
|
123
|
+
anchor,
|
|
124
|
+
display,
|
|
125
|
+
labelled,
|
|
126
|
+
...(attributes ? { attributes } : {}),
|
|
127
|
+
};
|
|
112
128
|
}
|
|
113
129
|
|
|
114
130
|
/**
|
|
@@ -193,6 +209,7 @@ export function unlabelledLinkMessage(target) {
|
|
|
193
209
|
*/
|
|
194
210
|
export const LINK_FINDING_REASONS = Object.freeze(
|
|
195
211
|
new Set([
|
|
212
|
+
"invalid-attributes",
|
|
196
213
|
"unlabelled",
|
|
197
214
|
"not-an-address",
|
|
198
215
|
"not-lowercase",
|
|
@@ -201,7 +218,6 @@ export const LINK_FINDING_REASONS = Object.freeze(
|
|
|
201
218
|
"not-an-asset",
|
|
202
219
|
"unresolved",
|
|
203
220
|
"stub",
|
|
204
|
-
"gm",
|
|
205
221
|
"ambiguous",
|
|
206
222
|
"unknown-anchor",
|
|
207
223
|
"event-anchor",
|
|
@@ -323,6 +339,7 @@ export function ambiguousAddressMessage(target, packages = []) {
|
|
|
323
339
|
* @param {string} [finding.stub] - For `stub`, where the stub is.
|
|
324
340
|
* @param {string} [finding.shortcode] - For `shortcode-not-lowercase`, the
|
|
325
341
|
* shortcode as written.
|
|
342
|
+
* @param {string[]} [finding.problems] - Invalid attribute diagnostics.
|
|
326
343
|
* @param {string} [finding.declared] - For `shortcode-not-lowercase`, the
|
|
327
344
|
* lowercase spelling, when it is a declared shortcode.
|
|
328
345
|
* @returns {string} The message.
|
|
@@ -338,8 +355,11 @@ export function linkFindingMessage({
|
|
|
338
355
|
stub,
|
|
339
356
|
shortcode,
|
|
340
357
|
declared,
|
|
358
|
+
problems,
|
|
341
359
|
}) {
|
|
342
360
|
switch (reason) {
|
|
361
|
+
case "invalid-attributes":
|
|
362
|
+
return `wikilink [[${target}]] has invalid attributes: ${(problems ?? []).join("; ")}`;
|
|
343
363
|
case "unlabelled":
|
|
344
364
|
return unlabelledLinkMessage(target);
|
|
345
365
|
case "not-an-address":
|
|
@@ -386,8 +406,6 @@ export function linkFindingMessage({
|
|
|
386
406
|
return unresolvedAddressMessage(target);
|
|
387
407
|
case "stub":
|
|
388
408
|
return stubAddressMessage(target, stub);
|
|
389
|
-
case "gm":
|
|
390
|
-
return `address [[${target}]] names a GM-only note from a player-facing note`;
|
|
391
409
|
case "no-content-index":
|
|
392
410
|
return (
|
|
393
411
|
`address [[${target}]] names a package declared \`contentIndex: false\` — ` +
|
|
@@ -419,10 +437,11 @@ export function isSamePage({ target, anchor }) {
|
|
|
419
437
|
* Read an operational link target in its enclosing Address context.
|
|
420
438
|
* @param {string} inner - Lexical link contents.
|
|
421
439
|
* @param {object} context - Builder defaults and type vocabulary.
|
|
440
|
+
* @param {string} [literal] - Whole matched link, including trailing attributes.
|
|
422
441
|
* @returns {object} Typed target and diagnostic source spelling.
|
|
423
442
|
*/
|
|
424
|
-
export function readWikilink(inner, context) {
|
|
425
|
-
const parsed = parseWikilink(inner);
|
|
443
|
+
export function readWikilink(inner, context, literal = "") {
|
|
444
|
+
const parsed = parseWikilink(inner, literal);
|
|
426
445
|
const rawTarget = parsed.target;
|
|
427
446
|
if (!rawTarget) return { ...parsed, rawTarget, target: { kind: "same-page" } };
|
|
428
447
|
const target = parseAddress(rawTarget, context);
|
|
@@ -433,3 +452,41 @@ export function readWikilink(inner, context) {
|
|
|
433
452
|
{ ...parsed, rawTarget, target: { kind: "literal", text: rawTarget }, problem: target }
|
|
434
453
|
: { ...parsed, rawTarget, target };
|
|
435
454
|
}
|
|
455
|
+
|
|
456
|
+
/** Whether the actual referenced destination needs spoiler presentation.
|
|
457
|
+
* @param {object} destination - Resolved note metadata.
|
|
458
|
+
* @param {string} [anchor] - Referenced prose anchor.
|
|
459
|
+
* @returns {boolean} Whole-note or section audience.
|
|
460
|
+
*/
|
|
461
|
+
export function secretDestination(destination, anchor = "") {
|
|
462
|
+
return Boolean(
|
|
463
|
+
destination?.gm ||
|
|
464
|
+
isGmNote(destination?.fm ?? destination) ||
|
|
465
|
+
(anchor && destination?.noteAnchors?.some((one) => one.slug === anchor && one.secret)),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Apply destination-derived presentation without changing its address.
|
|
470
|
+
* @param {string} reference - Rendered reference Markdown.
|
|
471
|
+
* @param {object} parsed - Shared parsed wikilink.
|
|
472
|
+
* @param {object} destination - Resolved target metadata.
|
|
473
|
+
* @returns {string} Inline spoiler or visible reference.
|
|
474
|
+
*/
|
|
475
|
+
export function presentWikilink(reference, parsed, destination) {
|
|
476
|
+
const attrs = parsed.attributes;
|
|
477
|
+
const classes = (attrs?.classes ?? []).filter((value) => value !== "show");
|
|
478
|
+
if (
|
|
479
|
+
!attrs?.classes.includes("show") &&
|
|
480
|
+
secretDestination(destination, parsed.anchor) &&
|
|
481
|
+
!classes.includes("secret")
|
|
482
|
+
)
|
|
483
|
+
classes.push("secret");
|
|
484
|
+
const fields = [
|
|
485
|
+
attrs?.id ? `#${attrs.id}` : "",
|
|
486
|
+
...classes.map((value) => `.${value}`),
|
|
487
|
+
...Object.entries(attrs?.values ?? {}).map(
|
|
488
|
+
([key, value]) => `${key}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`,
|
|
489
|
+
),
|
|
490
|
+
].filter(Boolean);
|
|
491
|
+
return fields.length ? `[${reference}]{${fields.join(" ")}}` : reference;
|
|
492
|
+
}
|
package/engine/wikilinks.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { resolveShortcodeReference } from "./shortcode-references.mjs";
|
|
29
29
|
|
|
30
|
-
import { readWikilink } from "./wikilink-syntax.mjs";
|
|
30
|
+
import { presentWikilink, readWikilink } from "./wikilink-syntax.mjs";
|
|
31
31
|
import crypto from "crypto";
|
|
32
32
|
|
|
33
33
|
import { compendiumUuid, ITEM_PACK, packForType, pageUuid, PACK_BY_TYPE } from "./ids.mjs";
|
|
@@ -407,15 +407,29 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
|
|
|
407
407
|
// one capture group. It is what makes two identical unresolved links on
|
|
408
408
|
// one note tellable apart, and a position reportable at all.
|
|
409
409
|
const out = replaceOutsideCode(markdown, WIKILINK, (all, rawInner, offset) => {
|
|
410
|
-
const parsed = readWikilink(
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
410
|
+
const parsed = readWikilink(
|
|
411
|
+
rawInner,
|
|
412
|
+
{
|
|
413
|
+
package: index.contentPackage,
|
|
414
|
+
system: "note",
|
|
415
|
+
types: index.types,
|
|
416
|
+
packages: index.packages,
|
|
417
|
+
noIndexPackages: index.noIndexPackages,
|
|
418
|
+
},
|
|
419
|
+
all,
|
|
420
|
+
);
|
|
417
421
|
const target = parsed.rawTarget;
|
|
418
422
|
const slug = parsed.anchor || null;
|
|
423
|
+
if (parsed.attributes?.problems.length) {
|
|
424
|
+
unresolved.push({
|
|
425
|
+
link: all,
|
|
426
|
+
target,
|
|
427
|
+
offset,
|
|
428
|
+
reason: "invalid-attributes",
|
|
429
|
+
problems: parsed.attributes.problems,
|
|
430
|
+
});
|
|
431
|
+
return unresolvedLink(authoredLabel(parsed) ?? target, target);
|
|
432
|
+
}
|
|
419
433
|
|
|
420
434
|
// **Every link carries a label**. Without one there is nothing
|
|
421
435
|
// to resolve against: a shortcode is an address rather than prose, so
|
|
@@ -512,7 +526,11 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
|
|
|
512
526
|
if (hit) {
|
|
513
527
|
const uuid = slug ? hit.anchors?.[slug] : hit.uuid;
|
|
514
528
|
if (uuid) {
|
|
515
|
-
return
|
|
529
|
+
return presentWikilink(
|
|
530
|
+
`@UUID[${uuid}]{${text || hit.name || target}}`,
|
|
531
|
+
parsed,
|
|
532
|
+
hit,
|
|
533
|
+
);
|
|
516
534
|
}
|
|
517
535
|
unresolved.push({
|
|
518
536
|
link: all,
|
|
@@ -559,7 +577,7 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
|
|
|
559
577
|
const entryUuid = itemDoc ? addresses.docUuid : addresses.uuid;
|
|
560
578
|
// The target has no single Foundry document. Its address is real, but
|
|
561
579
|
// a compendium has nothing to open, so the reader gets the label.
|
|
562
|
-
if (entryUuid === undefined) return text;
|
|
580
|
+
if (entryUuid === undefined) return presentWikilink(text, parsed, doc);
|
|
563
581
|
const entryId = itemDoc ? itemDocEntryId(doc.id) : doc.id;
|
|
564
582
|
const isJournal = itemDoc || packForType(doc.type).docType === "JournalEntry";
|
|
565
583
|
// A JournalEntry link opens a journal — at its first page, or at the
|
|
@@ -614,7 +632,7 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
|
|
|
614
632
|
// Presentation only — the UUID above is unchanged, and a
|
|
615
633
|
// `[[#slug]]` self-link is not marked because the reader is already in
|
|
616
634
|
// the note it would be telling them about.
|
|
617
|
-
return doc.draft ? draftLink(link) : link;
|
|
635
|
+
return presentWikilink(doc.draft ? draftLink(link) : link, parsed, doc);
|
|
618
636
|
});
|
|
619
637
|
|
|
620
638
|
return { markdown: out, unresolved };
|
|
@@ -2992,3 +2992,19 @@ summary.info-sidebar-title::after {
|
|
|
2992
2992
|
.single-content .alert-warning {
|
|
2993
2993
|
--pb-alert-color: #d29922;
|
|
2994
2994
|
}
|
|
2995
|
+
|
|
2996
|
+
/* Inline spoiler controls remain inline, with links outside the button. */
|
|
2997
|
+
[data-inline-secret] .inline-secret-content[hidden] { display: none !important; }
|
|
2998
|
+
.inline-secret-toggle {
|
|
2999
|
+
display: inline;
|
|
3000
|
+
padding: 0.05em 0.35em;
|
|
3001
|
+
border: 1px solid currentColor;
|
|
3002
|
+
border-radius: 0.2em;
|
|
3003
|
+
color: inherit;
|
|
3004
|
+
background: transparent;
|
|
3005
|
+
font: inherit;
|
|
3006
|
+
font-size: 0.85em;
|
|
3007
|
+
cursor: pointer;
|
|
3008
|
+
}
|
|
3009
|
+
.inline-secret-toggle:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
3010
|
+
.inline-secret-content { margin-inline-start: 0.25em; }
|
|
@@ -0,0 +1,23 @@
|
|
|
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 native button provides pointer, Enter and Space activation without taking
|
|
9
|
+
// keyboard events away from links within the independently revealed content.
|
|
10
|
+
document.addEventListener("click", (event) => {
|
|
11
|
+
const button = event.target.closest?.(".inline-secret-toggle");
|
|
12
|
+
if (!button) return;
|
|
13
|
+
const wrapper = button.parentElement;
|
|
14
|
+
const content = button.nextElementSibling;
|
|
15
|
+
if (!wrapper?.hasAttribute("data-inline-secret") || !content?.classList.contains("inline-secret-content")) return;
|
|
16
|
+
const expanded = button.getAttribute("aria-expanded") !== "true";
|
|
17
|
+
if (!expanded && content.contains(document.activeElement)) button.focus();
|
|
18
|
+
content.hidden = !expanded;
|
|
19
|
+
button.setAttribute("aria-expanded", String(expanded));
|
|
20
|
+
const label = expanded ? "Hide spoiler" : "Show spoiler";
|
|
21
|
+
button.setAttribute("aria-label", label);
|
|
22
|
+
button.textContent = label;
|
|
23
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "23.
|
|
3
|
+
"version": "23.11.0",
|
|
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",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* {@link module:engine/content-figures.scanFigures}.
|
|
15
15
|
* @param {import("./content-session.mjs").ContentSyntax} [syntax] - Exact-text scanner projections.
|
|
16
16
|
* @returns {Array<{slug: string, name: string, level: number, line: number,
|
|
17
|
-
* kind: string}>} In document order.
|
|
17
|
+
* kind: string, secret?: boolean}>} In document order.
|
|
18
18
|
*/
|
|
19
19
|
export function collectAnchors(body: string, bodyLine?: number, resolveRole?: (address: string) => string | undefined, syntax?: import("./content-session.mjs").ContentSyntax): Array<{
|
|
20
20
|
slug: string;
|
|
@@ -22,6 +22,7 @@ export function collectAnchors(body: string, bodyLine?: number, resolveRole?: (a
|
|
|
22
22
|
level: number;
|
|
23
23
|
line: number;
|
|
24
24
|
kind: string;
|
|
25
|
+
secret?: boolean;
|
|
25
26
|
}>;
|
|
26
27
|
/** Collisions involving a div, verse or inline-span anchor.
|
|
27
28
|
* Heading and caption collisions are diagnosed by their existing scanners.
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/** Add bracket spans after recognized Markdown links and footnotes.
|
|
2
2
|
* @param {import("markdown-it")} md - Markdown parser to extend.
|
|
3
|
+
* @param {object} [options] - Surface presentation.
|
|
4
|
+
* @param {boolean} [options.spoilers] - Add visible static Secret labels (Foundry).
|
|
3
5
|
*/
|
|
4
|
-
export function spanMarkdownPlugin(md: typeof import("markdown-it")
|
|
6
|
+
export function spanMarkdownPlugin(md: typeof import("markdown-it"), { spoilers }?: {
|
|
7
|
+
spoilers?: boolean | undefined;
|
|
8
|
+
}): void;
|
|
5
9
|
/** Read span anchors and located attribute diagnostics outside literal code.
|
|
6
10
|
* @param {string} source - Authored Markdown.
|
|
7
11
|
* @returns {import("./name-markup.mjs").SpanScan} Actual spans, explicit names and located diagnostics.
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @property {string} anchor - The `#section` slug, `""` when there is none.
|
|
10
10
|
* @property {string|null} display - The text after `|`, or `null` when the link
|
|
11
11
|
* is unlabelled. `null` and `""` differ: an author may write `[[x|]]`.
|
|
12
|
+
* @property {ReturnType<typeof parseExtensionAttributes>} [attributes] - Trailing extension attributes.
|
|
12
13
|
* @property {boolean} labelled - Whether a `|` was present at all. A link
|
|
13
14
|
* without one addresses nothing and is a finding — see
|
|
14
15
|
* {@link unlabelledLinkMessage} — so every reader has to be able to ask.
|
|
@@ -20,9 +21,10 @@
|
|
|
20
21
|
* not the whole link, so a caller that has already matched does not re-match.
|
|
21
22
|
*
|
|
22
23
|
* @param {string} rawInner - The text between `[[` and `]]`.
|
|
24
|
+
* @param {string} [literal] - Whole matched link, including trailing attributes.
|
|
23
25
|
* @returns {ParsedWikilink} The parts, each trimmed.
|
|
24
26
|
*/
|
|
25
|
-
export function parseWikilink(rawInner: string): ParsedWikilink;
|
|
27
|
+
export function parseWikilink(rawInner: string, literal?: string): ParsedWikilink;
|
|
26
28
|
/**
|
|
27
29
|
* The label an author actually supplied, or `null` when they supplied none.
|
|
28
30
|
*
|
|
@@ -158,13 +160,14 @@ export function ambiguousAddressMessage(target: string, packages?: Iterable<stri
|
|
|
158
160
|
* @param {string} [finding.stub] - For `stub`, where the stub is.
|
|
159
161
|
* @param {string} [finding.shortcode] - For `shortcode-not-lowercase`, the
|
|
160
162
|
* shortcode as written.
|
|
163
|
+
* @param {string[]} [finding.problems] - Invalid attribute diagnostics.
|
|
161
164
|
* @param {string} [finding.declared] - For `shortcode-not-lowercase`, the
|
|
162
165
|
* lowercase spelling, when it is a declared shortcode.
|
|
163
166
|
* @returns {string} The message.
|
|
164
167
|
* @throws {Error} On a reason outside the closed set — a resolver inventing one
|
|
165
168
|
* would otherwise report a link with no explanation at all.
|
|
166
169
|
*/
|
|
167
|
-
export function linkFindingMessage({ reason, target, packages, anchor, type, stub, shortcode, declared, }: {
|
|
170
|
+
export function linkFindingMessage({ reason, target, packages, anchor, type, stub, shortcode, declared, problems, }: {
|
|
168
171
|
reason: string;
|
|
169
172
|
target: string;
|
|
170
173
|
packages?: Iterable<string> | undefined;
|
|
@@ -172,6 +175,7 @@ export function linkFindingMessage({ reason, target, packages, anchor, type, stu
|
|
|
172
175
|
type?: string | undefined;
|
|
173
176
|
stub?: string | undefined;
|
|
174
177
|
shortcode?: string | undefined;
|
|
178
|
+
problems?: string[] | undefined;
|
|
175
179
|
declared?: string | undefined;
|
|
176
180
|
}): string;
|
|
177
181
|
/**
|
|
@@ -188,9 +192,23 @@ export function isSamePage({ target, anchor }: ParsedWikilink): boolean;
|
|
|
188
192
|
* Read an operational link target in its enclosing Address context.
|
|
189
193
|
* @param {string} inner - Lexical link contents.
|
|
190
194
|
* @param {object} context - Builder defaults and type vocabulary.
|
|
195
|
+
* @param {string} [literal] - Whole matched link, including trailing attributes.
|
|
191
196
|
* @returns {object} Typed target and diagnostic source spelling.
|
|
192
197
|
*/
|
|
193
|
-
export function readWikilink(inner: string, context: object): object;
|
|
198
|
+
export function readWikilink(inner: string, context: object, literal?: string): object;
|
|
199
|
+
/** Whether the actual referenced destination needs spoiler presentation.
|
|
200
|
+
* @param {object} destination - Resolved note metadata.
|
|
201
|
+
* @param {string} [anchor] - Referenced prose anchor.
|
|
202
|
+
* @returns {boolean} Whole-note or section audience.
|
|
203
|
+
*/
|
|
204
|
+
export function secretDestination(destination: object, anchor?: string): boolean;
|
|
205
|
+
/** Apply destination-derived presentation without changing its address.
|
|
206
|
+
* @param {string} reference - Rendered reference Markdown.
|
|
207
|
+
* @param {object} parsed - Shared parsed wikilink.
|
|
208
|
+
* @param {object} destination - Resolved target metadata.
|
|
209
|
+
* @returns {string} Inline spoiler or visible reference.
|
|
210
|
+
*/
|
|
211
|
+
export function presentWikilink(reference: string, parsed: object, destination: object): string;
|
|
194
212
|
/**
|
|
195
213
|
* What a `[[…]]` **is**, before anything decides where it points.
|
|
196
214
|
*
|
|
@@ -297,6 +315,25 @@ export type ParsedWikilink = {
|
|
|
297
315
|
* is unlabelled. `null` and `""` differ: an author may write `[[x|]]`.
|
|
298
316
|
*/
|
|
299
317
|
display: string | null;
|
|
318
|
+
/**
|
|
319
|
+
* - Trailing extension attributes.
|
|
320
|
+
*/
|
|
321
|
+
attributes?: {
|
|
322
|
+
id: string;
|
|
323
|
+
classes: string[];
|
|
324
|
+
values: Record<string, string>;
|
|
325
|
+
problems: string[];
|
|
326
|
+
positions?: Record<string, {
|
|
327
|
+
key: {
|
|
328
|
+
start: number;
|
|
329
|
+
end: number;
|
|
330
|
+
};
|
|
331
|
+
value: {
|
|
332
|
+
start: number;
|
|
333
|
+
end: number;
|
|
334
|
+
};
|
|
335
|
+
}>;
|
|
336
|
+
} | undefined;
|
|
300
337
|
/**
|
|
301
338
|
* - Whether a `|` was present at all. A link
|
|
302
339
|
* without one addresses nothing and is a finding — see
|