{
- 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("")).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",
([\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("")).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",