Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

web: switch prettier to double quotes

Signed-off-by: eti <eti@eti.tf>

eti (Jul 14, 2026, 2:37 AM +0200) 708d2e84 97165ae5

+571 -571
+21 -21
web/lex.config.ts
··· 1 - import { defineLexiconConfig } from '@atcute/lex-cli'; 1 + import { defineLexiconConfig } from "@atcute/lex-cli"; 2 2 3 3 // generate sh.tangled types; resolve com.atproto refs from @atcute/atproto. 4 4 export default defineLexiconConfig({ 5 - formatter: { type: 'prettier' }, 5 + formatter: { type: "prettier" }, 6 6 generate: { 7 - outdir: 'src/lib/api/lexicons/', 7 + outdir: "src/lib/api/lexicons/", 8 8 clean: true, 9 - imports: ['@atcute/atproto'], 9 + imports: ["@atcute/atproto"], 10 10 files: [ 11 - '../lexicons/*.json', 12 - '../lexicons/actor/**/*.json', 13 - '../lexicons/ci/**/*.json', 14 - '../lexicons/feed/**/*.json', 15 - '../lexicons/git/**/*.json', 16 - '../lexicons/graph/**/*.json', 17 - '../lexicons/issue/**/*.json', 18 - '../lexicons/knot/**/*.json', 19 - '../lexicons/label/**/*.json', 20 - '../lexicons/markup/**/*.json', 21 - '../lexicons/pipeline/**/*.json', 22 - '../lexicons/pulls/**/*.json', 23 - '../lexicons/repo/**/*.json', 24 - '../lexicons/spindle/**/*.json', 25 - '../lexicons/string/**/*.json', 26 - '../lexicons/sync/**/*.json', 27 - '../bobbin/crates/types/lexicons/**/*.json' 11 + "../lexicons/*.json", 12 + "../lexicons/actor/**/*.json", 13 + "../lexicons/ci/**/*.json", 14 + "../lexicons/feed/**/*.json", 15 + "../lexicons/git/**/*.json", 16 + "../lexicons/graph/**/*.json", 17 + "../lexicons/issue/**/*.json", 18 + "../lexicons/knot/**/*.json", 19 + "../lexicons/label/**/*.json", 20 + "../lexicons/markup/**/*.json", 21 + "../lexicons/pipeline/**/*.json", 22 + "../lexicons/pulls/**/*.json", 23 + "../lexicons/repo/**/*.json", 24 + "../lexicons/spindle/**/*.json", 25 + "../lexicons/string/**/*.json", 26 + "../lexicons/sync/**/*.json", 27 + "../bobbin/crates/types/lexicons/**/*.json" 28 28 ] 29 29 } 30 30 });
+3 -3
web/playwright.config.ts
··· 1 - import { defineConfig } from '@playwright/test'; 1 + import { defineConfig } from "@playwright/test"; 2 2 3 3 export default defineConfig({ 4 - webServer: { command: 'pnpm run build && pnpm run preview', port: 4173 }, 5 - testMatch: '**/*.e2e.{ts,js}' 4 + webServer: { command: "pnpm run build && pnpm run preview", port: 4173 }, 5 + testMatch: "**/*.e2e.{ts,js}" 6 6 });
+5 -5
web/prettier.config.js
··· 1 1 /** @type {import("prettier").Config} */ 2 2 const config = { 3 3 useTabs: true, 4 - singleQuote: true, 5 - trailingComma: 'none', 4 + singleQuote: false, 5 + trailingComma: "none", 6 6 printWidth: 100, 7 - plugins: ['prettier-plugin-svelte', 'prettier-plugin-tailwindcss'], 8 - overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }], 9 - tailwindStylesheet: './src/app.css' 7 + plugins: ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], 8 + overrides: [{ files: "*.svelte", options: { parser: "svelte" } }], 9 + tailwindStylesheet: "./src/app.css" 10 10 }; 11 11 12 12 export default config;
+2 -2
web/src/hooks.server.ts
··· 1 - import type { Handle } from '@sveltejs/kit'; 1 + import type { Handle } from "@sveltejs/kit"; 2 2 3 3 export const handle: Handle = async ({ event, resolve }) => { 4 4 return resolve(event, { ··· 9 9 // the data we already had. 10 10 // so we allow these headers to have atcute function properly. 11 11 filterSerializedResponseHeaders(name) { 12 - return name === 'content-type' || name === 'content-length'; 12 + return name === "content-type" || name === "content-length"; 13 13 } 14 14 }); 15 15 };
+4 -4
web/src/icon.d.ts
··· 1 1 // `$icon/<name>` is a vite alias for unplugin-icons' `~icons/lucide/<name>` 2 2 // virtual module (see vite.config.ts). mirror its ambient type here so tsc / 3 3 // svelte-check resolve the aliased imports. must stay a global .d.ts (no export) 4 - declare module '$icon/*' { 5 - import type { Component } from 'svelte'; 6 - import type { SvelteHTMLElements } from 'svelte/elements'; 4 + declare module "$icon/*" { 5 + import type { Component } from "svelte"; 6 + import type { SvelteHTMLElements } from "svelte/elements"; 7 7 8 - const component: Component<SvelteHTMLElements['svg']>; 8 + const component: Component<SvelteHTMLElements["svg"]>; 9 9 10 10 export default component; 11 11 }
+4 -4
web/src/lib/api/_request.ts
··· 1 - import { ClientResponseError, isXRPCErrorPayload, type XRPCErrorPayload } from '@atcute/client'; 2 - import type { BobbinContext, QueryValue, XrpcRequestInit } from './client'; 1 + import { ClientResponseError, isXRPCErrorPayload, type XRPCErrorPayload } from "@atcute/client"; 2 + import type { BobbinContext, QueryValue, XrpcRequestInit } from "./client"; 3 3 4 4 export const buildUrl = ( 5 5 origin: string, ··· 21 21 }; 22 22 23 23 export const toResponseError = async (response: Response): Promise<ClientResponseError> => { 24 - let data: XRPCErrorPayload = { error: 'XRPCError', message: response.statusText }; 24 + let data: XRPCErrorPayload = { error: "XRPCError", message: response.statusText }; 25 25 try { 26 26 const body: unknown = await response.json(); 27 27 if (isXRPCErrorPayload(body)) data = body; ··· 38 38 init?: XrpcRequestInit 39 39 ): Promise<T> => { 40 40 const response = await ctx.fetch(buildUrl(ctx.serviceUrl, nsid, params), { 41 - headers: { accept: 'application/json', ...init?.headers }, 41 + headers: { accept: "application/json", ...init?.headers }, 42 42 signal: init?.signal 43 43 }); 44 44 if (!response.ok) throw await toResponseError(response);
+42 -42
web/src/lib/api/client.test.ts
··· 1 - import { describe, expect, it, vi, type Mock } from 'vitest'; 2 - import { ClientResponseError, createBobbinClient, type BobbinContext } from './client'; 3 - import { jsonGet, rawGet } from './_request'; 1 + import { describe, expect, it, vi, type Mock } from "vitest"; 2 + import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client"; 3 + import { jsonGet, rawGet } from "./_request"; 4 4 5 5 const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => 6 6 new Response(JSON.stringify(body), { 7 7 status: 200, 8 - headers: { 'content-type': 'application/json' }, 8 + headers: { "content-type": "application/json" }, 9 9 ...init 10 10 }); 11 11 12 12 const makeCtx = ( 13 13 fetchMock: typeof globalThis.fetch, 14 - serviceUrl = 'https://bobbin.test' 14 + serviceUrl = "https://bobbin.test" 15 15 ): BobbinContext => createBobbinClient({ serviceUrl, fetch: fetchMock }); 16 16 17 17 const fetchedUrl = (mock: Mock<typeof globalThis.fetch>, n = 0): URL => 18 18 new URL(String(mock.mock.calls[n][0])); 19 19 20 - describe('createBobbinClient', () => { 21 - it('normalizes trailing slashes off the service url', () => { 22 - const ctx = makeCtx(vi.fn(), 'https://bobbin.test///'); 23 - expect(ctx.serviceUrl).toBe('https://bobbin.test'); 20 + describe("createBobbinClient", () => { 21 + it("normalizes trailing slashes off the service url", () => { 22 + const ctx = makeCtx(vi.fn(), "https://bobbin.test///"); 23 + expect(ctx.serviceUrl).toBe("https://bobbin.test"); 24 24 }); 25 25 }); 26 26 27 - describe('jsonGet URL construction', () => { 28 - it('appends an array param as repeated keys', async () => { 27 + describe("jsonGet URL construction", () => { 28 + it("appends an array param as repeated keys", async () => { 29 29 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 })); 30 - await jsonGet(makeCtx(fetchMock), 'sh.tangled.repo.getRepos', { repos: ['a', 'b'] }); 30 + await jsonGet(makeCtx(fetchMock), "sh.tangled.repo.getRepos", { repos: ["a", "b"] }); 31 31 const url = fetchedUrl(fetchMock); 32 - expect(url.searchParams.getAll('repos')).toEqual(['a', 'b']); 33 - expect(url.search).toBe('?repos=a&repos=b'); 32 + expect(url.searchParams.getAll("repos")).toEqual(["a", "b"]); 33 + expect(url.search).toBe("?repos=a&repos=b"); 34 34 }); 35 35 36 - it('omits undefined params entirely', async () => { 36 + it("omits undefined params entirely", async () => { 37 37 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 })); 38 - await jsonGet(makeCtx(fetchMock), 'sh.tangled.x', { keep: 'yes', drop: undefined }); 38 + await jsonGet(makeCtx(fetchMock), "sh.tangled.x", { keep: "yes", drop: undefined }); 39 39 const url = fetchedUrl(fetchMock); 40 - expect(url.searchParams.has('drop')).toBe(false); 41 - expect(url.searchParams.get('keep')).toBe('yes'); 40 + expect(url.searchParams.has("drop")).toBe(false); 41 + expect(url.searchParams.get("keep")).toBe("yes"); 42 42 }); 43 43 44 - it('resolves the query against the normalized origin', async () => { 44 + it("resolves the query against the normalized origin", async () => { 45 45 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 })); 46 - await jsonGet(makeCtx(fetchMock, 'https://bobbin.test/'), 'sh.tangled.x', { a: '1' }); 47 - expect(fetchedUrl(fetchMock).href).toBe('https://bobbin.test/xrpc/sh.tangled.x?a=1'); 46 + await jsonGet(makeCtx(fetchMock, "https://bobbin.test/"), "sh.tangled.x", { a: "1" }); 47 + expect(fetchedUrl(fetchMock).href).toBe("https://bobbin.test/xrpc/sh.tangled.x?a=1"); 48 48 }); 49 49 }); 50 50 51 - describe('jsonGet request headers & signal', () => { 52 - it('sends accept: application/json and merges caller headers + signal', async () => { 51 + describe("jsonGet request headers & signal", () => { 52 + it("sends accept: application/json and merges caller headers + signal", async () => { 53 53 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 })); 54 54 const controller = new AbortController(); 55 - await jsonGet(makeCtx(fetchMock), 'sh.tangled.x', undefined, { 56 - headers: { 'x-custom': '1' }, 55 + await jsonGet(makeCtx(fetchMock), "sh.tangled.x", undefined, { 56 + headers: { "x-custom": "1" }, 57 57 signal: controller.signal 58 58 }); 59 59 const init = fetchMock.mock.calls[0][1] as RequestInit; 60 - expect(init.headers).toMatchObject({ accept: 'application/json', 'x-custom': '1' }); 60 + expect(init.headers).toMatchObject({ accept: "application/json", "x-custom": "1" }); 61 61 expect(init.signal).toBe(controller.signal); 62 62 }); 63 63 }); 64 64 65 - describe('jsonGet responses', () => { 66 - it('throws ClientResponseError carrying status + error/description for a JSON error body', async () => { 65 + describe("jsonGet responses", () => { 66 + it("throws ClientResponseError carrying status + error/description for a JSON error body", async () => { 67 67 const fetchMock = vi 68 68 .fn<typeof globalThis.fetch>() 69 69 .mockResolvedValue( 70 - jsonResponse({ error: 'RecordNotFound', message: 'no such repo' }, { status: 404 }) 70 + jsonResponse({ error: "RecordNotFound", message: "no such repo" }, { status: 404 }) 71 71 ); 72 - const err = await jsonGet(makeCtx(fetchMock), 'sh.tangled.x').catch((e: unknown) => e); 72 + const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e); 73 73 expect(err).toBeInstanceOf(ClientResponseError); 74 74 const cre = err as ClientResponseError; 75 75 expect(cre.status).toBe(404); 76 - expect(cre.error).toBe('RecordNotFound'); 77 - expect(cre.description).toBe('no such repo'); 76 + expect(cre.error).toBe("RecordNotFound"); 77 + expect(cre.description).toBe("no such repo"); 78 78 }); 79 79 80 - it('falls back to the status line for a non-JSON error body (does not hang)', async () => { 80 + it("falls back to the status line for a non-JSON error body (does not hang)", async () => { 81 81 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( 82 - new Response('<html>502 Bad Gateway</html>', { 82 + new Response("<html>502 Bad Gateway</html>", { 83 83 status: 502, 84 - statusText: 'Bad Gateway' 84 + statusText: "Bad Gateway" 85 85 }) 86 86 ); 87 - const err = await jsonGet(makeCtx(fetchMock), 'sh.tangled.x').catch((e: unknown) => e); 87 + const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e); 88 88 expect(err).toBeInstanceOf(ClientResponseError); 89 89 const cre = err as ClientResponseError; 90 90 expect(cre.status).toBe(502); 91 - expect(cre.error).toBe('XRPCError'); 92 - expect(cre.description).toBe('Bad Gateway'); 91 + expect(cre.error).toBe("XRPCError"); 92 + expect(cre.description).toBe("Bad Gateway"); 93 93 }); 94 94 }); 95 95 96 - describe('rawGet', () => { 97 - it('throws ClientResponseError on a non-2xx status', async () => { 96 + describe("rawGet", () => { 97 + it("throws ClientResponseError on a non-2xx status", async () => { 98 98 const fetchMock = vi 99 99 .fn<typeof globalThis.fetch>() 100 100 .mockResolvedValue( 101 - jsonResponse({ error: 'UpstreamFailed', message: 'knot down' }, { status: 502 }) 101 + jsonResponse({ error: "UpstreamFailed", message: "knot down" }, { status: 502 }) 102 102 ); 103 - const err = await rawGet(makeCtx(fetchMock), 'sh.tangled.repo.archive').catch( 103 + const err = await rawGet(makeCtx(fetchMock), "sh.tangled.repo.archive").catch( 104 104 (e: unknown) => e 105 105 ); 106 106 expect(err).toBeInstanceOf(ClientResponseError);
+4 -4
web/src/lib/api/client.ts
··· 1 - import { Client, simpleFetchHandler } from '@atcute/client'; 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 2 3 - export { ClientResponseError, isXRPCErrorPayload, ok } from '@atcute/client'; 4 - export type { XRPCErrorPayload } from '@atcute/client'; 3 + export { ClientResponseError, isXRPCErrorPayload, ok } from "@atcute/client"; 4 + export type { XRPCErrorPayload } from "@atcute/client"; 5 5 6 6 // generated lexicons register ambient types; don't import the barrel at runtime. 7 7 ··· 17 17 } 18 18 19 19 export const createBobbinClient = ({ serviceUrl, fetch }: CreateBobbinOptions): BobbinContext => { 20 - const origin = serviceUrl.replace(/\/+$/, ''); 20 + const origin = serviceUrl.replace(/\/+$/, ""); 21 21 const boundFetch = fetch ?? globalThis.fetch; 22 22 const xrpc = new Client({ handler: simpleFetchHandler({ service: origin, fetch: boundFetch }) }); 23 23 return { xrpc, serviceUrl: origin, fetch: boundFetch };
+42 -42
web/src/lib/api/count.ts
··· 1 - import type { BobbinContext, XrpcRequestInit } from './client'; 2 - import { jsonGet } from './_request'; 1 + import type { BobbinContext, XrpcRequestInit } from "./client"; 2 + import { jsonGet } from "./_request"; 3 3 4 4 // count endpoints share { subject } params and { count, distinctAuthors } output. 5 5 export type CountName = 6 - | 'sh.tangled.feed.countStars' 7 - | 'sh.tangled.feed.countStarsBy' 8 - | 'sh.tangled.feed.countComments' 9 - | 'sh.tangled.feed.countCommentsBy' 10 - | 'sh.tangled.feed.countReactions' 11 - | 'sh.tangled.feed.countReactionsBy' 12 - | 'sh.tangled.graph.countFollows' 13 - | 'sh.tangled.graph.countFollowsBy' 14 - | 'sh.tangled.graph.countVouches' 15 - | 'sh.tangled.graph.countVouchesBy' 16 - | 'sh.tangled.git.countRefUpdates' 17 - | 'sh.tangled.git.countRefUpdatesBy' 18 - | 'sh.tangled.knot.countKnots' 19 - | 'sh.tangled.knot.countMembers' 20 - | 'sh.tangled.knot.countMembersBy' 21 - | 'sh.tangled.label.countDefinitions' 22 - | 'sh.tangled.label.countOps' 23 - | 'sh.tangled.label.countOpsBy' 24 - | 'sh.tangled.pipeline.countPipelines' 25 - | 'sh.tangled.pipeline.countPipelinesBy' 26 - | 'sh.tangled.pipeline.countStatuses' 27 - | 'sh.tangled.pipeline.countStatusesBy' 28 - | 'sh.tangled.publicKey.countKeys' 29 - | 'sh.tangled.repo.countArtifacts' 30 - | 'sh.tangled.repo.countArtifactsBy' 31 - | 'sh.tangled.repo.countCollaborators' 32 - | 'sh.tangled.repo.countCollaboratorsBy' 33 - | 'sh.tangled.repo.countIssues' 34 - | 'sh.tangled.repo.countIssuesBy' 35 - | 'sh.tangled.repo.countPulls' 36 - | 'sh.tangled.repo.countPullsBy' 37 - | 'sh.tangled.repo.countRepos' 38 - | 'sh.tangled.repo.issue.countStates' 39 - | 'sh.tangled.repo.issue.countStatesBy' 40 - | 'sh.tangled.repo.pull.countStatuses' 41 - | 'sh.tangled.repo.pull.countStatusesBy' 42 - | 'sh.tangled.spindle.countSpindles' 43 - | 'sh.tangled.spindle.countMembers' 44 - | 'sh.tangled.spindle.countMembersBy' 45 - | 'sh.tangled.string.countStrings'; 6 + | "sh.tangled.feed.countStars" 7 + | "sh.tangled.feed.countStarsBy" 8 + | "sh.tangled.feed.countComments" 9 + | "sh.tangled.feed.countCommentsBy" 10 + | "sh.tangled.feed.countReactions" 11 + | "sh.tangled.feed.countReactionsBy" 12 + | "sh.tangled.graph.countFollows" 13 + | "sh.tangled.graph.countFollowsBy" 14 + | "sh.tangled.graph.countVouches" 15 + | "sh.tangled.graph.countVouchesBy" 16 + | "sh.tangled.git.countRefUpdates" 17 + | "sh.tangled.git.countRefUpdatesBy" 18 + | "sh.tangled.knot.countKnots" 19 + | "sh.tangled.knot.countMembers" 20 + | "sh.tangled.knot.countMembersBy" 21 + | "sh.tangled.label.countDefinitions" 22 + | "sh.tangled.label.countOps" 23 + | "sh.tangled.label.countOpsBy" 24 + | "sh.tangled.pipeline.countPipelines" 25 + | "sh.tangled.pipeline.countPipelinesBy" 26 + | "sh.tangled.pipeline.countStatuses" 27 + | "sh.tangled.pipeline.countStatusesBy" 28 + | "sh.tangled.publicKey.countKeys" 29 + | "sh.tangled.repo.countArtifacts" 30 + | "sh.tangled.repo.countArtifactsBy" 31 + | "sh.tangled.repo.countCollaborators" 32 + | "sh.tangled.repo.countCollaboratorsBy" 33 + | "sh.tangled.repo.countIssues" 34 + | "sh.tangled.repo.countIssuesBy" 35 + | "sh.tangled.repo.countPulls" 36 + | "sh.tangled.repo.countPullsBy" 37 + | "sh.tangled.repo.countRepos" 38 + | "sh.tangled.repo.issue.countStates" 39 + | "sh.tangled.repo.issue.countStatesBy" 40 + | "sh.tangled.repo.pull.countStatuses" 41 + | "sh.tangled.repo.pull.countStatusesBy" 42 + | "sh.tangled.spindle.countSpindles" 43 + | "sh.tangled.spindle.countMembers" 44 + | "sh.tangled.spindle.countMembersBy" 45 + | "sh.tangled.string.countStrings"; 46 46 47 47 export interface CountResult { 48 48 count: number;
+3 -3
web/src/lib/api/coverage.ts
··· 1 - import type { BobbinContext, XrpcRequestInit } from './client'; 2 - import { jsonGet } from './_request'; 1 + import type { BobbinContext, XrpcRequestInit } from "./client"; 2 + import { jsonGet } from "./_request"; 3 3 4 4 export interface Coverage { 5 5 ready: boolean; ··· 8 8 } 9 9 10 10 export const getCoverage = (ctx: BobbinContext, init?: XrpcRequestInit): Promise<Coverage> => 11 - jsonGet<Coverage>(ctx, 'sh.tangled.bobbin.getCoverage', undefined, init); 11 + jsonGet<Coverage>(ctx, "sh.tangled.bobbin.getCoverage", undefined, init);
+20 -20
web/src/lib/api/graph.ts
··· 1 - import { ok } from '@atcute/client'; 2 - import { mainSchema as createRecordSchema } from '@atcute/atproto/types/repo/createRecord'; 3 - import { mainSchema as deleteRecordSchema } from '@atcute/atproto/types/repo/deleteRecord'; 4 - import type { Did, Nsid, RecordKey } from '@atcute/lexicons/syntax'; 5 - import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 6 - import { createClient } from '$lib/auth/agent'; 7 - import type { BobbinContext } from './client'; 8 - import { items } from './pagination'; 9 - import { rkeyFromUri } from './uri'; 10 - import type * as ShTangledGraphFollow from './lexicons/types/sh/tangled/graph/follow'; 11 - import type * as ShTangledGraphVouch from './lexicons/types/sh/tangled/graph/vouch'; 12 - import type * as ShTangledFeedStar from './lexicons/types/sh/tangled/feed/star'; 1 + import { ok } from "@atcute/client"; 2 + import { mainSchema as createRecordSchema } from "@atcute/atproto/types/repo/createRecord"; 3 + import { mainSchema as deleteRecordSchema } from "@atcute/atproto/types/repo/deleteRecord"; 4 + import type { Did, Nsid, RecordKey } from "@atcute/lexicons/syntax"; 5 + import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 6 + import { createClient } from "$lib/auth/agent"; 7 + import type { BobbinContext } from "./client"; 8 + import { items } from "./pagination"; 9 + import { rkeyFromUri } from "./uri"; 10 + import type * as ShTangledGraphFollow from "./lexicons/types/sh/tangled/graph/follow"; 11 + import type * as ShTangledGraphVouch from "./lexicons/types/sh/tangled/graph/vouch"; 12 + import type * as ShTangledFeedStar from "./lexicons/types/sh/tangled/feed/star"; 13 13 14 14 export type FollowRecord = ShTangledGraphFollow.Main; 15 15 export type VouchRecord = ShTangledGraphVouch.Main; 16 16 export type StarRecord = ShTangledFeedStar.Main; 17 17 18 - const FOLLOW_COLLECTION = 'sh.tangled.graph.follow' as Nsid; 19 - const STAR_COLLECTION = 'sh.tangled.feed.star' as Nsid; 18 + const FOLLOW_COLLECTION = "sh.tangled.graph.follow" as Nsid; 19 + const STAR_COLLECTION = "sh.tangled.feed.star" as Nsid; 20 20 21 21 // TODO(bobbin): needs a relation point-lookup (e.g. graph.getFollow?actor=&subject=) 22 22 // or a `viewer` hydration param on lists; scanning listFollowsBy pages is O(follows). ··· 28 28 ): Promise<string | null> => { 29 29 for await (const item of items( 30 30 ctx, 31 - 'sh.tangled.graph.listFollowsBy', 31 + "sh.tangled.graph.listFollowsBy", 32 32 { subject: viewerDid as Did }, 33 33 { maxPages: options.maxPages ?? 10 } 34 34 )) { ··· 66 66 67 67 export const createFollow = (agent: OAuthUserAgent, subject: string): Promise<string> => 68 68 createGenericRecord(agent, FOLLOW_COLLECTION, { 69 - $type: 'sh.tangled.graph.follow', 69 + $type: "sh.tangled.graph.follow", 70 70 subject: subject as Did, 71 71 createdAt: new Date().toISOString() 72 72 }); ··· 76 76 77 77 export const createStar = (agent: OAuthUserAgent, repoDid: string): Promise<string> => 78 78 createGenericRecord(agent, STAR_COLLECTION, { 79 - $type: 'sh.tangled.feed.star', 79 + $type: "sh.tangled.feed.star", 80 80 subject: { 81 - $type: 'sh.tangled.feed.star#repo', 81 + $type: "sh.tangled.feed.star#repo", 82 82 did: repoDid as Did 83 83 }, 84 84 createdAt: new Date().toISOString() ··· 97 97 const rkeys = new Map<string, string>(); 98 98 for await (const item of items( 99 99 ctx, 100 - 'sh.tangled.feed.listStarsBy', 100 + "sh.tangled.feed.listStarsBy", 101 101 { subject: viewerDid as Did }, 102 102 { maxPages: options.maxPages ?? 10 } 103 103 )) { 104 104 const value = item.value as StarRecord; 105 - if (value.subject.$type === 'sh.tangled.feed.star#repo') { 105 + if (value.subject.$type === "sh.tangled.feed.star#repo") { 106 106 rkeys.set(value.subject.did, rkeyFromUri(item.uri)); 107 107 } 108 108 }
+32 -32
web/src/lib/api/identity.test.ts
··· 1 - import { describe, expect, it, vi } from 'vitest'; 2 - import { createBobbinClient, type BobbinContext } from './client'; 3 - import { IdentityCache, type MiniDoc } from './identity'; 1 + import { describe, expect, it, vi } from "vitest"; 2 + import { createBobbinClient, type BobbinContext } from "./client"; 3 + import { IdentityCache, type MiniDoc } from "./identity"; 4 4 5 - const DOC: MiniDoc = { did: 'did:plc:x', handle: 'alice.test' }; 5 + const DOC: MiniDoc = { did: "did:plc:x", handle: "alice.test" }; 6 6 7 7 const docResponse = (doc: MiniDoc): Response => 8 8 new Response(JSON.stringify(doc), { 9 9 status: 200, 10 - headers: { 'content-type': 'application/json' } 10 + headers: { "content-type": "application/json" } 11 11 }); 12 12 13 13 const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext => 14 - createBobbinClient({ serviceUrl: 'https://bobbin.test', fetch: fetchMock }); 14 + createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); 15 15 16 16 const deferred = <T>(): { promise: Promise<T>; resolve: (value: T) => void } => { 17 17 let resolve!: (value: T) => void; ··· 21 21 return { promise, resolve }; 22 22 }; 23 23 24 - describe('IdentityCache.resolve', () => { 25 - it('fetches on the first miss then serves the cached document', async () => { 24 + describe("IdentityCache.resolve", () => { 25 + it("fetches on the first miss then serves the cached document", async () => { 26 26 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(docResponse(DOC)); 27 27 const cache = new IdentityCache(makeCtx(fetchMock)); 28 28 29 - const first = await cache.resolve('alice.test'); 30 - const second = await cache.resolve('alice.test'); 29 + const first = await cache.resolve("alice.test"); 30 + const second = await cache.resolve("alice.test"); 31 31 32 32 expect(first).toEqual(DOC); 33 33 expect(second).toEqual(DOC); 34 34 expect(fetchMock).toHaveBeenCalledTimes(1); 35 - expect((fetchMock.mock.calls[0][0] as URL).searchParams.get('identifier')).toBe('alice.test'); 35 + expect((fetchMock.mock.calls[0][0] as URL).searchParams.get("identifier")).toBe("alice.test"); 36 36 }); 37 37 38 - it('coalesces concurrent misses for the same key into one in-flight fetch', async () => { 38 + it("coalesces concurrent misses for the same key into one in-flight fetch", async () => { 39 39 const gate = deferred<Response>(); 40 40 const fetchMock = vi.fn<typeof globalThis.fetch>().mockReturnValue(gate.promise); 41 41 const cache = new IdentityCache(makeCtx(fetchMock)); 42 42 43 - const a = cache.resolve('alice.test'); 44 - const b = cache.resolve('alice.test'); 43 + const a = cache.resolve("alice.test"); 44 + const b = cache.resolve("alice.test"); 45 45 expect(fetchMock).toHaveBeenCalledTimes(1); 46 46 47 47 gate.resolve(docResponse(DOC)); ··· 51 51 expect(fetchMock).toHaveBeenCalledTimes(1); 52 52 }); 53 53 54 - it('re-fetches after the in-flight promise settles (no permanent stampede lock)', async () => { 54 + it("re-fetches after the in-flight promise settles (no permanent stampede lock)", async () => { 55 55 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(docResponse(DOC)); 56 56 const cache = new IdentityCache(makeCtx(fetchMock)); 57 57 58 - await cache.resolve('bob.test'); 59 - fetchMock.mockResolvedValueOnce(docResponse({ did: 'did:plc:y', handle: 'carol.test' })); 60 - await cache.resolve('carol.test'); 58 + await cache.resolve("bob.test"); 59 + fetchMock.mockResolvedValueOnce(docResponse({ did: "did:plc:y", handle: "carol.test" })); 60 + await cache.resolve("carol.test"); 61 61 expect(fetchMock).toHaveBeenCalledTimes(2); 62 62 }); 63 63 64 - it('populates both directions of the index from a resolved document', async () => { 64 + it("populates both directions of the index from a resolved document", async () => { 65 65 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(docResponse(DOC)); 66 66 const cache = new IdentityCache(makeCtx(fetchMock)); 67 67 68 - await cache.resolve('alice.test'); 69 - expect(cache.didFor('alice.test')).toBe('did:plc:x'); 70 - expect(cache.handleFor('did:plc:x')).toBe('alice.test'); 68 + await cache.resolve("alice.test"); 69 + expect(cache.didFor("alice.test")).toBe("did:plc:x"); 70 + expect(cache.handleFor("did:plc:x")).toBe("alice.test"); 71 71 }); 72 72 }); 73 73 74 - describe('IdentityCache.prime & map selection', () => { 75 - it('seeds both directions without any fetch', () => { 74 + describe("IdentityCache.prime & map selection", () => { 75 + it("seeds both directions without any fetch", () => { 76 76 const fetchMock = vi.fn<typeof globalThis.fetch>(); 77 77 const cache = new IdentityCache(makeCtx(fetchMock)); 78 78 79 79 cache.prime(DOC); 80 - expect(cache.didFor('alice.test')).toBe('did:plc:x'); 81 - expect(cache.handleFor('did:plc:x')).toBe('alice.test'); 80 + expect(cache.didFor("alice.test")).toBe("did:plc:x"); 81 + expect(cache.handleFor("did:plc:x")).toBe("alice.test"); 82 82 expect(fetchMock).not.toHaveBeenCalled(); 83 83 }); 84 84 85 - it('resolves a DID against the did map and a handle against the handle map', async () => { 85 + it("resolves a DID against the did map and a handle against the handle map", async () => { 86 86 const fetchMock = vi.fn<typeof globalThis.fetch>(); 87 87 const cache = new IdentityCache(makeCtx(fetchMock)); 88 88 cache.prime(DOC); 89 89 90 - await expect(cache.resolve('did:plc:x')).resolves.toEqual(DOC); 91 - await expect(cache.resolve('alice.test')).resolves.toEqual(DOC); 90 + await expect(cache.resolve("did:plc:x")).resolves.toEqual(DOC); 91 + await expect(cache.resolve("alice.test")).resolves.toEqual(DOC); 92 92 expect(fetchMock).not.toHaveBeenCalled(); 93 93 94 94 // unknown dids miss the handle map and fetch. 95 - fetchMock.mockResolvedValueOnce(docResponse({ did: 'did:plc:z', handle: 'dan.test' })); 96 - await cache.resolve('did:plc:z'); 95 + fetchMock.mockResolvedValueOnce(docResponse({ did: "did:plc:z", handle: "dan.test" })); 96 + await cache.resolve("did:plc:z"); 97 97 expect(fetchMock).toHaveBeenCalledTimes(1); 98 - expect((fetchMock.mock.calls[0][0] as URL).searchParams.get('identifier')).toBe('did:plc:z'); 98 + expect((fetchMock.mock.calls[0][0] as URL).searchParams.get("identifier")).toBe("did:plc:z"); 99 99 }); 100 100 });
+4 -4
web/src/lib/api/identity.ts
··· 1 - import type { BobbinContext, XrpcRequestInit } from './client'; 2 - import { jsonGet } from './_request'; 1 + import type { BobbinContext, XrpcRequestInit } from "./client"; 2 + import { jsonGet } from "./_request"; 3 3 4 4 export interface MiniDoc { 5 5 did: string; ··· 13 13 identifier: string, 14 14 init?: XrpcRequestInit 15 15 ): Promise<MiniDoc> => 16 - jsonGet<MiniDoc>(ctx, 'com.bad-example.identity.resolveMiniDoc', { identifier }, init); 16 + jsonGet<MiniDoc>(ctx, "com.bad-example.identity.resolveMiniDoc", { identifier }, init); 17 17 18 18 // did/handle cache with in-flight de-dupe. 19 19 export class IdentityCache { ··· 40 40 } 41 41 42 42 resolve(identifier: string, init?: XrpcRequestInit): Promise<MiniDoc> { 43 - const cached = identifier.startsWith('did:') 43 + const cached = identifier.startsWith("did:") 44 44 ? this.#byDid.get(identifier) 45 45 : this.#byHandle.get(identifier); 46 46 if (cached) return Promise.resolve(cached);
+11 -11
web/src/lib/api/index.ts
··· 1 - export * from './client'; 2 - export * from './pagination'; 3 - export * from './count'; 4 - export * from './records'; 5 - export * as knot from './knot'; 6 - export * from './search'; 7 - export * from './coverage'; 8 - export * from './identity'; 9 - export * from './load'; 10 - export * from './uri'; 11 - export * from './graph'; 1 + export * from "./client"; 2 + export * from "./pagination"; 3 + export * from "./count"; 4 + export * from "./records"; 5 + export * as knot from "./knot"; 6 + export * from "./search"; 7 + export * from "./coverage"; 8 + export * from "./identity"; 9 + export * from "./load"; 10 + export * from "./uri"; 11 + export * from "./graph";
+9 -9
web/src/lib/api/knot.test.ts
··· 1 - import { describe, expect, it, vi } from 'vitest'; 2 - import * as knot from './knot'; 3 - import { ClientResponseError, createBobbinClient, type BobbinContext } from './client'; 1 + import { describe, expect, it, vi } from "vitest"; 2 + import * as knot from "./knot"; 3 + import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client"; 4 4 5 5 const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => 6 6 new Response(JSON.stringify(body), { 7 7 status: 200, 8 - headers: { 'content-type': 'application/json' }, 8 + headers: { "content-type": "application/json" }, 9 9 ...init 10 10 }); 11 11 12 12 const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext => 13 - createBobbinClient({ serviceUrl: 'https://bobbin.test', fetch: fetchMock }); 13 + createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); 14 14 15 - describe('knot.archive (binary passthrough)', () => { 16 - it('throws ClientResponseError on a non-2xx archive response', async () => { 15 + describe("knot.archive (binary passthrough)", () => { 16 + it("throws ClientResponseError on a non-2xx archive response", async () => { 17 17 const fetchMock = vi 18 18 .fn<typeof globalThis.fetch>() 19 - .mockResolvedValue(jsonResponse({ error: 'UpstreamGone' }, { status: 502 })); 19 + .mockResolvedValue(jsonResponse({ error: "UpstreamGone" }, { status: 502 })); 20 20 const err = await knot 21 - .archive(makeCtx(fetchMock), { repo: 'did:plc:x/r', ref: 'main' }) 21 + .archive(makeCtx(fetchMock), { repo: "did:plc:x/r", ref: "main" }) 22 22 .catch((e: unknown) => e); 23 23 expect(err).toBeInstanceOf(ClientResponseError); 24 24 expect((err as ClientResponseError).status).toBe(502);
+30 -30
web/src/lib/api/knot.ts
··· 1 - import type { BobbinContext, QueryValue, XrpcRequestInit } from './client'; 2 - import { jsonGet, rawGet } from './_request'; 3 - import type * as Archive from './lexicons/types/sh/tangled/repo/archive'; 4 - import type * as Blob_ from './lexicons/types/sh/tangled/repo/blob'; 5 - import type * as Branch from './lexicons/types/sh/tangled/repo/branch'; 6 - import type * as Branches from './lexicons/types/sh/tangled/repo/branches'; 7 - import type * as Compare from './lexicons/types/sh/tangled/repo/compare'; 8 - import type * as DescribeRepo from './lexicons/types/sh/tangled/repo/describeRepo'; 9 - import type * as Diff from './lexicons/types/sh/tangled/repo/diff'; 10 - import type * as GetDefaultBranch from './lexicons/types/sh/tangled/repo/getDefaultBranch'; 11 - import type * as Languages from './lexicons/types/sh/tangled/repo/languages'; 12 - import type * as ListSecrets from './lexicons/types/sh/tangled/repo/listSecrets'; 13 - import type * as Log from './lexicons/types/sh/tangled/repo/log'; 14 - import type * as Tag from './lexicons/types/sh/tangled/repo/tag'; 15 - import type * as Tags from './lexicons/types/sh/tangled/repo/tags'; 16 - import type * as Tree from './lexicons/types/sh/tangled/repo/tree'; 1 + import type { BobbinContext, QueryValue, XrpcRequestInit } from "./client"; 2 + import { jsonGet, rawGet } from "./_request"; 3 + import type * as Archive from "./lexicons/types/sh/tangled/repo/archive"; 4 + import type * as Blob_ from "./lexicons/types/sh/tangled/repo/blob"; 5 + import type * as Branch from "./lexicons/types/sh/tangled/repo/branch"; 6 + import type * as Branches from "./lexicons/types/sh/tangled/repo/branches"; 7 + import type * as Compare from "./lexicons/types/sh/tangled/repo/compare"; 8 + import type * as DescribeRepo from "./lexicons/types/sh/tangled/repo/describeRepo"; 9 + import type * as Diff from "./lexicons/types/sh/tangled/repo/diff"; 10 + import type * as GetDefaultBranch from "./lexicons/types/sh/tangled/repo/getDefaultBranch"; 11 + import type * as Languages from "./lexicons/types/sh/tangled/repo/languages"; 12 + import type * as ListSecrets from "./lexicons/types/sh/tangled/repo/listSecrets"; 13 + import type * as Log from "./lexicons/types/sh/tangled/repo/log"; 14 + import type * as Tag from "./lexicons/types/sh/tangled/repo/tag"; 15 + import type * as Tags from "./lexicons/types/sh/tangled/repo/tags"; 16 + import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; 17 17 18 18 // wrappers for bobbin's knot-proxied repo endpoints. 19 19 ··· 21 21 params as unknown as Record<string, QueryValue>; 22 22 23 23 export const tree = (ctx: BobbinContext, params: Tree.$params, init?: XrpcRequestInit) => 24 - jsonGet<Tree.$output>(ctx, 'sh.tangled.repo.tree', asParams(params), init); 24 + jsonGet<Tree.$output>(ctx, "sh.tangled.repo.tree", asParams(params), init); 25 25 26 26 export const blob = (ctx: BobbinContext, params: Blob_.$params, init?: XrpcRequestInit) => 27 - jsonGet<Blob_.$output>(ctx, 'sh.tangled.repo.blob', asParams(params), init); 27 + jsonGet<Blob_.$output>(ctx, "sh.tangled.repo.blob", asParams(params), init); 28 28 29 29 export const branch = (ctx: BobbinContext, params: Branch.$params, init?: XrpcRequestInit) => 30 - jsonGet<Branch.$output>(ctx, 'sh.tangled.repo.branch', asParams(params), init); 30 + jsonGet<Branch.$output>(ctx, "sh.tangled.repo.branch", asParams(params), init); 31 31 32 32 export const getDefaultBranch = ( 33 33 ctx: BobbinContext, ··· 36 36 ) => 37 37 jsonGet<GetDefaultBranch.$output>( 38 38 ctx, 39 - 'sh.tangled.repo.getDefaultBranch', 39 + "sh.tangled.repo.getDefaultBranch", 40 40 asParams(params), 41 41 init 42 42 ); ··· 45 45 ctx: BobbinContext, 46 46 params: DescribeRepo.$params, 47 47 init?: XrpcRequestInit 48 - ) => jsonGet<DescribeRepo.$output>(ctx, 'sh.tangled.repo.describeRepo', asParams(params), init); 48 + ) => jsonGet<DescribeRepo.$output>(ctx, "sh.tangled.repo.describeRepo", asParams(params), init); 49 49 50 50 export const languages = (ctx: BobbinContext, params: Languages.$params, init?: XrpcRequestInit) => 51 - jsonGet<Languages.$output>(ctx, 'sh.tangled.repo.languages', asParams(params), init); 51 + jsonGet<Languages.$output>(ctx, "sh.tangled.repo.languages", asParams(params), init); 52 52 53 53 export const listSecrets = ( 54 54 ctx: BobbinContext, 55 55 params: ListSecrets.$params, 56 56 init?: XrpcRequestInit 57 - ) => jsonGet<ListSecrets.$output>(ctx, 'sh.tangled.repo.listSecrets', asParams(params), init); 57 + ) => jsonGet<ListSecrets.$output>(ctx, "sh.tangled.repo.listSecrets", asParams(params), init); 58 58 59 59 // schema-less json passthroughs; callers supply the shape. 60 60 61 61 export const log = <T = unknown>(ctx: BobbinContext, params: Log.$params, init?: XrpcRequestInit) => 62 - jsonGet<T>(ctx, 'sh.tangled.repo.log', asParams(params), init); 62 + jsonGet<T>(ctx, "sh.tangled.repo.log", asParams(params), init); 63 63 64 64 export const diff = <T = unknown>( 65 65 ctx: BobbinContext, 66 66 params: Diff.$params, 67 67 init?: XrpcRequestInit 68 - ) => jsonGet<T>(ctx, 'sh.tangled.repo.diff', asParams(params), init); 68 + ) => jsonGet<T>(ctx, "sh.tangled.repo.diff", asParams(params), init); 69 69 70 70 export const compare = <T = unknown>( 71 71 ctx: BobbinContext, 72 72 params: Compare.$params, 73 73 init?: XrpcRequestInit 74 - ) => jsonGet<T>(ctx, 'sh.tangled.repo.compare', asParams(params), init); 74 + ) => jsonGet<T>(ctx, "sh.tangled.repo.compare", asParams(params), init); 75 75 76 76 export const branches = <T = unknown>( 77 77 ctx: BobbinContext, 78 78 params: Branches.$params, 79 79 init?: XrpcRequestInit 80 - ) => jsonGet<T>(ctx, 'sh.tangled.repo.branches', asParams(params), init); 80 + ) => jsonGet<T>(ctx, "sh.tangled.repo.branches", asParams(params), init); 81 81 82 82 export const tags = <T = unknown>( 83 83 ctx: BobbinContext, 84 84 params: Tags.$params, 85 85 init?: XrpcRequestInit 86 - ) => jsonGet<T>(ctx, 'sh.tangled.repo.tags', asParams(params), init); 86 + ) => jsonGet<T>(ctx, "sh.tangled.repo.tags", asParams(params), init); 87 87 88 88 export const tag = <T = unknown>(ctx: BobbinContext, params: Tag.$params, init?: XrpcRequestInit) => 89 - jsonGet<T>(ctx, 'sh.tangled.repo.tag', asParams(params), init); 89 + jsonGet<T>(ctx, "sh.tangled.repo.tag", asParams(params), init); 90 90 91 91 // raw response for streaming/download. 92 92 93 93 export const archive = (ctx: BobbinContext, params: Archive.$params, init?: XrpcRequestInit) => 94 - rawGet(ctx, 'sh.tangled.repo.archive', asParams(params), init); 94 + rawGet(ctx, "sh.tangled.repo.archive", asParams(params), init);
+49 -49
web/src/lib/api/load.test.ts
··· 1 - import { describe, expect, it, vi } from 'vitest'; 2 - import { ClientResponseError } from './client'; 3 - import { createRequestCache, httpStatusFor, parallel, toHttpError } from './load'; 1 + import { describe, expect, it, vi } from "vitest"; 2 + import { ClientResponseError } from "./client"; 3 + import { createRequestCache, httpStatusFor, parallel, toHttpError } from "./load"; 4 4 5 5 const cre = (status: number, error: string, message?: string): ClientResponseError => 6 6 new ClientResponseError({ status, data: { error, message } }); 7 7 8 - describe('httpStatusFor', () => { 9 - it('passes through an in-band XRPC status', () => { 10 - expect(httpStatusFor(cre(404, 'RecordNotFound'))).toBe(404); 8 + describe("httpStatusFor", () => { 9 + it("passes through an in-band XRPC status", () => { 10 + expect(httpStatusFor(cre(404, "RecordNotFound"))).toBe(404); 11 11 }); 12 12 13 - it('prefers an in-band status over the error-name mapping', () => { 13 + it("prefers an in-band status over the error-name mapping", () => { 14 14 // status 503 is in [400,599] so it wins even though the name maps to 404. 15 - expect(httpStatusFor(cre(503, 'RecordNotFound'))).toBe(503); 15 + expect(httpStatusFor(cre(503, "RecordNotFound"))).toBe(503); 16 16 }); 17 17 18 - it('maps the error name when the status is out of the 400-599 band', () => { 19 - expect(httpStatusFor(cre(200, 'RecordNotFound'))).toBe(404); 20 - expect(httpStatusFor(cre(200, 'InvalidRequest'))).toBe(400); 21 - expect(httpStatusFor(cre(200, 'UpstreamFailed'))).toBe(502); 22 - expect(httpStatusFor(cre(200, 'UpstreamGone'))).toBe(502); 23 - expect(httpStatusFor(cre(200, 'InvalidRecord'))).toBe(502); 24 - expect(httpStatusFor(cre(200, 'Overloaded'))).toBe(503); 25 - expect(httpStatusFor(cre(200, 'SomethingElse'))).toBe(500); 18 + it("maps the error name when the status is out of the 400-599 band", () => { 19 + expect(httpStatusFor(cre(200, "RecordNotFound"))).toBe(404); 20 + expect(httpStatusFor(cre(200, "InvalidRequest"))).toBe(400); 21 + expect(httpStatusFor(cre(200, "UpstreamFailed"))).toBe(502); 22 + expect(httpStatusFor(cre(200, "UpstreamGone"))).toBe(502); 23 + expect(httpStatusFor(cre(200, "InvalidRecord"))).toBe(502); 24 + expect(httpStatusFor(cre(200, "Overloaded"))).toBe(503); 25 + expect(httpStatusFor(cre(200, "SomethingElse"))).toBe(500); 26 26 }); 27 27 28 - it('maps any non-XRPC cause to 500', () => { 29 - expect(httpStatusFor(new Error('boom'))).toBe(500); 30 - expect(httpStatusFor('a string')).toBe(500); 28 + it("maps any non-XRPC cause to 500", () => { 29 + expect(httpStatusFor(new Error("boom"))).toBe(500); 30 + expect(httpStatusFor("a string")).toBe(500); 31 31 expect(httpStatusFor(undefined)).toBe(500); 32 32 expect(httpStatusFor({ status: 404 })).toBe(500); 33 33 }); 34 34 }); 35 35 36 - describe('toHttpError', () => { 37 - it('throws a SvelteKit error with the mapped status and the XRPC description', () => { 36 + describe("toHttpError", () => { 37 + it("throws a SvelteKit error with the mapped status and the XRPC description", () => { 38 38 let thrown: unknown; 39 39 try { 40 - toHttpError(cre(404, 'RecordNotFound', 'no such repo')); 40 + toHttpError(cre(404, "RecordNotFound", "no such repo")); 41 41 } catch (e) { 42 42 thrown = e; 43 43 } 44 - expect(thrown).toMatchObject({ status: 404, body: { message: 'no such repo' } }); 44 + expect(thrown).toMatchObject({ status: 404, body: { message: "no such repo" } }); 45 45 }); 46 46 47 - it('falls back to the error name when the description is absent', () => { 47 + it("falls back to the error name when the description is absent", () => { 48 48 let thrown: unknown; 49 49 try { 50 - toHttpError(cre(200, 'Overloaded')); 50 + toHttpError(cre(200, "Overloaded")); 51 51 } catch (e) { 52 52 thrown = e; 53 53 } 54 - expect(thrown).toMatchObject({ status: 503, body: { message: 'Overloaded' } }); 54 + expect(thrown).toMatchObject({ status: 503, body: { message: "Overloaded" } }); 55 55 }); 56 56 57 - it('uses the fallback message + 500 for a non-XRPC cause', () => { 57 + it("uses the fallback message + 500 for a non-XRPC cause", () => { 58 58 let thrown: unknown; 59 59 try { 60 - toHttpError(new Error('boom'), 'could not load'); 60 + toHttpError(new Error("boom"), "could not load"); 61 61 } catch (e) { 62 62 thrown = e; 63 63 } 64 - expect(thrown).toMatchObject({ status: 500, body: { message: 'could not load' } }); 64 + expect(thrown).toMatchObject({ status: 500, body: { message: "could not load" } }); 65 65 }); 66 66 }); 67 67 68 - describe('parallel', () => { 69 - it('preserves key/value pairing regardless of settle order', async () => { 68 + describe("parallel", () => { 69 + it("preserves key/value pairing regardless of settle order", async () => { 70 70 // deferred across a few microtasks (no real timer) so it settles after `fast`. 71 71 const slow = Promise.resolve() 72 72 .then(() => Promise.resolve()) 73 - .then(() => 'slow'); 74 - const result = await parallel({ fast: Promise.resolve('fast'), slow }); 75 - expect(result).toEqual({ fast: 'fast', slow: 'slow' }); 73 + .then(() => "slow"); 74 + const result = await parallel({ fast: Promise.resolve("fast"), slow }); 75 + expect(result).toEqual({ fast: "fast", slow: "slow" }); 76 76 }); 77 77 78 - it('resolves to an empty object for no tasks', async () => { 78 + it("resolves to an empty object for no tasks", async () => { 79 79 await expect(parallel({})).resolves.toEqual({}); 80 80 }); 81 81 82 - it('rejects if any input promise rejects', async () => { 82 + it("rejects if any input promise rejects", async () => { 83 83 await expect( 84 - parallel({ a: Promise.resolve(1), b: Promise.reject(new Error('boom')) }) 85 - ).rejects.toThrow('boom'); 84 + parallel({ a: Promise.resolve(1), b: Promise.reject(new Error("boom")) }) 85 + ).rejects.toThrow("boom"); 86 86 }); 87 87 }); 88 88 89 - describe('createRequestCache', () => { 90 - it('invokes the loader once per key and shares the value', async () => { 89 + describe("createRequestCache", () => { 90 + it("invokes the loader once per key and shares the value", async () => { 91 91 const cache = createRequestCache(); 92 92 const load = vi.fn(async () => 42); 93 93 94 - const first = await cache.run('k', load); 95 - const second = await cache.run('k', load); 94 + const first = await cache.run("k", load); 95 + const second = await cache.run("k", load); 96 96 97 97 expect(first).toBe(42); 98 98 expect(second).toBe(42); 99 99 expect(load).toHaveBeenCalledTimes(1); 100 100 }); 101 101 102 - it('shares one in-flight promise for concurrent same-key calls', () => { 102 + it("shares one in-flight promise for concurrent same-key calls", () => { 103 103 const cache = createRequestCache(); 104 - const load = vi.fn(async () => 'v'); 104 + const load = vi.fn(async () => "v"); 105 105 106 - const a = cache.run('k', load); 107 - const b = cache.run('k', load); 106 + const a = cache.run("k", load); 107 + const b = cache.run("k", load); 108 108 109 109 expect(a).toBe(b); 110 110 expect(load).toHaveBeenCalledTimes(1); 111 111 }); 112 112 113 - it('runs distinct keys independently', async () => { 113 + it("runs distinct keys independently", async () => { 114 114 const cache = createRequestCache(); 115 115 const load = vi.fn(async (key: string) => key.toUpperCase()); 116 116 117 - await cache.run('a', () => load('a')); 118 - await cache.run('b', () => load('b')); 117 + await cache.run("a", () => load("a")); 118 + await cache.run("b", () => load("b")); 119 119 120 120 expect(load).toHaveBeenCalledTimes(2); 121 121 });
+9 -9
web/src/lib/api/load.ts
··· 1 - import { error, type NumericRange } from '@sveltejs/kit'; 2 - import { ClientResponseError } from './client'; 1 + import { error, type NumericRange } from "@sveltejs/kit"; 2 + import { ClientResponseError } from "./client"; 3 3 4 4 export const httpStatusFor = (cause: unknown): number => { 5 5 if (cause instanceof ClientResponseError) { 6 6 if (cause.status >= 400 && cause.status <= 599) return cause.status; 7 7 switch (cause.error) { 8 - case 'RecordNotFound': 8 + case "RecordNotFound": 9 9 return 404; 10 - case 'InvalidRequest': 10 + case "InvalidRequest": 11 11 return 400; 12 - case 'UpstreamFailed': 13 - case 'UpstreamGone': 14 - case 'InvalidRecord': 12 + case "UpstreamFailed": 13 + case "UpstreamGone": 14 + case "InvalidRecord": 15 15 return 502; 16 - case 'Overloaded': 16 + case "Overloaded": 17 17 return 503; 18 18 default: 19 19 return 500; ··· 22 22 return 500; 23 23 }; 24 24 25 - export const toHttpError = (cause: unknown, fallbackMessage = 'Request failed'): never => { 25 + export const toHttpError = (cause: unknown, fallbackMessage = "Request failed"): never => { 26 26 const status = httpStatusFor(cause) as NumericRange<400, 599>; 27 27 const message = 28 28 cause instanceof ClientResponseError ? (cause.description ?? cause.error) : fallbackMessage;
+29 -29
web/src/lib/api/pagination.test.ts
··· 1 - import { describe, expect, it, vi, type Mock } from 'vitest'; 2 - import { createBobbinClient, type BobbinContext } from './client'; 3 - import { collect, pages, paginateBy } from './pagination'; 1 + import { describe, expect, it, vi, type Mock } from "vitest"; 2 + import { createBobbinClient, type BobbinContext } from "./client"; 3 + import { collect, pages, paginateBy } from "./pagination"; 4 4 5 - const NAME = 'sh.tangled.feed.listStars'; 6 - const PARAMS = { subject: 'did:plc:x' } as const; 5 + const NAME = "sh.tangled.feed.listStars"; 6 + const PARAMS = { subject: "did:plc:x" } as const; 7 7 8 8 interface Page { 9 9 items: readonly { uri: string }[]; ··· 13 13 const pageResponse = (page: Page): Response => 14 14 new Response(JSON.stringify(page), { 15 15 status: 200, 16 - headers: { 'content-type': 'application/json' } 16 + headers: { "content-type": "application/json" } 17 17 }); 18 18 19 19 const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext => 20 - createBobbinClient({ serviceUrl: 'https://bobbin.test', fetch: fetchMock }); 20 + createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); 21 21 22 22 const cursorOf = (mock: Mock<typeof globalThis.fetch>, n: number): string | null => 23 - new URL(String(mock.mock.calls[n][0])).searchParams.get('cursor'); 23 + new URL(String(mock.mock.calls[n][0])).searchParams.get("cursor"); 24 24 25 25 const threePageFetch = (): Mock<typeof globalThis.fetch> => 26 26 vi 27 27 .fn<typeof globalThis.fetch>() 28 - .mockResolvedValueOnce(pageResponse({ items: [{ uri: 'a' }, { uri: 'b' }], cursor: 'c1' })) 29 - .mockResolvedValueOnce(pageResponse({ items: [{ uri: 'c' }], cursor: 'c2' })) 30 - .mockResolvedValueOnce(pageResponse({ items: [{ uri: 'd' }] })); 28 + .mockResolvedValueOnce(pageResponse({ items: [{ uri: "a" }, { uri: "b" }], cursor: "c1" })) 29 + .mockResolvedValueOnce(pageResponse({ items: [{ uri: "c" }], cursor: "c2" })) 30 + .mockResolvedValueOnce(pageResponse({ items: [{ uri: "d" }] })); 31 31 32 - describe('pages / items / collect follow the cursor', () => { 33 - it('pages() yields every page then stops when a page omits the cursor', async () => { 32 + describe("pages / items / collect follow the cursor", () => { 33 + it("pages() yields every page then stops when a page omits the cursor", async () => { 34 34 const fetchMock = threePageFetch(); 35 35 const seen: Page[] = []; 36 36 for await (const p of pages(makeCtx(fetchMock), NAME, PARAMS)) seen.push(p as Page); 37 37 expect(fetchMock).toHaveBeenCalledTimes(3); 38 - expect(seen.map((p) => p.cursor)).toEqual(['c1', 'c2', undefined]); 38 + expect(seen.map((p) => p.cursor)).toEqual(["c1", "c2", undefined]); 39 39 expect(cursorOf(fetchMock, 0)).toBeNull(); 40 - expect(cursorOf(fetchMock, 1)).toBe('c1'); 41 - expect(cursorOf(fetchMock, 2)).toBe('c2'); 40 + expect(cursorOf(fetchMock, 1)).toBe("c1"); 41 + expect(cursorOf(fetchMock, 2)).toBe("c2"); 42 42 }); 43 43 }); 44 44 45 - describe('pagination caps', () => { 46 - it('maxPages caps the number of network round-trips even with unbounded cursors', async () => { 45 + describe("pagination caps", () => { 46 + it("maxPages caps the number of network round-trips even with unbounded cursors", async () => { 47 47 const fetchMock = vi 48 48 .fn<typeof globalThis.fetch>() 49 - .mockImplementation(async () => pageResponse({ items: [{ uri: 'x' }], cursor: 'always' })); 49 + .mockImplementation(async () => pageResponse({ items: [{ uri: "x" }], cursor: "always" })); 50 50 const out = await collect(makeCtx(fetchMock), NAME, PARAMS, { maxPages: 2 }); 51 51 expect(fetchMock).toHaveBeenCalledTimes(2); 52 52 expect(out).toHaveLength(2); 53 53 }); 54 54 55 - it('collect({ max }) caps items mid-page without fetching the next page', async () => { 55 + it("collect({ max }) caps items mid-page without fetching the next page", async () => { 56 56 const fetchMock = vi 57 57 .fn<typeof globalThis.fetch>() 58 58 .mockResolvedValueOnce( 59 - pageResponse({ items: [{ uri: 'a' }, { uri: 'b' }, { uri: 'c' }], cursor: 'c1' }) 59 + pageResponse({ items: [{ uri: "a" }, { uri: "b" }, { uri: "c" }], cursor: "c1" }) 60 60 ); 61 61 const out = await collect(makeCtx(fetchMock), NAME, PARAMS, { max: 2 }); 62 - expect(out).toEqual([{ uri: 'a' }, { uri: 'b' }]); 62 + expect(out).toEqual([{ uri: "a" }, { uri: "b" }]); 63 63 expect(fetchMock).toHaveBeenCalledTimes(1); 64 64 }); 65 65 }); 66 66 67 - describe('paginateBy', () => { 68 - it('treats cursor: null as the end of the stream', async () => { 67 + describe("paginateBy", () => { 68 + it("treats cursor: null as the end of the stream", async () => { 69 69 const load = vi.fn(async () => ({ 70 70 items: [1, 2] as const, 71 71 cursor: null ··· 76 76 expect(load).toHaveBeenCalledTimes(1); 77 77 }); 78 78 79 - it('follows a string cursor and forwards it to the loader', async () => { 79 + it("follows a string cursor and forwards it to the loader", async () => { 80 80 const load = vi 81 81 .fn< 82 82 ( 83 83 cursor: string | undefined 84 84 ) => Promise<{ items: readonly number[]; cursor?: string | null }> 85 85 >() 86 - .mockResolvedValueOnce({ items: [1], cursor: 'p2' }) 86 + .mockResolvedValueOnce({ items: [1], cursor: "p2" }) 87 87 .mockResolvedValueOnce({ items: [2], cursor: undefined }); 88 88 const out: number[] = []; 89 89 for await (const n of paginateBy(load)) out.push(n); 90 90 expect(out).toEqual([1, 2]); 91 91 expect(load).toHaveBeenCalledTimes(2); 92 92 expect(load.mock.calls[0][0]).toBeUndefined(); 93 - expect(load.mock.calls[1][0]).toBe('p2'); 93 + expect(load.mock.calls[1][0]).toBe("p2"); 94 94 }); 95 95 96 - it('maxPages caps loader invocations', async () => { 96 + it("maxPages caps loader invocations", async () => { 97 97 const load = vi.fn(async () => ({ 98 98 items: [0], 99 - cursor: 'always' 99 + cursor: "always" 100 100 })); 101 101 const out: number[] = []; 102 102 for await (const n of paginateBy(load, { maxPages: 3 })) out.push(n);
+8 -8
web/src/lib/api/pagination.ts
··· 1 - import type { XRPCQueries } from '@atcute/lexicons/ambient'; 1 + import type { XRPCQueries } from "@atcute/lexicons/ambient"; 2 2 import type { 3 3 InferInput, 4 4 InferOutput, 5 5 ObjectSchema, 6 6 XRPCLexBodyParam 7 - } from '@atcute/lexicons/validations'; 8 - import { ok, type BobbinContext, type XrpcRequestInit } from './client'; 7 + } from "@atcute/lexicons/validations"; 8 + import { ok, type BobbinContext, type XrpcRequestInit } from "./client"; 9 9 10 10 export type QueryOutput<TName extends keyof XRPCQueries> = 11 - XRPCQueries[TName]['output'] extends XRPCLexBodyParam 12 - ? InferOutput<XRPCQueries[TName]['output']['schema']> 11 + XRPCQueries[TName]["output"] extends XRPCLexBodyParam 12 + ? InferOutput<XRPCQueries[TName]["output"]["schema"]> 13 13 : never; 14 14 15 15 export type QueryParams<TName extends keyof XRPCQueries> = 16 - XRPCQueries[TName]['params'] extends ObjectSchema 17 - ? InferInput<XRPCQueries[TName]['params']> 16 + XRPCQueries[TName]["params"] extends ObjectSchema 17 + ? InferInput<XRPCQueries[TName]["params"]> 18 18 : Record<string, never>; 19 19 20 20 type CursorPage = { items: readonly unknown[]; cursor?: string }; ··· 27 27 export type PageItem<TName extends PaginatedQuery> = 28 28 QueryOutput<TName> extends { items: readonly (infer I)[] } ? I : never; 29 29 30 - export type PageParams<TName extends PaginatedQuery> = Omit<QueryParams<TName>, 'cursor'>; 30 + export type PageParams<TName extends PaginatedQuery> = Omit<QueryParams<TName>, "cursor">; 31 31 32 32 export interface PaginateOptions extends XrpcRequestInit { 33 33 /** stop after this many network round-trips. */
+8 -8
web/src/lib/api/profile.ts
··· 1 - import { ok } from '@atcute/client'; 2 - import { mainSchema as putRecordSchema } from '@atcute/atproto/types/repo/putRecord'; 3 - import type { Nsid, RecordKey } from '@atcute/lexicons/syntax'; 4 - import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 5 - import { createClient } from '$lib/auth/agent'; 6 - import type { ProfileRecord } from './records'; 1 + import { ok } from "@atcute/client"; 2 + import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; 3 + import type { Nsid, RecordKey } from "@atcute/lexicons/syntax"; 4 + import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 5 + import { createClient } from "$lib/auth/agent"; 6 + import type { ProfileRecord } from "./records"; 7 7 8 - const PROFILE_COLLECTION = 'sh.tangled.actor.profile' as Nsid; 8 + const PROFILE_COLLECTION = "sh.tangled.actor.profile" as Nsid; 9 9 10 10 // the profile record lives at the fixed rkey `self`; put upserts it. 11 11 export const putProfile = async (agent: OAuthUserAgent, record: ProfileRecord): Promise<void> => { ··· 15 15 input: { 16 16 repo: agent.sub, 17 17 collection: PROFILE_COLLECTION, 18 - rkey: 'self' as RecordKey, 18 + rkey: "self" as RecordKey, 19 19 record 20 20 } 21 21 })
+15 -15
web/src/lib/api/records.ts
··· 1 - import type { BobbinContext, XrpcRequestInit } from './client'; 2 - import { jsonGet } from './_request'; 3 - import type * as ShTangledActorProfile from './lexicons/types/sh/tangled/actor/profile'; 4 - import type * as ShTangledRepo from './lexicons/types/sh/tangled/repo'; 5 - import type * as ShTangledRepoIssue from './lexicons/types/sh/tangled/repo/issue'; 6 - import type * as ShTangledRepoPull from './lexicons/types/sh/tangled/repo/pull'; 1 + import type { BobbinContext, XrpcRequestInit } from "./client"; 2 + import { jsonGet } from "./_request"; 3 + import type * as ShTangledActorProfile from "./lexicons/types/sh/tangled/actor/profile"; 4 + import type * as ShTangledRepo from "./lexicons/types/sh/tangled/repo"; 5 + import type * as ShTangledRepoIssue from "./lexicons/types/sh/tangled/repo/issue"; 6 + import type * as ShTangledRepoPull from "./lexicons/types/sh/tangled/repo/pull"; 7 7 8 8 export interface RecordView<V> { 9 9 uri: string; ··· 23 23 export const BULK_LIMIT = 50; 24 24 25 25 export const getRepo = (ctx: BobbinContext, repo: string, init?: XrpcRequestInit) => 26 - jsonGet<RecordView<RepoRecord>>(ctx, 'sh.tangled.repo.getRepo', { repo }, init); 26 + jsonGet<RecordView<RepoRecord>>(ctx, "sh.tangled.repo.getRepo", { repo }, init); 27 27 28 28 export const getRepoByRepoDid = (ctx: BobbinContext, repoDid: string, init?: XrpcRequestInit) => 29 - jsonGet<RecordView<RepoRecord>>(ctx, 'sh.tangled.repo.getRepoByRepoDid', { repoDid }, init); 29 + jsonGet<RecordView<RepoRecord>>(ctx, "sh.tangled.repo.getRepoByRepoDid", { repoDid }, init); 30 30 31 31 export const getProfile = (ctx: BobbinContext, did: string, init?: XrpcRequestInit) => 32 32 jsonGet<RecordView<ProfileRecord>>( 33 33 ctx, 34 - 'sh.tangled.actor.getProfile', 34 + "sh.tangled.actor.getProfile", 35 35 { actor: `at://${did}/sh.tangled.actor.profile/self` }, 36 36 init 37 37 ); 38 38 39 39 export const getIssue = (ctx: BobbinContext, issue: string, init?: XrpcRequestInit) => 40 - jsonGet<RecordView<IssueRecord>>(ctx, 'sh.tangled.repo.getIssue', { issue }, init); 40 + jsonGet<RecordView<IssueRecord>>(ctx, "sh.tangled.repo.getIssue", { issue }, init); 41 41 42 42 export const getPull = (ctx: BobbinContext, pull: string, init?: XrpcRequestInit) => 43 - jsonGet<RecordView<PullRecord>>(ctx, 'sh.tangled.repo.getPull', { pull }, init); 43 + jsonGet<RecordView<PullRecord>>(ctx, "sh.tangled.repo.getPull", { pull }, init); 44 44 45 45 export const getRepos = (ctx: BobbinContext, repos: readonly string[], init?: XrpcRequestInit) => 46 - jsonGet<RecordList<RepoRecord>>(ctx, 'sh.tangled.repo.getRepos', { repos }, init); 46 + jsonGet<RecordList<RepoRecord>>(ctx, "sh.tangled.repo.getRepos", { repos }, init); 47 47 48 48 export const getProfiles = ( 49 49 ctx: BobbinContext, 50 50 actors: readonly string[], 51 51 init?: XrpcRequestInit 52 - ) => jsonGet<RecordList<ProfileRecord>>(ctx, 'sh.tangled.actor.getProfiles', { actors }, init); 52 + ) => jsonGet<RecordList<ProfileRecord>>(ctx, "sh.tangled.actor.getProfiles", { actors }, init); 53 53 54 54 export const getIssues = (ctx: BobbinContext, issues: readonly string[], init?: XrpcRequestInit) => 55 - jsonGet<RecordList<IssueRecord>>(ctx, 'sh.tangled.repo.getIssues', { issues }, init); 55 + jsonGet<RecordList<IssueRecord>>(ctx, "sh.tangled.repo.getIssues", { issues }, init); 56 56 57 57 export const getPulls = (ctx: BobbinContext, pulls: readonly string[], init?: XrpcRequestInit) => 58 - jsonGet<RecordList<PullRecord>>(ctx, 'sh.tangled.repo.getPulls', { pulls }, init); 58 + jsonGet<RecordList<PullRecord>>(ctx, "sh.tangled.repo.getPulls", { pulls }, init);
+5 -5
web/src/lib/api/search.ts
··· 1 - import type { BobbinContext, XrpcRequestInit } from './client'; 2 - import { jsonGet } from './_request'; 3 - import { paginateBy } from './pagination'; 1 + import type { BobbinContext, XrpcRequestInit } from "./client"; 2 + import { jsonGet } from "./_request"; 3 + import { paginateBy } from "./pagination"; 4 4 5 5 export interface SearchHit { 6 6 uri: string; ··· 28 28 } 29 29 30 30 export const search = (ctx: BobbinContext, params: SearchParams, init?: XrpcRequestInit) => 31 - jsonGet<SearchPage>(ctx, 'sh.tangled.search.query', { ...params }, init); 31 + jsonGet<SearchPage>(ctx, "sh.tangled.search.query", { ...params }, init); 32 32 33 33 export interface SearchAllOptions extends XrpcRequestInit { 34 34 maxPages?: number; ··· 37 37 /** Async-iterate every search hit across cursor pages. */ 38 38 export async function* searchAll( 39 39 ctx: BobbinContext, 40 - params: Omit<SearchParams, 'cursor'>, 40 + params: Omit<SearchParams, "cursor">, 41 41 options: SearchAllOptions = {} 42 42 ): AsyncGenerator<SearchHit, void, unknown> { 43 43 yield* paginateBy<SearchHit>(async (cursor) => {
+3 -3
web/src/lib/api/uri.ts
··· 1 1 // at-uri helpers: at://<authority>/<collection>/<rkey> 2 2 3 3 export const didFromUri = (uri: string): string => { 4 - const rest = uri.startsWith('at://') ? uri.slice(5) : uri; 5 - const slash = rest.indexOf('/'); 4 + const rest = uri.startsWith("at://") ? uri.slice(5) : uri; 5 + const slash = rest.indexOf("/"); 6 6 return slash === -1 ? rest : rest.slice(0, slash); 7 7 }; 8 8 9 - export const rkeyFromUri = (uri: string): string => uri.slice(uri.lastIndexOf('/') + 1); 9 + export const rkeyFromUri = (uri: string): string => uri.slice(uri.lastIndexOf("/") + 1);
+32 -32
web/src/lib/auth.svelte.ts
··· 1 - import { browser } from '$app/environment'; 1 + import { browser } from "$app/environment"; 2 2 import { 3 3 CompositeDidDocumentResolver, 4 4 LocalActorResolver, 5 5 PlcDidDocumentResolver, 6 6 WebDidDocumentResolver, 7 7 XrpcHandleResolver 8 - } from '@atcute/identity-resolver'; 9 - import type { ActorIdentifier, Did } from '@atcute/lexicons/syntax'; 8 + } from "@atcute/identity-resolver"; 9 + import type { ActorIdentifier, Did } from "@atcute/lexicons/syntax"; 10 10 import { 11 11 OAuthUserAgent, 12 12 configureOAuth, ··· 15 15 finalizeAuthorization, 16 16 getSession, 17 17 listStoredSessions 18 - } from '@atcute/oauth-browser-client'; 19 - import { getContext } from 'svelte'; 20 - import { SvelteURL, SvelteURLSearchParams } from 'svelte/reactivity'; 21 - import oauthMetadata from '../../static/oauth-client-metadata.json'; 18 + } from "@atcute/oauth-browser-client"; 19 + import { getContext } from "svelte"; 20 + import { SvelteURL, SvelteURLSearchParams } from "svelte/reactivity"; 21 + import oauthMetadata from "../../static/oauth-client-metadata.json"; 22 22 import { 23 23 type AuthAccount, 24 24 clearActive, ··· 29 29 reconcileAccounts, 30 30 saveAccounts, 31 31 upsertAccount 32 - } from './auth/accounts'; 32 + } from "./auth/accounts"; 33 33 34 - export const AUTH_KEY = Symbol('auth'); 35 - const DEV_REDIRECT_URI = 'http://127.0.0.1:5173/oauth/callback'; 34 + export const AUTH_KEY = Symbol("auth"); 35 + const DEV_REDIRECT_URI = "http://127.0.0.1:5173/oauth/callback"; 36 36 const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`; 37 37 38 38 // local dev (localinfra) points these at the local pds/plc; defaults are the public network. 39 39 const HANDLE_RESOLVER_URL = 40 - (import.meta.env.VITE_HANDLE_RESOLVER_URL as string | undefined)?.replace(/\/+$/, '') ?? 41 - 'https://public.api.bsky.app'; 40 + (import.meta.env.VITE_HANDLE_RESOLVER_URL as string | undefined)?.replace(/\/+$/, "") ?? 41 + "https://public.api.bsky.app"; 42 42 const PLC_DIRECTORY_URL = (import.meta.env.VITE_PLC_DIRECTORY_URL as string | undefined)?.replace( 43 43 /\/+$/, 44 - '' 44 + "" 45 45 ); 46 46 47 47 const identityResolver = new LocalActorResolver({ ··· 68 68 avatar?: string; 69 69 } 70 70 71 - export type { AuthAccount } from './auth/accounts'; 71 + export type { AuthAccount } from "./auth/accounts"; 72 72 73 73 export interface Auth { 74 74 readonly agent: OAuthUserAgent | null; ··· 100 100 type OAuthSession = ConstructorParameters<typeof OAuthUserAgent>[0]; 101 101 102 102 const ENV_OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI as string | undefined; 103 - const HAS_LOCALHOST_REDIRECT = ENV_OAUTH_REDIRECT_URI?.includes('://localhost') ?? false; 103 + const HAS_LOCALHOST_REDIRECT = ENV_OAUTH_REDIRECT_URI?.includes("://localhost") ?? false; 104 104 const OAUTH_CLIENT_ID = HAS_LOCALHOST_REDIRECT 105 105 ? DEV_CLIENT_ID 106 106 : ((import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined) ?? DEV_CLIENT_ID); ··· 125 125 126 126 const errorMessage = (cause: unknown) => { 127 127 const message = cause instanceof Error ? cause.message : String(cause); 128 - return message.toLowerCase().includes('unknown state') 129 - ? 'Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost.' 128 + return message.toLowerCase().includes("unknown state") 129 + ? "Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost." 130 130 : message; 131 131 }; 132 132 ··· 135 135 bobbinUrl: string 136 136 ): Promise<AuthProfile | null> => { 137 137 try { 138 - const url = new URL('/xrpc/com.bad-example.identity.resolveMiniDoc', bobbinUrl); 139 - url.searchParams.set('identifier', identifier); 140 - const response = await fetch(url, { headers: { accept: 'application/json' } }); 138 + const url = new URL("/xrpc/com.bad-example.identity.resolveMiniDoc", bobbinUrl); 139 + url.searchParams.set("identifier", identifier); 140 + const response = await fetch(url, { headers: { accept: "application/json" } }); 141 141 if (response.ok) { 142 142 const profile = (await response.json()) as MiniDoc; 143 143 return { ··· 162 162 }; 163 163 164 164 const returnToFromState = (state: object | null): string => { 165 - if (state && typeof state === 'object' && 'returnTo' in state) { 165 + if (state && typeof state === "object" && "returnTo" in state) { 166 166 const returnTo = state.returnTo; 167 - if (typeof returnTo === 'string') return returnTo; 167 + if (typeof returnTo === "string") return returnTo; 168 168 } 169 - return '/'; 169 + return "/"; 170 170 }; 171 171 172 172 export const createAuth = ( ··· 255 255 resetLoggedOut(); 256 256 }; 257 257 258 - const signIn = async (identifier: string, returnTo = '/') => { 258 + const signIn = async (identifier: string, returnTo = "/") => { 259 259 if (!browser) return; 260 260 configure(); 261 261 error = null; 262 262 263 263 const trimmed = identifier.trim(); 264 264 if (!trimmed) { 265 - error = 'Handle or DID required'; 265 + error = "Handle or DID required"; 266 266 return; 267 267 } 268 268 269 - if (location.hostname === 'localhost') { 269 + if (location.hostname === "localhost") { 270 270 const url = new SvelteURL(location.href); 271 - url.hostname = '127.0.0.1'; 272 - url.searchParams.set('identifier', trimmed); 273 - url.searchParams.set('return_url', returnTo); 271 + url.hostname = "127.0.0.1"; 272 + url.searchParams.set("identifier", trimmed); 273 + url.searchParams.set("return_url", returnTo); 274 274 location.replace(url); 275 275 return; 276 276 } ··· 278 278 try { 279 279 const url = await createAuthorizationUrl({ 280 280 target: { 281 - type: 'account', 281 + type: "account", 282 282 identifier: trimmed as ActorIdentifier 283 283 }, 284 284 scope: OAUTH_SCOPE, ··· 293 293 }; 294 294 295 295 const completeSignIn = async () => { 296 - if (!browser) return '/'; 296 + if (!browser) return "/"; 297 297 configure(); 298 298 error = null; 299 299 authenticating = true; 300 300 301 301 try { 302 302 const params = new SvelteURLSearchParams(location.hash.slice(1)); 303 - history.replaceState(null, '', location.pathname + location.search); 303 + history.replaceState(null, "", location.pathname + location.search); 304 304 305 305 const { session, state } = await finalizeAuthorization(params); 306 306 adoptSession(session);
+10 -10
web/src/lib/auth/accounts.ts
··· 1 - import { browser } from '$app/environment'; 2 - import type { Did } from '@atcute/lexicons/syntax'; 1 + import { browser } from "$app/environment"; 2 + import type { Did } from "@atcute/lexicons/syntax"; 3 3 4 4 // atcute owns oauth sessions; this stores metadata/order and active-account cookies. 5 5 6 - export const CURRENT_DID_KEY = 'tangled.currentDid'; 7 - export const CURRENT_HANDLE_KEY = 'tangled.currentHandle'; 8 - const ACCOUNTS_KEY = 'tangled.accounts'; 6 + export const CURRENT_DID_KEY = "tangled.currentDid"; 7 + export const CURRENT_HANDLE_KEY = "tangled.currentHandle"; 8 + const ACCOUNTS_KEY = "tangled.accounts"; 9 9 10 10 // appview account cap parity 11 11 export const MAX_ACCOUNTS = 20; ··· 19 19 } 20 20 21 21 const isDid = (value: unknown): value is Did => 22 - typeof value === 'string' && value.startsWith('did:'); 22 + typeof value === "string" && value.startsWith("did:"); 23 23 24 24 const isAccount = (value: unknown): value is AuthAccount => 25 25 !!value && 26 - typeof value === 'object' && 26 + typeof value === "object" && 27 27 isDid((value as AuthAccount).did) && 28 - typeof (value as AuthAccount).handle === 'string'; 28 + typeof (value as AuthAccount).handle === "string"; 29 29 30 30 export const loadAccounts = (): AuthAccount[] => { 31 31 if (!browser) return []; ··· 38 38 did: account.did, 39 39 handle: account.handle, 40 40 avatar: account.avatar, 41 - addedAt: typeof account.addedAt === 'number' ? account.addedAt : 0 41 + addedAt: typeof account.addedAt === "number" ? account.addedAt : 0 42 42 })); 43 43 } catch { 44 44 return []; ··· 88 88 if (!browser) return; 89 89 localStorage.setItem(CURRENT_DID_KEY, did); 90 90 localStorage.setItem(CURRENT_HANDLE_KEY, handle); 91 - const secure = location.protocol === 'https:' ? '; secure' : ''; 91 + const secure = location.protocol === "https:" ? "; secure" : ""; 92 92 const attrs = `; path=/; max-age=31536000; samesite=lax${secure}`; 93 93 document.cookie = `${CURRENT_DID_KEY}=${encodeURIComponent(did)}${attrs}`; 94 94 document.cookie = `${CURRENT_HANDLE_KEY}=${encodeURIComponent(handle)}${attrs}`;
+5 -5
web/src/lib/auth/agent.ts
··· 1 - import { Client, ok } from '@atcute/client'; 2 - import { mainSchema as getServiceAuthSchema } from '@atcute/atproto/types/server/getServiceAuth'; 3 - import type { Nsid } from '@atcute/lexicons/syntax'; 4 - import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 1 + import { Client, ok } from "@atcute/client"; 2 + import { mainSchema as getServiceAuthSchema } from "@atcute/atproto/types/server/getServiceAuth"; 3 + import type { Nsid } from "@atcute/lexicons/syntax"; 4 + import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 5 5 6 6 export const createClient = (agent: OAuthUserAgent): Client => new Client({ handler: agent }); 7 7 ··· 14 14 } 15 15 16 16 // did:web service id, with ports percent-encoded like serviceauth didweb. 17 - export const serviceDidForHost = (host: string): string => `did:web:${host.replace(/:/g, '%3A')}`; 17 + export const serviceDidForHost = (host: string): string => `did:web:${host.replace(/:/g, "%3A")}`; 18 18 19 19 // mint a service-auth jwt for knot/spindle xrpc calls. 20 20 export const mintServiceAuth = async (
+6 -6
web/src/lib/auth/guards.ts
··· 1 - import { goto } from '$app/navigation'; 2 - import { resolve } from '$app/paths'; 3 - import { redirect, type RequestEvent } from '@sveltejs/kit'; 4 - import { CURRENT_DID_KEY, CURRENT_HANDLE_KEY } from './accounts'; 5 - import type { Auth } from '../auth.svelte'; 1 + import { goto } from "$app/navigation"; 2 + import { resolve } from "$app/paths"; 3 + import { redirect, type RequestEvent } from "@sveltejs/kit"; 4 + import { CURRENT_DID_KEY, CURRENT_HANDLE_KEY } from "./accounts"; 5 + import type { Auth } from "../auth.svelte"; 6 6 7 7 export interface RequireAuthResult { 8 8 did: string; ··· 22 22 23 23 export const requireAuthClient = (auth: Auth, url: URL): boolean => { 24 24 if (auth.currentDid) return true; 25 - void goto(resolve(loginWithReturn(url.pathname + url.search) as '/login'), { 25 + void goto(resolve(loginWithReturn(url.pathname + url.search) as "/login"), { 26 26 replaceState: true 27 27 }); 28 28 return false;
+4 -4
web/src/lib/components/profile/FollowerFollowing.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from '$app/paths'; 3 - import Users from '$icon/users'; 2 + import { resolve } from "$app/paths"; 3 + import Users from "$icon/users"; 4 4 5 5 interface Props { 6 6 handle: string; ··· 16 16 > 17 17 <span class="flex-shrink-0"><Users class="size-4" aria-hidden="true" /></span> 18 18 <span> 19 - <a href={resolve(`/${handle}?tab=followers` as '/')} class="no-underline hover:underline" 19 + <a href={resolve(`/${handle}?tab=followers` as "/")} class="no-underline hover:underline" 20 20 >{followers} followers</a 21 21 > 22 22 </span> 23 23 <span class="select-none after:content-['·']"></span> 24 24 <span> 25 - <a href={resolve(`/${handle}?tab=following` as '/')} class="no-underline hover:underline" 25 + <a href={resolve(`/${handle}?tab=following` as "/")} class="no-underline hover:underline" 26 26 >{following} following</a 27 27 > 28 28 </span>
+10 -10
web/src/lib/components/profile/counts.svelte.ts
··· 1 - import { getContext } from 'svelte'; 2 - import { createOptimisticCount, type OptimisticCount } from '$lib/optimistic.svelte'; 3 - import type { ProfileCounts } from './types'; 1 + import { getContext } from "svelte"; 2 + import { createOptimisticCount, type OptimisticCount } from "$lib/optimistic.svelte"; 3 + import type { ProfileCounts } from "./types"; 4 4 5 5 export type ProfileCountName = keyof ProfileCounts; 6 6 ··· 10 10 adjust(subjectDid: string, name: ProfileCountName, delta: 1 | -1): void; 11 11 } 12 12 13 - export const PROFILE_COUNTS_KEY = Symbol('profile-counts'); 13 + export const PROFILE_COUNTS_KEY = Symbol("profile-counts"); 14 14 15 15 export const createProfileCounts = ( 16 16 did: () => string, ··· 20 20 createOptimisticCount({ key: did, loaded: () => loaded()[name] }); 21 21 22 22 const counts = { 23 - repos: counter('repos'), 24 - stars: counter('stars'), 25 - strings: counter('strings'), 26 - followers: counter('followers'), 27 - following: counter('following'), 28 - vouches: counter('vouches') 23 + repos: counter("repos"), 24 + stars: counter("stars"), 25 + strings: counter("strings"), 26 + followers: counter("followers"), 27 + following: counter("following"), 28 + vouches: counter("vouches") 29 29 }; 30 30 31 31 return {
+4 -4
web/src/lib/components/profile/tabs/OverviewTab.svelte
··· 1 1 <script lang="ts"> 2 - import RepoCard from '$lib/components/repo/RepoCard.svelte'; 3 - import EmptyState from '../EmptyState.svelte'; 4 - import ActivityStub from '../ActivityStub.svelte'; 5 - import type { RepoCardData } from '../types'; 2 + import RepoCard from "$lib/components/repo/RepoCard.svelte"; 3 + import EmptyState from "../EmptyState.svelte"; 4 + import ActivityStub from "../ActivityStub.svelte"; 5 + import type { RepoCardData } from "../types"; 6 6 7 7 interface Props { 8 8 pinned: RepoCardData[];
+3 -3
web/src/lib/components/profile/tabs/PeopleTab.svelte
··· 1 1 <script lang="ts"> 2 - import FollowCard from '../FollowCard.svelte'; 3 - import EmptyState from '../EmptyState.svelte'; 4 - import type { PersonData } from '../types'; 2 + import FollowCard from "../FollowCard.svelte"; 3 + import EmptyState from "../EmptyState.svelte"; 4 + import type { PersonData } from "../types"; 5 5 6 6 interface Props { 7 7 people: PersonData[];
+3 -3
web/src/lib/components/profile/tabs/StringListTab.svelte
··· 1 1 <script lang="ts"> 2 - import StringCard from '../StringCard.svelte'; 3 - import EmptyState from '../EmptyState.svelte'; 4 - import type { StringCardData } from '../types'; 2 + import StringCard from "../StringCard.svelte"; 3 + import EmptyState from "../EmptyState.svelte"; 4 + import type { StringCardData } from "../types"; 5 5 6 6 let { strings }: { strings: StringCardData[] } = $props(); 7 7 </script>
+3 -3
web/src/lib/components/profile/tabs/VouchTab.svelte
··· 1 1 <script lang="ts"> 2 - import VouchCard from '../VouchCard.svelte'; 3 - import EmptyState from '../EmptyState.svelte'; 4 - import type { VouchData } from '../types'; 2 + import VouchCard from "../VouchCard.svelte"; 3 + import EmptyState from "../EmptyState.svelte"; 4 + import type { VouchData } from "../types"; 5 5 6 6 let { vouches }: { vouches: VouchData[] } = $props(); 7 7 </script>
+3 -3
web/src/lib/components/profile/types.ts
··· 58 58 did: string; 59 59 handle: string; 60 60 avatar?: string; 61 - kind: 'vouch' | 'denounce'; 61 + kind: "vouch" | "denounce"; 62 62 reason?: string; 63 63 createdAt: string; 64 64 } 65 65 66 66 export type StarData = 67 - | { kind: 'repo'; uri: string; createdAt: string; repo: RepoCardData } 68 - | { kind: 'string'; uri: string; createdAt: string; ownerHandle: string; rkey: string }; 67 + | { kind: "repo"; uri: string; createdAt: string; repo: RepoCardData } 68 + | { kind: "string"; uri: string; createdAt: string; ownerHandle: string; rkey: string };
+2 -2
web/src/lib/components/ui/Spinner.svelte
··· 1 1 <script lang="ts"> 2 - import LoaderCircle from '$icon/loader-circle'; 2 + import LoaderCircle from "$icon/loader-circle"; 3 3 4 4 interface Props { 5 5 class?: string; 6 6 } 7 7 8 - let { class: className = 'size-4' }: Props = $props(); 8 + let { class: className = "size-4" }: Props = $props(); 9 9 </script> 10 10 11 11 <LoaderCircle class={`${className} animate-spin`} aria-hidden="true" />
+17 -17
web/src/lib/format.ts
··· 1 1 const DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [ 2 - { amount: 60, unit: 'seconds' }, 3 - { amount: 60, unit: 'minutes' }, 4 - { amount: 24, unit: 'hours' }, 5 - { amount: 7, unit: 'days' }, 6 - { amount: 4.34524, unit: 'weeks' }, 7 - { amount: 12, unit: 'months' }, 8 - { amount: Number.POSITIVE_INFINITY, unit: 'years' } 2 + { amount: 60, unit: "seconds" }, 3 + { amount: 60, unit: "minutes" }, 4 + { amount: 24, unit: "hours" }, 5 + { amount: 7, unit: "days" }, 6 + { amount: 4.34524, unit: "weeks" }, 7 + { amount: 12, unit: "months" }, 8 + { amount: Number.POSITIVE_INFINITY, unit: "years" } 9 9 ]; 10 10 11 - const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); 12 - const dtf = new Intl.DateTimeFormat('en', { year: 'numeric', month: 'short', day: 'numeric' }); 11 + const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); 12 + const dtf = new Intl.DateTimeFormat("en", { year: "numeric", month: "short", day: "numeric" }); 13 13 14 14 // "3 days ago", "in 2 hours", etc. 15 15 export const relativeTime = (input: string | Date, now: Date = new Date()): string => { 16 - const date = typeof input === 'string' ? new Date(input) : input; 17 - if (Number.isNaN(date.getTime())) return ''; 16 + const date = typeof input === "string" ? new Date(input) : input; 17 + if (Number.isNaN(date.getTime())) return ""; 18 18 let duration = (date.getTime() - now.getTime()) / 1000; 19 19 for (const division of DIVISIONS) { 20 20 if (Math.abs(duration) < division.amount) 21 21 return rtf.format(Math.round(duration), division.unit); 22 22 duration /= division.amount; 23 23 } 24 - return rtf.format(Math.round(duration), 'years'); 24 + return rtf.format(Math.round(duration), "years"); 25 25 }; 26 26 export const compactRelativeTime = (input: string | Date, now: Date = new Date()): string => { 27 - const date = typeof input === 'string' ? new Date(input) : input; 28 - if (Number.isNaN(date.getTime())) return ''; 27 + const date = typeof input === "string" ? new Date(input) : input; 28 + if (Number.isNaN(date.getTime())) return ""; 29 29 let duration = Math.abs((now.getTime() - date.getTime()) / 1000); 30 - const suffix = date.getTime() > now.getTime() ? 'from now' : 'ago'; 30 + const suffix = date.getTime() > now.getTime() ? "from now" : "ago"; 31 31 32 32 if (duration < 60) return `${Math.floor(duration)}s ${suffix}`; 33 33 duration /= 60; ··· 45 45 }; 46 46 47 47 export const formatDate = (input: string | Date): string => { 48 - const date = typeof input === 'string' ? new Date(input) : input; 49 - return Number.isNaN(date.getTime()) ? '' : dtf.format(date); 48 + const date = typeof input === "string" ? new Date(input) : input; 49 + return Number.isNaN(date.getTime()) ? "" : dtf.format(date); 50 50 };
+7 -7
web/src/lib/server/config.ts
··· 1 - import { env } from '$env/dynamic/private'; 1 + import { env } from "$env/dynamic/private"; 2 2 3 3 const cleanUrl = (value: string | undefined, fallback: string) => { 4 4 const raw = value?.trim() || fallback; 5 - return raw.replace(/\/+$/, ''); 5 + return raw.replace(/\/+$/, ""); 6 6 }; 7 7 8 8 export type WebConfig = { ··· 14 14 15 15 export type PublicWebConfig = Pick< 16 16 WebConfig, 17 - 'bobbinUrl' | 'apiUrl' | 'knotResolverUrl' | 'camoUrl' 17 + "bobbinUrl" | "apiUrl" | "knotResolverUrl" | "camoUrl" 18 18 >; 19 19 20 20 type WebConfigEnv = { ··· 26 26 }; 27 27 28 28 export const resolveConfig = (values: WebConfigEnv): WebConfig => ({ 29 - bobbinUrl: cleanUrl(values.BOBBIN_URL, 'http://127.0.0.1:8090'), 30 - apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, 'http://127.0.0.1:8080'), 31 - knotResolverUrl: cleanUrl(values.KNOT_RESOLVER_URL, 'https://knot1.tangled.sh'), 32 - camoUrl: cleanUrl(values.CAMO_URL, 'https://camo.tangled.sh') 29 + bobbinUrl: cleanUrl(values.BOBBIN_URL, "http://127.0.0.1:8090"), 30 + apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, "http://127.0.0.1:8080"), 31 + knotResolverUrl: cleanUrl(values.KNOT_RESOLVER_URL, "https://knot1.tangled.sh"), 32 + camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh") 33 33 }); 34 34 35 35 export const getConfig = (): WebConfig => resolveConfig(env as WebConfigEnv);
+4 -4
web/src/routes/+layout.server.ts
··· 1 - import { getPublicConfig } from '$lib/server/config'; 2 - import type { LayoutServerLoad } from './$types'; 1 + import { getPublicConfig } from "$lib/server/config"; 2 + import type { LayoutServerLoad } from "./$types"; 3 3 4 4 export const load: LayoutServerLoad = ({ cookies }) => { 5 - const did = cookies.get('tangled.currentDid'); 6 - const handle = cookies.get('tangled.currentHandle'); 5 + const did = cookies.get("tangled.currentDid"); 6 + const handle = cookies.get("tangled.currentHandle"); 7 7 8 8 return { 9 9 publicConfig: getPublicConfig(),
+2 -2
web/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { getAuth } from '$lib/auth.svelte'; 3 - import MarketingHome from '$lib/components/home/MarketingHome.svelte'; 2 + import { getAuth } from "$lib/auth.svelte"; 3 + import MarketingHome from "$lib/components/home/MarketingHome.svelte"; 4 4 5 5 const auth = getAuth(); 6 6 </script>
+21 -21
web/src/routes/[handle]/+layout.ts
··· 1 - import { error, redirect } from '@sveltejs/kit'; 2 - import { createBobbinClient } from '$lib/api/client'; 3 - import { resolveMiniDoc } from '$lib/api/identity'; 4 - import { getProfile, type ProfileRecord } from '$lib/api/records'; 5 - import { count } from '$lib/api/count'; 6 - import { parallel, toHttpError, httpStatusFor } from '$lib/api/load'; 7 - import { ClientResponseError } from '$lib/api/client'; 8 - import { findFollowRkey } from '$lib/api/graph'; 9 - import type { ProfileCounts } from '$lib/components/profile/types'; 10 - import type { LayoutLoad } from './$types'; 1 + import { error, redirect } from "@sveltejs/kit"; 2 + import { createBobbinClient } from "$lib/api/client"; 3 + import { resolveMiniDoc } from "$lib/api/identity"; 4 + import { getProfile, type ProfileRecord } from "$lib/api/records"; 5 + import { count } from "$lib/api/count"; 6 + import { parallel, toHttpError, httpStatusFor } from "$lib/api/load"; 7 + import { ClientResponseError } from "$lib/api/client"; 8 + import { findFollowRkey } from "$lib/api/graph"; 9 + import type { ProfileCounts } from "$lib/components/profile/types"; 10 + import type { LayoutLoad } from "./$types"; 11 11 12 12 export const load: LayoutLoad = async (event) => { 13 13 const parent = await event.parent(); ··· 15 15 16 16 // actor identifiers are dids or dotted handles; reject bare words early so 17 17 // unrelated paths (/settings, /signup, ...) 404 instead of resolving. 18 - if (!identifier.startsWith('did:') && !identifier.includes('.')) { 19 - error(404, 'Not found'); 18 + if (!identifier.startsWith("did:") && !identifier.includes(".")) { 19 + error(404, "Not found"); 20 20 } 21 21 22 22 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 23 23 const doc = await resolveMiniDoc(ctx, identifier).catch((cause) => 24 - toHttpError(cause, 'Could not resolve user') 24 + toHttpError(cause, "Could not resolve user") 25 25 ); 26 26 27 27 // canonical url is the handle; redirect dids and stale handles. 28 - const canonical = doc.handle && !doc.handle.endsWith('.invalid') ? doc.handle : null; 28 + const canonical = doc.handle && !doc.handle.endsWith(".invalid") ? doc.handle : null; 29 29 if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { 30 30 redirect(307, `/${canonical}${event.url.search}`); 31 31 } ··· 38 38 profile = (await getProfile(ctx, did)).value; 39 39 } catch (cause) { 40 40 if (!(cause instanceof ClientResponseError && httpStatusFor(cause) === 404)) { 41 - toHttpError(cause, 'Could not load profile'); 41 + toHttpError(cause, "Could not load profile"); 42 42 } 43 43 } 44 44 45 45 const raw = await parallel({ 46 - repos: count(ctx, 'sh.tangled.repo.countRepos', did), 47 - strings: count(ctx, 'sh.tangled.string.countStrings', did), 48 - stars: count(ctx, 'sh.tangled.feed.countStarsBy', did), 49 - followers: count(ctx, 'sh.tangled.graph.countFollows', did), 50 - following: count(ctx, 'sh.tangled.graph.countFollowsBy', did), 51 - vouches: count(ctx, 'sh.tangled.graph.countVouches', did), 46 + repos: count(ctx, "sh.tangled.repo.countRepos", did), 47 + strings: count(ctx, "sh.tangled.string.countStrings", did), 48 + stars: count(ctx, "sh.tangled.feed.countStarsBy", did), 49 + followers: count(ctx, "sh.tangled.graph.countFollows", did), 50 + following: count(ctx, "sh.tangled.graph.countFollowsBy", did), 51 + vouches: count(ctx, "sh.tangled.graph.countVouches", did), 52 52 viewerFollowRkey: 53 53 viewerDid && viewerDid !== did 54 54 ? findFollowRkey(ctx, viewerDid, did).catch(() => null)
+13 -13
web/src/routes/[handle]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import OverviewTab from '$lib/components/profile/tabs/OverviewTab.svelte'; 3 - import PeopleTab from '$lib/components/profile/tabs/PeopleTab.svelte'; 4 - import RepoListTab from '$lib/components/profile/tabs/RepoListTab.svelte'; 5 - import StarredTab from '$lib/components/profile/tabs/StarredTab.svelte'; 6 - import StringListTab from '$lib/components/profile/tabs/StringListTab.svelte'; 7 - import VouchTab from '$lib/components/profile/tabs/VouchTab.svelte'; 2 + import OverviewTab from "$lib/components/profile/tabs/OverviewTab.svelte"; 3 + import PeopleTab from "$lib/components/profile/tabs/PeopleTab.svelte"; 4 + import RepoListTab from "$lib/components/profile/tabs/RepoListTab.svelte"; 5 + import StarredTab from "$lib/components/profile/tabs/StarredTab.svelte"; 6 + import StringListTab from "$lib/components/profile/tabs/StringListTab.svelte"; 7 + import VouchTab from "$lib/components/profile/tabs/VouchTab.svelte"; 8 8 9 9 let { data } = $props(); 10 10 </script> 11 11 12 - {#if data.tab === 'overview'} 12 + {#if data.tab === "overview"} 13 13 <OverviewTab pinned={data.overview.pinned} /> 14 - {:else if data.tab === 'repos'} 14 + {:else if data.tab === "repos"} 15 15 <RepoListTab repos={data.repos} /> 16 - {:else if data.tab === 'starred'} 16 + {:else if data.tab === "starred"} 17 17 <StarredTab stars={data.stars} /> 18 - {:else if data.tab === 'strings'} 18 + {:else if data.tab === "strings"} 19 19 <StringListTab strings={data.strings} /> 20 - {:else if data.tab === 'followers'} 20 + {:else if data.tab === "followers"} 21 21 <PeopleTab people={data.people} title="Followers" emptyMessage="No followers yet." /> 22 - {:else if data.tab === 'following'} 22 + {:else if data.tab === "following"} 23 23 <PeopleTab people={data.people} title="Following" emptyMessage="Not following anyone yet." /> 24 - {:else if data.tab === 'vouches'} 24 + {:else if data.tab === "vouches"} 25 25 <VouchTab vouches={data.vouches} /> 26 26 {/if}
+56 -56
web/src/routes/[handle]/+page.ts
··· 1 - import type { Did } from '@atcute/lexicons/syntax'; 2 - import { createBobbinClient } from '$lib/api/client'; 3 - import { fetchPage, items } from '$lib/api/pagination'; 4 - import { count } from '$lib/api/count'; 5 - import { getRepoByRepoDid, type RepoRecord } from '$lib/api/records'; 6 - import { IdentityCache } from '$lib/api/identity'; 7 - import { didFromUri, rkeyFromUri } from '$lib/api/uri'; 8 - import { toHttpError, parallel } from '$lib/api/load'; 9 - import { search } from '$lib/api/search'; 10 - import type { BobbinContext } from '$lib/api/client'; 11 - import { listStarRkeys, type VouchRecord, type FollowRecord } from '$lib/api/graph'; 12 - import type * as ShTangledFeedStar from '$lib/api/lexicons/types/sh/tangled/feed/star'; 13 - import type * as ShTangledString from '$lib/api/lexicons/types/sh/tangled/string'; 14 - import type * as ShTangledGraphFollow from '$lib/api/lexicons/types/sh/tangled/graph/follow'; 1 + import type { Did } from "@atcute/lexicons/syntax"; 2 + import { createBobbinClient } from "$lib/api/client"; 3 + import { fetchPage, items } from "$lib/api/pagination"; 4 + import { count } from "$lib/api/count"; 5 + import { getRepoByRepoDid, type RepoRecord } from "$lib/api/records"; 6 + import { IdentityCache } from "$lib/api/identity"; 7 + import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 8 + import { toHttpError, parallel } from "$lib/api/load"; 9 + import { search } from "$lib/api/search"; 10 + import type { BobbinContext } from "$lib/api/client"; 11 + import { listStarRkeys, type VouchRecord, type FollowRecord } from "$lib/api/graph"; 12 + import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; 13 + import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; 14 + import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; 15 15 import type { 16 16 RepoCardData, 17 17 StringCardData, 18 18 PersonData, 19 19 VouchData, 20 20 StarData 21 - } from '$lib/components/profile/types'; 22 - import type { PageLoad } from './$types'; 21 + } from "$lib/components/profile/types"; 22 + import type { PageLoad } from "./$types"; 23 23 24 24 const PAGE_LIMIT = 50; 25 25 26 26 const TABS = [ 27 - 'overview', 28 - 'repos', 29 - 'starred', 30 - 'strings', 31 - 'followers', 32 - 'following', 33 - 'vouches' 27 + "overview", 28 + "repos", 29 + "starred", 30 + "strings", 31 + "followers", 32 + "following", 33 + "vouches" 34 34 ] as const; 35 35 type Tab = (typeof TABS)[number]; 36 36 37 37 const normalizeTab = (raw: string | null): Tab => 38 - TABS.includes(raw as Tab) ? (raw as Tab) : 'overview'; 38 + TABS.includes(raw as Tab) ? (raw as Tab) : "overview"; 39 39 40 40 interface ListItem { 41 41 uri: string; ··· 47 47 return { 48 48 rkey: rkeyFromUri(item.uri), 49 49 name: value.name ?? rkeyFromUri(item.uri), 50 - repoDid: value.repoDid ?? '', 50 + repoDid: value.repoDid ?? "", 51 51 ownerHandle, 52 52 description: value.description, 53 53 knot: value.knot, ··· 69 69 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; 70 70 // TODO(bobbin): instead of doing this, listing repos should return star counts 71 71 // and most likely other stats as well. 72 - const stars = await count(ctx, 'sh.tangled.feed.countStars', repo.repoDid); 72 + const stars = await count(ctx, "sh.tangled.feed.countStars", repo.repoDid); 73 73 return { 74 74 ...repo, 75 75 stars: stars.count, ··· 87 87 filename: value.filename, 88 88 description: value.description, 89 89 createdAt: value.createdAt, 90 - lines: value.contents?.split('\n').length ?? 1 90 + lines: value.contents?.split("\n").length ?? 1 91 91 }; 92 92 }; 93 93 ··· 105 105 const counts = await parallel( 106 106 unique.reduce( 107 107 (acc, did) => { 108 - acc[`${did}-followers`] = count(ctx, 'sh.tangled.graph.countFollows', did) 108 + acc[`${did}-followers`] = count(ctx, "sh.tangled.graph.countFollows", did) 109 109 .then((result) => result.count) 110 110 .catch(() => 0); 111 - acc[`${did}-following`] = count(ctx, 'sh.tangled.graph.countFollowsBy', did) 111 + acc[`${did}-following`] = count(ctx, "sh.tangled.graph.countFollowsBy", did) 112 112 .then((result) => result.count) 113 113 .catch(() => 0); 114 114 return acc; ··· 121 121 if (viewerDid) { 122 122 for await (const item of items( 123 123 ctx, 124 - 'sh.tangled.graph.listFollowsBy', 124 + "sh.tangled.graph.listFollowsBy", 125 125 { subject: viewerDid as Did }, 126 126 { maxPages: 10 } 127 127 )) { ··· 167 167 did: voucher, 168 168 handle: doc?.handle ?? voucher, 169 169 avatar: doc?.avatar, 170 - kind: value.kind === 'denounce' ? 'denounce' : 'vouch', 170 + kind: value.kind === "denounce" ? "denounce" : "vouch", 171 171 reason: value.reason, 172 172 createdAt: value.createdAt 173 173 }; ··· 185 185 items.map(async (item): Promise<StarData | null> => { 186 186 const value = item.value as ShTangledFeedStar.Main; 187 187 const subject = value.subject; 188 - if (subject && 'did' in subject && subject.did) { 188 + if (subject && "did" in subject && subject.did) { 189 189 try { 190 190 const repo = await getRepoByRepoDid(ctx, subject.did); 191 191 const ownerDid = didFromUri(repo.uri); 192 192 const owner = await cache.resolve(ownerDid).catch(() => null); 193 193 return { 194 - kind: 'repo', 194 + kind: "repo", 195 195 uri: item.uri, 196 196 createdAt: value.createdAt, 197 197 repo: await resolveRepoCard(ctx, repo, owner?.handle ?? ownerDid, options) ··· 200 200 return null; 201 201 } 202 202 } 203 - if (subject && 'uri' in subject && subject.uri) { 203 + if (subject && "uri" in subject && subject.uri) { 204 204 const ownerDid = didFromUri(subject.uri); 205 205 const owner = await cache.resolve(ownerDid).catch(() => null); 206 206 return { 207 - kind: 'string', 207 + kind: "string", 208 208 uri: item.uri, 209 209 createdAt: value.createdAt, 210 210 ownerHandle: owner?.handle ?? ownerDid, ··· 219 219 220 220 export const load: PageLoad = async (event) => { 221 221 const parent = await event.parent(); 222 - const tab = normalizeTab(event.url.searchParams.get('tab')); 222 + const tab = normalizeTab(event.url.searchParams.get("tab")); 223 223 224 - if (parent.notJoined) return { tab: 'overview' as const, overview: { pinned: [] } }; 224 + if (parent.notJoined) return { tab: "overview" as const, overview: { pinned: [] } }; 225 225 226 226 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 227 227 const did = parent.identity.did as Did; ··· 229 229 230 230 try { 231 231 switch (tab) { 232 - case 'repos': { 233 - const q = event.url.searchParams.get('q')?.trim(); 232 + case "repos": { 233 + const q = event.url.searchParams.get("q")?.trim(); 234 234 const [found, viewerStarRkeys] = await Promise.all([ 235 235 q 236 - ? search(ctx, { q, nsid: 'sh.tangled.repo', author: did, limit: PAGE_LIMIT }).then( 236 + ? search(ctx, { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }).then( 237 237 (page) => page.hits 238 238 ) 239 - : fetchPage(ctx, 'sh.tangled.repo.listRepos', { subject: did, limit: PAGE_LIMIT }).then( 239 + : fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }).then( 240 240 (page) => page.items 241 241 ), 242 242 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined ··· 248 248 ) 249 249 }; 250 250 } 251 - case 'strings': { 252 - const page = await fetchPage(ctx, 'sh.tangled.string.listStrings', { 251 + case "strings": { 252 + const page = await fetchPage(ctx, "sh.tangled.string.listStrings", { 253 253 subject: did, 254 254 limit: PAGE_LIMIT 255 255 }); 256 256 return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; 257 257 } 258 - case 'followers': { 259 - const page = await fetchPage(ctx, 'sh.tangled.graph.listFollows', { 258 + case "followers": { 259 + const page = await fetchPage(ctx, "sh.tangled.graph.listFollows", { 260 260 subject: did, 261 261 limit: PAGE_LIMIT 262 262 }); ··· 266 266 people: await resolvePeople(ctx, dids, parent.auth?.did) 267 267 }; 268 268 } 269 - case 'following': { 270 - const page = await fetchPage(ctx, 'sh.tangled.graph.listFollowsBy', { 269 + case "following": { 270 + const page = await fetchPage(ctx, "sh.tangled.graph.listFollowsBy", { 271 271 subject: did, 272 272 limit: PAGE_LIMIT 273 273 }); ··· 277 277 people: await resolvePeople(ctx, dids, parent.auth?.did) 278 278 }; 279 279 } 280 - case 'vouches': { 281 - const page = await fetchPage(ctx, 'sh.tangled.graph.listVouches', { 280 + case "vouches": { 281 + const page = await fetchPage(ctx, "sh.tangled.graph.listVouches", { 282 282 subject: did, 283 283 limit: PAGE_LIMIT 284 284 }); 285 285 return { tab, vouches: await resolveVouches(ctx, page.items) }; 286 286 } 287 - case 'starred': { 287 + case "starred": { 288 288 const [page, viewerStarRkeys] = await Promise.all([ 289 - fetchPage(ctx, 'sh.tangled.feed.listStarsBy', { subject: did, limit: PAGE_LIMIT }), 289 + fetchPage(ctx, "sh.tangled.feed.listStarsBy", { subject: did, limit: PAGE_LIMIT }), 290 290 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 291 291 ]); 292 292 return { ··· 294 294 stars: await resolveStars(ctx, page.items, { viewerStarRkeys }) 295 295 }; 296 296 } 297 - case 'overview': 297 + case "overview": 298 298 default: { 299 299 const [page, viewerStarRkeys] = await Promise.all([ 300 - fetchPage(ctx, 'sh.tangled.repo.listRepos', { subject: did, limit: PAGE_LIMIT }), 300 + fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }), 301 301 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 302 302 ]); 303 303 ··· 315 315 pinnedItems.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) 316 316 ); 317 317 318 - return { tab: 'overview' as const, overview: { pinned } }; 318 + return { tab: "overview" as const, overview: { pinned } }; 319 319 } 320 320 } 321 321 } catch (cause) { 322 - console.error('Page load error:', cause); 323 - toHttpError(cause, 'Could not load profile data'); 322 + console.error("Page load error:", cause); 323 + toHttpError(cause, "Could not load profile data"); 324 324 } 325 325 };
+1 -1
web/src/routes/about/+page.svelte
··· 1 1 <script lang="ts"> 2 - import MarketingHome from '$lib/components/home/MarketingHome.svelte'; 2 + import MarketingHome from "$lib/components/home/MarketingHome.svelte"; 3 3 </script> 4 4 5 5 <svelte:head>
+3 -3
web/svelte.config.js
··· 1 - import adapter from '@sveltejs/adapter-node'; 2 - import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 1 + import adapter from "@sveltejs/adapter-node"; 2 + import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 3 3 4 4 const config = { 5 5 preprocess: vitePreprocess(), 6 6 compilerOptions: { 7 - runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) 7 + runes: ({ filename }) => (filename.split(/[/\\]/).includes("node_modules") ? undefined : true) 8 8 }, 9 9 kit: { 10 10 adapter: adapter()