diff --git a/public/editor/preview.ts b/public/editor/preview.ts index 538d493..a8819ac 100644 --- a/public/editor/preview.ts +++ b/public/editor/preview.ts @@ -1,40 +1,37 @@ import DOMPurify from "dompurify"; import MarkdownIt from "markdown-it"; -import footnotePlugin from "markdown-it-footnote"; -import markPlugin from "markdown-it-mark"; -import taskListPlugin from "markdown-it-task-lists"; -import { calloutPlugin } from "../../src/lib/markdown/callout-plugin.ts"; -import { commentPlugin } from "../../src/lib/markdown/comment-plugin.ts"; -import { katexPlugin } from "../../src/lib/markdown/katex-plugin.ts"; -import { wikilinkPlugin } from "../../src/lib/markdown/wikilink-plugin.ts"; +import { useBaseMarkdownPlugins } from "../../src/lib/markdown/base-plugins.ts"; const md = new MarkdownIt({ html: false, linkify: true }); -md.use(commentPlugin); -md.use(wikilinkPlugin); -md.use(footnotePlugin); -md.use(markPlugin); -md.use(taskListPlugin, { enabled: false, label: true }); -md.use(calloutPlugin); -md.use(katexPlugin, { throwOnError: false }); +useBaseMarkdownPlugins(md); const PURIFY_CONFIG = { + // `iframe` + these attrs are emitted only by the YouTube embed plugin. ADD_TAGS: ["iframe"], - ADD_ATTR: [ - "allow", - "allowfullscreen", - "loading", - "frameborder", - "data-viz-type", - "data-viz", - ], + ADD_ATTR: ["allow", "allowfullscreen", "loading"], }; -function getWikiSlug(): string | undefined { - const match = window.location.pathname.match(/^\/wiki\/([^/]+)/); - return match?.[1]; +// Defence in depth: lock embedded iframes to the youtube-nocookie host (the +// only iframe source our markdown produces), mirroring the server sanitizer. +DOMPurify.addHook("uponSanitizeElement", (node, data) => { + if (data.tagName !== "iframe" || !(node instanceof Element)) return; + const src = node.getAttribute("src") ?? ""; + if (!src.startsWith("https://www.youtube-nocookie.com/")) node.remove(); +}); + +// Editor URLs are /@{handle}/{wikiSlug}/... — recover both so wikilinks in the +// preview resolve to the same hrefs the server would render. +function getWikiContext(): { ownerHandle?: string; wikiSlug?: string } { + const match = window.location.pathname.match(/^\/@([^/]+)\/([^/]+)/); + if (!match) return {}; + const [, ownerHandle, wikiSlug] = match; + return { + ...(ownerHandle ? { ownerHandle } : {}), + ...(wikiSlug ? { wikiSlug } : {}), + }; } export function renderPreview(preview: HTMLElement, doc: string): void { - const dirty = md.render(doc, { wikiSlug: getWikiSlug() }); + const dirty = md.render(doc, getWikiContext()); preview.innerHTML = DOMPurify.sanitize(dirty, PURIFY_CONFIG); } diff --git a/public/style.css b/public/style.css index 07d4f41..1d01c05 100644 --- a/public/style.css +++ b/public/style.css @@ -29,13 +29,16 @@ margin-left: 0.25rem; } -/* Shiki syntax highlighting: activate dark theme vars */ -@media (prefers-color-scheme: dark) { - .shiki, - .shiki span { - color: var(--shiki-dark); - background-color: var(--shiki-dark-bg); - } +/* Shiki syntax highlighting. The highlighter emits both palettes as CSS vars + (defaultColor:false); `light-dark()` resolves them from the element's + `color-scheme`, which the theme system sets per user/wiki preference — so + code follows the chosen Lichen theme, not the OS `prefers-color-scheme`. */ +.shiki, +.shiki span { + color: light-dark(var(--shiki-light), var(--shiki-dark)); +} +.shiki { + background-color: light-dark(var(--shiki-light-bg), var(--shiki-dark-bg)); } /* Callouts */ diff --git a/src/lib/assets.ts b/src/lib/assets.ts index 56d7399..72a21f4 100644 --- a/src/lib/assets.ts +++ b/src/lib/assets.ts @@ -48,6 +48,9 @@ export function assetUrl(path: string): string { if (!path.startsWith("/public/")) return path; const localPath = path.slice("/public/".length); + // Defence in depth: never let a path escape the public dir via `..`. + // Inputs are static constants today, but this keeps the disk read safe. + if (localPath.includes("..")) return path; let hash = hashCache.get(localPath); if (hash === undefined) { hash = computeHash(localPath) ?? ""; diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 1f014db..ceff602 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,18 +1,12 @@ import MarkdownIt from "markdown-it"; -import footnotePlugin from "markdown-it-footnote"; -import markPlugin from "markdown-it-mark"; -import taskListPlugin from "markdown-it-task-lists"; -import { calloutPlugin } from "./markdown/callout-plugin.ts"; -import { commentPlugin } from "./markdown/comment-plugin.ts"; -import { headingAnchorPlugin } from "./markdown/heading-anchor-plugin.ts"; +import { useBaseMarkdownPlugins } from "./markdown/base-plugins.ts"; import { highlightPlugin } from "./markdown/highlight-plugin.ts"; -import { type KatexPluginEnv, katexPlugin } from "./markdown/katex-plugin.ts"; +import type { KatexPluginEnv } from "./markdown/katex-plugin.ts"; import { sanitizeMarkdownHtml } from "./markdown/sanitize.ts"; import { parseWikilinkTarget, type WikilinkEnv, type WikilinkTarget, - wikilinkPlugin, } from "./markdown/wikilink-plugin.ts"; import { type VizPluginEnv, vizPlugin } from "./viz/plugin.ts"; @@ -26,54 +20,12 @@ interface MarkdownEnv extends VizPluginEnv, WikilinkEnv, KatexPluginEnv {} const md = new MarkdownIt({ html: false, linkify: true }); -// YouTube embed plugin: replaces paragraphs containing only a YouTube link -// with a responsive iframe embed (privacy-enhanced mode). -function youtubePlugin(mdi: MarkdownIt): void { - const youtubeRegex = - /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[?&]\S*)?$/; - - mdi.core.ruler.push("youtube_embed", (state) => { - const tokens = state.tokens; - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - if (token?.type !== "paragraph_open") continue; - - const inline = tokens[i + 1]; - const close = tokens[i + 2]; - if ( - !inline || - inline.type !== "inline" || - !close || - close.type !== "paragraph_close" - ) - continue; - - // Check if the inline content is a single autolinked or plain URL - const text = inline.content.trim(); - const match = youtubeRegex.exec(text); - if (!match) continue; - - const videoId = match[1]; - const htmlToken = new state.Token("html_block", "", 0); - htmlToken.content = `
\n`; - - // Replace the 3 tokens (p_open, inline, p_close) with the html_block - tokens.splice(i, 3, htmlToken); - } - }); -} - -md.use(commentPlugin); -md.use(wikilinkPlugin); -md.use(headingAnchorPlugin); -md.use(footnotePlugin); -md.use(markPlugin); -md.use(taskListPlugin, { enabled: false, label: true }); -md.use(calloutPlugin); +useBaseMarkdownPlugins(md); +// Server-only additions. Both wrap the fence renderer and chain through the +// previous one, so viz blocks bypass the highlighter and vice versa. Kept out +// of the shared base set so they never reach the browser editor bundle. md.use(highlightPlugin); md.use(vizPlugin); -md.use(katexPlugin, { throwOnError: false }); -md.use(youtubePlugin); export function renderMarkdown( source: string, diff --git a/src/lib/markdown/base-plugins.ts b/src/lib/markdown/base-plugins.ts new file mode 100644 index 0000000..6cd9f62 --- /dev/null +++ b/src/lib/markdown/base-plugins.ts @@ -0,0 +1,32 @@ +import type MarkdownIt from "markdown-it"; +import footnotePlugin from "markdown-it-footnote"; +import markPlugin from "markdown-it-mark"; +import taskListPlugin from "markdown-it-task-lists"; +import { calloutPlugin } from "./callout-plugin.ts"; +import { commentPlugin } from "./comment-plugin.ts"; +import { headingAnchorPlugin } from "./heading-anchor-plugin.ts"; +import { katexPlugin } from "./katex-plugin.ts"; +import { wikilinkPlugin } from "./wikilink-plugin.ts"; +import { youtubePlugin } from "./youtube-plugin.ts"; + +/** + * Registers the markdown plugins shared by the server renderer (`markdown.ts`) + * and the client-side editor preview (`public/editor/preview.ts`), keeping the + * two pipelines from drifting apart. + * + * Deliberately excludes the syntax highlighter (shiki ships WASM grammars — far + * too heavy for the browser bundle) and the viz plugin (renders through a + * separate D3 bundle not loaded in the editor). Both are server-only and added + * in `markdown.ts` after this base set. + */ +export function useBaseMarkdownPlugins(md: MarkdownIt): void { + md.use(commentPlugin); + md.use(wikilinkPlugin); + md.use(headingAnchorPlugin); + md.use(footnotePlugin); + md.use(markPlugin); + md.use(taskListPlugin, { enabled: false, label: true }); + md.use(calloutPlugin); + md.use(katexPlugin, { throwOnError: false }); + md.use(youtubePlugin); +} diff --git a/src/lib/markdown/comment-plugin.ts b/src/lib/markdown/comment-plugin.ts index f133b18..f6d848a 100644 --- a/src/lib/markdown/comment-plugin.ts +++ b/src/lib/markdown/comment-plugin.ts @@ -38,6 +38,10 @@ function commentBlock( const restOfLine = state.src.slice(startPos + 2, startMax); const closeOnSameLine = restOfLine.indexOf("%%"); if (closeOnSameLine !== -1) { + // Only consume the line as a block comment when nothing visible follows + // the closing %%. Otherwise defer to the inline rule, which strips just + // the %%...%% span and keeps the trailing text. + if (restOfLine.slice(closeOnSameLine + 2).trim() !== "") return false; if (silent) return true; state.line = startLine + 1; return true; diff --git a/src/lib/markdown/highlight-plugin.ts b/src/lib/markdown/highlight-plugin.ts index 108f093..9ba7763 100644 --- a/src/lib/markdown/highlight-plugin.ts +++ b/src/lib/markdown/highlight-plugin.ts @@ -48,6 +48,11 @@ function highlightCode(code: string, lang: string): string | null { return highlighter.codeToHtml(code, { lang, themes: { light: "github-light", dark: "github-dark" }, + // Emit both palettes as CSS vars (--shiki-light/--shiki-dark) instead + // of a baked-in `color`, so the active theme is chosen in CSS via + // `light-dark()` keyed off `color-scheme` — matching Lichen's theme + // system rather than the OS `prefers-color-scheme`. + defaultColor: false, }); } catch { return null; @@ -69,13 +74,9 @@ export function highlightPlugin(mdi: MarkdownIt): void { return defaultFence(tokens, idx, options, env, slf); } - const highlighted = lang ? highlightCode(code(token), lang) : null; + const highlighted = lang ? highlightCode(token.content, lang) : null; if (highlighted) return highlighted; return defaultFence(tokens, idx, options, env, slf); }; } - -function code(token: { content: string }): string { - return token.content; -} diff --git a/src/lib/markdown/sanitize.ts b/src/lib/markdown/sanitize.ts index 50d8632..4da901a 100644 --- a/src/lib/markdown/sanitize.ts +++ b/src/lib/markdown/sanitize.ts @@ -152,6 +152,8 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { "border-right": [/^[\w.\-\s#()]+$/], "border-top": [/^[\w.\-\s#()]+$/], "border-bottom": [/^[\w.\-\s#()]+$/], + "--shiki-light": [/^#[\da-fA-F]{3,8}$/], + "--shiki-light-bg": [/^#[\da-fA-F]{3,8}$/], "--shiki-dark": [/^#[\da-fA-F]{3,8}$/], "--shiki-dark-bg": [/^#[\da-fA-F]{3,8}$/], }, diff --git a/src/lib/markdown/youtube-plugin.ts b/src/lib/markdown/youtube-plugin.ts new file mode 100644 index 0000000..ae98765 --- /dev/null +++ b/src/lib/markdown/youtube-plugin.ts @@ -0,0 +1,41 @@ +import type MarkdownIt from "markdown-it"; + +const YOUTUBE_RE = + /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[?&]\S*)?$/; + +/** + * Replaces paragraphs containing only a YouTube link with a responsive iframe + * embed (privacy-enhanced `youtube-nocookie` mode). The video id is constrained + * to 11 url-safe chars by the regex, so the interpolated src can't break out. + */ +export function youtubePlugin(mdi: MarkdownIt): void { + mdi.core.ruler.push("youtube_embed", (state) => { + const tokens = state.tokens; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token?.type !== "paragraph_open") continue; + + const inline = tokens[i + 1]; + const close = tokens[i + 2]; + if ( + !inline || + inline.type !== "inline" || + !close || + close.type !== "paragraph_close" + ) + continue; + + // Check if the inline content is a single autolinked or plain URL + const text = inline.content.trim(); + const match = YOUTUBE_RE.exec(text); + if (!match) continue; + + const videoId = match[1]; + const htmlToken = new state.Token("html_block", "", 0); + htmlToken.content = `\n`; + + // Replace the 3 tokens (p_open, inline, p_close) with the html_block + tokens.splice(i, 3, htmlToken); + } + }); +} diff --git a/tests/lib/markdown.test.ts b/tests/lib/markdown.test.ts index 4292866..6813c80 100644 --- a/tests/lib/markdown.test.ts +++ b/tests/lib/markdown.test.ts @@ -382,6 +382,17 @@ describe("comments", () => { expect(html).not.toContain("across lines"); }); + test("whole-line %% comment %% is stripped entirely", () => { + const { html } = renderMarkdown("%% just a comment %%"); + expect(html.trim()).toBe(""); + }); + + test("preserves visible text after a same-line %% close", () => { + const { html } = renderMarkdown("%% hidden %% visible text"); + expect(html).toContain("visible text"); + expect(html).not.toContain("hidden"); + }); + test("does not affect single percent signs", () => { const { html } = renderMarkdown("50% complete and 100% done"); expect(html).toContain("50% complete");