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:
parent
60da363a93
commit
51f7c803e0
21 changed files with 45929 additions and 29 deletions
13
bun.lock
13
bun.lock
|
|
@ -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
204
docs/wiki-demo-article.md
Normal 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**
|
||||
|
||||

|
||||
|
||||
| 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:
|
||||
|
||||

|
||||
|
||||
## 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 |
|
||||
92
docs/wiki-infobox-template.md
Normal file
92
docs/wiki-infobox-template.md
Normal 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}}**
|
||||
|
||||

|
||||
|
||||
| {{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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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">
|
||||
<>
|
||||
{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}
|
||||
>
|
||||
{prepared}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
136
src/lib/wiki/templateCss.ts
Normal 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 };
|
||||
}
|
||||
58
src/lib/wiki/templateHtml.ts
Normal file
58
src/lib/wiki/templateHtml.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
22455
src/migrations/20260906_061451_add_wiki_template_css.json
Normal file
22455
src/migrations/20260906_061451_add_wiki_template_css.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260906_061451_add_wiki_template_css.ts
Normal file
11
src/migrations/20260906_061451_add_wiki_template_css.ts
Normal 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";`)
|
||||
}
|
||||
22461
src/migrations/20260906_065405_add_wiki_template_html.json
Normal file
22461
src/migrations/20260906_065405_add_wiki_template_html.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260906_065405_add_wiki_template_html.ts
Normal file
11
src/migrations/20260906_065405_add_wiki_template_html.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue