From 51f7c803e070e08fff7b0ce6f8a2411374cbaca2 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 6 Sep 2026 05:32:48 -0400 Subject: [PATCH] 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 --- bun.lock | 13 + docs/wiki-demo-article.md | 204 + docs/wiki-infobox-template.md | 92 + package.json | 1 + src/app/(frontend)/styles.css | 277 + src/app/(frontend)/wiki/[slug]/edit/page.tsx | 22 + src/app/(frontend)/wiki/[slug]/page.tsx | 38 +- src/app/(frontend)/wiki/new/page.tsx | 22 + src/collections/wiki/WikiTemplates.ts | 26 +- src/components/frontend/wiki/WikiContent.tsx | 57 +- src/lib/wiki/markdown.tsx | 4 + src/lib/wiki/prepare.ts | 8 +- src/lib/wiki/templateCss.ts | 136 + src/lib/wiki/templateHtml.ts | 58 + src/lib/wiki/templates.ts | 40 +- ...20260906_061451_add_wiki_template_css.json | 22455 +++++++++++++++ .../20260906_061451_add_wiki_template_css.ts | 11 + ...0260906_065405_add_wiki_template_html.json | 22461 ++++++++++++++++ .../20260906_065405_add_wiki_template_html.ts | 11 + src/migrations/index.ts | 12 + src/payload-types.ts | 10 + 21 files changed, 45929 insertions(+), 29 deletions(-) create mode 100644 docs/wiki-demo-article.md create mode 100644 docs/wiki-infobox-template.md create mode 100644 src/lib/wiki/templateCss.ts create mode 100644 src/lib/wiki/templateHtml.ts create mode 100644 src/migrations/20260906_061451_add_wiki_template_css.json create mode 100644 src/migrations/20260906_061451_add_wiki_template_css.ts create mode 100644 src/migrations/20260906_065405_add_wiki_template_html.json create mode 100644 src/migrations/20260906_065405_add_wiki_template_html.ts diff --git a/bun.lock b/bun.lock index 1aabdb6..d0189bd 100644 --- a/bun.lock +++ b/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=="], diff --git a/docs/wiki-demo-article.md b/docs/wiki-demo-article.md new file mode 100644 index 0000000..b9e35b3 --- /dev/null +++ b/docs/wiki-demo-article.md @@ -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 | diff --git a/docs/wiki-infobox-template.md b/docs/wiki-infobox-template.md new file mode 100644 index 0000000..617a1d3 --- /dev/null +++ b/docs/wiki-infobox-template.md @@ -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. diff --git a/package.json b/package.json index 77bf48f..d4fbd28 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 086c29e..3e6f08f 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -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; +} diff --git a/src/app/(frontend)/wiki/[slug]/edit/page.tsx b/src/app/(frontend)/wiki/[slug]/edit/page.tsx index 005f247..51db6d3 100644 --- a/src/app/(frontend)/wiki/[slug]/edit/page.tsx +++ b/src/app/(frontend)/wiki/[slug]/edit/page.tsx @@ -13,6 +13,26 @@ export const metadata = { title: "Edit wiki page — Polaris Task Force", }; +function templateCssMap( + templates: readonly { name: string; css?: string | null }[], +): Record { + const map: Record = {}; + 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 { + const map: Record = {}; + 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} /> diff --git a/src/app/(frontend)/wiki/[slug]/page.tsx b/src/app/(frontend)/wiki/[slug]/page.tsx index 1bba8ab..aa42372 100644 --- a/src/app/(frontend)/wiki/[slug]/page.tsx +++ b/src/app/(frontend)/wiki/[slug]/page.tsx @@ -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 = {}; + 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 ( -
+
+ {templateCss &&