diff --git a/web/src/app.css b/web/src/app.css index 4e7845e7e..ff26e7d99 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -178,6 +178,19 @@ html { --color-ansi-bright-cyan: #04a5e5; --color-ansi-bright-white: #bcc0cc; + --color-syntax-foreground: #4c4f69; + --color-syntax-comment: #7c7f93; + --color-syntax-string: #40a02b; + --color-syntax-keyword: #8839ef; + --color-syntax-function: #1e66f5; + --color-syntax-constant: #fe640b; + --color-syntax-parameter: #e64553; + --color-syntax-punctuation: #7c7f93; + --color-syntax-link: #1e66f5; + --color-syntax-inserted: #40a02b; + --color-syntax-deleted: #d20f39; + --color-syntax-changed: #df8e1d; + --animate-spin-step: spin-step 2.5s infinite; @keyframes spin-step { @@ -690,6 +703,19 @@ html { --color-ansi-bright-magenta: #f4dbd6; --color-ansi-bright-cyan: #91d7e3; --color-ansi-bright-white: #f0c6c6; + + --color-syntax-foreground: #cdd6f4; + --color-syntax-comment: #9399b2; + --color-syntax-string: #a6e3a1; + --color-syntax-keyword: #cba6f7; + --color-syntax-function: #89b4fa; + --color-syntax-constant: #fab387; + --color-syntax-parameter: #eba0ac; + --color-syntax-punctuation: #9399b2; + --color-syntax-link: #89b4fa; + --color-syntax-inserted: #a6e3a1; + --color-syntax-deleted: #f38ba8; + --color-syntax-changed: #f9e2af; } } @@ -705,5 +731,5 @@ html { /* chromium doesn't break links so we have to set it explicitely */ p > a { - line-break: anywhere; + line-break: anywhere; } diff --git a/web/src/lib/components/comment/Comment.svelte b/web/src/lib/components/comment/Comment.svelte index 1d78dee54..3d847e9fa 100644 --- a/web/src/lib/components/comment/Comment.svelte +++ b/web/src/lib/components/comment/Comment.svelte @@ -5,6 +5,7 @@ import Separator from "$lib/components/ui/Separator.svelte"; import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; import Reactions from "$lib/components/reaction/Reactions.svelte"; + import { enhanceCodeblocks } from "$lib/markup/highlight"; import type { ReactionGroup } from "$lib/components/reaction/reactions"; interface Props { @@ -62,8 +63,10 @@ {#if editor}
{@render editor()}
{:else if bodyHtml} - -
{@html bodyHtml}
+
+ + {@html bodyHtml} +
{:else if body}
{body}
{/if} diff --git a/web/src/lib/components/repo/BlobView.svelte b/web/src/lib/components/repo/BlobView.svelte index dad6f0688..11fac4bbe 100644 --- a/web/src/lib/components/repo/BlobView.svelte +++ b/web/src/lib/components/repo/BlobView.svelte @@ -5,6 +5,7 @@ import BlobHeader from "$lib/components/repo/BlobHeader.svelte"; import { languageForPath } from "$lib/components/repo/languageColors"; import LastCommitPanel from "$lib/components/repo/LastCommitPanel.svelte"; + import { enhanceCodeblocks } from "$lib/markup/highlight"; import PierreFile from "$lib/components/repo/PierreFile.svelte"; import { baseName, fileHistoryHref } from "$lib/components/repo/urls"; import { createBlame, type BlameSource } from "$lib/components/repo/blame/request.svelte"; @@ -185,8 +186,10 @@ {:else if blob.kind === "markup" && view === "rendered" && blob.renderedHtml !== null} - -
{@html blob.renderedHtml}
+
+ + {@html blob.renderedHtml} +
{:else if blob.contents !== null}
import FileText from "$icon/file-text"; import Card from "$lib/components/ui/Card.svelte"; + import { enhanceCodeblocks } from "$lib/markup/highlight"; interface Props { filename: string; @@ -19,8 +20,10 @@
{#if html} - -
{@html html}
+
+ + {@html html} +
{:else}
["diffStyle"]>;
 
-const PIERRE_THEME_NAME = "tangled";
+export const PIERRE_THEME_NAME = "tangled";
 
 registerCustomCSSVariableTheme(PIERRE_THEME_NAME, {
-	// pierre.css defines the real tokens on diffs-container, these are just
-	// fallbacks for the generated bindings
 	background: "#ffffff",
-	foreground: "#4c4f69",
-	"token-comment": "#7c7f93",
-	"token-string": "#40a02b",
-	"token-string-expression": "#40a02b",
-	"token-keyword": "#8839ef",
-	"token-function": "#1e66f5",
-	"token-constant": "#fe640b",
-	"token-parameter": "#e64553",
-	"token-punctuation": "#7c7f93",
-	"token-link": "#1e66f5",
-	"token-inserted": "#40a02b",
-	"token-deleted": "#d20f39",
-	"token-changed": "#df8e1d"
+	foreground: "#4c4f69"
 });
 
 // pierre skips highlighting lines past 1e3 chars by default.
diff --git a/web/src/lib/components/repo/pulls/PullReviewComment.svelte b/web/src/lib/components/repo/pulls/PullReviewComment.svelte
index 8d9d044e4..34f5f8c38 100644
--- a/web/src/lib/components/repo/pulls/PullReviewComment.svelte
+++ b/web/src/lib/components/repo/pulls/PullReviewComment.svelte
@@ -8,6 +8,7 @@
 	import TimeAgo from "$lib/components/ui/TimeAgo.svelte";
 	import User from "$lib/components/ui/User.svelte";
 	import type { MarkupContext } from "$lib/markup";
+	import { enhanceCodeblocks } from "$lib/markup/highlight";
 	import { renderMarkdown } from "$lib/markup/markdown";
 	import PullReviewCommentEditor from "$lib/components/repo/pulls/PullReviewCommentEditor.svelte";
 
@@ -32,7 +33,7 @@
 	}: Props = $props();
 
 	async function submit(body: string) {
-		const bodyHtml = renderMarkdown(body, markup);
+		const bodyHtml = await renderMarkdown(body, markup);
 		await onedit({ ...comment, body, bodyHtml });
 		editing = false;
 	}
@@ -94,7 +95,10 @@
 			/>
 		{:else}
 			{#if comment.bodyHtml}
-				
+
{@html comment.bodyHtml}
diff --git a/web/src/lib/components/repo/tickets/TicketBody.svelte b/web/src/lib/components/repo/tickets/TicketBody.svelte index 1d321bcb2..6355bbe94 100644 --- a/web/src/lib/components/repo/tickets/TicketBody.svelte +++ b/web/src/lib/components/repo/tickets/TicketBody.svelte @@ -1,4 +1,6 @@ ")).not.toContain("alert"); - expect(render('')).not.toContain("onerror"); + it("does not let highlighted code smuggle styles past the sanitizer", async () => { + const html = await render("```rust\nfn main() {}\n```"); + expect(html).not.toContain("background-color"); + expect(html).not.toContain("shiki"); + }); + + it("strips scripts, event handlers and javascript urls", async () => { + expect(await render("")).not.toContain("alert"); + expect(await render('')).not.toContain("onerror"); // markdown-it refuses the destination outright, so this stays plain text - expect(render("[x](javascript:alert(1))")).not.toContain("x')).not.toContain("data:"); + expect(await render("[x](javascript:alert(1))")).not.toContain("x')).not.toContain("data:"); }); - it("drops classes it does not recognise", () => { - expect(render('
boo
')).not.toContain("inset-0"); + it("drops classes it does not recognise", async () => { + expect(await render('
boo
')).not.toContain( + "inset-0" + ); }); - it("drops inputs that are not task list checkboxes", () => { - expect(render('')).not.toContain(" { + expect(await render('')).not.toContain(" { - const html = render( + it("keeps the html a readme actually uses for layout", async () => { + const html = await render( '

infra

\n\n
more\n\nhidden\n\n
' ); expect(html).toContain('
'); diff --git a/web/src/lib/markup/markdown.ts b/web/src/lib/markup/markdown.ts index 6370501bb..5135effdb 100644 --- a/web/src/lib/markup/markdown.ts +++ b/web/src/lib/markup/markdown.ts @@ -4,6 +4,7 @@ import { tasklist } from "@mdit/plugin-tasklist"; import MarkdownIt from "markdown-it"; import anchor from "markdown-it-anchor"; import { full as emoji } from "markdown-it-emoji"; +import { highlightCodeblocks } from "$lib/markup/highlight"; import { sanitizeMarkup } from "$lib/markup/sanitize"; import type { MarkupContext } from "$lib/markup/paths"; @@ -86,8 +87,8 @@ const commitLinks = }); }; -// todo: no syntax highlighting, mermaid or math yet. the first two want a client -// side renderer, math wants mathjax loaded on demand the way the appview does it +// todo: mermaid and math still want a client side renderer, math wants mathjax +// loaded on demand the way the appview does it const build = (host: string): MarkdownIt => { const md = new MarkdownIt({ html: true, @@ -121,5 +122,8 @@ const rendererFor = (host: string): MarkdownIt => { return md; }; -export const renderMarkdown = (source: string, ctx: MarkupContext): string => - sanitizeMarkup(rendererFor(ctx.host ?? "").render(source), ctx); +export const renderMarkdown = async (source: string, ctx: MarkupContext): Promise => { + const raw = rendererFor(ctx.host ?? "").render(source); + // sanitize before highlighting so the allowlist doesn't have to allow spans + return highlightCodeblocks(sanitizeMarkup(raw, ctx)); +}; diff --git a/web/src/pierre.css b/web/src/pierre.css index 3c066e183..f5af618fc 100644 --- a/web/src/pierre.css +++ b/web/src/pierre.css @@ -1,14 +1,30 @@ +:root { + /* the "tangled" theme (pierre.ts) binds --diffs-bg/--diffs-fg to these and + forces color-scheme: dark. without them the bg chain goes + guaranteed-invalid and light-dark() is stuck on the dark branch */ + --diffs-background: var(--color-background-default); + --diffs-foreground: var(--color-syntax-foreground); + + --diffs-token-comment: var(--color-syntax-comment); + --diffs-token-string: var(--color-syntax-string); + --diffs-token-string-expression: var(--color-syntax-string); + --diffs-token-keyword: var(--color-syntax-keyword); + --diffs-token-function: var(--color-syntax-function); + --diffs-token-constant: var(--color-syntax-constant); + --diffs-token-parameter: var(--color-syntax-parameter); + --diffs-token-punctuation: var(--color-syntax-punctuation); + --diffs-token-link: var(--color-syntax-link); + --diffs-token-inserted: var(--color-syntax-inserted); + --diffs-token-deleted: var(--color-syntax-deleted); + --diffs-token-changed: var(--color-syntax-changed); +} + /* Override Pierre's unlayered :host stylesheet using unlayered :root diffs-container. */ :root diffs-container { --diffs-font-family: var(--font-mono); --diffs-font-size: 0.875rem; --diffs-line-height: 1.5; - /* the "tangled" theme (pierre.ts) binds --diffs-bg/--diffs-fg to these and - forces color-scheme: dark. without them the bg chain goes - guaranteed-invalid and light-dark() is stuck on the dark branch */ - --diffs-background: var(--color-background-default); - --diffs-foreground: #4c4f69; color-scheme: light dark; --diffs-light-bg: var(--color-background-default); --diffs-dark-bg: var(--color-background-default); @@ -24,20 +40,6 @@ --diffs-deletion-color-override: var(--color-foreground-danger); --diffs-editor-selection-bg: color-mix(in srgb, Highlight 50%, transparent); - - /* syntax palette — catppuccin latte */ - --diffs-token-comment: #7c7f93; /* overlay2 */ - --diffs-token-string: #40a02b; /* green */ - --diffs-token-string-expression: #40a02b; /* green */ - --diffs-token-keyword: #8839ef; /* mauve */ - --diffs-token-function: #1e66f5; /* blue */ - --diffs-token-constant: #fe640b; /* peach */ - --diffs-token-parameter: #e64553; /* maroon */ - --diffs-token-punctuation: #7c7f93; /* overlay2 */ - --diffs-token-link: #1e66f5; /* blue */ - --diffs-token-inserted: #40a02b; /* green */ - --diffs-token-deleted: #d20f39; /* red */ - --diffs-token-changed: #df8e1d; /* yellow */ } /* a card whose hunks are still on the wire has pierre's reserved height and @@ -77,22 +79,5 @@ --diffs-bg-gutter: var(--color-background-default); --diffs-bg-gutter-wash: var(--color-alpha-dark-5); - - /* catppuccin mocha text (background stays on the app token) */ - --diffs-foreground: #cdd6f4; - - /* syntax palette — catppuccin mocha */ - --diffs-token-comment: #9399b2; /* overlay2 */ - --diffs-token-string: #a6e3a1; /* green */ - --diffs-token-string-expression: #a6e3a1; /* green */ - --diffs-token-keyword: #cba6f7; /* mauve */ - --diffs-token-function: #89b4fa; /* blue */ - --diffs-token-constant: #fab387; /* peach */ - --diffs-token-parameter: #eba0ac; /* maroon */ - --diffs-token-punctuation: #9399b2; /* overlay2 */ - --diffs-token-link: #89b4fa; /* blue */ - --diffs-token-inserted: #a6e3a1; /* green */ - --diffs-token-deleted: #f38ba8; /* red */ - --diffs-token-changed: #f9e2af; /* yellow */ } }