diff --git a/web/.storybook/markupPreview.ts b/web/.storybook/markupPreview.ts new file mode 100644 index 000000000..98da1caeb --- /dev/null +++ b/web/.storybook/markupPreview.ts @@ -0,0 +1,40 @@ +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 40ab21e7d..b50c3855c 100644 --- a/web/.storybook/preview.ts +++ b/web/.storybook/preview.ts @@ -3,11 +3,14 @@ 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/api/blob.ts b/web/src/lib/api/blob.ts index ea06f703a..7dae003cd 100644 --- a/web/src/lib/api/blob.ts +++ b/web/src/lib/api/blob.ts @@ -1,11 +1,5 @@ -import { error } from "@sveltejs/kit"; -import { ClientResponseError } from "$lib/api/client"; -import { blob as gitBlob, gitTarget } from "$lib/api/gitclient"; -import { orNull, toHttpError } from "$lib/api/load"; -import { toTreeCommitSummary, type CommitSummary } from "$lib/api/repo"; -import { isMarkdownFile, renderDocument, type MarkupContext } from "$lib/markup"; -import type { StringRecord } from "$lib/api/records"; -import { renderReadme, type RepoLoadEvent, type RepoParent } from "$lib/api/repoIndex"; +import type { CommitSummary } from "$lib/api/repo"; +import { isMarkdownFile } from "$lib/markup"; export type BlobKind = "code" | "markup" | "svg" | "image" | "video" | "submodule" | "other"; @@ -23,7 +17,6 @@ export interface RepoBlobView { defaultView: BlobViewMode; submodule: { name: string; url: string } | null; lastCommit: CommitSummary | null; - prerenderedHTML?: string; } // same 1MiB inline cap as the appview's maxBlobSize @@ -76,111 +69,12 @@ export const hasTextView = (kind: BlobKind): boolean => export const hasRenderedView = (kind: BlobKind): boolean => kind === "markup" || kind === "svg"; // a trailing newline terminates the last line instead of starting a new one -const countLines = (text: string): number => { +export const countLines = (text: string): number => { if (text === "") return 0; const lines = text.split("\n").length; return text.endsWith("\n") ? lines - 1 : lines; }; -export const loadRepoBlob = async ( - event: RepoLoadEvent, - parent: RepoParent, - ref: string, - path: string -): Promise => { - const repo = await parent.repo; - const git = gitTarget(parent.publicConfig, repo, event.fetch); - - // the knot 404s directories and missing paths alike - // knot2's repo_blob answers 413 over its serving limit instead of - // sending size json (knot-xrpc reads.rs) - const output = await gitBlob(git, { ref, path }, MAX_INLINE_SIZE).catch( - (cause: unknown): Awaited> | null => { - if (cause instanceof ClientResponseError && cause.status === 404) { - error(404, `${path} does not exist at ${ref}`); - } - if ( - cause instanceof ClientResponseError && - (cause.status === 413 || cause.error === "BlobTooLarge") - ) { - return null; - } - return toHttpError(cause, "Could not load file"); - } - ); - - if (output === null) { - return { - ref, - path, - kind: "other", - sizeLabel: null, - lines: null, - contents: null, - renderedHtml: null, - fileTooLarge: true, - defaultView: "code", - submodule: null, - lastCommit: null - }; - } - - const kind = classifyBlob(output); - const size = output.size ?? null; - const fileTooLarge = - output.fileTooLarge === true || - (hasTextView(kind) && size !== null && size > MAX_INLINE_SIZE); - - const contents = - hasTextView(kind) && !fileTooLarge && output.encoding === "utf-8" - ? (output.content ?? null) - : null; - - const renderedHtml = - kind === "markup" && contents !== null - ? await renderReadme( - { filename: path, contents }, - repo, - parent.publicConfig, - event, - ref, - path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : undefined - ) - : null; - - // renderReadme gives up over its source limit, fall back to code - const viewKind = kind === "markup" && renderedHtml === null ? "code" : kind; - - const lastCommit = output.lastCommit - ? toTreeCommitSummary({ - hash: output.lastCommit.hash, - message: output.lastCommit.message, - when: output.lastCommit.when, - author: output.lastCommit.author - }) - : null; - - return { - ref, - path, - kind: viewKind, - sizeLabel: size === null ? null : formatBytes(size), - lines: contents !== null ? countLines(contents) : null, - contents, - renderedHtml, - fileTooLarge, - defaultView: hasRenderedView(viewKind) - ? event.url.searchParams.has("code") - ? "code" - : "rendered" - : "code", - submodule: output.submodule - ? { name: output.submodule.name, url: output.submodule.url } - : null, - lastCommit - }; -}; - const textEncoder = new TextEncoder(); export const contentStats = ( @@ -189,40 +83,3 @@ export const contentStats = ( const bytes = textEncoder.encode(contents).length; return { lines: countLines(contents), bytes, sizeLabel: formatBytes(bytes) }; }; - -export interface StringBlobSource { - rkey: string; - code: boolean; -} - -export const loadStringBlob = async ( - record: StringRecord, - contents: string, - { rkey, code }: StringBlobSource, - markup: MarkupContext -): Promise => { - const filename = record.snippet.name; - const stats = contentStats(contents); - const fileTooLarge = stats.bytes > MAX_INLINE_SIZE; - const text = fileTooLarge ? null : contents; - const isMarkdown = isMarkdownFile(filename); - - const renderedHtml = - text !== null && isMarkdown ? await orNull(renderDocument(filename, text, markup)) : null; - const kind = isMarkdown && renderedHtml !== null ? "markup" : "code"; - const defaultView = kind === "markup" && !code ? "rendered" : "code"; - - return { - ref: rkey, - path: filename, - kind, - sizeLabel: stats.sizeLabel, - lines: text === null ? null : stats.lines, - contents: text, - renderedHtml, - fileTooLarge, - defaultView, - submodule: null, - lastCommit: null - }; -}; diff --git a/web/src/lib/api/load.ts b/web/src/lib/api/load.ts index e30852461..300316f89 100644 --- a/web/src/lib/api/load.ts +++ b/web/src/lib/api/load.ts @@ -1,5 +1,5 @@ import { browser } from "$app/environment"; -import { error, type NumericRange } from "@sveltejs/kit"; +import { error, type NumericRange, type RequestEvent } from "@sveltejs/kit"; import { ClientResponseError } from "$lib/api/client"; export const httpStatusFor = (cause: unknown): number => { @@ -67,6 +67,20 @@ const patience = (): number => { return Math.max(0, windowEnds - now); }; +type DataEvent = Pick; + +const serverWindows = new WeakMap(); + +const serverPatience = ({ request }: DataEvent): number => { + const now = performance.now(); + let ends = serverWindows.get(request); + if (ends === undefined || now - ends > PATIENCE_MS) { + ends = now + PATIENCE_MS; + serverWindows.set(request, ends); + } + return Math.max(0, ends - now); +}; + type Held = { value: T } | { promise: Promise }; const within = (promise: Promise, left: number): Promise> => @@ -90,10 +104,13 @@ export type Settled = { }; // the navigation waits on these, so the page it is leaving stays until the next one can paint -export const settle = async >(data: T): Promise> => { - if (!browser) return data as Settled; +export const settle = async >( + data: T, + event?: DataEvent +): Promise> => { + if (!browser && !event?.isDataRequest) return data as Settled; - const left = patience(); + const left = event ? serverPatience(event) : patience(); const entries = await Promise.all( Object.entries(data).map(async ([key, value]) => { @@ -106,6 +123,17 @@ export const settle = async >(data: T): Promis return Object.fromEntries(entries) as Settled; }; +export const early = async (event: DataEvent, promise: Promise): Promise => { + if (!event.isDataRequest) { + await promise; + return; + } + await Promise.race([ + promise, + new Promise((resolve) => setTimeout(resolve, serverPatience(event))) + ]); +}; + // per-request promise de-dupe cache. export interface RequestCache { run(key: string, load: () => Promise): Promise; diff --git a/web/src/lib/api/repoIndex.ts b/web/src/lib/api/repoIndex.ts index b45793361..307f5e674 100644 --- a/web/src/lib/api/repoIndex.ts +++ b/web/src/lib/api/repoIndex.ts @@ -1,31 +1,10 @@ -import { error } from "@sveltejs/kit"; import { ClientResponseError, createBobbinClient } from "$lib/api/client"; import { resolveEmailToDid } from "$lib/api/emailDid"; import { resolveMiniDoc } from "$lib/api/identity"; -import { - branch as gitBranch, - branches as gitBranches, - gitTarget, - type GitTarget, - languages as gitLanguages, - log as gitLog, - tags as gitTags, - tree as gitTree -} from "$lib/api/gitclient"; -import { toMirrorCommitSummary } from "$lib/api/gitmirror"; -import { orNull, parallel, stream } from "$lib/api/load"; -import { - branchSummaries, - sortTreeEntries, - tagsByCommitHash, - toTagSummary, - toTreeCommitSummary, - toTreeEntrySummary -} from "$lib/api/repo"; -import { importState, type ImportState } from "$lib/api/repo"; -import { renderDocument } from "$lib/markup"; -import type { CommitSummary, LanguageSlice, RepoInfo } from "$lib/components/repo/types"; -// `/tree/{ref}` is this same page at another ref, so they share a load +import { branches as gitBranches, type GitTarget } from "$lib/api/gitclient"; +import { orNull } from "$lib/api/load"; +import { branchSummaries, type ImportState } from "$lib/api/repo"; +import type { CommitSummary, LanguageSlice } from "$lib/components/repo/types"; export const COMMIT_LIMIT = 10; export const BRANCH_LIMIT = 5; @@ -33,22 +12,12 @@ export const TAG_LIMIT = 5; // a knot only ever lists 100 refs, so any total we get is really a minimum export const REF_LIMIT = 100; -export interface RepoParent { - publicConfig: { bobbinUrl: string; camoEnabled: boolean }; - repo: RepoInfo | Promise; -} - -export interface RepoLoadEvent { - fetch: typeof globalThis.fetch; - url: URL; -} - -interface Attempt { +export interface Attempt { value: T | null; error: unknown | null; } -const attempt = (promise: Promise): Promise> => +export const attempt = (promise: Promise): Promise> => promise.then( (value) => ({ value, error: null }), (error) => ({ value: null, error }) @@ -106,40 +75,6 @@ export const toLanguageSlices = (languages: { name: string; size: number }[]): L export const repoBranches = async (git: GitTarget) => branchSummaries(await gitBranches(git, REF_LIMIT)); -const refNames = (branches: { name: string }[], tags: { name: string }[]) => ({ - branches: branches.map((branch) => branch.name), - tags: tags.map((tag) => tag.name) -}); - -export const renderReadme = ( - readme: { filename: string; contents: string } | null, - repo: RepoInfo, - publicConfig: { camoEnabled: boolean }, - event: RepoLoadEvent, - ref: string, - dir?: string -) => - readme - ? renderDocument(readme.filename, readme.contents, { - repo: `${repo.ownerHandle}/${repo.name}`, - ref, - dir, - host: event.url.host, - camo: publicConfig.camoEnabled - }) - : Promise.resolve(null); - -// the knot sends a readme with empty fields when a directory has none -const readmeOf = (tree: { readme?: { filename: string; contents: string } } | null) => { - const readme = tree?.readme; - return readme?.filename ? readme : null; -}; - -export interface RepoIndexOptions { - requireRef?: boolean; - importStatus?: boolean; -} - // for the handle and the row reads like the rest of the site. a plain git // email that belongs to a verified signup resolves to that same did first, so // those rows get the identical treatment. @@ -178,132 +113,3 @@ export const withAuthorHandles = async ( commit.authorDid ? { ...commit, authorHandle: handles.get(commit.authorDid) } : commit ); }; - -export const loadRepoIndex = ( - event: RepoLoadEvent, - parent: RepoParent, - ref?: string, - // a ref from the url has to resolve or a typo looks like a repo with no - // files. the default branch renders whatever the knot managed to answer - { requireRef = false, importStatus: withImportStatus = false }: RepoIndexOptions = {} -) => { - const repoP = Promise.resolve(parent.repo); - - // each list falls back on its own, so half a page still renders - const content = repoP.then(async (repo) => { - const targetRef = ref ?? (await repo.defaultBranch); - const git = gitTarget(parent.publicConfig, repo, event.fetch); - - const results = await parallel({ - tree: attempt(gitTree(git, { ref: targetRef })), - log: attempt(gitLog(git, { ref: targetRef, limit: COMMIT_LIMIT })), - branch: attempt(gitBranch(git, targetRef)), - branches: attempt(gitBranches(git, REF_LIMIT)), - tags: attempt(gitTags(git, REF_LIMIT)) - }); - - const branches = results.branches.value ? branchSummaries(results.branches.value) : []; - const tags = (results.tags.value?.tags ?? []).map(toTagSummary); - const commits = await withAuthorHandles( - (results.log.value?.commits ?? []).map(toMirrorCommitSummary), - parent.publicConfig.bobbinUrl, - event.fetch - ); - const files = sortTreeEntries((results.tree.value?.files ?? []).map(toTreeEntrySummary)); - - const contentAttempts = [results.tree, results.log, results.branches]; - const knot = classifyRepoAvailability(contentAttempts); - const availability: RepoAvailability = - knot === "ok" && files.length === 0 && branches.length === 0 ? "empty" : knot; - - // there are refs but not this one, so it is not a real ref. an empty repo has - // no refs at all and still gets a page - if (requireRef && results.tree.value === null && branches.length > 0) { - error(404, `${targetRef} does not exist in this repository`); - } - - return { - ref: targetRef, - availability, - files, - readme: readmeOf(results.tree.value), - commits, - tagsByCommit: tagsByCommitHash(commits, tags), - totalCommits: results.branch.value?.totalCommits ?? commits.length, - branches: branches.slice(0, BRANCH_LIMIT), - totalBranches: results.branches.value?.total ?? branches.length, - tags: tags.slice(0, TAG_LIMIT), - totalTags: results.tags.value?.total ?? tags.length, - // the switcher needs every ref, not just the visible slice - refs: refNames(branches, tags) - }; - }); - - const languages = repoP.then(async (repo) => { - const targetRef = ref ?? (await repo.defaultBranch); - const git = gitTarget(parent.publicConfig, repo, event.fetch); - const result = await attempt(gitLanguages(git, targetRef)); - return toLanguageSlices(result.value?.languages ?? []); - }); - - const readmeHtml = Promise.all([repoP, content]).then(([repo, resolved]) => - renderReadme(resolved.readme, repo, parent.publicConfig, event, resolved.ref) - ); - - const importStatusP = withImportStatus - ? repoP.then(async (repo): Promise => { - if (!repo.importSource) return null; - const ctx = createBobbinClient({ - serviceUrl: parent.publicConfig.bobbinUrl, - fetch: event.fetch - }); - const state = await orNull(importState(ctx, repo)); - return state ? { ...state, source: repo.importSource } : null; - }) - : Promise.resolve(null); - - return { - ref: ref ?? "", - content: stream(content), - languages: stream(languages), - readmeHtml: stream(readmeHtml), - importStatus: stream(importStatusP) - }; -}; - -export const loadRepoTree = ( - event: RepoLoadEvent, - parent: RepoParent, - ref: string, - path: string -) => { - const repoP = Promise.resolve(parent.repo); - - const tree = repoP.then(async (repo) => { - const git = gitTarget(parent.publicConfig, repo, event.fetch); - const result = await orNull(gitTree(git, { ref, path })); - const files = sortTreeEntries((result?.files ?? []).map(toTreeEntrySummary)); - // git cannot store an empty directory. so nothing here means the path is a - // file, or was never there - if (result === null || files.length === 0) { - error(404, `${path} does not exist at ${ref}`); - } - - return { - files, - readme: readmeOf(result), - lastCommit: result.lastCommit ? toTreeCommitSummary(result.lastCommit) : null - }; - }); - - const readmeHtml = Promise.all([repoP, tree]).then(([repo, resolved]) => - renderReadme(resolved.readme, repo, parent.publicConfig, event, ref, path) - ); - - return { - ref, - path, - tree: stream(tree), - readmeHtml: stream(readmeHtml) - }; -}; diff --git a/web/src/lib/components/comment/Comment.svelte b/web/src/lib/components/comment/Comment.svelte index 94c8ad3f8..aab06d7af 100644 --- a/web/src/lib/components/comment/Comment.svelte +++ b/web/src/lib/components/comment/Comment.svelte @@ -48,7 +48,6 @@ import User from "$lib/components/ui/User.svelte"; import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; import Reactions from "$lib/components/reaction/Reactions.svelte"; - import { enhanceCodeblocks } from "$lib/markup/highlight"; import { codeblockToolbars } from "$lib/components/ui/codeblockToolbars"; import type { ReactionGroup } from "$lib/components/reaction/reactions"; @@ -132,7 +131,6 @@
diff --git a/web/src/lib/components/comment/CommentEditor.svelte b/web/src/lib/components/comment/CommentEditor.svelte index b2a1d05e7..a85650b23 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 { renderMarkup, type MarkupContext } from "$lib/markup"; + import { previewMarkup, type MarkupContext } from "$lib/markup"; import { createAction } from "$lib/action.svelte"; import type { ThreadInput } from "$lib/components/comment/comments"; @@ -123,8 +123,7 @@ console.error("Failed to auto-subscribe via comment:", err); }); } - // render markdown client-side so the optimistic comment matches a real one - const bodyHtml = await renderMarkup(body, markup).catch(() => null); + const bodyHtml = await previewMarkup(body, markup).catch(() => null); onsubmitted?.({ comment: { uri: saved.uri, diff --git a/web/src/lib/components/comment/comments.ts b/web/src/lib/components/comment/comments.ts index d89f9bd51..64a714630 100644 --- a/web/src/lib/components/comment/comments.ts +++ b/web/src/lib/components/comment/comments.ts @@ -5,10 +5,8 @@ import { COMMENT_AUTHOR_DOCS } from "$lib/api/descriptors"; import { enrich, handleOf, target, type Sidecar } from "$lib/api/enrich"; import { COMMENT_COLLECTION } from "$lib/api/comment"; import { moderationTargets, subjectModeration, type Moderation } from "$lib/api/moderationLabels"; -import { INVALID_HANDLE } from "$lib/api/identity"; import { orNull } from "$lib/api/load"; import { didFromUri, rkeyFromUri } from "$lib/api/uri"; -import { renderMarkup, type MarkupContext } from "$lib/markup"; import type { CommitSummary } from "$lib/api/repo"; import type { ReactionGroup } from "$lib/components/reaction/reactions"; @@ -175,35 +173,6 @@ export const commentModerationOf = (docs: Sidecar, item: CommentRef): Moderation author: commentAuthorOf(docs, item).did }); -export const toThreadInputs = async ( - items: CommentListPage["items"], - docs: Sidecar, - markupCtx: MarkupContext, - extra?: (uri: string) => Partial -): Promise => - Promise.all( - items.map(async (item): Promise => { - const author = commentAuthorOf(docs, item); - const body = item.body?.text ?? ""; - return { - comment: { - uri: item.uri, - cid: item.cid, - rkey: rkeyFromUri(item.uri), - authorDid: author.did, - authorHandle: author.handle ?? INVALID_HANDLE, - moderation: commentModerationOf(docs, item), - createdAt: item.createdAt, - body, - bodyHtml: body ? await orNull(renderMarkup(body, markupCtx)) : null, - ...extra?.(item.uri) - }, - replyTo: item.replyTo?.uri ?? null, - replyToCid: item.replyTo?.cid - }; - }) - ); - export interface CommentItems { items: CommentListPage["items"]; docs: Sidecar; @@ -224,16 +193,6 @@ export const listCommentItems = async ( return { items: result?.output?.items ?? [], docs: result?.data ?? {} }; }; -export const loadCommentThreads = async ( - ctx: BobbinContext, - subjectUri: string, - markupCtx: MarkupContext, - extra?: (uri: string) => Partial -): Promise => { - const { items, docs } = await listCommentItems(ctx, subjectUri); - return buildCommentThreads(await toThreadInputs(items, docs, markupCtx, extra)); -}; - export const insertComment = ( threads: CommentThread[], { comment, replyTo }: ThreadInput diff --git a/web/src/lib/components/repo/BlobView.svelte b/web/src/lib/components/repo/BlobView.svelte index 0a6f7e755..a73c07bc3 100644 --- a/web/src/lib/components/repo/BlobView.svelte +++ b/web/src/lib/components/repo/BlobView.svelte @@ -5,7 +5,6 @@ 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"; @@ -186,11 +185,7 @@ {:else if blob.kind === "markup" && view === "rendered" && blob.renderedHtml !== null} -
+
{@html blob.renderedHtml}
@@ -200,7 +195,6 @@ name={fileName} contents={blob.contents} {wrap} - prerenderedHTML={blob.prerenderedHTML} {onPostRender} blame={blame ? blameRequest.runs : undefined} /> diff --git a/web/src/lib/components/repo/PierreFile.svelte b/web/src/lib/components/repo/PierreFile.svelte index fdcd0940e..7bac95789 100644 --- a/web/src/lib/components/repo/PierreFile.svelte +++ b/web/src/lib/components/repo/PierreFile.svelte @@ -1,7 +1,12 @@ {#if bodyHtml} -
+
{@html bodyHtml}
diff --git a/web/src/lib/components/ui/MarkdownEditor.svelte b/web/src/lib/components/ui/MarkdownEditor.svelte index 19a8af811..80f1a9a7e 100644 --- a/web/src/lib/components/ui/MarkdownEditor.svelte +++ b/web/src/lib/components/ui/MarkdownEditor.svelte @@ -118,8 +118,7 @@ scrollEditorToLine, scrollPreviewToLine } from "$lib/components/ui/markdownScroll"; - import { renderMarkup, type MarkupContext } from "$lib/markup"; - import { enhanceCodeblocks } from "$lib/markup/highlight"; + import { previewMarkup, type MarkupContext } from "$lib/markup"; import { codeblockToolbars } from "$lib/components/ui/codeblockToolbars"; interface Tool { @@ -289,7 +288,7 @@ const rendered = $derived( preview && hasContent - ? renderMarkup(value, { ...markup, sourceLines: true }).then((html) => html ?? "") + ? previewMarkup(value, { ...markup, sourceLines: true }).then((html) => html ?? "") : null ); @@ -542,7 +541,6 @@
diff --git a/web/src/lib/markup/index.ts b/web/src/lib/markup/index.ts index 5fe339303..65932a8ff 100644 --- a/web/src/lib/markup/index.ts +++ b/web/src/lib/markup/index.ts @@ -1,3 +1,3 @@ export * from "$lib/markup/format"; export * from "$lib/markup/paths"; -export * from "$lib/markup/render"; +export * from "$lib/markup/preview"; diff --git a/web/src/lib/markup/preview.ts b/web/src/lib/markup/preview.ts new file mode 100644 index 000000000..43edcafa4 --- /dev/null +++ b/web/src/lib/markup/preview.ts @@ -0,0 +1,15 @@ +import type { MarkupContext } from "$lib/markup/paths"; + +export const previewMarkup = async ( + contents: string, + { repo, ref, dir, sourceLines }: 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; +}; diff --git a/web/src/lib/markup/render.test.ts b/web/src/lib/markup/render.test.ts deleted file mode 100644 index 966d0cde9..000000000 --- a/web/src/lib/markup/render.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; -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" }; - -const keyOf = (html: string | null): string => { - const key = /^
/.exec(html ?? "")?.[1]; - if (!key) throw new Error(`no markup key in ${html}`); - return key; -}; - -describe("renderDocument", () => { - it("wraps what it renders under a key", async () => { - const html = await renderDocument("README.md", "# hello", ctx); - expect(keyOf(html)).toMatch(/^[0-9a-z]+\.[0-9a-z]+\.[0-9a-z]+$/); - expect(html).toContain(" { - expect(await renderDocument("notes.txt", "# hello", ctx)).toBeNull(); - }); - - it("keys the same contents rendered elsewhere apart", async () => { - const here = keyOf(await renderDocument("README.md", "see [x](y)", ctx)); - const elsewhere = keyOf( - await renderDocument("README.md", "see [x](y)", { ...ctx, ref: "dev" }) - ); - const copied = keyOf(await renderDocument("docs/README.md", "see [x](y)", ctx)); - expect(elsewhere).not.toBe(here); - expect(copied).not.toBe(here); - }); - - it("keys different contents apart", async () => { - expect(keyOf(await renderDocument("README.md", "# a", ctx))).not.toBe( - keyOf(await renderDocument("README.md", "# b", ctx)) - ); - }); - - it("gives the same contents the same key every time", async () => { - expect(keyOf(await renderDocument("README.md", "# a", ctx))).toBe( - keyOf(await renderDocument("README.md", "# a", ctx)) - ); - }); -}); - -describe("renderMarkup", () => { - // a body and a file with the same contents need different keys - it("wraps a body under a key of its own", async () => { - const html = await renderMarkup("a **body**", ctx); - expect(html).toContain("body"); - expect(keyOf(html)).not.toBe(keyOf(await renderDocument("README.md", "a **body**", ctx))); - }); -}); diff --git a/web/src/lib/markup/render.ts b/web/src/lib/markup/render.ts deleted file mode 100644 index 6adf707c5..000000000 --- a/web/src/lib/markup/render.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { browser } from "$app/environment"; -import { isMarkdownFile } from "$lib/markup/format"; -import type { MarkupContext } from "$lib/markup/paths"; - -// hydration re-runs every load, so the browser hands back the server's render -// instead of loading the markdown pipeline again - -const MARKUP_KEY = "data-markup-key"; - -// the key is only compared against markup already on this page, so stable and -// short is enough, not cryptographic -const digest = (value: string): string => { - let hash = 5381; - for (let i = 0; i < value.length; i += 1) hash = (hash * 33) ^ value.charCodeAt(i); - return (hash >>> 0).toString(36); -}; - -const keyOf = (filename: string, contents: string, ctx: MarkupContext): string => - [ - digest( - [ - ctx.host ?? "", - ctx.repo, - ctx.ref, - ctx.dir ?? "", - String(!!ctx.sourceLines), - String(!!ctx.collapseCode), - filename - ].join("\n") - ), - contents.length.toString(36), - digest(contents) - ].join("."); - -const mark = (key: string, html: string): string => `
${html}
`; - -// reads back a render this module wrote for the same contents and context -const rendered = (key: string): string | null => { - if (!browser) return null; - const node = document.querySelector(`[${MARKUP_KEY}="${key}"]`); - return node instanceof HTMLElement ? node.innerHTML : null; -}; - -const render = async ( - key: string, - contents: string, - ctx: MarkupContext -): Promise => { - const reused = rendered(key); - if (reused !== null) return mark(key, reused); - - const { SOURCE_LIMIT, renderMarkdown } = await import("./markdown"); - if (contents.length > SOURCE_LIMIT) return null; - const html = await renderMarkdown(contents, ctx); - return html === null ? null : mark(key, html); -}; - -export const renderDocument = async ( - filename: string, - contents: string, - ctx: MarkupContext -): Promise => - isMarkdownFile(filename) ? render(keyOf(filename, contents, ctx), contents, ctx) : null; - -// like renderDocument, but for content that is always markdown (issue and -// comment bodies) rather than a repo file with an extension to sniff -export const renderMarkup = (contents: string, ctx: MarkupContext): Promise => { - const body = { ...ctx, collapseCode: true }; - return render(keyOf("", contents, body), contents, body); -}; diff --git a/web/src/lib/server/blob.ts b/web/src/lib/server/blob.ts new file mode 100644 index 000000000..f078e60b6 --- /dev/null +++ b/web/src/lib/server/blob.ts @@ -0,0 +1,155 @@ +import { error } from "@sveltejs/kit"; +import { + classifyBlob, + contentStats, + countLines, + formatBytes, + hasRenderedView, + hasTextView, + MAX_INLINE_SIZE, + type RepoBlobView +} from "$lib/api/blob"; +import { ClientResponseError } from "$lib/api/client"; +import { blob as gitBlob, gitTarget } from "$lib/api/gitclient"; +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 { renderReadme, type RepoLoadEvent, type RepoParent } from "$lib/server/repoIndex"; + +export const loadRepoBlob = async ( + event: RepoLoadEvent, + parent: RepoParent, + ref: string, + path: string +): Promise => { + const repo = await parent.repo; + const git = gitTarget(parent.publicConfig, repo, event.fetch); + + // the knot 404s directories and missing paths alike + // knot2's repo_blob answers 413 over its serving limit instead of + // sending size json (knot-xrpc reads.rs) + const output = await gitBlob(git, { ref, path }, MAX_INLINE_SIZE).catch( + (cause: unknown): Awaited> | null => { + if (cause instanceof ClientResponseError && cause.status === 404) { + error(404, `${path} does not exist at ${ref}`); + } + if ( + cause instanceof ClientResponseError && + (cause.status === 413 || cause.error === "BlobTooLarge") + ) { + return null; + } + return toHttpError(cause, "Could not load file"); + } + ); + + if (output === null) { + return { + ref, + path, + kind: "other", + sizeLabel: null, + lines: null, + contents: null, + renderedHtml: null, + fileTooLarge: true, + defaultView: "code", + submodule: null, + lastCommit: null + }; + } + + const kind = classifyBlob(output); + const size = output.size ?? null; + const fileTooLarge = + output.fileTooLarge === true || + (hasTextView(kind) && size !== null && size > MAX_INLINE_SIZE); + + const contents = + hasTextView(kind) && !fileTooLarge && output.encoding === "utf-8" + ? (output.content ?? null) + : null; + + const renderedHtml = + kind === "markup" && contents !== null + ? await renderReadme( + { filename: path, contents }, + repo, + parent.publicConfig, + event, + ref, + path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : undefined + ) + : null; + + // renderReadme gives up over its source limit, fall back to code + const viewKind = kind === "markup" && renderedHtml === null ? "code" : kind; + + const lastCommit = output.lastCommit + ? toTreeCommitSummary({ + hash: output.lastCommit.hash, + message: output.lastCommit.message, + when: output.lastCommit.when, + author: output.lastCommit.author + }) + : null; + + return { + ref, + path, + kind: viewKind, + sizeLabel: size === null ? null : formatBytes(size), + lines: contents !== null ? countLines(contents) : null, + contents, + renderedHtml, + fileTooLarge, + defaultView: hasRenderedView(viewKind) + ? event.url.searchParams.has("code") + ? "code" + : "rendered" + : "code", + submodule: output.submodule + ? { name: output.submodule.name, url: output.submodule.url } + : null, + lastCommit + }; +}; + +export interface StringBlobSource { + rkey: string; + code: boolean; +} + +export const loadStringBlob = async ( + record: StringRecord, + contents: string, + { rkey, code }: StringBlobSource, + markup: MarkupContext +): Promise => { + const filename = record.snippet.name; + const stats = contentStats(contents); + const fileTooLarge = stats.bytes > MAX_INLINE_SIZE; + const text = fileTooLarge ? null : contents; + const isMarkdown = isMarkdownFile(filename); + + const renderedHtml = + text !== null && isMarkdown ? await orNull(renderDocument(filename, text, markup)) : null; + const kind = isMarkdown && renderedHtml !== null ? "markup" : "code"; + const defaultView = kind === "markup" && !code ? "rendered" : "code"; + + return { + ref: rkey, + path: filename, + kind, + sizeLabel: stats.sizeLabel, + lines: text === null ? null : stats.lines, + contents: text, + renderedHtml, + fileTooLarge, + defaultView, + submodule: null, + lastCommit: null + }; +}; diff --git a/web/src/lib/server/comments.ts b/web/src/lib/server/comments.ts new file mode 100644 index 000000000..7f343a907 --- /dev/null +++ b/web/src/lib/server/comments.ts @@ -0,0 +1,56 @@ +import type { CommentListPage } from "$lib/api/comment"; +import type { BobbinContext } from "$lib/api/client"; +import { INVALID_HANDLE } from "$lib/api/identity"; +import { orNull } from "$lib/api/load"; +import type { Sidecar } from "$lib/api/enrich"; +import { rkeyFromUri } from "$lib/api/uri"; +import { + buildCommentThreads, + commentAuthorOf, + commentModerationOf, + listCommentItems, + type CommentThread, + type CommentView, + type ThreadInput +} from "$lib/components/comment/comments"; +import type { MarkupContext } from "$lib/markup/paths"; +import { renderMarkup } from "$lib/server/markup/render"; + +export const toThreadInputs = async ( + items: CommentListPage["items"], + docs: Sidecar, + markupCtx: MarkupContext, + extra?: (uri: string) => Partial +): Promise => + Promise.all( + items.map(async (item): Promise => { + const author = commentAuthorOf(docs, item); + const body = item.body?.text ?? ""; + return { + comment: { + uri: item.uri, + cid: item.cid, + rkey: rkeyFromUri(item.uri), + authorDid: author.did, + authorHandle: author.handle ?? INVALID_HANDLE, + moderation: commentModerationOf(docs, item), + createdAt: item.createdAt, + body, + bodyHtml: body ? await orNull(renderMarkup(body, markupCtx)) : null, + ...extra?.(item.uri) + }, + replyTo: item.replyTo?.uri ?? null, + replyToCid: item.replyTo?.cid + }; + }) + ); + +export const loadCommentThreads = async ( + ctx: BobbinContext, + subjectUri: string, + markupCtx: MarkupContext, + extra?: (uri: string) => Partial +): Promise => { + const { items, docs } = await listCommentItems(ctx, subjectUri); + return buildCommentThreads(await toThreadInputs(items, docs, markupCtx, extra)); +}; diff --git a/web/src/lib/markup/collapse.test.ts b/web/src/lib/server/markup/collapse.test.ts similarity index 92% rename from web/src/lib/markup/collapse.test.ts rename to web/src/lib/server/markup/collapse.test.ts index e3c3a0695..8c92baeb4 100644 --- a/web/src/lib/markup/collapse.test.ts +++ b/web/src/lib/server/markup/collapse.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { COLLAPSED_LINES, collapseCodeblocks } from "$lib/markup/collapse"; -import { renderDocument, renderMarkup } from "$lib/markup/render"; +import { COLLAPSED_LINES, collapseCodeblocks } from "$lib/server/markup/collapse"; +import { renderDocument, renderMarkup } from "$lib/server/markup/render"; import type { MarkupContext } from "$lib/markup/paths"; const ctx: MarkupContext = { repo: "ada.test/infra", ref: "main" }; diff --git a/web/src/lib/markup/collapse.ts b/web/src/lib/server/markup/collapse.ts similarity index 100% rename from web/src/lib/markup/collapse.ts rename to web/src/lib/server/markup/collapse.ts diff --git a/web/src/lib/markup/highlight.ts b/web/src/lib/server/markup/highlight.ts similarity index 51% rename from web/src/lib/markup/highlight.ts rename to web/src/lib/server/markup/highlight.ts index 365eb6af8..323e75168 100644 --- a/web/src/lib/markup/highlight.ts +++ b/web/src/lib/server/markup/highlight.ts @@ -1,10 +1,7 @@ -import type { Action } from "svelte/action"; +const BLOCK = + /
([\s\S]*?)<\/code><\/pre>/g;
 
-const BLOCK = /
([\s\S]*?)<\/code><\/pre>/g;
-
-const MAX_SSR_BLOCK_CHARS = 50_000;
-
-const MAX_CSR_BLOCK_CHARS = 200_000;
+const MAX_BLOCK_CHARS = 200_000;
 
 const ENTITIES: Record = {
 	amp: "&",
@@ -68,9 +65,17 @@ export const highlightCodeblocks = async (html: string): Promise => {
 	if (found.length === 0) return html;
 
 	const blocks = found.flatMap((match) => {
-		const [raw, lang, escaped] = match as unknown as [string, string, string];
+		const [raw, before, lang, after, escaped] = match as unknown as [
+			string,
+			string,
+			string,
+			string,
+			string
+		];
 		const code = decodeEntities(escaped);
-		return code.length > 0 && code.length <= MAX_SSR_BLOCK_CHARS ? [{ raw, lang, code }] : [];
+		return code.length > 0 && code.length <= MAX_BLOCK_CHARS
+			? [{ raw, before, lang, after, code }]
+			: [];
 	});
 	if (blocks.length === 0) return html;
 
@@ -84,63 +89,9 @@ export const highlightCodeblocks = async (html: string): Promise => {
 		if (inner !== null)
 			swapped.set(
 				block.raw,
-				`
${inner}
` + `
${inner}
` ); } if (swapped.size === 0) return html; return html.replace(BLOCK, (raw) => swapped.get(raw) ?? raw); }; - -const LANG = /(?:^|\s)language-([^\s]+)/; - -const idle = (): Promise => - new Promise((resolve) => { - const ric = ( - globalThis as { - requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => void; - } - ).requestIdleCallback; - if (typeof ric === "function") ric(() => resolve(), { timeout: 1500 }); - else setTimeout(resolve, 0); - }); - -export const enhanceCodeblocks: Action = (node) => { - let cancelled = false; - - const run = async (): Promise => { - const plain = (el: Element): { code: HTMLElement; text: string } | null => { - const code = el.firstElementChild; - if (!(code instanceof HTMLElement) || code.querySelector("span") !== null) return null; - const text = code.textContent ?? ""; - return text.length > 0 && text.length <= MAX_CSR_BLOCK_CHARS ? { code, text } : null; - }; - const targets = [...node.querySelectorAll("pre")].flatMap((pre) => { - const found = plain(pre); - if (!found) return []; - const lang = LANG.exec(found.code.className)?.[1]; - return lang ? [{ ...found, lang }] : []; - }); - if (targets.length === 0) return; - - await idle(); - if (cancelled || !node.isConnected) return; - const loaded = await loadHighlighter([...new Set(targets.map((target) => target.lang))]); - if (!loaded || cancelled) return; - - for (const target of targets) { - if (cancelled || !target.code.isConnected) continue; - // a re-render may have highlighted it since - if (target.code.querySelector("span") !== null) continue; - const inner = highlightInner(loaded, target.text, target.lang); - if (inner !== null) target.code.innerHTML = inner; - } - }; - - void run(); - return { - update: () => void run(), - destroy: () => { - cancelled = true; - } - }; -}; diff --git a/web/src/lib/markup/markdown.test.ts b/web/src/lib/server/markup/markdown.test.ts similarity index 92% rename from web/src/lib/markup/markdown.test.ts rename to web/src/lib/server/markup/markdown.test.ts index d8af10cec..35082f913 100644 --- a/web/src/lib/markup/markdown.test.ts +++ b/web/src/lib/server/markup/markdown.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { isMarkdownFile } from "$lib/markup/format"; -import { renderMarkdown } from "$lib/markup/markdown"; +import { renderMarkdown } from "$lib/server/markup/markdown"; import type { MarkupContext } from "$lib/markup/paths"; const ctx: MarkupContext = { repo: "ada.test/infra", ref: "main", host: "tangled.org" }; @@ -76,6 +76,14 @@ describe("renderMarkdown", () => { ); }); + it("loads images lazily and decodes them off the main path, whatever the source says", async () => { + expect(await render("![logo](a.png)")).toContain('loading="lazy" decoding="async"'); + const html = await render(''); + expect(html).toContain('loading="lazy"'); + expect(html).toContain('decoding="async"'); + expect(html).not.toContain("eager"); + }); + it("rewrites every candidate in a srcset", async () => { const html = await render(''); expect(html).toContain( @@ -223,6 +231,12 @@ describe("renderMarkdown", () => { expect(html).toContain(" { + const html = await render("```rust\nfn main() {}\n```", { sourceLines: true }); + expect(html).toContain('data-line="0"'); + expect(html).toContain("--diffs-token-"); + }); + it("leaves fenced blocks without a known language plain", async () => { const unknown = await render("```nosuchlang\nhello\n```"); expect(unknown).toContain('class="language-nosuchlang"'); @@ -232,8 +246,8 @@ describe("renderMarkdown", () => { expect(plain).not.toContain("--diffs-token-"); }); - it("leaves oversized blocks plain for the client instead of stalling ssr", async () => { - const html = await render("```rust\n" + "x".repeat(60_000) + "\n```"); + it("leaves oversized blocks plain instead of stalling ssr", async () => { + const html = await render("```rust\n" + "x".repeat(210_000) + "\n```"); expect(html).toContain('class="language-rust"'); expect(html).not.toContain("--diffs-token-"); }); diff --git a/web/src/lib/markup/markdown.ts b/web/src/lib/server/markup/markdown.ts similarity index 96% rename from web/src/lib/markup/markdown.ts rename to web/src/lib/server/markup/markdown.ts index 0001419d0..5c417aa3c 100644 --- a/web/src/lib/markup/markdown.ts +++ b/web/src/lib/server/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/markup/collapse"; -import { highlightCodeblocks } from "$lib/markup/highlight"; -import { sanitizeMarkup } from "$lib/markup/sanitize"; +import { collapseCodeblocks } from "$lib/server/markup/collapse"; +import { highlightCodeblocks } from "$lib/server/markup/highlight"; +import { sanitizeMarkup } from "$lib/server/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/server/markup/render.test.ts b/web/src/lib/server/markup/render.test.ts new file mode 100644 index 000000000..a9052dd4b --- /dev/null +++ b/web/src/lib/server/markup/render.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { SOURCE_LIMIT } from "$lib/server/markup/markdown"; +import { renderDocument, renderMarkup } from "$lib/server/markup/render"; +import type { MarkupContext } from "$lib/markup/paths"; + +const ctx: MarkupContext = { repo: "ada.test/infra", ref: "main", host: "tangled.org" }; + +describe("renderDocument", () => { + it("wraps what it renders", async () => { + const html = await renderDocument("README.md", "# hello", ctx); + expect(html).toMatch(/^
/); + expect(html).toContain(" { + expect(await renderDocument("notes.txt", "# hello", ctx)).toBeNull(); + }); + + it("gives up on a document over the source limit", async () => { + expect(await renderDocument("README.md", "a".repeat(SOURCE_LIMIT + 1), ctx)).toBeNull(); + }); +}); + +describe("renderMarkup", () => { + it("renders a body whatever its name", async () => { + const html = await renderMarkup("a **body**", ctx); + expect(html).toMatch(/^
/); + expect(html).toContain("body"); + }); +}); diff --git a/web/src/lib/server/markup/render.ts b/web/src/lib/server/markup/render.ts new file mode 100644 index 000000000..f84c24cf4 --- /dev/null +++ b/web/src/lib/server/markup/render.ts @@ -0,0 +1,17 @@ +import { isMarkdownFile } from "$lib/markup/format"; +import type { MarkupContext } from "$lib/markup/paths"; +import { SOURCE_LIMIT, renderMarkdown } from "$lib/server/markup/markdown"; + +const render = async (contents: string, ctx: MarkupContext): Promise => { + if (contents.length > SOURCE_LIMIT) return null; + return `
${await renderMarkdown(contents, ctx)}
`; +}; + +export const renderDocument = async ( + filename: string, + contents: string, + ctx: MarkupContext +): Promise => (isMarkdownFile(filename) ? render(contents, ctx) : null); + +export const renderMarkup = (contents: string, ctx: MarkupContext): Promise => + render(contents, { ...ctx, collapseCode: true }); diff --git a/web/src/lib/markup/sanitize.ts b/web/src/lib/server/markup/sanitize.ts similarity index 95% rename from web/src/lib/markup/sanitize.ts rename to web/src/lib/server/markup/sanitize.ts index 9e3f73336..c7de1b765 100644 --- a/web/src/lib/markup/sanitize.ts +++ b/web/src/lib/server/markup/sanitize.ts @@ -81,7 +81,7 @@ const GLOBAL_ATTRIBUTES = ["id", "title", "dir", "lang", "align"]; const ALLOWED_ATTRIBUTES: sanitizeHtml.IOptions["allowedAttributes"] = { "*": GLOBAL_ATTRIBUTES, a: ["href", "name", "rel", "aria-hidden"], - img: ["src", "srcset", "alt", "width", "height", "loading"], + img: ["src", "srcset", "alt", "width", "height", "loading", "decoding"], source: ["src", "srcset", "type", "media"], video: ["src", "poster", "controls", "autoplay", "loop", "muted", "width", "height"], // the tasklist plugin renders disabled checkboxes tied to their labels @@ -135,7 +135,10 @@ const optionsFor = (ctx: MarkupContext): sanitizeHtml.IOptions => ({ const rel = externalRel(rewritten); return { tagName, attribs: { ...attribs, href: rewritten, ...(rel ? { rel } : {}) } }; }, - img: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }), + img: (tagName, attribs) => ({ + tagName, + attribs: { ...resolveMedia(attribs, ctx), loading: "lazy", decoding: "async" } + }), source: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }), video: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }) }, diff --git a/web/src/lib/server/repoIndex.ts b/web/src/lib/server/repoIndex.ts new file mode 100644 index 000000000..3264262d2 --- /dev/null +++ b/web/src/lib/server/repoIndex.ts @@ -0,0 +1,241 @@ +import { error } from "@sveltejs/kit"; +import { createBobbinClient } from "$lib/api/client"; +import { + branch as gitBranch, + branches as gitBranches, + gitTarget, + languages as gitLanguages, + log as gitLog, + tags as gitTags, + tree as gitTree +} from "$lib/api/gitclient"; +import { toMirrorCommitSummary } from "$lib/api/gitmirror"; +import { orNull, parallel, stream } from "$lib/api/load"; +import { + branchSummaries, + importState, + sortTreeEntries, + tagsByCommitHash, + toTagSummary, + toTreeCommitSummary, + toTreeEntrySummary, + type ImportState +} from "$lib/api/repo"; +import { + attempt, + BRANCH_LIMIT, + classifyRepoAvailability, + COMMIT_LIMIT, + REF_LIMIT, + TAG_LIMIT, + toLanguageSlices, + withAuthorHandles, + type RepoAvailability +} from "$lib/api/repoIndex"; +import type { RepoInfo } from "$lib/components/repo/types"; +import { renderDocument } from "$lib/server/markup/render"; + +export interface RepoParent { + publicConfig: { bobbinUrl: string; camoEnabled: boolean }; + repo: RepoInfo | Promise; +} + +export interface RepoLoadEvent { + fetch: typeof globalThis.fetch; + url: URL; +} + +export interface ReadmeBody { + html: string | null; + contents: string; +} + +const refNames = (branches: { name: string }[], tags: { name: string }[]) => ({ + branches: branches.map((branch) => branch.name), + tags: tags.map((tag) => tag.name) +}); + +export const renderReadme = ( + readme: { filename: string; contents: string } | null, + repo: RepoInfo, + publicConfig: { camoEnabled: boolean }, + event: RepoLoadEvent, + ref: string, + dir?: string +) => + readme + ? renderDocument(readme.filename, readme.contents, { + repo: `${repo.ownerHandle}/${repo.name}`, + ref, + dir, + host: event.url.host, + camo: publicConfig.camoEnabled + }) + : Promise.resolve(null); + +const readmeBodyOf = async ( + readme: { filename: string; contents: string } | null, + repo: RepoInfo, + publicConfig: { camoEnabled: boolean }, + event: RepoLoadEvent, + ref: string, + dir?: string +): Promise => { + const html = await renderReadme(readme, repo, publicConfig, event, ref, dir); + return { html, contents: html === null ? (readme?.contents ?? "") : "" }; +}; + +// the knot sends a readme with empty fields when a directory has none +const readmeOf = (tree: { readme?: { filename: string; contents: string } } | null) => { + const readme = tree?.readme; + return readme?.filename ? readme : null; +}; + +export interface RepoIndexOptions { + requireRef?: boolean; + importStatus?: boolean; +} + +// `/tree/{ref}` is this same page at another ref, so they share a load +export const loadRepoIndex = ( + event: RepoLoadEvent, + parent: RepoParent, + ref?: string, + // a ref from the url has to resolve or a typo looks like a repo with no + // files. the default branch renders whatever the knot managed to answer + { requireRef = false, importStatus: withImportStatus = false }: RepoIndexOptions = {} +) => { + const repoP = Promise.resolve(parent.repo); + + // each list falls back on its own, so half a page still renders + const loaded = repoP.then(async (repo) => { + const targetRef = ref ?? (await repo.defaultBranch); + const git = gitTarget(parent.publicConfig, repo, event.fetch); + + const results = await parallel({ + tree: attempt(gitTree(git, { ref: targetRef })), + log: attempt(gitLog(git, { ref: targetRef, limit: COMMIT_LIMIT })), + branch: attempt(gitBranch(git, targetRef)), + branches: attempt(gitBranches(git, REF_LIMIT)), + tags: attempt(gitTags(git, REF_LIMIT)) + }); + + const branches = results.branches.value ? branchSummaries(results.branches.value) : []; + const tags = (results.tags.value?.tags ?? []).map(toTagSummary); + const commits = await withAuthorHandles( + (results.log.value?.commits ?? []).map(toMirrorCommitSummary), + parent.publicConfig.bobbinUrl, + event.fetch + ); + const files = sortTreeEntries((results.tree.value?.files ?? []).map(toTreeEntrySummary)); + + const contentAttempts = [results.tree, results.log, results.branches]; + const knot = classifyRepoAvailability(contentAttempts); + const availability: RepoAvailability = + knot === "ok" && files.length === 0 && branches.length === 0 ? "empty" : knot; + + // there are refs but not this one, so it is not a real ref. an empty repo has + // no refs at all and still gets a page + if (requireRef && results.tree.value === null && branches.length > 0) { + error(404, `${targetRef} does not exist in this repository`); + } + + const readme = readmeOf(results.tree.value); + + return { + readme, + content: { + ref: targetRef, + availability, + files, + readme: readme && { filename: readme.filename }, + commits, + tagsByCommit: tagsByCommitHash(commits, tags), + totalCommits: results.branch.value?.totalCommits ?? commits.length, + branches: branches.slice(0, BRANCH_LIMIT), + totalBranches: results.branches.value?.total ?? branches.length, + tags: tags.slice(0, TAG_LIMIT), + totalTags: results.tags.value?.total ?? tags.length, + // the switcher needs every ref, not just the visible slice + refs: refNames(branches, tags) + } + }; + }); + + const content = loaded.then((resolved) => resolved.content); + + const languages = repoP.then(async (repo) => { + const targetRef = ref ?? (await repo.defaultBranch); + const git = gitTarget(parent.publicConfig, repo, event.fetch); + const result = await attempt(gitLanguages(git, targetRef)); + return toLanguageSlices(result.value?.languages ?? []); + }); + + const readmeBody = Promise.all([repoP, loaded]).then(([repo, resolved]) => + readmeBodyOf(resolved.readme, repo, parent.publicConfig, event, resolved.content.ref) + ); + + const importStatusP = withImportStatus + ? repoP.then(async (repo): Promise => { + if (!repo.importSource) return null; + const ctx = createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }); + const state = await orNull(importState(ctx, repo)); + return state ? { ...state, source: repo.importSource } : null; + }) + : Promise.resolve(null); + + return { + ref: ref ?? "", + content: stream(content), + languages: stream(languages), + readmeBody: stream(readmeBody), + importStatus: stream(importStatusP) + }; +}; + +export const loadRepoTree = ( + event: RepoLoadEvent, + parent: RepoParent, + ref: string, + path: string +) => { + const repoP = Promise.resolve(parent.repo); + + const loaded = repoP.then(async (repo) => { + const git = gitTarget(parent.publicConfig, repo, event.fetch); + const result = await orNull(gitTree(git, { ref, path })); + const files = sortTreeEntries((result?.files ?? []).map(toTreeEntrySummary)); + // git cannot store an empty directory. so nothing here means the path is a + // file, or was never there + if (result === null || files.length === 0) { + error(404, `${path} does not exist at ${ref}`); + } + + const readme = readmeOf(result); + + return { + readme, + tree: { + files, + readme: readme && { filename: readme.filename }, + lastCommit: result.lastCommit ? toTreeCommitSummary(result.lastCommit) : null + } + }; + }); + + const tree = loaded.then((resolved) => resolved.tree); + + const readmeBody = Promise.all([repoP, loaded]).then(([repo, resolved]) => + readmeBodyOf(resolved.readme, repo, parent.publicConfig, event, ref, path) + ); + + return { + ref, + path, + tree: stream(tree), + readmeBody: stream(readmeBody) + }; +}; diff --git a/web/src/markup.css b/web/src/markup.css index 5a27b2c18..e3f3dc18b 100644 --- a/web/src/markup.css +++ b/web/src/markup.css @@ -7,12 +7,12 @@ .markup > :first-child, /* the wrapper hides the render's first child from the rule above, so repeat it inside */ - .markup > [data-markup-key] > :first-child { + .markup > [data-markup] > :first-child { @apply mt-none; } .markup > :last-child, - .markup > [data-markup-key] > :last-child { + .markup > [data-markup] > :last-child { @apply mb-none; } diff --git a/web/src/routes/[handle]/[repo]/+layout.ts b/web/src/routes/[handle]/[repo]/+layout.server.ts similarity index 91% rename from web/src/routes/[handle]/[repo]/+layout.ts rename to web/src/routes/[handle]/[repo]/+layout.server.ts index 1c301af35..5de6b940f 100644 --- a/web/src/routes/[handle]/[repo]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/+layout.server.ts @@ -1,4 +1,3 @@ -import { browser } from "$app/environment"; import { isDid, isHandle } from "@atcute/lexicons/syntax"; import { error, redirect } from "@sveltejs/kit"; import { NO_ACCESS, resolveRepoAccess } from "$lib/api/access"; @@ -7,13 +6,13 @@ import { count } from "$lib/api/count"; import { gitTarget, resolveDefaultBranch } from "$lib/api/gitclient"; import { getStarRkey } from "$lib/api/graph"; import { resolveMiniDoc } from "$lib/api/identity"; -import { orNull, parallel, settle, stream, toHttpError } from "$lib/api/load"; +import { early, orNull, parallel, settle, stream, toHttpError } from "$lib/api/load"; import type { RepoRecord } from "$lib/api/records"; import { resolveRepoByName, resolveRepoByRepoDid } from "$lib/api/repo"; import { didFromUri } from "$lib/api/uri"; import type { BobbinContext } from "$lib/api/client"; import type { RepoCounts, RepoInfo, RepoSource } from "$lib/components/repo/types"; -import type { LayoutLoad } from "./$types"; +import type { LayoutServerLoad } from "./$types"; const FALLBACK_BRANCH = "main"; @@ -29,7 +28,7 @@ const resolveSource = async ( // the layout reset also cuts us off from `[handle]/+layout.ts`, so identity gets // resolved again here -export const load: LayoutLoad = async (event) => { +export const load: LayoutServerLoad = async (event) => { const parent = await event.parent(); const identifier = decodeURIComponent(event.params.handle); const name = decodeURIComponent(event.params.repo); @@ -144,7 +143,7 @@ export const load: LayoutLoad = async (event) => { resolved.then(({ repo }) => resolveRepoAccess(ctx, repo, viewerDid).catch(() => NO_ACCESS)) ); - if (!browser || isDid(identifier)) { + if (!event.isDataRequest || isDid(identifier)) { const { repo, canonical } = await resolved; if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { redirect(307, `/${canonical}/${event.params.repo}${event.url.search}`); @@ -156,20 +155,28 @@ export const load: LayoutLoad = async (event) => { const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 300)); counts = await Promise.race([orNull(countsPromise), timeout]); } - return settle({ - repo, - counts: counts ?? countsPromise, + return settle( + { + repo, + counts: counts ?? countsPromise, + viewerStarRkey: viewerStarPromise, + viewerAccess: viewerAccessPromise, + source: sourcePromise + }, + event + ); + } + + await early(event, resolved); + + return settle( + { + repo: repoPromise, + counts: countsPromise, viewerStarRkey: viewerStarPromise, viewerAccess: viewerAccessPromise, source: sourcePromise - }); - } - - return settle({ - repo: repoPromise, - counts: countsPromise, - viewerStarRkey: viewerStarPromise, - viewerAccess: viewerAccessPromise, - source: sourcePromise - }); + }, + event + ); }; diff --git a/web/src/routes/[handle]/[repo]/+page.ts b/web/src/routes/[handle]/[repo]/+page.server.ts similarity index 55% rename from web/src/routes/[handle]/[repo]/+page.ts rename to web/src/routes/[handle]/[repo]/+page.server.ts index 3a98f3848..30bfcfa7e 100644 --- a/web/src/routes/[handle]/[repo]/+page.ts +++ b/web/src/routes/[handle]/[repo]/+page.server.ts @@ -1,16 +1,15 @@ -import { browser } from "$app/environment"; import { orNull, settle } from "$lib/api/load"; -import { loadRepoIndex } from "$lib/api/repoIndex"; -import type { PageLoad } from "./$types"; +import { loadRepoIndex } from "$lib/server/repoIndex"; +import type { PageServerLoad } from "./$types"; -export const load: PageLoad = async (event) => { +export const load: PageServerLoad = async (event) => { const parent = await event.parent(); - const index = await loadRepoIndex(event, parent, undefined, { importStatus: true }); - if (!browser) { + const index = loadRepoIndex(event, parent, undefined, { importStatus: true }); + if (!event.isDataRequest) { // race the language slices so crawler html gets them without blocking ssr const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 300)); const languages = await Promise.race([orNull(index.languages), timeout]); - return settle({ ...index, languages: languages ?? index.languages }); + return settle({ ...index, languages: languages ?? index.languages }, event); } - return settle(index); + return settle(index, event); }; diff --git a/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.server.ts b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.server.ts new file mode 100644 index 000000000..c1f62b036 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.server.ts @@ -0,0 +1,24 @@ +import { error } from "@sveltejs/kit"; +import { early, settle, stream } from "$lib/api/load"; +import { loadRepoBlob } from "$lib/server/blob"; +import type { PageServerLoad } from "./$types"; + +// the ref is a single encoded segment, `feature/x` arrives intact. the +// rest param is already the path as git knows it +export const load: PageServerLoad = async (event) => { + // no path means the knot would 400 on the empty path, 404 instead + if (event.params.path === "") error(404, "Not found"); + const parent = await event.parent(); + + const blob = stream(loadRepoBlob(event, parent, event.params.ref, event.params.path)); + await early(event, blob); + + return settle( + { + ref: event.params.ref, + path: event.params.path, + blob + }, + event + ); +}; diff --git a/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.ts b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.ts deleted file mode 100644 index 9ecaba68d..000000000 --- a/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { error } from "@sveltejs/kit"; -import { loadRepoBlob, type RepoBlobView } from "$lib/api/blob"; -import { settle, stream } from "$lib/api/load"; -import { prerenderFile } from "$lib/components/repo/pierre"; -import { baseName } from "$lib/components/repo/urls"; -import type { PageLoad } from "./$types"; - -// the ref is a single encoded segment, `feature/x` arrives intact. the -// rest param is already the path as git knows it -export const load: PageLoad = async (event) => { - // no path means the knot would 400 on the empty path, 404 instead - if (event.params.path === "") error(404, "Not found"); - const parent = await event.parent(); - - const blobPromise = loadRepoBlob(event, parent, event.params.ref, event.params.path).then( - async (blob): Promise => { - const prerenderedHTML = - blob.defaultView === "code" - ? await prerenderFile(baseName(blob.path), blob.contents) - : undefined; - return { ...blob, prerenderedHTML }; - } - ); - - return settle({ - ref: event.params.ref, - path: event.params.path, - blob: stream(blobPromise) - }); -}; diff --git a/web/src/routes/[handle]/[repo]/issues/+page.ts b/web/src/routes/[handle]/[repo]/issues/+page.server.ts similarity index 70% rename from web/src/routes/[handle]/[repo]/issues/+page.ts rename to web/src/routes/[handle]/[repo]/issues/+page.server.ts index 7d2d338b0..6f37069a5 100644 --- a/web/src/routes/[handle]/[repo]/issues/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/+page.server.ts @@ -1,4 +1,3 @@ -import { cached } from "$lib/api/cache"; import { createBobbinClient } from "$lib/api/client"; import { count } from "$lib/api/count"; import { TICKET_AUTHOR_DOCS } from "$lib/api/descriptors"; @@ -10,18 +9,17 @@ import { settle, stream } from "$lib/api/load"; import { rkeyFromUri } from "$lib/api/uri"; import type { IssueSummary } from "$lib/components/repo/types"; import { offsetFor, pageCount, pageParam } from "$lib/pagination"; -import type { PageLoad } from "./$types"; +import type { PageServerLoad } from "./$types"; const ISSUE_LIMIT = 30; const AUTHOR_TARGETS = ["items[].value.x-tngl-editor", "items[].uri"]; -export const load: PageLoad = async (event) => { +export const load: PageServerLoad = async (event) => { const parent = await event.parent(); const repo = Promise.resolve(parent.repo); const state = event.url.searchParams.get("state") === "closed" ? "closed" : "open"; const page = pageParam(event.url); const offset = offsetFor(page, ISSUE_LIMIT); - const viewerDid = parent.auth?.did; const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch @@ -30,19 +28,17 @@ export const load: PageLoad = async (event) => { const issuesResult = repo.then(async (r) => { const repoDid = r.repoDid; if (!repoDid) return null; - return cached(`issues:${viewerDid}:${repoDid}:${state}:${page}`, 30_000, () => - enrich(ctx, { - xrpc: "sh.tangled.repo.listIssues", - params: { subject: repoDid, state, limit: ISSUE_LIMIT, offset }, - enrich: [ - target(TICKET_AUTHOR_DOCS, AUTHOR_TARGETS), - ...moderationTargets(TICKET_COLLECTION, { - records: ["items[].uri"], - authors: AUTHOR_TARGETS - }) - ] - }) - ); + return enrich(ctx, { + xrpc: "sh.tangled.repo.listIssues", + params: { subject: repoDid, state, limit: ISSUE_LIMIT, offset }, + enrich: [ + target(TICKET_AUTHOR_DOCS, AUTHOR_TARGETS), + ...moderationTargets(TICKET_COLLECTION, { + records: ["items[].uri"], + authors: AUTHOR_TARGETS + }) + ] + }); }); const issues = issuesResult.then((result) => @@ -69,9 +65,7 @@ export const load: PageLoad = async (event) => { const closedCount = repo.then(async (r) => { const repoDid = r.repoDid; if (!repoDid) return 0; - return cached(`countIssues:${repoDid}:closed`, 30_000, () => - count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }) - ) + return count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }) .then((c) => c?.count ?? 0) .catch(() => 0); }); @@ -85,12 +79,15 @@ export const load: PageLoad = async (event) => { return countPromise.then((c) => pageCount(c, ISSUE_LIMIT)); }); - return settle({ - state: state as "open" | "closed", - page, - pageCount: stream(pages), - issues: stream(issues), - openCount: stream(Promise.resolve(parent.counts).then((c) => c.issues)), - closedCount: stream(closedCount) - }); + return settle( + { + state: state as "open" | "closed", + page, + pageCount: stream(pages), + issues: stream(issues), + openCount: stream(Promise.resolve(parent.counts).then((c) => c.issues)), + closedCount: stream(closedCount) + }, + event + ); }; diff --git a/web/src/routes/[handle]/[repo]/issues/[rkey]/+page.ts b/web/src/routes/[handle]/[repo]/issues/[rkey]/+page.server.ts similarity index 94% rename from web/src/routes/[handle]/[repo]/issues/[rkey]/+page.ts rename to web/src/routes/[handle]/[repo]/issues/[rkey]/+page.server.ts index bca51699b..c54dbca12 100644 --- a/web/src/routes/[handle]/[repo]/issues/[rkey]/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/[rkey]/+page.server.ts @@ -1,4 +1,3 @@ -import { browser } from "$app/environment"; import { isRecordKey } from "@atcute/lexicons/syntax"; import { error, isHttpError } from "@sveltejs/kit"; import { createBobbinClient, ClientResponseError, type BobbinContext } from "$lib/api/client"; @@ -12,18 +11,18 @@ 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/markup"; +import { renderMarkup } from "$lib/server/markup/render"; import type { MarkupContext } from "$lib/markup/paths"; import { buildCommentThreads, listCommentItems, - toThreadInputs, type CommentThread } from "$lib/components/comment/comments"; +import { toThreadInputs } from "$lib/server/comments"; import { buildReactions, type ReactionGroup } from "$lib/components/reaction/reactions"; import type { IssueData } from "$lib/components/repo/issues/IssueThreadView.svelte"; import type { RepoInfo } from "$lib/components/repo/types"; -import type { PageLoad } from "./$types"; +import type { PageServerLoad } from "./$types"; export type { IssueData }; @@ -155,7 +154,7 @@ const loadThread = async ( } }; -export const load: PageLoad = async (event) => { +export const load: PageServerLoad = async (event) => { const parent = await event.parent(); const rkey = event.params.rkey; @@ -197,14 +196,6 @@ export const load: PageLoad = async (event) => { parent.publicConfig.camoEnabled, viewerDid ); - let initialResult: IssueThreadResult | null = null; - if (!browser) { - const resolved = await page; - if (resolved.kind === "not-found") error(404, "Issue not found"); - if (resolved.kind === "error") error(500, resolved.message); - initialResult = resolved; - } - const labels = Promise.all([parent.repo, ticket]) .then(([repo, record]) => repo.repoDid @@ -213,5 +204,12 @@ export const load: PageLoad = async (event) => { ) .catch(() => NO_SUBJECT_LABELS); - return settle({ page: stream(page), initialResult, labels: stream(labels) }); + if (!event.isDataRequest) { + const resolved = await page; + if (resolved.kind === "not-found") error(404, "Issue not found"); + if (resolved.kind === "error") error(500, resolved.message); + return settle({ page: resolved, labels: stream(labels) }, event); + } + + return settle({ page: stream(page), labels: stream(labels) }, event); }; diff --git a/web/src/routes/[handle]/[repo]/issues/[rkey]/+page.svelte b/web/src/routes/[handle]/[repo]/issues/[rkey]/+page.svelte index 721788e6c..50b200199 100644 --- a/web/src/routes/[handle]/[repo]/issues/[rkey]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/issues/[rkey]/+page.svelte @@ -2,7 +2,7 @@ import { page } from "$app/state"; import type { PageData } from "./$types"; import type { RepoInfo } from "$lib/components/repo/types"; - import type { IssueThreadResult } from "./+page"; + import type { IssueThreadResult } from "./+page.server"; import Bones from "$lib/components/ui/Bones.svelte"; import repoIssuesIssuethreadviewSkeletonFixture from "$lib/bones/repo-issues-issuethreadview--skeleton-fixture.bones.json"; import IssueThreadView from "$lib/components/repo/issues/IssueThreadView.svelte"; @@ -33,7 +33,9 @@ const resolvedRepo = $derived(asyncRepo ?? synchronousRepo); const repoName = $derived(resolvedRepo?.name ?? decodeURIComponent(page.params.repo ?? "")); - const currentResult = $derived(asyncPageResult ?? data.initialResult); + const currentResult = $derived( + asyncPageResult ?? (data.page instanceof Promise ? null : data.page) + ); const issueData = $derived(currentResult?.kind === "ok" ? currentResult.issue : null); const labeled = $derived(issueData?.moderation !== undefined); const issueTitle = $derived((!labeled && issueData?.title) || "Issue"); diff --git a/web/src/routes/[handle]/[repo]/issues/[rkey]/issue.test.ts b/web/src/routes/[handle]/[repo]/issues/[rkey]/issue.test.ts index 0146853fe..5b43cc2f2 100644 --- a/web/src/routes/[handle]/[repo]/issues/[rkey]/issue.test.ts +++ b/web/src/routes/[handle]/[repo]/issues/[rkey]/issue.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { load } from "./+page"; +import { load } from "./+page.server"; import type { Sidecar } from "$lib/api/enrich"; import { ACCOUNT_MODERATION, recordModeration } from "$lib/api/moderationLabels"; import { TICKET_COLLECTION } from "$lib/api/ticket"; @@ -73,12 +73,11 @@ const makeEvent = (fetchOverride?: typeof fetch, ticketData: Sidecar = {}) => { }; describe("issue detail load", () => { - it("returns streamed page promise that resolves to ok kind", async () => { + it("returns the resolved page with the ok kind", async () => { const { event } = makeEvent(); const data = (await load(event))!; - expect(data.page).toBeInstanceOf(Promise); - const result = await data.page; + const result = data.page; if (result.kind === "error") { throw new Error(`got error: ${result.message}`); } diff --git a/web/src/routes/[handle]/[repo]/pulls/+page.ts b/web/src/routes/[handle]/[repo]/pulls/+page.server.ts similarity index 68% rename from web/src/routes/[handle]/[repo]/pulls/+page.ts rename to web/src/routes/[handle]/[repo]/pulls/+page.server.ts index 2186ff33c..d01073858 100644 --- a/web/src/routes/[handle]/[repo]/pulls/+page.ts +++ b/web/src/routes/[handle]/[repo]/pulls/+page.server.ts @@ -1,4 +1,3 @@ -import { cached } from "$lib/api/cache"; import { createBobbinClient } from "$lib/api/client"; import { count } from "$lib/api/count"; import { TICKET_AUTHOR_DOCS } from "$lib/api/descriptors"; @@ -8,18 +7,17 @@ import { settle, stream } from "$lib/api/load"; import { rkeyFromUri } from "$lib/api/uri"; import type { PullSummary } from "$lib/components/repo/types"; import { offsetFor, pageCount, pageParam } from "$lib/pagination"; -import type { PageLoad } from "./$types"; +import type { PageServerLoad } from "./$types"; const PULL_LIMIT = 30; -export const load: PageLoad = async (event) => { +export const load: PageServerLoad = async (event) => { const parent = await event.parent(); const repo = Promise.resolve(parent.repo); const raw = event.url.searchParams.get("state"); const state: TicketState = raw === "closed" || raw === "completed" ? raw : "open"; const page = pageParam(event.url); const offset = offsetFor(page, PULL_LIMIT); - const viewerDid = parent.auth?.did; const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch @@ -28,13 +26,11 @@ export const load: PageLoad = async (event) => { const pullsResult = repo.then(async (r) => { const repoDid = r.repoDid; if (!repoDid) return null; - return cached(`pulls:${viewerDid}:${repoDid}:${state}:${page}`, 30_000, () => - enrich(ctx, { - xrpc: "sh.tangled.repo.listPulls", - params: { subject: repoDid, status: state, limit: PULL_LIMIT, offset }, - enrich: [target(TICKET_AUTHOR_DOCS, ["items[].value.x-tngl-editor", "items[].uri"])] - }) - ); + return enrich(ctx, { + xrpc: "sh.tangled.repo.listPulls", + params: { subject: repoDid, status: state, limit: PULL_LIMIT, offset }, + enrich: [target(TICKET_AUTHOR_DOCS, ["items[].value.x-tngl-editor", "items[].uri"])] + }); }); const pulls = pullsResult.then((result) => @@ -57,9 +53,7 @@ export const load: PageLoad = async (event) => { const closedCount = repo.then(async (r) => { const repoDid = r.repoDid; if (!repoDid) return 0; - return cached(`countPulls:${repoDid}:closed`, 30_000, () => - count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "closed" }) - ) + return count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "closed" }) .then((c) => c?.count ?? 0) .catch(() => 0); }); @@ -67,9 +61,7 @@ export const load: PageLoad = async (event) => { const completedCount = repo.then(async (r) => { const repoDid = r.repoDid; if (!repoDid) return 0; - return cached(`countPulls:${repoDid}:completed`, 30_000, () => - count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "completed" }) - ) + return count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "completed" }) .then((c) => c?.count ?? 0) .catch(() => 0); }); @@ -87,13 +79,16 @@ export const load: PageLoad = async (event) => { return countPromise.then((c) => pageCount(c, PULL_LIMIT)); }); - return settle({ - state, - page, - pageCount: stream(pages), - pulls: stream(pulls), - openCount: stream(Promise.resolve(parent.counts).then((c) => c.pulls)), - closedCount: stream(closedCount), - completedCount: stream(completedCount) - }); + return settle( + { + state, + page, + pageCount: stream(pages), + pulls: stream(pulls), + openCount: stream(Promise.resolve(parent.counts).then((c) => c.pulls)), + closedCount: stream(closedCount), + completedCount: stream(completedCount) + }, + event + ); }; diff --git a/web/src/routes/[handle]/[repo]/pulls/[rkey]/+layout.ts b/web/src/routes/[handle]/[repo]/pulls/[rkey]/+layout.server.ts similarity index 64% rename from web/src/routes/[handle]/[repo]/pulls/[rkey]/+layout.ts rename to web/src/routes/[handle]/[repo]/pulls/[rkey]/+layout.server.ts index 9445e1f94..cf2d9c4fb 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[rkey]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/pulls/[rkey]/+layout.server.ts @@ -1,4 +1,3 @@ -import { browser } from "$app/environment"; import { isRecordKey } from "@atcute/lexicons/syntax"; import { error } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; @@ -6,8 +5,9 @@ import { enrich } from "$lib/api/enrich"; import { loadSubjectLabels, NO_SUBJECT_LABELS } from "$lib/api/labels"; import { getTicketView, listPatches } from "$lib/api/records"; import { rkeyFromUri } from "$lib/api/uri"; -import { settle, stream } from "$lib/api/load"; -import { renderMarkup, type MarkupContext } from "$lib/markup"; +import { early, settle, stream } from "$lib/api/load"; +import type { MarkupContext } from "$lib/markup"; +import { renderMarkup } from "$lib/server/markup/render"; import type { CommentListPage } from "$lib/api/comment"; import { COMMENT_ENRICH, @@ -15,25 +15,25 @@ import { commentAuthorOf, type CommentView } from "$lib/components/comment/comments"; -import type { LayoutLoad } from "./$types"; +import type { LayoutServerLoad } from "./$types"; -export const load: LayoutLoad = async (event) => { +export const load: LayoutServerLoad = async (event) => { const parent = await event.parent(); const rkey = event.params.rkey; if (!isRecordKey(rkey)) error(404, "Pull request not found"); - const pullPage = (async () => { + const ctx = createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }); + + const loaded = (async () => { const repo = await parent.repo; const defaultBranch = await repo.defaultBranch; const repoDid = repo.repoDid; if (!repoDid) error(404, "Pull request not found"); const uri = `at://${repoDid}/org.tangled.track.ticket/${rkey}`; - const ctx = createBobbinClient({ - serviceUrl: parent.publicConfig.bobbinUrl, - fetch: event.fetch - }); - const markup: MarkupContext = { repo: `${repo.ownerHandle}/${repo.name}`, ref: defaultBranch, @@ -78,43 +78,44 @@ export const load: LayoutLoad = async (event) => { ); return { - repo, - comments, - markup, - sourceRepoDid: repoDid, - pull: { - ...ticket, - rkey: rkeyFromUri(ticket.uri), - title: ticket.title.text, - targetBranch: ticket.targetBranch, - // merging takes the patch uri, so each version keeps its own - versions: (patches?.items ?? []).map(({ uri, value }) => ({ - uri, - base: value.base.oid, - head: value.head.oid, - createdAt: value.createdAt - })), - body, - bodyHtml + tangledLabels: ticket.tangledLabels ?? [], + pullPage: { + comments, + markup, + sourceRepoDid: repoDid, + pull: { + uri: ticket.uri, + cid: ticket.cid, + rkey: rkeyFromUri(ticket.uri), + title: ticket.title.text, + state: ticket.state, + author: ticket.author, + createdAt: ticket.createdAt, + targetBranch: ticket.targetBranch, + // merging takes the patch uri, so each version keeps its own + versions: (patches?.items ?? []).map(({ uri, value }) => ({ + uri, + base: value.base.oid, + head: value.head.oid, + createdAt: value.createdAt + })), + body, + bodyHtml + } } }; })(); + const pullPage = stream(loaded.then(({ pullPage }) => pullPage)); + // preserve SSR status handling while client navigation paints its skeleton - if (!browser) await pullPage; + await early(event, pullPage); - const labels = pullPage - .then(({ sourceRepoDid, pull }) => - loadSubjectLabels( - createBobbinClient({ - serviceUrl: parent.publicConfig.bobbinUrl, - fetch: event.fetch - }), - sourceRepoDid, - pull.tangledLabels ?? [] - ) + const labels = loaded + .then(({ tangledLabels, pullPage }) => + loadSubjectLabels(ctx, pullPage.sourceRepoDid, tangledLabels) ) .catch(() => NO_SUBJECT_LABELS); - return settle({ pullPage: stream(pullPage), labels: stream(labels) }); + return settle({ pullPage, labels: stream(labels) }, event); }; diff --git a/web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/+layout.ts b/web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/+layout.server.ts similarity index 82% rename from web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/+layout.ts rename to web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/+layout.server.ts index dabac7397..ab3a16fa7 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/+layout.server.ts @@ -1,15 +1,14 @@ -import { browser } from "$app/environment"; import { redirect } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; import { listLog } from "$lib/api/gitmirror"; -import { settle, stream } from "$lib/api/load"; +import { early, settle, stream } from "$lib/api/load"; import { isRedirect, parseDiffRoute, parseInterdiffRoute } from "$lib/api/pullRoute"; import { withAuthorHandles } from "$lib/api/repoIndex"; import { countCommentsByCommit } from "$lib/components/comment/comments"; import type { Did } from "@atcute/lexicons/syntax"; -import type { LayoutLoad } from "./$types"; +import type { LayoutServerLoad } from "./$types"; -export const load: LayoutLoad = async (event) => { +export const load: LayoutServerLoad = async (event) => { const parent = await event.parent(); const versionPage = Promise.resolve(parent.pullPage).then(async (pullPage) => { @@ -20,7 +19,7 @@ export const load: LayoutLoad = async (event) => { if (isRedirect(route)) { const { handle, repo, rkey } = event.params; const redirectTo = `/${handle}/${repo}/pulls/${rkey}/latest`; - if (!browser) redirect(307, redirectTo); + if (!event.isDataRequest) redirect(307, redirectTo); return { redirectTo }; } @@ -41,7 +40,6 @@ export const load: LayoutLoad = async (event) => { const commentCounts = countCommentsByCommit(pullPage.comments); return { - ...pullPage, commits: commits.map((commit) => ({ ...commit, commentCount: commentCounts.get(commit.hash) ?? 0 @@ -49,7 +47,7 @@ export const load: LayoutLoad = async (event) => { }; }); - if (!browser) await versionPage; + await early(event, versionPage); - return settle({ versionPage: stream(versionPage) }); + return settle({ versionPage: stream(versionPage) }, event); }; diff --git a/web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/[[range]]/+page.ts b/web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/[[range]]/+page.ts index f912fc5a9..3ef8496c8 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/[[range]]/+page.ts +++ b/web/src/routes/[handle]/[repo]/pulls/[rkey]/[version]/[[range]]/+page.ts @@ -12,65 +12,70 @@ import type { PageLoad } from "./$types"; export const load: PageLoad = async (event) => { const parent = await event.parent(); - const page = Promise.resolve(parent.versionPage).then((versionPage) => { - if ("redirectTo" in versionPage) return versionPage; - const { pull, commits } = versionPage; - const route = event.params.version.includes("..") - ? parseInterdiffRoute(pull.versions, event.params.version, event.params.range) - : parseDiffRoute(pull.versions, event.params.version, event.params.range); - if (isRedirect(route)) { - // the layout already vetted the version against the same parser, so the range is - // the only thing left that can be bad -- drop it and keep the version - const { handle, repo, rkey, version } = event.params; - const redirectTo = `/${handle}/${repo}/pulls/${rkey}/${version}`; - if (!browser) redirect(307, redirectTo); - return { redirectTo }; - } + const page = Promise.all([parent.repo, parent.pullPage, parent.versionPage]).then( + ([repo, pullPage, versionPage]) => { + if ("redirectTo" in versionPage) return versionPage; + const { pull } = pullPage; + const { commits } = versionPage; + const route = event.params.version.includes("..") + ? parseInterdiffRoute(pull.versions, event.params.version, event.params.range) + : parseDiffRoute(pull.versions, event.params.version, event.params.range); + if (isRedirect(route)) { + // the layout already vetted the version against the same parser, so the range is + // the only thing left that can be bad -- drop it and keep the version + const { handle, repo, rkey, version } = event.params; + const redirectTo = `/${handle}/${repo}/pulls/${rkey}/${version}`; + if (!browser) redirect(307, redirectTo); + return { redirectTo }; + } - const currentVersion = pull.versions[route.version]; + const currentVersion = pull.versions[route.version]; - if (route.mode === "diff") { - route.base = - commits.find((commit) => commit.hash.startsWith(route.base))?.hash ?? - currentVersion?.base ?? - ""; - route.head = - commits.find((commit) => commit.hash.startsWith(route.head))?.hash ?? - currentVersion?.head ?? - ""; - } else if (route.mode === "interdiff" && route.changeId) { - route.changeId = - commits.find((commit) => commit.changeId?.startsWith(route.changeId))?.changeId ?? - ""; - } + if (route.mode === "diff") { + route.base = + commits.find((commit) => commit.hash.startsWith(route.base))?.hash ?? + currentVersion?.base ?? + ""; + route.head = + commits.find((commit) => commit.hash.startsWith(route.head))?.hash ?? + currentVersion?.head ?? + ""; + } else if (route.mode === "interdiff" && route.changeId) { + route.changeId = + commits.find((commit) => commit.changeId?.startsWith(route.changeId)) + ?.changeId ?? ""; + } - // the patch carries the same versions the page is about to diff, so it - // downloads alongside the file list instead of after it - the component - // picks up whichever files have already parsed by the time it mounts. - // an interdiff's left side is a tree the mirror rebases in memory, - // reachable by no ref, so only a plain version diff has a patch to stream - if ( - browser && - route.mode === "diff" && - route.base && - route.head && - versionPage.sourceRepoDid - ) { - getPatchStream( - formatPatchUrl(parent.publicConfig.bobbinUrl, { - repo: versionPage.sourceRepoDid as Did, - from: route.base, - to: route.head - }) - ); - } + // the patch carries the same versions the page is about to diff, so it + // downloads alongside the file list instead of after it - the component + // picks up whichever files have already parsed by the time it mounts. + // an interdiff's left side is a tree the mirror rebases in memory, + // reachable by no ref, so only a plain version diff has a patch to stream + if ( + browser && + route.mode === "diff" && + route.base && + route.head && + pullPage.sourceRepoDid + ) { + getPatchStream( + formatPatchUrl(parent.publicConfig.bobbinUrl, { + repo: pullPage.sourceRepoDid as Did, + from: route.base, + to: route.head + }) + ); + } - return { - ...versionPage, - view: route, - uri: pull.uri - }; - }); + return { + ...pullPage, + ...versionPage, + repo, + view: route, + uri: pull.uri + }; + } + ); if (!browser) await page; 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 b1e45b497..e7871719b 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 @@ -44,7 +44,7 @@ import ResizeHandle from "$lib/components/ui/ResizeHandle.svelte"; import { getAuth } from "$lib/auth.svelte"; import { getNotifications } from "$lib/notifications.svelte"; - import { renderMarkup } from "$lib/markup"; + import { previewMarkup } from "$lib/markup"; import RepoTabs from "$lib/components/repo/RepoTabs.svelte"; import Ticket from "$lib/components/repo/tickets/Ticket.svelte"; import type { Person } from "$lib/components/repo/tickets/TicketInfoBar.svelte"; @@ -482,8 +482,7 @@ authorHandle: user.handle, createdAt, body, - // rendered client-side so an optimistic comment matches a loaded one - bodyHtml: await renderMarkup(body, data.markup).catch(() => null) + bodyHtml: await previewMarkup(body, data.markup).catch(() => null) } ]; }; @@ -520,7 +519,7 @@ cid: saved.cid, title, body, - bodyHtml: body ? await renderMarkup(body, data.markup).catch(() => null) : null, + bodyHtml: body ? await previewMarkup(body, data.markup).catch(() => null) : null, targetBranch: targetBranch ?? pull.targetBranch }; // mockTargetBranch = targetBranch === pull.targetBranch ? undefined : targetBranch; diff --git a/web/src/routes/[handle]/[repo]/pulls/pulls.test.ts b/web/src/routes/[handle]/[repo]/pulls/pulls.test.ts index a1d8bc068..5daa94b1f 100644 --- a/web/src/routes/[handle]/[repo]/pulls/pulls.test.ts +++ b/web/src/routes/[handle]/[repo]/pulls/pulls.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { load } from "./+page"; +import { load } from "./+page.server"; import { TICKET_AUTHOR_DOCS } from "$lib/api/descriptors"; import { TYPE_MINIDOC } from "$lib/api/enrich"; diff --git a/web/src/routes/[handle]/[repo]/tree/[ref]/+page.server.ts b/web/src/routes/[handle]/[repo]/tree/[ref]/+page.server.ts new file mode 100644 index 000000000..bfd9f66d3 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/tree/[ref]/+page.server.ts @@ -0,0 +1,11 @@ +import { early, settle } from "$lib/api/load"; +import { loadRepoIndex } from "$lib/server/repoIndex"; +import type { PageServerLoad } from "./$types"; + +// the ref is a single encoded segment, so `feature/x` arrives here intact +export const load: PageServerLoad = async (event) => { + const parent = await event.parent(); + const index = loadRepoIndex(event, parent, event.params.ref, { requireRef: true }); + await early(event, index.content); + return settle(index, event); +}; diff --git a/web/src/routes/[handle]/[repo]/tree/[ref]/+page.ts b/web/src/routes/[handle]/[repo]/tree/[ref]/+page.ts deleted file mode 100644 index 1b605f103..000000000 --- a/web/src/routes/[handle]/[repo]/tree/[ref]/+page.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { settle } from "$lib/api/load"; -import { loadRepoIndex } from "$lib/api/repoIndex"; -import type { PageLoad } from "./$types"; - -// the ref is a single encoded segment, so `feature/x` arrives here intact -export const load: PageLoad = async (event) => { - const parent = await event.parent(); - return settle(await loadRepoIndex(event, parent, event.params.ref, { requireRef: true })); -}; diff --git a/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.server.ts b/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.server.ts new file mode 100644 index 000000000..16f4afe86 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.server.ts @@ -0,0 +1,20 @@ +import { early, settle } from "$lib/api/load"; +import { loadRepoTree } from "$lib/server/repoIndex"; +import type { PageServerLoad } from "./$types"; + +// the rest param arrives decoded and joined, so it is the path as git knows it +export const load: PageServerLoad = async (event) => { + const parent = await event.parent(); + const tree = loadRepoTree(event, parent, event.params.ref, event.params.path); + await early(event, tree.tree); + return settle( + { + ...tree, + meta: { + title: `${event.params.path} · ${event.params.repo} · Tangled`, + description: `Viewing ${event.params.path} in ${event.params.repo} on Tangled.` + } + }, + event + ); +}; diff --git a/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte b/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte index e86d9a75e..8330fe588 100644 --- a/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte @@ -53,11 +53,8 @@ {/snippet} - + {@const body = await data.readmeBody} + {/if} diff --git a/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.ts b/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.ts deleted file mode 100644 index 62a35ada1..000000000 --- a/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { settle } from "$lib/api/load"; -import { loadRepoTree } from "$lib/api/repoIndex"; -import type { PageLoad } from "./$types"; - -// the rest param arrives decoded and joined, so it is the path as git knows it -export const load: PageLoad = async (event) => { - const parent = await event.parent(); - return settle({ - ...(await loadRepoTree(event, parent, event.params.ref, event.params.path)), - meta: { - title: `${event.params.path} · ${event.params.repo} · Tangled`, - description: `Viewing ${event.params.path} in ${event.params.repo} on Tangled.` - } - }); -}; diff --git a/web/src/routes/[handle]/handle.test.ts b/web/src/routes/[handle]/handle.test.ts index f05de662e..32773b0c1 100644 --- a/web/src/routes/[handle]/handle.test.ts +++ b/web/src/routes/[handle]/handle.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { load as handleLayoutLoad } from "$routes/[handle]/+layout"; -import { load as repoLayoutLoad } from "$routes/[handle]/[repo]/+layout"; +import { load as repoLayoutLoad } from "$routes/[handle]/[repo]/+layout.server"; import { REPO_OWNER_DOCS } from "$lib/api/descriptors"; import { ACCOUNT_MODERATION, PROFILE_MODERATION } from "$lib/api/moderationLabels"; import { TYPE_MINIDOC } from "$lib/api/enrich"; diff --git a/web/src/routes/_internal/markup/+server.ts b/web/src/routes/_internal/markup/+server.ts new file mode 100644 index 000000000..5e2105d7e --- /dev/null +++ b/web/src/routes/_internal/markup/+server.ts @@ -0,0 +1,30 @@ +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/src/routes/strings/[handle]/[rkey]/+page.ts b/web/src/routes/strings/[handle]/[rkey]/+page.server.ts similarity index 66% rename from web/src/routes/strings/[handle]/[rkey]/+page.ts rename to web/src/routes/strings/[handle]/[rkey]/+page.server.ts index 24415a8f8..f291576a1 100644 --- a/web/src/routes/strings/[handle]/[rkey]/+page.ts +++ b/web/src/routes/strings/[handle]/[rkey]/+page.server.ts @@ -1,15 +1,16 @@ import type { ResourceUri } from "@atcute/lexicons/syntax"; import { createBobbinClient } from "$lib/api/client"; -import { loadStringBlob, type RepoBlobView } from "$lib/api/blob"; +import type { RepoBlobView } from "$lib/api/blob"; import { count } from "$lib/api/count"; import { getStarRkey } from "$lib/api/graph"; import { orNull, settle, stream } from "$lib/api/load"; import { type StringRecord } from "$lib/api/records"; import { fetchStringContents, loadStringSubject } from "$lib/api/strings"; -import { prerenderFile } from "$lib/components/repo/pierre"; -import { loadCommentThreads, type CommentThread } from "$lib/components/comment/comments"; +import type { CommentThread } from "$lib/components/comment/comments"; +import { loadStringBlob } from "$lib/server/blob"; +import { loadCommentThreads } from "$lib/server/comments"; import type { MarkupContext } from "$lib/markup/paths"; -import type { PageLoad } from "./$types"; +import type { PageServerLoad } from "./$types"; export interface StringPage { ownerHandle: string; @@ -24,7 +25,7 @@ export interface StringPage { isOwner: boolean; } -export const load: PageLoad = async (event) => { +export const load: PageServerLoad = async (event) => { const parent = await event.parent(); const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, @@ -62,13 +63,7 @@ export const load: PageLoad = async (event) => { cid: record.cid, string: record.value, contents, - blob: { - ...blob, - prerenderedHTML: - blob.defaultView === "code" - ? await prerenderFile(blob.path, blob.contents) - : undefined - }, + blob, comments, isOwner: Boolean(viewerDid && viewerDid === did) }; @@ -76,23 +71,26 @@ export const load: PageLoad = async (event) => { const subjectUri = orNull(subject.then(({ uri }) => uri)); - return settle({ - page: stream(page), - markup, - initEdit: event.url.searchParams.has("edit"), - starCount: stream( - subjectUri.then((uri) => - uri - ? count(ctx, "sh.tangled.feed.countStars", uri) - .then((result) => result.distinctAuthors ?? 0) - .catch(() => 0) - : 0 - ) - ), - viewerStarRkey: stream( - subjectUri.then((uri) => - uri && viewerDid ? getStarRkey(ctx, viewerDid, uri).catch(() => null) : null + return settle( + { + page: stream(page), + markup, + initEdit: event.url.searchParams.has("edit"), + starCount: stream( + subjectUri.then((uri) => + uri + ? count(ctx, "sh.tangled.feed.countStars", uri) + .then((result) => result.distinctAuthors ?? 0) + .catch(() => 0) + : 0 + ) + ), + viewerStarRkey: stream( + subjectUri.then((uri) => + uri && viewerDid ? getStarRkey(ctx, viewerDid, uri).catch(() => null) : null + ) ) - ) - }); + }, + event + ); }; diff --git a/web/src/routes/strings/[handle]/[rkey]/StringPageContent.svelte b/web/src/routes/strings/[handle]/[rkey]/StringPageContent.svelte index e306c05d1..acc3fa2b0 100644 --- a/web/src/routes/strings/[handle]/[rkey]/StringPageContent.svelte +++ b/web/src/routes/strings/[handle]/[rkey]/StringPageContent.svelte @@ -15,7 +15,7 @@ import CommentSection from "$lib/components/comment/CommentSection.svelte"; import type { CommentThread } from "$lib/components/comment/comments"; import type { MarkupContext } from "$lib/markup/paths"; - import type { StringPage } from "./+page"; + import type { StringPage } from "./+page.server"; interface Props { loaded: StringPage; diff --git a/web/src/routes/timeline/+page.ts b/web/src/routes/timeline/+page.server.ts similarity index 64% rename from web/src/routes/timeline/+page.ts rename to web/src/routes/timeline/+page.server.ts index dc56cf3f6..7e252fdb0 100644 --- a/web/src/routes/timeline/+page.ts +++ b/web/src/routes/timeline/+page.server.ts @@ -1,9 +1,9 @@ import { createBobbinClient } from "$lib/api/client"; import { settle } from "$lib/api/load"; import { loadFeed, loadTrendingRepos } from "$lib/components/timeline/load"; -import type { PageLoad } from "./$types"; +import type { PageServerLoad } from "./$types"; -export const load: PageLoad = async (event) => { +export const load: PageServerLoad = async (event) => { const parent = await event.parent(); const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, @@ -12,8 +12,11 @@ export const load: PageLoad = async (event) => { const followingOnly = event.url.searchParams.get("following") === "true"; - return settle({ - feed: loadFeed({ ctx, followingOnly }), - trending: loadTrendingRepos(ctx) - }); + return settle( + { + feed: loadFeed({ ctx, followingOnly }), + trending: loadTrendingRepos(ctx) + }, + event + ); }; diff --git a/web/vite.config.ts b/web/vite.config.ts index 75ae8afd7..826ad4b3c 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -107,10 +107,10 @@ export default defineConfig({ }) ], // vite only crawls kit routes here, so undeclared story deps would - // optimize mid-run and wedge chromium; sanitize-html is cjs + // optimize mid-run and wedge chromium optimizeDeps: { noDiscovery: true, - include: ["storybook/test", "sanitize-html", "textarea-caret"] + include: ["storybook/test", "textarea-caret"] }, test: { name: "storybook",