diff --git a/.gitignore b/.gitignore index 3888c87..d698f31 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules public/viz/dist.js public/editor/dist.js public/dist.css +public/htmx.min.js public/katex.css public/fonts/ .env diff --git a/bun.lock b/bun.lock index 620537e..f21e634 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,7 @@ "@types/diff-match-patch": "^1.0.36", "@types/markdown-it": "^14.1.2", "@typescript/native-preview": "^7.0.0-dev.20260322.1", + "htmx.org": "^2.0.10", "knip": "^6.0.2", "tailwindcss": "^4.2.1", }, @@ -526,6 +527,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "htmx.org": ["htmx.org@2.0.10", "", {}, "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], diff --git a/deploy/Caddyfile b/deploy/Caddyfile index f7b7eee..87cb9bf 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -25,6 +25,13 @@ lichen.wiki { header Cache-Control "public, max-age=2" reverse_proxy localhost:3000 } + + # Override the long immutable cache on any error so a stray 404 (missing + # build artifact, typo'd asset path) can't get pinned at the CDN edge. + handle_errors { + header Cache-Control "public, max-age=60" + respond "{err.status_code} {err.status_text}" {err.status_code} + } } stats.lichen.wiki { diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 59fece6..fa2bc59 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -14,6 +14,7 @@ log "==> Building assets" bun run build:css bun run build:editor bun run build:viz +bun run build:htmx log "==> Backing up DB and previous deploy on VPS" ssh "$VPS" " diff --git a/knip.config.ts b/knip.config.ts index 89766e4..b6f341a 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -7,7 +7,7 @@ const config: KnipConfig = { "public/viz/viz-hydrate.ts", ], project: ["**/*.{ts,tsx,mjs}!"], - ignoreDependencies: ["tailwindcss", "@tailwindcss/typography"], + ignoreDependencies: ["tailwindcss", "@tailwindcss/typography", "htmx.org"], includeEntryExports: true, treatConfigHintsAsErrors: true, }; diff --git a/lexicons/wiki.lichen.wiki.json b/lexicons/wiki.lichen.wiki.json index 4ed23c0..589d8a8 100644 --- a/lexicons/wiki.lichen.wiki.json +++ b/lexicons/wiki.lichen.wiki.json @@ -37,41 +37,10 @@ "description": "Short description of the wiki's purpose or topic." }, "theme": { - "type": "object", - "description": "Optional visual theme enforced by the wiki. Mirrors the shape of site.standard.theme.basic so other atproto apps can honor it. When present, visitors see this color scheme regardless of their reader preference; when absent, the reader's preferred theme is used.", - "required": [ - "background", - "foreground", - "accent", - "accentForeground" - ], - "properties": { - "background": { - "type": "union", - "refs": ["site.standard.theme.color#rgb"], - "description": "Color used for content background." - }, - "foreground": { - "type": "union", - "refs": ["site.standard.theme.color#rgb"], - "description": "Color used for content text." - }, - "accent": { - "type": "union", - "refs": ["site.standard.theme.color#rgb"], - "description": "Color used for links and button backgrounds." - }, - "accentForeground": { - "type": "union", - "refs": ["site.standard.theme.color#rgb"], - "description": "Color used for button text." - } - } - }, - "themeId": { "type": "string", "maxLength": 64, - "description": "Lichen-internal theme preset identifier (e.g. 'light', 'dark'). Lets the appview restore the named preset on round-trip even if a preset's hex values drift between versions. Foreign clients can ignore this and read 'theme' directly." + "knownValues": ["light", "dark"], + "description": "Optional theme preference set by the wiki creator (e.g. 'light', 'dark'). When present, clients should render with this theme; when absent, the viewer's own preference is used. Apps that don't recognize the name should fall back to their default." } } } diff --git a/package.json b/package.json index fb9c616..20e5219 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "build:viz": "bun build public/viz/viz-hydrate.ts --outfile public/viz/dist.js --minify", "dev:viz": "bun build public/viz/viz-hydrate.ts --outfile public/viz/dist.js --watch", "build:editor": "bun build public/editor/editor.ts --outfile public/editor/dist.js --minify", - "dev:editor": "bun build public/editor/editor.ts --outfile public/editor/dist.js --watch" + "dev:editor": "bun build public/editor/editor.ts --outfile public/editor/dist.js --watch", + "build:htmx": "cp node_modules/htmx.org/dist/htmx.min.js public/htmx.min.js" }, "devDependencies": { "@biomejs/biome": "^2.4.4", @@ -29,6 +30,7 @@ "@types/diff-match-patch": "^1.0.36", "@types/markdown-it": "^14.1.2", "@typescript/native-preview": "^7.0.0-dev.20260322.1", + "htmx.org": "^2.0.10", "knip": "^6.0.2", "tailwindcss": "^4.2.1" }, diff --git a/src/atproto/pds.ts b/src/atproto/pds.ts index e68f466..8aeb71d 100644 --- a/src/atproto/pds.ts +++ b/src/atproto/pds.ts @@ -15,53 +15,6 @@ export interface RevisionBlob { size: number; } -/** - * Theme payload written to a wiki record on PDS. The shape mirrors - * `site.standard.theme.basic` for cross-app interop; `themeId` lets us - * restore the named preset on round-trip even if hex values drift. - */ -export interface ThemePdsPayload { - themeId: string; - background: string; - foreground: string; - accent: string; - accentForeground: string; -} - -interface RgbColor { - $type: "site.standard.theme.color#rgb"; - r: number; - g: number; - b: number; -} - -function hexToRgb(hex: string): RgbColor { - const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); - if (!m) { - throw new Error(`Invalid hex color: ${hex}`); - } - return { - $type: "site.standard.theme.color#rgb", - r: Number.parseInt(m[1] as string, 16), - g: Number.parseInt(m[2] as string, 16), - b: Number.parseInt(m[3] as string, 16), - }; -} - -function buildThemeRecord(theme: ThemePdsPayload): { - background: RgbColor; - foreground: RgbColor; - accent: RgbColor; - accentForeground: RgbColor; -} { - return { - background: hexToRgb(theme.background), - foreground: hexToRgb(theme.foreground), - accent: hexToRgb(theme.accent), - accentForeground: hexToRgb(theme.accentForeground), - }; -} - async function putRecord( rpc: Client, did: string, @@ -91,7 +44,7 @@ export function writeWikiRecord( createdAt: string, language: string, description?: string, - theme?: ThemePdsPayload, + theme?: string, ): Promise { const fields: Record = { name, @@ -100,10 +53,7 @@ export function writeWikiRecord( createdAt, }; if (description) fields["description"] = description; - if (theme) { - fields["theme"] = buildThemeRecord(theme); - fields["themeId"] = theme.themeId; - } + if (theme) fields["theme"] = theme; return putRecord(rpc, did, COLLECTIONS.wiki, slug, fields); } diff --git a/src/firehose/handlers.ts b/src/firehose/handlers.ts index 0ba26d7..039e2a2 100644 --- a/src/firehose/handlers.ts +++ b/src/firehose/handlers.ts @@ -24,7 +24,7 @@ interface WikiRecord { createdAt: string; language?: string; description?: string; - themeId?: string; + theme?: string; } interface NoteRecord { @@ -269,13 +269,12 @@ function handleWiki( record.description ?? "", ); - // Theme sync from PDS. Presence of a known themeId means the wiki enforces - // that preset; absence (or an unknown id from a future Lichen build, or a - // foreign client that wrote `theme` without `themeId`) falls back to - // reader mode so visitors keep their own theme. - const themeId = record.themeId; - if (typeof themeId === "string" && themeId in themes) { - setWikiTheme(rkey, "enforce", themeId); + // Theme sync from PDS. A known theme name means the wiki enforces that + // preset. Absence (or an unknown name from a future Lichen build / another + // app's preset) falls back to reader mode so visitors keep their own theme. + const theme = record.theme; + if (typeof theme === "string" && theme in themes) { + setWikiTheme(rkey, "enforce", theme); } else { setWikiTheme(rkey, "reader", "light"); } diff --git a/src/lib/orchestrators/wiki.ts b/src/lib/orchestrators/wiki.ts index 9d3ed96..c662e46 100644 --- a/src/lib/orchestrators/wiki.ts +++ b/src/lib/orchestrators/wiki.ts @@ -1,7 +1,6 @@ import * as TID from "@atcute/tid"; import { deleteRecord, - type ThemePdsPayload, writeMembershipRecord, writeNoteRecord, writeRevisionRecord, @@ -36,32 +35,17 @@ export interface WikiFormFields { } /** - * Build the standard PDS theme payload from a known preset key. Buttons in - * Lichen always render text-white, so accentForeground is fixed. - */ -function buildThemePdsPayload(themeId: keyof typeof themes): ThemePdsPayload { - const t = themes[themeId]; - return { - themeId, - background: t.bg, - foreground: t.text, - accent: t.accent, - accentForeground: "#ffffff", - }; -} - -/** - * Resolve the theme payload to write alongside a wiki record on PDS. - * Returns undefined when the wiki is in "reader" mode so the field is + * Resolve the theme value to write on PDS. Returns the theme name when the + * wiki enforces a theme; undefined when in "reader" mode so the field is * omitted from the record entirely. */ function themePayloadFor( themeMode: string, themeKey: string, -): ThemePdsPayload | undefined { +): string | undefined { if (themeMode !== "enforce") return undefined; if (!(themeKey in themes)) return undefined; - return buildThemePdsPayload(themeKey as keyof typeof themes); + return themeKey; } interface WikiCoreResult { diff --git a/src/lib/profile.ts b/src/lib/profile.ts index 6b6060a..b707e6d 100644 --- a/src/lib/profile.ts +++ b/src/lib/profile.ts @@ -12,6 +12,13 @@ export interface ProfileInfo { avatar: string | null; } +// Bluesky's CDN serves a ~150px variant at /img/avatar_thumbnail/. We display +// avatars at 16–64px, so the full 1000px asset is wasted bytes (~75 KiB each). +export function thumbnailAvatar(url: string | null): string | null { + if (!url) return null; + return url.replace("/img/avatar/", "/img/avatar_thumbnail/"); +} + /** * Resolve a handle or DID string to a DID. * If input already starts with "did:", returns it unchanged. @@ -60,7 +67,7 @@ export async function resolveProfile( return { handle: cached.handle, displayName: cached.display_name, - avatar: cached.avatar, + avatar: thumbnailAvatar(cached.avatar), }; } } @@ -87,7 +94,7 @@ export async function resolveProfile( const result: ProfileInfo = { handle: handle ?? profile.handle ?? null, displayName: profile.displayName ?? null, - avatar: profile.avatar ?? null, + avatar: thumbnailAvatar(profile.avatar ?? null), }; if (fetchFn === fetch) { diff --git a/src/server/app.ts b/src/server/app.ts index 103f4ae..92d5305 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -14,6 +14,7 @@ import { noteRoutes } from "./routes/note.ts"; import { ogRoutes } from "./routes/og.ts"; import { profileRoutes } from "./routes/profile.ts"; import { searchRoutes } from "./routes/search.ts"; +import { seoRoutes } from "./routes/seo.ts"; import { themeRoutes } from "./routes/theme.ts"; import { wikiRoutes } from "./routes/wiki.ts"; @@ -49,6 +50,7 @@ export function buildApp() { .use(profileRoutes) .use(ogRoutes) .use(homeRoute) + .use(seoRoutes) .use(exploreRoutes) .use(searchRoutes) .use(membershipRoutes) diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index bde731f..4c60d26 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -52,6 +52,8 @@ export { getWikiLanguages, listCollaboratingWikis, listOwnedWikis, + listPublicNotesForSitemap, + listPublicWikisForSitemap, listPublicWikisPaginated, setWikiTheme, upsertWiki, diff --git a/src/server/db/queries/wiki.ts b/src/server/db/queries/wiki.ts index 9a6f06d..32fcfe1 100644 --- a/src/server/db/queries/wiki.ts +++ b/src/server/db/queries/wiki.ts @@ -76,6 +76,44 @@ export function getWikiLanguages(): string[] { return rows.map((r) => r.language); } +interface SitemapWikiEntry { + slug: string; + updated_at: string; +} + +interface SitemapNoteEntry { + wiki_slug: string; + note_slug: string; + updated_at: string; +} + +// Public wikis only — private wikis must never appear in the sitemap. +export function listPublicWikisForSitemap(): SitemapWikiEntry[] { + const db = getDb(); + return db + .query( + `SELECT slug, updated_at FROM wikis + WHERE visibility = 'public' + ORDER BY updated_at DESC`, + ) + .all() as SitemapWikiEntry[]; +} + +// Falls back to note.created_at when current_note has no row (e.g. firehose race). +export function listPublicNotesForSitemap(): SitemapNoteEntry[] { + const db = getDb(); + return db + .query( + `SELECT n.wiki_slug, n.slug as note_slug, + COALESCE(c.updated_at, n.created_at) as updated_at + FROM notes n + INNER JOIN wikis w ON w.slug = n.wiki_slug AND w.visibility = 'public' + LEFT JOIN current_note c ON c.note_at_uri = n.at_uri + ORDER BY updated_at DESC`, + ) + .all() as SitemapNoteEntry[]; +} + export function getWiki(slug: string): WikiRow | null { const db = getDb(); return ( diff --git a/src/server/routes/seo.ts b/src/server/routes/seo.ts new file mode 100644 index 0000000..05e7883 --- /dev/null +++ b/src/server/routes/seo.ts @@ -0,0 +1,85 @@ +import { Elysia } from "elysia"; +import { getAtprotoEnv } from "../../atproto/env.ts"; +import { escapeHtml } from "../../lib/html.ts"; +import { noteUrl, wikiUrl } from "../../lib/urls.ts"; +import { + listPublicNotesForSitemap, + listPublicWikisForSitemap, +} from "../db/queries/index.ts"; + +function originFor(request: Request): string { + return getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; +} + +// SQLite returns updated_at as either ISO 8601 (the wikis seed) or +// "YYYY-MM-DD HH:MM:SS" (datetime('now') default). Sitemap requires W3C +// datetime — we coerce both to ISO via Date. +function toW3C(value: string): string { + const normalized = value.includes("T") + ? value + : `${value.replace(" ", "T")}Z`; + const d = new Date(normalized); + return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); +} + +export const seoRoutes = new Elysia() + .get("/robots.txt", ({ request }) => { + const origin = originFor(request); + const body = `User-agent: * +Allow: / +Disallow: /api/ +Disallow: /login +Disallow: /logout +Disallow: /search +Disallow: /set-locale +Disallow: /set-theme +Disallow: /profile-redirect +Disallow: /wiki/new +Disallow: /wiki/*/new +Disallow: /wiki/*/edit +Disallow: /wiki/*/-/ + +Sitemap: ${origin}/sitemap.xml +`; + return new Response(body, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }); + }) + .get("/sitemap.xml", ({ request }) => { + const origin = originFor(request); + const wikis = listPublicWikisForSitemap(); + const notes = listPublicNotesForSitemap(); + + const urls: string[] = []; + urls.push( + `${escapeHtml(origin)}/daily1.0`, + `${escapeHtml(origin)}/exploredaily0.8`, + ); + + for (const w of wikis) { + urls.push( + `${escapeHtml(origin + wikiUrl(w.slug))}${toW3C(w.updated_at)}weekly0.7`, + ); + } + + for (const n of notes) { + urls.push( + `${escapeHtml(origin + noteUrl(n.wiki_slug, n.note_slug))}${toW3C(n.updated_at)}weekly0.6`, + ); + } + + const body = ` + +${urls.join("\n")} +`; + + return new Response(body, { + headers: { + "Content-Type": "application/xml; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }); + }); diff --git a/src/views/access-denied.ts b/src/views/access-denied.ts index ac67851..5323816 100644 --- a/src/views/access-denied.ts +++ b/src/views/access-denied.ts @@ -37,6 +37,6 @@ export function accessDeniedPage( ${actionHtml} `, - options, + { ...options, noindex: true }, ); } diff --git a/src/views/edit-note.ts b/src/views/edit-note.ts index 07a8d49..a9ec1f3 100644 --- a/src/views/edit-note.ts +++ b/src/views/edit-note.ts @@ -95,5 +95,6 @@ export function editNotePage( ...options, wikiName, wikiSlug, + noindex: true, }); } diff --git a/src/views/layout.ts b/src/views/layout.ts index e49a50c..ac3efa7 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -44,6 +44,9 @@ export interface LayoutOptions { wikiThemeMode?: WikiThemeMode; wikiTheme?: ThemeName; enableSearchShortcut?: boolean; + // Auth-gated and access-restricted pages set this so search engines don't + // index login forms, edit screens, settings, or access-denied pages. + noindex?: boolean; } // Defaults so even pages without per-page overrides emit a usable Bluesky/Twitter card. @@ -243,6 +246,8 @@ export function layout( : "Lichen", )} + ${options?.noindex ? `` : ""} + ${renderOpenGraph(options, title)} diff --git a/src/views/login.ts b/src/views/login.ts index 84070bd..c0d01ff 100644 --- a/src/views/login.ts +++ b/src/views/login.ts @@ -44,6 +44,6 @@ export function loginPage(options?: LoginOptions): string { `, - options, + { ...options, noindex: true }, ); } diff --git a/src/views/new-note.ts b/src/views/new-note.ts index 259c42b..ce18fc8 100644 --- a/src/views/new-note.ts +++ b/src/views/new-note.ts @@ -112,6 +112,6 @@ export function newNotePage( `, - { ...options, wikiName, wikiSlug }, + { ...options, wikiName, wikiSlug, noindex: true }, ); } diff --git a/src/views/new-wiki.ts b/src/views/new-wiki.ts index 2701906..2d078b8 100644 --- a/src/views/new-wiki.ts +++ b/src/views/new-wiki.ts @@ -117,6 +117,6 @@ export function newWikiPage(options?: NewWikiOptions): string { `, - options, + { ...options, noindex: true }, ); } diff --git a/src/views/profile.ts b/src/views/profile.ts index 8dc113e..4590904 100644 --- a/src/views/profile.ts +++ b/src/views/profile.ts @@ -86,6 +86,6 @@ export function profilePage( `, - options, + { ...options, noindex: true }, ); } diff --git a/src/views/settings.ts b/src/views/settings.ts index 3c6e965..2dc0374 100644 --- a/src/views/settings.ts +++ b/src/views/settings.ts @@ -376,6 +376,6 @@ export function settingsPage( return layout( `${msg.settings.heading} — ${wikiName}`, `${backLink}

${msg.settings.heading}

${errorHtml}${detailsHtml}${themeHtml}${membersHtml}${dangerHtml}`, - { ...options, wikiName, wikiSlug }, + { ...options, wikiName, wikiSlug, noindex: true }, ); } diff --git a/src/views/wiki-card.ts b/src/views/wiki-card.ts index b36d70b..01fb469 100644 --- a/src/views/wiki-card.ts +++ b/src/views/wiki-card.ts @@ -1,5 +1,6 @@ import { escapeHtml } from "../lib/html.ts"; import { fmt, type Locale, t } from "../lib/i18n/index.ts"; +import { thumbnailAvatar } from "../lib/profile.ts"; import type { WikiWithNoteCount } from "../server/db/queries/index.ts"; import { THEME } from "./theme/index.ts"; @@ -17,9 +18,10 @@ export function wikiCard(wiki: WikiWithNoteCount, locale: Locale): string { const ownerHandle = wiki.owner_handle ? `${escapeHtml(wiki.owner_handle)}` : ""; - const ownerAvatar = wiki.owner_avatar + const ownerAvatarUrl = thumbnailAvatar(wiki.owner_avatar); + const ownerAvatar = ownerAvatarUrl ? ` { expect(wiki).toBeNull(); }); - test("applies enforced theme from themeId on the wiki record", () => { + test("applies enforced theme from theme on the wiki record", () => { handleCommitEvent( makeCommitEvt({ event: "create", @@ -189,7 +189,7 @@ describe("wiki handler", () => { name: "Themed Wiki", visibility: "public", createdAt: "2026-01-01T00:00:00.000Z", - themeId: "dark", + theme: "dark", }, }), ); @@ -199,7 +199,7 @@ describe("wiki handler", () => { expect(wiki?.theme).toBe("dark"); }); - test("falls back to reader mode for unknown themeId", () => { + test("falls back to reader mode for unknown theme name", () => { handleCommitEvent( makeCommitEvt({ event: "create", @@ -220,7 +220,7 @@ describe("wiki handler", () => { expect(wiki?.theme).toBe("light"); }); - test("clears enforce mode when themeId is removed on update", () => { + test("clears enforce mode when theme is removed on update", () => { handleCommitEvent( makeCommitEvt({ event: "create", @@ -231,7 +231,7 @@ describe("wiki handler", () => { name: "Toggle Theme Wiki", visibility: "public", createdAt: "2026-01-01T00:00:00.000Z", - themeId: "dark", + theme: "dark", }, }), ); diff --git a/tests/lib/orchestrators/wiki.test.ts b/tests/lib/orchestrators/wiki.test.ts index 5025c6b..786140d 100644 --- a/tests/lib/orchestrators/wiki.test.ts +++ b/tests/lib/orchestrators/wiki.test.ts @@ -375,9 +375,7 @@ describe("setWikiThemeAction", () => { expect(mockWriteWikiRecord).toHaveBeenCalledTimes(1); const call = mockWriteWikiRecord.mock.calls[0] as unknown[]; - const themeArg = call[8] as { themeId: string } | undefined; - expect(themeArg).toBeDefined(); - expect(themeArg?.themeId).toBe("dark"); + expect(call[8]).toBe("dark"); }); test("strips theme from PDS record in reader mode", async () => {