@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 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)` | {ParsedWikilink} The parts, each trimmed. | Split a wikilink's interior into its parts. |
1606
- | `readWikilink` | `function readWikilink(inner, context)` | Typed target or literal target with diagnostic source. | Parse a link target using complete builder Address 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. Links from untagged notes to GM notes are errors; GM notes may link to one another.
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 name. Bold is emphasis.
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 link from a public note to a GM-only
1001
- note is still reported.
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
 
@@ -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. An untagged note cannot link
56
- to it. The [frontmatter guide](authoring/frontmatter.md#frontmatter-and-system-blocks)
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.
@@ -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,
@@ -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. A regular note cannot link to it; a GM note can.
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 names without indexed notes and links for names with notes.
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
 
@@ -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
- for (const heading of scanHeadingAttributes(body, bodyLine).headings) {
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
- for (const span of (syntax ? syntax(body).spans : scanSpans(body)).spans) {
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
 
@@ -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(rawInner, {
448
- package: pkg,
449
- system: "note",
450
- types,
451
- packages,
452
- noIndexPackages,
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 { target, rawTarget, anchor, text, occurrence, labelled } of linksOf(note)) {
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. A public note's GM target is an audience refusal below.
984
+ // sections, including private destinations.
972
985
  if (!local) {
973
- if (!foreign || (foreign.gm && !isGmNote(note.fm))) continue;
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 { target, rawTarget, anchor, text, occurrence, labelled } of linksOf(note)) {
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
  }
@@ -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 (end < 0 || ["(", "["].includes(state.src[end + 1])) return false;
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
- markdown.slice(0, span.start) +
264
- `<span${attrs}>${renderSpans(markdown.slice(span.start + 1, span.contentEnd)).markdown}</span>` +
265
- markdown.slice(span.end);
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
  }
@@ -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
@@ -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
- " box(styled)\n" +
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.
@@ -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
  };
@@ -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 { readWikilink } from "./wikilink-syntax.mjs";
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(rawInner, {
408
- package: ctx.contentPackage,
409
- system: "note",
410
- types: ctx.contentTypes ?? new Set(),
411
- packages: ctx.packages,
412
- noIndexPackages: ctx.noIndexPackages,
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 `[${label ?? ctx.captionLabels?.get(anchor) ?? anchor}](#${slugify(anchor)})`;
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 && hit.url && !hasAnchor(hit.noteAnchors ?? hit.anchors, 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
- if (!hit.draft) return link;
510
- return ctx.output === "book" ? `${link} (draft)` : draftLink(link);
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]+)\]\]/g;
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
- return { inner: inner.trim(), target, anchor, display, labelled };
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
+ }
@@ -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(rawInner, {
411
- package: index.contentPackage,
412
- system: "note",
413
- types: index.types,
414
- packages: index.packages,
415
- noIndexPackages: index.noIndexPackages,
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 `@UUID[${uuid}]{${text || hit.name || target}}`;
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 };
@@ -27,3 +27,5 @@ document.querySelector('.nav-toggle')?.addEventListener('click', function() {
27
27
  this.classList.toggle('open');
28
28
  });
29
29
  </script>
30
+
31
+ <script src="{{ "js/inline-spoilers.js" | relURL }}" defer></script>
@@ -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.10.0",
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")): void;
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