diff --git a/web/.storybook/markupPreview.ts b/web/.storybook/markupPreview.ts deleted file mode 100644 index 98da1caeb..000000000 --- a/web/.storybook/markupPreview.ts +++ /dev/null @@ -1,40 +0,0 @@ -const ENDPOINT = "/_internal/markup"; - -const escape = (text: string): string => - text.replace(/&/g, "&").replace(//g, ">"); - -const render = (source: string, sourceLines: boolean): string => { - const blocks: string[] = []; - let start = 0; - let lines: string[] = []; - const flush = () => { - if (lines.length === 0) return; - const attr = sourceLines ? ` data-line="${start}"` : ""; - const first = lines[0]; - const heading = /^(#{1,6}) (.*)$/.exec(first); - blocks.push( - heading - ? `${escape(heading[2])}` - : `${escape(lines.join(" "))}

` - ); - lines = []; - }; - source.split("\n").forEach((line, index) => { - if (line.trim() === "") return flush(); - if (lines.length === 0) start = index; - lines.push(line); - if (/^#{1,6} /.test(line)) flush(); - }); - flush(); - return `
${blocks.join("\n")}
`; -}; - -export const stubMarkupEndpoint = (): void => { - const original = window.fetch; - window.fetch = async (input, init) => { - const href = input instanceof Request ? input.url : String(input); - if (new URL(href, location.href).pathname !== ENDPOINT) return original(input, init); - const { contents, sourceLines } = JSON.parse(String(init?.body)); - return Response.json({ html: render(contents, sourceLines === true) }); - }; -}; diff --git a/web/.storybook/preview.ts b/web/.storybook/preview.ts index b50c3855c..40ab21e7d 100644 --- a/web/.storybook/preview.ts +++ b/web/.storybook/preview.ts @@ -3,14 +3,11 @@ import { withThemeByDataAttribute } from "@storybook/addon-themes"; import MockAuth, { type MockAuthParam } from "./MockAuth.svelte"; import MockNotifications, { type MockNotificationsParam } from "./MockNotifications.svelte"; import BoneCaptureDecorator from "./BoneCaptureDecorator.svelte"; -import { stubMarkupEndpoint } from "./markupPreview"; // Load the app's global styles (Tailwind + theme tokens) so components // render with real styling inside Storybook. import "../src/app.css"; -stubMarkupEndpoint(); - const preview: Preview = { decorators: [ withThemeByDataAttribute({ diff --git a/web/src/lib/components/comment/CommentEditor.svelte b/web/src/lib/components/comment/CommentEditor.svelte index a85650b23..daf91fc95 100644 --- a/web/src/lib/components/comment/CommentEditor.svelte +++ b/web/src/lib/components/comment/CommentEditor.svelte @@ -18,7 +18,7 @@ import ErrorAlert from "$lib/components/ui/Error.svelte"; import MarkdownEditor from "$lib/components/ui/MarkdownEditor.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte"; - import { previewMarkup, type MarkupContext } from "$lib/markup"; + import { renderPreview, type MarkupContext } from "$lib/markup"; import { createAction } from "$lib/action.svelte"; import type { ThreadInput } from "$lib/components/comment/comments"; @@ -123,7 +123,7 @@ console.error("Failed to auto-subscribe via comment:", err); }); } - const bodyHtml = await previewMarkup(body, markup).catch(() => null); + const bodyHtml = await renderPreview(body, markup).catch(() => null); onsubmitted?.({ comment: { uri: saved.uri, diff --git a/web/src/lib/components/repo/issues/IssueThreadView.svelte b/web/src/lib/components/repo/issues/IssueThreadView.svelte index e43171611..3362cae38 100644 --- a/web/src/lib/components/repo/issues/IssueThreadView.svelte +++ b/web/src/lib/components/repo/issues/IssueThreadView.svelte @@ -49,7 +49,7 @@ import Spinner from "$lib/components/ui/Spinner.svelte"; import Card from "$lib/components/ui/Card.svelte"; import { setMentionPeople, type MentionPerson } from "$lib/components/ui/mentionPeople"; - import { previewMarkup } from "$lib/markup"; + import { renderPreview } from "$lib/markup"; import type { MarkupContext } from "$lib/markup/paths"; interface Props { @@ -117,7 +117,7 @@ if (props.bobbinUrl) { await awaitIndexedOrSaved(createBobbinClient({ serviceUrl: props.bobbinUrl }), written); } - const bodyHtml = body ? await previewMarkup(body, props.markup).catch(() => null) : null; + const bodyHtml = body ? await renderPreview(body, props.markup).catch(() => null) : null; issue = { ...issue, cid: written.cid, title, body, bodyHtml }; }; diff --git a/web/src/lib/components/repo/pulls/PullReviewComment.svelte b/web/src/lib/components/repo/pulls/PullReviewComment.svelte index e5cee5f8b..b30cc8845 100644 --- a/web/src/lib/components/repo/pulls/PullReviewComment.svelte +++ b/web/src/lib/components/repo/pulls/PullReviewComment.svelte @@ -10,7 +10,7 @@ import User from "$lib/components/ui/User.svelte"; import type { MarkupContext } from "$lib/markup"; import { codeblockToolbars } from "$lib/components/ui/codeblockToolbars"; - import { previewMarkup } from "$lib/markup"; + import { renderPreview } from "$lib/markup"; import PullReviewCommentEditor from "$lib/components/repo/pulls/PullReviewCommentEditor.svelte"; import ProfilePopover from "$lib/components/profile/ProfilePopover.svelte"; import { getAuth } from "$lib/auth.svelte"; @@ -40,7 +40,7 @@ const auth = getAuth(); async function submit(body: string) { - const bodyHtml = await previewMarkup(body, markup); + const bodyHtml = await renderPreview(body, markup); await onedit({ ...comment, body, bodyHtml }); editing = false; } diff --git a/web/src/lib/components/ui/MarkdownEditor.svelte b/web/src/lib/components/ui/MarkdownEditor.svelte index 80f1a9a7e..a93c3235e 100644 --- a/web/src/lib/components/ui/MarkdownEditor.svelte +++ b/web/src/lib/components/ui/MarkdownEditor.svelte @@ -118,7 +118,7 @@ scrollEditorToLine, scrollPreviewToLine } from "$lib/components/ui/markdownScroll"; - import { previewMarkup, type MarkupContext } from "$lib/markup"; + import { loadMarkup, renderPreview, type MarkupContext } from "$lib/markup"; import { codeblockToolbars } from "$lib/components/ui/codeblockToolbars"; interface Tool { @@ -288,7 +288,7 @@ const rendered = $derived( preview && hasContent - ? previewMarkup(value, { ...markup, sourceLines: true }).then((html) => html ?? "") + ? renderPreview(value, { ...markup, sourceLines: true }).then((html) => html ?? "") : null ); @@ -514,6 +514,7 @@ oninput={() => typeahead?.refresh()} onclick={() => typeahead?.refresh()} onscroll={() => typeahead?.refresh()} + onfocus={() => void loadMarkup().catch(() => {})} onblur={() => typeahead?.close()} class={classes.field()} /> diff --git a/web/src/lib/server/markup/collapse.test.ts b/web/src/lib/markup/collapse.test.ts similarity index 92% rename from web/src/lib/server/markup/collapse.test.ts rename to web/src/lib/markup/collapse.test.ts index 8c92baeb4..e3c3a0695 100644 --- a/web/src/lib/server/markup/collapse.test.ts +++ b/web/src/lib/markup/collapse.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { COLLAPSED_LINES, collapseCodeblocks } from "$lib/server/markup/collapse"; -import { renderDocument, renderMarkup } from "$lib/server/markup/render"; +import { COLLAPSED_LINES, collapseCodeblocks } from "$lib/markup/collapse"; +import { renderDocument, renderMarkup } from "$lib/markup/render"; import type { MarkupContext } from "$lib/markup/paths"; const ctx: MarkupContext = { repo: "ada.test/infra", ref: "main" }; diff --git a/web/src/lib/server/markup/collapse.ts b/web/src/lib/markup/collapse.ts similarity index 100% rename from web/src/lib/server/markup/collapse.ts rename to web/src/lib/markup/collapse.ts diff --git a/web/src/lib/server/markup/highlight.ts b/web/src/lib/markup/highlight.ts similarity index 100% rename from web/src/lib/server/markup/highlight.ts rename to web/src/lib/markup/highlight.ts diff --git a/web/src/lib/server/markup/markdown.test.ts b/web/src/lib/markup/markdown.test.ts similarity index 99% rename from web/src/lib/server/markup/markdown.test.ts rename to web/src/lib/markup/markdown.test.ts index 35082f913..02a4f4204 100644 --- a/web/src/lib/server/markup/markdown.test.ts +++ b/web/src/lib/markup/markdown.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { isMarkdownFile } from "$lib/markup/format"; -import { renderMarkdown } from "$lib/server/markup/markdown"; +import { renderMarkdown } from "$lib/markup/markdown"; import type { MarkupContext } from "$lib/markup/paths"; const ctx: MarkupContext = { repo: "ada.test/infra", ref: "main", host: "tangled.org" }; diff --git a/web/src/lib/server/markup/markdown.ts b/web/src/lib/markup/markdown.ts similarity index 96% rename from web/src/lib/server/markup/markdown.ts rename to web/src/lib/markup/markdown.ts index 5c417aa3c..0001419d0 100644 --- a/web/src/lib/server/markup/markdown.ts +++ b/web/src/lib/markup/markdown.ts @@ -4,9 +4,9 @@ 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 { collapseCodeblocks } from "$lib/server/markup/collapse"; -import { highlightCodeblocks } from "$lib/server/markup/highlight"; -import { sanitizeMarkup } from "$lib/server/markup/sanitize"; +import { collapseCodeblocks } from "$lib/markup/collapse"; +import { highlightCodeblocks } from "$lib/markup/highlight"; +import { sanitizeMarkup } from "$lib/markup/sanitize"; import type { MarkupContext } from "$lib/markup/paths"; // past this a document renders as plain text, a readme this big is pathological diff --git a/web/src/lib/markup/preview.test.ts b/web/src/lib/markup/preview.test.ts new file mode 100644 index 000000000..783a6f873 --- /dev/null +++ b/web/src/lib/markup/preview.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { renderPreview } from "$lib/markup/preview"; +import { renderMarkup } from "$lib/markup/render"; +import type { MarkupContext } from "$lib/markup/paths"; + +const ctx: MarkupContext = { repo: "ada.test/infra", ref: "main", host: "tangled.org" }; + +describe("renderPreview", () => { + it("renders what the server renders", async () => { + const body = "# hello\n\n- a\n- b\n\n![pic](pic.png)"; + expect(await renderPreview(body, ctx)).toBe(await renderMarkup(body, ctx)); + }); + + it("leaves a body over the source limit unrendered", async () => { + expect(await renderPreview("a".repeat(512 * 1024 + 1), ctx)).toBeNull(); + }); +}); diff --git a/web/src/lib/markup/preview.ts b/web/src/lib/markup/preview.ts index 43edcafa4..b703a6523 100644 --- a/web/src/lib/markup/preview.ts +++ b/web/src/lib/markup/preview.ts @@ -1,15 +1,11 @@ import type { MarkupContext } from "$lib/markup/paths"; -export const previewMarkup = async ( +export const loadMarkup = () => import("$lib/markup/render"); + +export const renderPreview = async ( contents: string, - { repo, ref, dir, sourceLines }: MarkupContext + ctx: MarkupContext ): Promise => { - const response = await fetch("/_internal/markup", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ contents, repo, ref, dir, sourceLines }) - }); - if (!response.ok) throw new Error(`markup render failed: ${response.status}`); - const { html } = (await response.json()) as { html: string | null }; - return html; + const { renderMarkup } = await loadMarkup(); + return renderMarkup(contents, ctx); }; diff --git a/web/src/lib/server/markup/render.test.ts b/web/src/lib/markup/render.test.ts similarity index 88% rename from web/src/lib/server/markup/render.test.ts rename to web/src/lib/markup/render.test.ts index a9052dd4b..db758b490 100644 --- a/web/src/lib/server/markup/render.test.ts +++ b/web/src/lib/markup/render.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { SOURCE_LIMIT } from "$lib/server/markup/markdown"; -import { renderDocument, renderMarkup } from "$lib/server/markup/render"; +import { SOURCE_LIMIT } from "$lib/markup/markdown"; +import { renderDocument, renderMarkup } from "$lib/markup/render"; import type { MarkupContext } from "$lib/markup/paths"; const ctx: MarkupContext = { repo: "ada.test/infra", ref: "main", host: "tangled.org" }; diff --git a/web/src/lib/server/markup/render.ts b/web/src/lib/markup/render.ts similarity index 89% rename from web/src/lib/server/markup/render.ts rename to web/src/lib/markup/render.ts index f84c24cf4..8e6e779f0 100644 --- a/web/src/lib/server/markup/render.ts +++ b/web/src/lib/markup/render.ts @@ -1,6 +1,6 @@ import { isMarkdownFile } from "$lib/markup/format"; import type { MarkupContext } from "$lib/markup/paths"; -import { SOURCE_LIMIT, renderMarkdown } from "$lib/server/markup/markdown"; +import { SOURCE_LIMIT, renderMarkdown } from "$lib/markup/markdown"; const render = async (contents: string, ctx: MarkupContext): Promise => { if (contents.length > SOURCE_LIMIT) return null; diff --git a/web/src/lib/server/markup/sanitize.ts b/web/src/lib/markup/sanitize.ts similarity index 100% rename from web/src/lib/server/markup/sanitize.ts rename to web/src/lib/markup/sanitize.ts diff --git a/web/src/lib/server/blob.ts b/web/src/lib/server/blob.ts index 04bba1c75..26fbe2dbe 100644 --- a/web/src/lib/server/blob.ts +++ b/web/src/lib/server/blob.ts @@ -16,7 +16,7 @@ import { orNull, toHttpError } from "$lib/api/load"; import { toTreeCommitSummary } from "$lib/api/repo"; import type { StringRecord } from "$lib/api/records"; import { isMarkdownFile, type MarkupContext } from "$lib/markup"; -import { renderDocument } from "$lib/server/markup/render"; +import { renderDocument } from "$lib/markup/render"; import { renderReadme, type RepoLoadEvent, type RepoParent } from "$lib/server/repoIndex"; export const loadRepoBlob = async ( diff --git a/web/src/lib/server/comments.ts b/web/src/lib/server/comments.ts index 7f343a907..20a318c3a 100644 --- a/web/src/lib/server/comments.ts +++ b/web/src/lib/server/comments.ts @@ -14,7 +14,7 @@ import { type ThreadInput } from "$lib/components/comment/comments"; import type { MarkupContext } from "$lib/markup/paths"; -import { renderMarkup } from "$lib/server/markup/render"; +import { renderMarkup } from "$lib/markup/render"; export const toThreadInputs = async ( items: CommentListPage["items"], diff --git a/web/src/lib/server/repoIndex.ts b/web/src/lib/server/repoIndex.ts index cd5754b2e..5be7dbd99 100644 --- a/web/src/lib/server/repoIndex.ts +++ b/web/src/lib/server/repoIndex.ts @@ -34,7 +34,7 @@ import { type RepoAvailability } from "$lib/api/repoIndex"; import type { RepoInfo } from "$lib/components/repo/types"; -import { renderDocument } from "$lib/server/markup/render"; +import { renderDocument } from "$lib/markup/render"; export interface RepoParent { publicConfig: { bobbinUrl: string; camoEnabled: boolean }; diff --git a/web/src/routes/[handle]/[repo]/issues/[rkey]/+page.server.ts b/web/src/routes/[handle]/[repo]/issues/[rkey]/+page.server.ts index c54dbca12..2b0de42e8 100644 --- a/web/src/routes/[handle]/[repo]/issues/[rkey]/+page.server.ts +++ b/web/src/routes/[handle]/[repo]/issues/[rkey]/+page.server.ts @@ -11,7 +11,7 @@ import { TICKET_COLLECTION } from "$lib/api/ticket"; import type { RecordView, TicketViewDetailed } from "$lib/api/records"; import { authorDidOf, rkeyFromUri } from "$lib/api/uri"; import { httpStatusFor, settle, stream } from "$lib/api/load"; -import { renderMarkup } from "$lib/server/markup/render"; +import { renderMarkup } from "$lib/markup/render"; import type { MarkupContext } from "$lib/markup/paths"; import { buildCommentThreads, diff --git a/web/src/routes/[handle]/[repo]/pulls/[rkey]/+layout.server.ts b/web/src/routes/[handle]/[repo]/pulls/[rkey]/+layout.server.ts index cf2d9c4fb..2e1fb47d8 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[rkey]/+layout.server.ts +++ b/web/src/routes/[handle]/[repo]/pulls/[rkey]/+layout.server.ts @@ -7,7 +7,7 @@ import { getTicketView, listPatches } from "$lib/api/records"; import { rkeyFromUri } from "$lib/api/uri"; import { early, settle, stream } from "$lib/api/load"; import type { MarkupContext } from "$lib/markup"; -import { renderMarkup } from "$lib/server/markup/render"; +import { renderMarkup } from "$lib/markup/render"; import type { CommentListPage } from "$lib/api/comment"; import { COMMENT_ENRICH, diff --git a/web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/[[range]]/PullViewPage.svelte b/web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/[[range]]/PullViewPage.svelte index d0a7cd234..d92f5b32d 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/[[range]]/PullViewPage.svelte +++ b/web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/[[range]]/PullViewPage.svelte @@ -38,7 +38,7 @@ import ResizeHandle from "$lib/components/ui/ResizeHandle.svelte"; import { getAuth } from "$lib/auth.svelte"; import { getNotifications } from "$lib/notifications.svelte"; - import { previewMarkup } from "$lib/markup"; + import { renderPreview } from "$lib/markup"; import RepoTabs from "$lib/components/repo/RepoTabs.svelte"; import Ticket from "$lib/components/repo/tickets/Ticket.svelte"; import TicketSubscribeButton from "$lib/components/repo/tickets/TicketSubscribeButton.svelte"; @@ -415,7 +415,7 @@ authorHandle: user.handle, createdAt, body, - bodyHtml: await previewMarkup(body, data.markup).catch(() => null) + bodyHtml: await renderPreview(body, data.markup).catch(() => null) } ]; }; @@ -452,7 +452,7 @@ cid: saved.cid, title, body, - bodyHtml: body ? await previewMarkup(body, data.markup).catch(() => null) : null, + bodyHtml: body ? await renderPreview(body, data.markup).catch(() => null) : null, targetBranch: targetBranch ?? pull.targetBranch }; // mockTargetBranch = targetBranch === pull.targetBranch ? undefined : targetBranch; diff --git a/web/src/routes/_internal/markup/+server.ts b/web/src/routes/_internal/markup/+server.ts deleted file mode 100644 index 5e2105d7e..000000000 --- a/web/src/routes/_internal/markup/+server.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { error, json } from "@sveltejs/kit"; -import type { RequestHandler } from "./$types"; -import type { MarkupContext } from "$lib/markup/paths"; -import { getPublicConfig } from "$lib/server/config"; -import { SOURCE_LIMIT } from "$lib/server/markup/markdown"; -import { renderMarkup } from "$lib/server/markup/render"; - -const text = (value: unknown, limit: number): value is string => - typeof value === "string" && value.length <= limit; - -export const POST: RequestHandler = async ({ request, url, platform }) => { - const body: unknown = await request.json().catch(() => null); - if (typeof body !== "object" || body === null) error(400, "Invalid request"); - - const { contents, repo, ref, dir, sourceLines } = body as Record; - if (!text(contents, SOURCE_LIMIT) || !text(repo, 512) || !text(ref, 512)) { - error(400, "Invalid request"); - } - if (dir !== undefined && !text(dir, 4096)) error(400, "Invalid request"); - - const ctx: MarkupContext = { - repo, - ref, - dir, - host: url.host, - camo: getPublicConfig(platform).camoEnabled, - sourceLines: sourceLines === true - }; - return json({ html: await renderMarkup(contents, ctx) }); -}; diff --git a/web/vite.config.ts b/web/vite.config.ts index e8843f69e..7588341f7 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -108,10 +108,10 @@ export default defineConfig({ }) ], // vite only crawls kit routes here, so undeclared story deps would - // optimize mid-run and wedge chromium + // optimize mid-run and wedge chromium; sanitize-html is cjs optimizeDeps: { noDiscovery: true, - include: ["storybook/test", "textarea-caret"] + include: ["storybook/test", "sanitize-html", "textarea-caret"] }, test: { name: "storybook",