1
0
Fork 0

feat(wiki): add scoped template CSS and developer HTML templates

WikiTemplates gains two fields: an optional css code field injected on
pages that use the template (selectors auto-scoped to a hash-based
.ptf-tpl-* class so templates never collide with each other or the app
shell), and a developer-only html field rendered verbatim (sanitized)
in place of the markdown body when transcluded.

- templateCss.ts scopes/sanitizes CSS, incl. nested at-rules; pages
  and the editor live preview inject scoped styles and drop the Card
  chrome in favor of a full-width article
- templateHtml.ts sanitizes HTML and splits prepared markdown around
  expansion sentinels so raw HTML renders without rehype-raw
- templates.ts adds extractTemplateNames/extractPlaceholders/
  extractDirectiveNames; prepareMarkdown threads an htmlTemplates map
- markdown pipeline gains rehype-highlight for fenced code blocks
- styles.css adds wiki prose typography + infobox demo styling
- docs: demo article and Infobox template authoring guide
This commit is contained in:
Jason Fraley 2026-09-06 05:32:48 -04:00
parent 60da363a93
commit 51f7c803e0
21 changed files with 45929 additions and 29 deletions

View file

@ -52,6 +52,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "10.1.0",
"rehype-highlight": "^7.0.2",
"remark-directive": "4.0.0",
"remark-gfm": "4.0.1",
"sharp": "0.34.5",
@ -1612,8 +1613,12 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
@ -1622,6 +1627,8 @@
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"highlight.js": ["highlight.js@11.11.2", "", {}, "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "16.13.1" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"hono": ["hono@4.13.4", "", {}, "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ=="],
@ -1858,6 +1865,8 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
"lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"lucide-react": ["lucide-react@0.561.0", "", { "peerDependencies": { "react": "19.2.3" } }, "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A=="],
@ -2226,6 +2235,8 @@
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-errors": "1.3.0", "get-proto": "1.0.1", "gopd": "1.2.0", "set-function-name": "2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="],
"remark-directive": ["remark-directive@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-directive": "^3.0.0", "micromark-extension-directive": "^4.0.0", "unified": "^11.0.0" } }, "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
@ -2488,6 +2499,8 @@
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
"unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],

204
docs/wiki-demo-article.md Normal file
View file

@ -0,0 +1,204 @@
# Operation Ashfall: Field Reference
This article exercises every styling component the wiki supports. Use it as a
live demo page, a smoke test after styling changes, or a copy-paste reference
when writing new entries.
## Inline Formatting
Body text supports **bold**, *italic*, ***bold italic***, ~~strikethrough~~,
and `inline code`. You can also link out — internally with wikilinks like
[[Campaigns]], [[Missions|mission index]], and [[Factions|faction dossiers]],
or externally to the [Arma 3 website](https://arma3.com).
There is no explicit underline in Markdown, but combined emphasis and links
cover most editorial needs.
## Lists
### Unordered
- Primary loadout
- Rifle: MX 6.5 mm
- Sidearm: P07 9 mm
- Secondary loadout
- Launcher: PCML
- AT mines: 2x
- Radio: Long-range AN/PRC-155
### Ordered
1. Brief at 0500 in the ops room
2. Gear check at 0530
3. Wheels roll at 0600
1. Alpha team leads the convoy
2. Bravo team trails at 200 m
4. First stop: FOB Reaper
### Task List
- [x] Finalize ORBAT
- [x] Submit logistics request
- [ ] Confirm air support window
- [ ] Debrief after action
## Blockquotes
> "We don't fight for the flag on the map. We fight so the person to your
> left goes home."
> — Colonel Vasquez, briefing before Ashfall
## Code
Inline commands like `hint "Hello world";` sit comfortably in prose. Fenced
blocks get their own container:
```sqf
// Spawn a fireteam at the objective
_fireteam = [getMarkerPos "obj_ashfall", independent, ["B_Soldier_TL_F", "B_Soldier_F", "B_Soldier_AR_F"]] call BIS_fnc_spawnGroup;
```
```bash
bun run payload game-tick
```
## Tables
| Callsign | Element | Vehicle | Grid | Status |
| -------- | ------- | -------------- | -------- | -------- |
| Alpha 1 | Fireteam | M2A4 Slammer | 034120 | Staged |
| Bravo 2 | Fireteam | HEMTT Fuel | 041118 | Staged |
| Reaper 6 | Air | AH-99 Blackfoot | 029133 | On station |
| Dagger 1 | Air | UH-80 Ghost Hawk | 029133 | RTB |
## Headings Hierarchy
### This is an H3
Body under an H3. Use these for sub-sections like insertion plans or CAS
briefs.
#### This is an H4
H4 is the deepest level — use it for granular notes like per-team frequency
assignments.
## Callouts
:::note
Night operations require NVGs distributed at gear check. Ask logistics the
evening before — supply will not break out NVGs after 2100.
:::
:::warning
The minefield north of Grid 038121 is live. Do not cross without EOD
escort. Last sweep found three APERS bouncing Betties at the tree line.
:::
## Infobox
The infobox is a template — insert `{{Infobox}}` near the top of an article
body (after creating the template from `docs/wiki-infobox-template.md`) and
it renders as a floated right-hand info card:
::::infobox
**Operation Ashfall**
![Task force insignia](/images/logo.webp "Unit insignia")
| Operation Summary | |
| ----------------- | ---------- |
| Conflict | Southern Campaign |
| Theater | Altis, northern coast |
| Dates | 12–19 Mar 2035 |
| Outcome | Decisive — airfield secured |
| Strength | 2x fireteams, 2x rotary |
A week-long clearing operation to seize the northern airfield and deny
opposing forces their last coastal staging area.
::::
Paragraphs flow around the card, the way Wikipedia infoboxes sit beside the
lead section. On narrow screens the card drops to full width.
## Columns
::::columns
:::column
**Left column — Ground plan.** Alpha and Bravo advance along the western
tree line, using the low rock walls as cover. Expect contact at the first
stone bridge.
:::
:::column
**Right column — Air plan.** Reaper 6 orbits south at 800 m for overwatch.
Dagger 1 holds at Landing Zone Hawk until the ground element calls the
push.
:::
::::
Two columns is the common case, but three works for briefing triads:
::::columns
:::column
**Signals.** Primary channel 3, alternate channel 5, emergency 9.
:::
:::column
**Medical.** Two combat lifers attached to Alpha, one to Bravo. Aid
station at FOB Reaper.
:::
:::column
**Resupply.** Cache at the shepherd's hut, Grid 036126, marked with a
green panel.
:::
::::
## Images
Captioned images render the caption as a hover title:
![An aerial reconnaissance photo of the objective](/images/logo.webp "AO recon — taken by Reaper 6 at 0642")
## Footnotes
The convoy plan follows the standard three-vehicle formation.[^1] The
alternate route through the wadi was rejected after the 0400 recon
sweep.[^2]
[^1]: See FM 3-21.8, Motorized Movement, Chapter 4.
[^2]: Recon report filed under ASHFALL/RECON/0400.
## Horizontal Rules
Use a rule to separate major sections of long articles.
---
## What This Article Does Not Cover
- Image uploads happen through the editor toolbar (this demo links an
existing file).
- `{{Template}}` placeholders expand at render time, but only if a template
with a matching name exists in the wiki template list — this article
intentionally skips one so you can see the fallback behavior with
`{{NonexistentTemplate}}`.
- HTML is not rendered; raw tags appear as literal text.
## Quick Styling Checklist
| Feature | Where above | Visual check |
| --------------- | ------------------ | ----------------------------------- |
| H1/H2/H3/H4 | Headings sections | Sizes shrink, H2 has underline |
| Inline emphasis | Inline Formatting | Bold/italic/strike/code distinct |
| Wikilinks | Inline Formatting | Primary color, underlined |
| Lists | Lists | Disc bullets, decimal, task boxes |
| Blockquote | Blockquotes | Muted text, left border |
| Code block | Code | Muted box, monospace, scrollable |
| Table | Tables | Bordered cells, muted header row |
| Callouts | Callouts | Blue note / red warning accent bar |
| Columns | Columns | Side-by-side layout |
| Captioned image | Images | Hover shows caption |
| Footnotes | Footnotes | Superscript links, rule, small text |
| Rule | Horizontal Rules | Thin border line |

View file

@ -0,0 +1,92 @@
# Wiki template: Infobox
A Wikipedia-style right-hand info card. Renders as a floated card on the
right of the article with a title, optional image, and a field/value
metadata table. Content wraps around it; on screens under 640px it becomes
a full-width block.
## Creating it
Payload admin panel → **Wiki** → **Wiki Templates** → *Create New*:
- **Name**: `Infobox`
- **Description**: Wikipedia-style info card. Place `{{Infobox}}` near the
top of an article body, then replace the `{{...}}` markers and table rows
with real content.
- **Body**: paste the block below exactly (the `::::` fence lines are part
of it).
- **CSS**: optional. Paste the block below (or write your own).
## Body
```
::::infobox
**{{Title}}**
![{{Image alt}}]({{Image url}} "{{Image caption}}")
| {{Section}} | {{Value}} |
| ----------- | --------- |
| {{Field 1}} | {{Value 1}} |
| {{Field 2}} | {{Value 2}} |
| {{Field 3}} | {{Value 3}} |
| {{Field 4}} | {{Value 4}} |
| {{Field 5}} | {{Value 5}} |
{{One-line summary of the subject.}}
::::
```
## CSS
```css
.infobox {
/* The shell already styles .infobox (float, card look, field table).
Add template-specific tweaks here, e.g.: */
}
.infobox table {
font-size: 0.875rem;
}
```
## The CSS scoping contract
Every template's **CSS** field is auto-scoped at render time. Each rule's
selector gets prefixed with a hash-based scope class derived from the
template **name**, e.g. for a template named `Infobox`:
```css
/* what you write in the admin panel */
.infobox table { font-size: 0.875rem; }
/* what actually ships to the browser */
.ptf-tpl-rpev9a .infobox table { font-size: 0.875rem; }
```
Consequences:
- **No collisions**: two templates defining the same class can never
override each other, and template CSS can never leak into (or override)
the app shell outside wiki article content.
- **Write plain selectors**: do **not** add `.wiki-markdown` or the scope
class yourself — the renderer adds the prefix to every selector,
including inside `@media`/`@supports` blocks.
- **@keyframes/@font-face** are passed through untouched (animation names
are global; prefer unique names).
- **Dynamic HTML**: classes added at runtime by markdown directives (e.g.
`infobox` from `::::infobox`) keep their literal names — the scoping
prefixes selectors, it does not rename classes.
- **Deliberate overrides** of other templates or shell styles belong in the
**Shims** collection, not here.
## Usage notes
- Place `{{Infobox}}` as the first line of the article body so the card
floats beside the lead paragraphs.
- Delete the image line if the article has no image.
- The header row of the table renders as a section band; use it for a
grouping label (e.g. "Service record") or keep it as Field/Value.
- Wikilinks and templates expand inside the card as usual — e.g.
`[[Factions|faction]]` works in a value cell.
- `{{...}}` markers left unedited render as literal text — replace or
delete every marker before publishing.

View file

@ -76,6 +76,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "10.1.0",
"rehype-highlight": "^7.0.2",
"remark-directive": "4.0.0",
"remark-gfm": "4.0.1",
"sharp": "0.34.5",

View file

@ -559,3 +559,280 @@
border-left-color: var(--color-destructive);
background: color-mix(in oklch, var(--color-destructive) 8%, transparent);
}
/* Wiki prose-article typography (scoped to .wiki-markdown) */
.wiki-markdown {
font-size: 0.9375rem;
line-height: 1.75;
color: var(--color-foreground);
}
.wiki-markdown > :first-child {
margin-top: 0;
}
.wiki-markdown h1,
.wiki-markdown h2,
.wiki-markdown h3,
.wiki-markdown h4 {
font-weight: 600;
line-height: 1.3;
color: var(--color-foreground);
}
.wiki-markdown h1 {
font-size: 1.5rem;
margin: 2rem 0 0.75rem;
}
.wiki-markdown h2 {
font-size: 1.25rem;
margin: 1.75rem 0 0.5rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--color-border);
}
.wiki-markdown h3 {
font-size: 1.0625rem;
margin: 1.5rem 0 0.375rem;
}
.wiki-markdown h4 {
font-size: 0.9375rem;
margin: 1.25rem 0 0.25rem;
}
.wiki-markdown p {
margin: 0.75rem 0;
}
.wiki-markdown ul,
.wiki-markdown ol {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
.wiki-markdown ul {
list-style: disc;
}
.wiki-markdown ol {
list-style: decimal;
}
.wiki-markdown li + li {
margin-top: 0.25rem;
}
.wiki-markdown li > ul,
.wiki-markdown li > ol {
margin: 0.25rem 0;
}
.wiki-markdown blockquote {
margin: 1rem 0;
border-left: 3px solid var(--color-border);
padding-left: 1rem;
color: var(--color-muted-foreground);
}
.wiki-markdown hr {
margin: 1.75rem 0;
border: 0;
border-top: 1px solid var(--color-border);
}
.wiki-markdown table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
margin: 1rem 0;
}
.wiki-markdown th,
.wiki-markdown td {
border: 1px solid var(--color-border);
padding: 0.5rem 0.75rem;
text-align: left;
}
.wiki-markdown th {
background: var(--color-muted);
font-weight: 600;
}
.wiki-markdown img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1rem 0;
}
.wiki-markdown a {
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.wiki-markdown a:hover {
text-decoration-color: var(--color-primary);
}
.wiki-markdown :not(pre) > code {
background: var(--color-muted);
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-size: 0.8125rem;
}
.wiki-markdown pre {
background: var(--color-muted);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.875rem 1rem;
overflow-x: auto;
margin: 1rem 0;
}
.wiki-markdown pre code {
background: transparent;
padding: 0;
font-size: 0.8125rem;
}
/* Wiki infobox — Wikipedia-style right-hand info card (scoped to .wiki-markdown) */
.wiki-markdown .infobox {
float: right;
clear: right;
width: 18rem;
margin: 0.25rem 0 1rem 1.5rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-card);
padding: 0.875rem 1rem;
font-size: 0.8125rem;
line-height: 1.55;
}
.wiki-markdown .infobox > p:first-child {
margin: 0 0 0.5rem;
text-align: center;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-foreground);
}
.wiki-markdown .infobox img {
display: block;
margin: 0.25rem auto 0.75rem;
border-radius: 0.375rem;
}
.wiki-markdown .infobox > div {
border: 0;
border-radius: 0;
overflow: visible;
}
.wiki-markdown .infobox table {
width: 100%;
min-width: 0;
font-size: 0.8125rem;
margin: 0;
}
.wiki-markdown .infobox th {
text-align: center;
}
.wiki-markdown .infobox td {
padding: 0.375rem 0.5rem;
vertical-align: top;
}
.wiki-markdown .infobox td:first-child {
width: 38%;
font-weight: 500;
white-space: nowrap;
}
@media (max-width: 640px) {
.wiki-markdown .infobox {
float: none;
width: 100%;
margin: 1rem 0;
}
}
/* Syntax-highlighted code blocks (rehype-highlight / highlight.js). */
.wiki-markdown pre code {
background: transparent;
padding: 0;
color: var(--muted-foreground);
}
.wiki-markdown pre code.hljs {
padding: 0;
background: transparent;
}
.wiki-markdown .hljs-keyword,
.wiki-markdown .hljs-selector-tag,
.wiki-markdown .hljs-literal,
.wiki-markdown .hljs-section,
.wiki-markdown .hljs-doctag,
.wiki-markdown .hljs-name {
color: #c792ea;
}
.wiki-markdown .hljs-string,
.wiki-markdown .hljs-regexp,
.wiki-markdown .hljs-addition,
.wiki-markdown .hljs-attribute,
.wiki-markdown .hljs-meta .hljs-string {
color: #a5d6a7;
}
.wiki-markdown .hljs-number,
.wiki-markdown .hljs-symbol,
.wiki-markdown .hljs-bullet,
.wiki-markdown .hljs-link,
.wiki-markdown .hljs-selector-attr,
.wiki-markdown .hljs-selector-pseudo {
color: #f78c6c;
}
.wiki-markdown .hljs-comment,
.wiki-markdown .hljs-quote {
color: #64748b;
font-style: italic;
}
.wiki-markdown .hljs-variable,
.wiki-markdown .hljs-template-variable,
.wiki-markdown .hljs-type,
.wiki-markdown .hljs-selector-class,
.wiki-markdown .hljs-selector-id,
.wiki-markdown .hljs-title,
.wiki-markdown .hljs-title.class_,
.wiki-markdown .hljs-title.function_ {
color: #82aaff;
}
.wiki-markdown .hljs-built_in,
.wiki-markdown .hljs-class .hljs-title,
.wiki-markdown .hljs-params {
color: #ffcb6b;
}
.wiki-markdown .hljs-deletion {
color: #ef5350;
}
.wiki-markdown .hljs-emphasis {
font-style: italic;
}
.wiki-markdown .hljs-strong {
font-weight: 600;
}

View file

@ -13,6 +13,26 @@ export const metadata = {
title: "Edit wiki page — Polaris Task Force",
};
function templateCssMap(
templates: readonly { name: string; css?: string | null }[],
): Record<string, string> {
const map: Record<string, string> = {};
for (const template of templates) {
if (template.css && template.css.trim().length > 0) map[template.name] = template.css;
}
return map;
}
function templateHtmlMap(
templates: readonly { name: string; html?: string | null }[],
): Record<string, string> {
const map: Record<string, string> = {};
for (const template of templates) {
if (template.html && template.html.trim().length > 0) map[template.name] = template.html;
}
return map;
}
export default async function EditWikiPage({
params,
}: {
@ -108,6 +128,8 @@ export default async function EditWikiPage({
body: page.body,
}}
templates={templateMap(templates)}
templateCssMap={templateCssMap(templates)}
templateHtmlMap={templateHtmlMap(templates)}
existingPages={existingPages}
isFirstEdit={!hasEditedRevisions}
/>

View file

@ -13,6 +13,7 @@ import { WikiToolbar } from "@/components/frontend/wiki/WikiToolbar";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { listTemplates, templateMap } from "@/lib/wiki/service";
import { prepareMarkdown } from "@/lib/wiki/prepare";
import { buildTemplateStyles } from "@/lib/wiki/templateCss";
export const metadata = {
title: "Wiki page — Polaris Task Force",
@ -101,6 +102,12 @@ export default async function WikiDetailPage({
hasIntelligenceQualification(payload, user),
]);
const templateMapValue = templateMap(templates);
const templateHtmlMap: Record<string, string> = {};
for (const template of templates) {
if (template.html && template.html.trim().length > 0) {
templateHtmlMap[template.name] = template.html;
}
}
if (revisionNumber !== null) {
const revisionResult = await payload.find({
@ -139,10 +146,15 @@ export default async function WikiDetailPage({
);
}
const prepared = prepareMarkdown(revision.contentSnapshot, templateMapValue);
const prepared = prepareMarkdown(revision.contentSnapshot, templateMapValue, templateHtmlMap);
const { css: templateCss, scopeClasses } = buildTemplateStyles(
revision.contentSnapshot,
templates,
);
return (
<div className="flex flex-col gap-6 p-5">
<div className="flex w-full flex-col gap-6 p-5">
{templateCss && <style dangerouslySetInnerHTML={{ __html: templateCss }} />}
<div className="flex flex-col gap-3">
<Link
href="/wiki"
@ -168,20 +180,20 @@ export default async function WikiDetailPage({
</div>
</div>
<Card>
<CardContent className="p-6">
<WikiContent prepared={prepared} />
</CardContent>
</Card>
<article>
<WikiContent prepared={prepared} scopeClasses={scopeClasses} htmlTemplates={templateHtmlMap} />
</article>
</div>
);
}
const prepared = prepareMarkdown(page.body, templateMapValue);
const prepared = prepareMarkdown(page.body, templateMapValue, templateHtmlMap);
const { css: templateCss, scopeClasses } = buildTemplateStyles(page.body, templates);
const lockdown = page.lockdown === true;
return (
<div className="flex flex-col gap-6 p-5">
<div className="flex w-full flex-col gap-6 p-5">
{templateCss && <style dangerouslySetInnerHTML={{ __html: templateCss }} />}
<div className="flex flex-col gap-3">
<Link
href="/wiki"
@ -214,11 +226,9 @@ export default async function WikiDetailPage({
{lockdown && <LockdownBanner />}
<Card>
<CardContent className="p-6">
<WikiContent prepared={prepared} />
</CardContent>
</Card>
<article>
<WikiContent prepared={prepared} scopeClasses={scopeClasses} htmlTemplates={templateHtmlMap} />
</article>
<WikiToolbar
slug={page.slug}

View file

@ -10,6 +10,26 @@ export const metadata = {
title: "New wiki page — Polaris Task Force",
};
function templateCssMap(
templates: readonly { name: string; css?: string | null }[],
): Record<string, string> {
const map: Record<string, string> = {};
for (const template of templates) {
if (template.css && template.css.trim().length > 0) map[template.name] = template.css;
}
return map;
}
function templateHtmlMap(
templates: readonly { name: string; html?: string | null }[],
): Record<string, string> {
const map: Record<string, string> = {};
for (const template of templates) {
if (template.html && template.html.trim().length > 0) map[template.name] = template.html;
}
return map;
}
export default async function NewWikiPage({
searchParams,
}: {
@ -66,6 +86,8 @@ export default async function NewWikiPage({
mode="create"
initialTitle={initialTitle}
templates={templateMap(templates)}
templateCssMap={templateCssMap(templates)}
templateHtmlMap={templateHtmlMap(templates)}
existingPages={existingPages}
isFirstEdit={!hasEditedRevisions}
/>

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload";
import hasRoles from "@/utils/access-control/hasRoles";
import { isDeveloper } from "@/utils/access-control/isRole";
export const WikiTemplates: CollectionConfig = {
slug: "wiki-templates",
@ -26,9 +27,10 @@ export const WikiTemplates: CollectionConfig = {
},
{
name: "body",
type: "textarea",
type: "code",
required: true,
admin: {
language: "markdown",
description: "Snippet text with {{param}}-style placeholders as plain markdown.",
},
},
@ -36,5 +38,27 @@ export const WikiTemplates: CollectionConfig = {
name: "description",
type: "textarea",
},
{
name: "css",
type: "code",
admin: {
language: "css",
description:
"Optional CSS injected on wiki pages while this template exists. Write plain selectors — they are automatically prefixed with a hash-based scope class (e.g. .ptf-tpl-3afb1c .my-class) so templates never collide with each other or the app shell.",
},
},
{
name: "html",
type: "textarea",
access: {
read: isDeveloper,
create: isDeveloper,
update: isDeveloper,
},
admin: {
description:
"Developer-only. Raw HTML rendered verbatim (sanitized) when this template is transcluded — replaces the markdown body.",
},
},
],
};

View file

@ -1,20 +1,59 @@
import Markdown from "react-markdown";
import { cn } from "@/lib/utils";
import { splitPreparedHtml } from "@/lib/wiki/templateHtml";
import type { TemplateMap } from "@/lib/wiki/templates";
import {
wikiMarkdownComponents,
wikiRemarkPlugins,
wikiRehypePlugins,
wikiRemarkRehypeOptions,
} from "@/lib/wiki/markdown";
export function WikiContent({ prepared }: { readonly prepared: string }) {
export function WikiPreparedBody({
prepared,
htmlTemplates,
}: {
readonly prepared: string;
readonly htmlTemplates?: TemplateMap;
}) {
const segments = splitPreparedHtml(prepared, htmlTemplates ?? {});
return (
<div className="wiki-markdown flex flex-col gap-4 text-sm">
<Markdown
remarkPlugins={wikiRemarkPlugins}
remarkRehypeOptions={wikiRemarkRehypeOptions}
components={wikiMarkdownComponents}
>
{prepared}
</Markdown>
<>
{segments.map((segment, index) =>
segment.type === "html" ? (
<div
key={segment.name ?? index}
className="wiki-template-html"
dangerouslySetInnerHTML={{ __html: segment.value }}
/>
) : (
<Markdown
key={index}
remarkPlugins={wikiRemarkPlugins}
rehypePlugins={wikiRehypePlugins}
remarkRehypeOptions={wikiRemarkRehypeOptions}
components={wikiMarkdownComponents}
>
{segment.value}
</Markdown>
),
)}
</>
);
}
export function WikiContent({
prepared,
scopeClasses,
htmlTemplates,
}: {
readonly prepared: string;
readonly scopeClasses?: readonly string[];
readonly htmlTemplates?: TemplateMap;
}) {
return (
<div className={cn("wiki-markdown", scopeClasses)}>
<WikiPreparedBody prepared={prepared} htmlTemplates={htmlTemplates} />
</div>
);
}

View file

@ -2,6 +2,7 @@ import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkDirective from "remark-directive";
import rehypeHighlight from "rehype-highlight";
import type { State } from "mdast-util-to-hast";
import type { ContainerDirective, LeafDirective, TextDirective } from "mdast-util-directive";
import { cn } from "@/lib/utils";
@ -17,6 +18,9 @@ import { Info, TriangleAlert } from "lucide-react";
/** remark plugins: GFM (tables, strikethrough, task lists, footnotes) + directives. */
export const wikiRemarkPlugins = [remarkGfm, remarkDirective];
/** rehype plugins: fenced code block syntax highlighting (hljs classes on <code>). */
export const wikiRehypePlugins = [rehypeHighlight];
type DirectiveNode = ContainerDirective | LeafDirective | TextDirective;
/**

View file

@ -6,6 +6,10 @@
import { expandTemplates, type TemplateMap } from "./templates";
import { preprocessWikilinks } from "./wikilinks";
export function prepareMarkdown(source: string, templates: TemplateMap): string {
return preprocessWikilinks(expandTemplates(source, templates));
export function prepareMarkdown(
source: string,
templates: TemplateMap,
htmlTemplates: TemplateMap = {},
): string {
return preprocessWikilinks(expandTemplates(source, templates, htmlTemplates));
}

136
src/lib/wiki/templateCss.ts Normal file
View file

@ -0,0 +1,136 @@
/**
* Wiki template CSS handling.
*
* Pure module — safe to import client-side. Template CSS is authored with
* plain selectors and is automatically scoped per template: every rule's
* selectors are prefixed with a template-specific hash class (e.g.
* `.ptf-tpl-k3x2f .infobox`) that is only added to the article wrapper on
* pages which actually use that template — either by transcluding `{{Name}}`
* or by rendering a directive (`::::infobox`) that the template's body
* introduces. Template styles therefore cannot reach the app shell or
* collide with other templates.
*/
import { extractDirectiveNames, extractTemplateNames } from "@/lib/wiki/templates";
interface TemplateWithCss {
readonly name: string;
readonly css?: string | null;
readonly body?: string | null;
}
const STYLE_ESCAPE_RE = /<\/?\s*(style|script)/gi;
const COMMENT_RE = /\/\*[\s\S]*?\*\//g;
/** At-rules whose blocks contain nested rules that should be scoped too. */
const NESTED_AT_RULES = new Set(["media", "supports", "layer", "container"]);
/** Strip sequences that would let the CSS break out of (or open tags inside) a <style> element. */
export function sanitizeTemplateCss(css: string): string {
return css.replace(STYLE_ESCAPE_RE, "");
}
/** Deterministic djb2-based scope class for a template name. */
export function hashTemplateScope(name: string): string {
let hash = 5381;
for (let i = 0; i < name.length; i++) {
hash = (((hash << 5) + hash) ^ name.charCodeAt(i)) >>> 0;
}
return `ptf-tpl-${hash.toString(36)}`;
}
/**
* Prefix every rule's selectors with `.${scope} `. At-rules with nested rule
* blocks (media/supports/layer/container) recurse; declarations-only at-rules
* (keyframes, font-face, ...) pass through untouched. Content outside any
* rule block (e.g. an `@import` line) is dropped.
*/
function scopeRules(css: string, scope: string): string {
const rules: string[] = [];
let i = 0;
while (i < css.length) {
while (i < css.length && /\s/.test(css[i])) i++;
if (i >= css.length) break;
const open = css.indexOf("{", i);
if (open === -1) break;
const prelude = css.slice(i, open).trim();
let depth = 1;
let cursor = open + 1;
while (cursor < css.length && depth > 0) {
if (css[cursor] === "{") depth++;
else if (css[cursor] === "}") depth--;
cursor++;
}
const body = css.slice(open + 1, depth === 0 ? cursor - 1 : cursor);
if (prelude.startsWith("@")) {
const at = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
if (NESTED_AT_RULES.has(at)) {
rules.push(`${prelude} { ${scopeRules(body, scope)} }`);
} else {
rules.push(`${prelude} { ${body.trim()} }`);
}
} else {
const scoped = prelude
.split(",")
.map((selector) => selector.trim())
.filter(Boolean)
.map((selector) => `.${scope} ${selector}`)
.join(", ");
rules.push(`${scoped} { ${body.trim().replace(/\s+/g, " ")} }`);
}
i = cursor;
}
return rules.join("\n");
}
/** Scope a template's CSS under a single scope class. */
export function scopeTemplateCss(css: string, scopeClass: string): string {
return scopeRules(css.replace(COMMENT_RE, ""), scopeClass);
}
export interface TemplateStyles {
readonly css: string;
readonly scopeClasses: readonly string[];
}
/**
* Build the stylesheet for one render: only templates actually referenced by
* the page body contribute CSS, each scoped under its own hash class.
*/
export function buildTemplateStyles(
source: string,
templates: readonly TemplateWithCss[],
): TemplateStyles {
const usedNames = extractTemplateNames(source);
const usedDirectives = new Set(extractDirectiveNames(source));
const cssByName = new Map<string, string>();
for (const template of templates) {
if (!template.css || template.css.trim().length === 0) continue;
const ownsUsedDirective =
template.body !== undefined &&
template.body !== null &&
extractDirectiveNames(template.body).some((name) => usedDirectives.has(name));
if (usedNames.includes(template.name) || ownsUsedDirective) {
cssByName.set(template.name, template.css);
}
}
const parts: string[] = [];
const scopeClasses: string[] = [];
for (const template of templates) {
const css = cssByName.get(template.name);
if (!css) continue;
const scopeClass = hashTemplateScope(template.name);
const scoped = scopeTemplateCss(sanitizeTemplateCss(css), scopeClass);
if (scoped.length > 0) {
parts.push(scoped);
scopeClasses.push(scopeClass);
}
}
return { css: parts.join("\n"), scopeClasses };
}

View file

@ -0,0 +1,58 @@
/**
* Developer-authored template HTML handling.
*
* Pure module — safe to import client-side. HTML templates are restricted to
* the developer role at the field level; this module sanitizes them (defense
* in depth) and splits prepared markdown around sentinel markers emitted by
* template expansion so raw HTML can be rendered without rehype-raw.
*/
const HTML_SENTINEL_RE = /PTF-TEMPLATE-HTML:(.+?):END/g;
const HTML_DANGEROUS_TAGS_RE =
/<\s*\/?\s*(script|iframe|object|embed|link|meta|base)\b[\s\S]*?(?:\/>|<\/\s*\1\s*>|$)/gi;
const HTML_EVENT_ATTR_RE = /\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi;
const HTML_JS_URL_RE =
/\s(?:href|src|action|formaction)\s*=\s*("\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]*)/gi;
export function sanitizeTemplateHtml(html: string): string {
return html
.replace(HTML_DANGEROUS_TAGS_RE, "")
.replace(HTML_EVENT_ATTR_RE, "")
.replace(HTML_JS_URL_RE, "");
}
export interface PreparedSegment {
readonly type: "markdown" | "html";
readonly value: string;
readonly name?: string;
}
export function splitPreparedHtml(
prepared: string,
htmlTemplates: Record<string, string>,
): PreparedSegment[] {
if (
Object.keys(htmlTemplates).length === 0 ||
!prepared.includes("PTF-TEMPLATE-HTML:")
) {
return [{ type: "markdown", value: prepared }];
}
const segments: PreparedSegment[] = [];
let last = 0;
for (const match of prepared.matchAll(HTML_SENTINEL_RE)) {
const name = match[1].trim();
const html = htmlTemplates[name];
if (html === undefined) continue;
if (match.index > last) {
segments.push({ type: "markdown", value: prepared.slice(last, match.index) });
}
segments.push({ type: "html", value: sanitizeTemplateHtml(html), name });
last = match.index + match[0].length;
}
if (last < prepared.length) {
segments.push({ type: "markdown", value: prepared.slice(last) });
}
return segments;
}

View file

@ -17,19 +17,53 @@ const PLACEHOLDER_RE = /\{\{\s*([^{}]+?)\s*\}\}/g;
function expandLevel(
text: string,
templates: TemplateMap,
htmlTemplates: TemplateMap,
depth: number,
chain: string[],
): string {
if (depth >= 2) return text;
return text.replace(PLACEHOLDER_RE, (match, rawName: string) => {
const name = rawName.trim();
if (htmlTemplates[name] !== undefined) {
return `PTF-TEMPLATE-HTML:${name}:END`;
}
const body = templates[name];
if (body === undefined) return match;
if (chain.includes(name)) return match;
return expandLevel(body, templates, depth + 1, [...chain, name]);
return expandLevel(body, templates, htmlTemplates, depth + 1, [...chain, name]);
});
}
export function expandTemplates(source: string, templates: TemplateMap): string {
return expandLevel(source, templates, 0, []);
export function expandTemplates(
source: string,
templates: TemplateMap,
htmlTemplates: TemplateMap = {},
): string {
return expandLevel(source, templates, htmlTemplates, 0, []);
}
/** Template names referenced by raw `{{Name}}` placeholders in the source, in first-appearance order, deduplicated. */
export function extractTemplateNames(source: string): string[] {
const names: string[] = [];
for (const match of source.matchAll(PLACEHOLDER_RE)) {
const name = match[1].trim();
if (name.length > 0 && !names.includes(name)) names.push(name);
}
return names;
}
/** `{{Param}}` placeholder names inside a template body, in first-appearance order, deduplicated. */
export function extractPlaceholders(source: string): string[] {
return extractTemplateNames(source);
}
const DIRECTIVE_NAME_RE = /^[ \t]{0,3}:{3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)/gm;
/** Container/leaf directive names opened in the source (`::::infobox` → "infobox"), deduplicated. */
export function extractDirectiveNames(source: string): string[] {
const names: string[] = [];
for (const match of source.matchAll(DIRECTIVE_NAME_RE)) {
if (!names.includes(match[1])) names.push(match[1]);
}
return names;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "wiki_templates" ADD COLUMN "css" varchar;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "wiki_templates" DROP COLUMN "css";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "wiki_templates" ADD COLUMN "html" varchar;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "wiki_templates" DROP COLUMN "html";`)
}

View file

@ -29,7 +29,9 @@ import * as migration_20260905_213824 from './20260905_213824';
import * as migration_20260905_215658 from './20260905_215658';
import * as migration_20260906_045502 from './20260906_045502';
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field';
import * as migration_20260906_061451_add_wiki_template_css from './20260906_061451_add_wiki_template_css';
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext';
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html';
export const migrations = [
{
@ -192,4 +194,14 @@ export const migrations = [
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
name: '20260906_063753_add_personal_excerpt_richtext',
},
{
up: migration_20260906_061451_add_wiki_template_css.up,
down: migration_20260906_061451_add_wiki_template_css.down,
name: '20260906_061451_add_wiki_template_css',
},
{
up: migration_20260906_065405_add_wiki_template_html.up,
down: migration_20260906_065405_add_wiki_template_html.down,
name: '20260906_065405_add_wiki_template_html'
},
];

View file

@ -3299,6 +3299,14 @@ export interface WikiTemplate {
*/
body: string;
description?: string | null;
/**
* Optional CSS injected on wiki pages while this template exists. Write plain selectors — they are automatically prefixed with a hash-based scope class (e.g. .ptf-tpl-3afb1c .my-class) so templates never collide with each other or the app shell.
*/
css?: string | null;
/**
* Developer-only. Raw HTML rendered verbatim (sanitized) when this template is transcluded — replaces the markdown body.
*/
html?: string | null;
updatedAt: string;
createdAt: string;
}
@ -5078,6 +5086,8 @@ export interface WikiTemplatesSelect<T extends boolean = true> {
name?: T;
body?: T;
description?: T;
css?: T;
html?: T;
updatedAt?: T;
createdAt?: T;
}