diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 79f0a56..f7b7eee 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -14,7 +14,7 @@ lichen.wiki { # Static assets served from disk handle /public/* { uri strip_prefix /public - root * /var/www/lichen/public + root * /opt/lichen/public file_server header Cache-Control "public, max-age=31536000, immutable" diff --git a/src/atproto/session.ts b/src/atproto/session.ts index 9ac7699..cb413cb 100644 --- a/src/atproto/session.ts +++ b/src/atproto/session.ts @@ -8,6 +8,7 @@ import { getAtprotoEnv, getDevAccounts } from "./env.ts"; export interface Session { did: string; handle: string; + avatar?: string | null; oauthSession?: OAuthSession; } @@ -25,7 +26,12 @@ async function getSession( try { const oauthSession = await client.restore(did as Did); const profile = await resolveProfile(did); - return { did, handle: profile.handle ?? did, oauthSession }; + return { + did, + handle: profile.handle ?? did, + avatar: profile.avatar, + oauthSession, + }; } catch { return null; } @@ -48,7 +54,7 @@ function getDevSession(cookieHeader: string | undefined): Session | null { const account = Object.values(accounts).find((a) => a.did === did); if (!account) return null; - return { did, handle: account.handle }; + return { did, handle: account.handle, avatar: null }; } /** diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 5cccc23..55956b7 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -141,12 +141,8 @@ export const en: Messages = { save: "Save changes", detailsSaved: "Wiki details saved.", theme: "Theme", - themeModeLabel: "Theme behavior", - themeReader: "Reader's choice", - themeReaderHint: "Visitors see the wiki in their preferred theme.", - themeEnforce: "Enforce a theme", - themeEnforceHint: "All visitors see the wiki in the theme you pick below.", - themePresetLabel: "Preset", + themeChoiceLabel: "Theme", + themeReaderDefault: "User's default", themeSaved: "Theme saved.", dangerZone: "Delete this wiki", deleteWikiDescription: diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index 8ac0d1e..fe0bac4 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -143,13 +143,8 @@ export const fr: PartialMessages = { save: "Enregistrer les modifications", detailsSaved: "Détails du wiki enregistrés.", theme: "Thème", - themeModeLabel: "Comportement du thème", - themeReader: "Choix du lecteur", - themeReaderHint: "Les visiteurs voient le wiki dans leur thème préféré.", - themeEnforce: "Imposer un thème", - themeEnforceHint: - "Tous les visiteurs voient le wiki dans le thème choisi ci-dessous.", - themePresetLabel: "Préréglage", + themeChoiceLabel: "Thème", + themeReaderDefault: "Choix du lecteur", themeSaved: "Thème enregistré.", dangerZone: "Supprimer ce wiki", deleteWikiDescription: diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index c3bd131..775a56b 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -141,12 +141,8 @@ export interface Messages { save: string; detailsSaved: string; theme: string; - themeModeLabel: string; - themeReader: string; - themeReaderHint: string; - themeEnforce: string; - themeEnforceHint: string; - themePresetLabel: string; + themeChoiceLabel: string; + themeReaderDefault: string; themeSaved: string; dangerZone: string; deleteWikiDescription: string; diff --git a/src/lib/og-card.ts b/src/lib/og-card.ts new file mode 100644 index 0000000..674eee9 --- /dev/null +++ b/src/lib/og-card.ts @@ -0,0 +1,231 @@ +import sharp from "sharp"; +import { escapeHtml } from "./html.ts"; + +const OG_CARD_WIDTH = 1200; +const OG_CARD_HEIGHT = 630; + +// OG cards always render against the light palette so previews look the same +// in every Bluesky/Discord/Twitter feed regardless of the viewer's device theme. +const PALETTE = { + bg: "#fafaf9", + surface: "#ffffff", + border: "#e7e5e4", + text: "#1c1917", + textSecondary: "#44403c", + textMuted: "#78716c", + accent: "#0f766e", + accentSoft: "#f0fdfa", +}; + +const FONT_STACK = + "system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif"; + +// Geometry copied verbatim from public/logo.svg so the card mark matches the +// favicon and nav logo. Re-centred to 0,0 with a viewBox the size of the path. +const LICHEN_LOGO_SVG = (size: number, color: string): string => + ``; + +/** + * Greedy word-wrap on character count. Approximate but works fine for sans-serif + * at the sizes we use (a real metric-aware wrapper would need font loading and + * isn't worth the complexity for an OG card). + */ +function wrapText( + text: string, + maxCharsPerLine: number, + maxLines: number, +): string[] { + const words = text.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let current = ""; + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length <= maxCharsPerLine) { + current = candidate; + continue; + } + if (current) lines.push(current); + current = word; + if (lines.length >= maxLines) break; + } + if (current && lines.length < maxLines) lines.push(current); + if (lines.length === maxLines) { + const last = lines[maxLines - 1] ?? ""; + const remainingWords = words.slice( + lines.flatMap((l) => l.split(/\s+/)).length, + ); + if (remainingWords.length > 0) { + const truncated = + last.length > maxCharsPerLine - 1 + ? `${last.slice(0, maxCharsPerLine - 1)}…` + : `${last}…`; + lines[maxLines - 1] = truncated; + } + } + return lines; +} + +function truncate(text: string, maxChars: number): string { + return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text; +} + +interface BrandingArgs { + x: number; + y: number; +} + +function brandingMark({ x, y }: BrandingArgs): string { + const logoSize = 36; + return ` + ${LICHEN_LOGO_SVG(logoSize, PALETTE.accent)} + Lichen + `; +} + +interface WikiCardData { + name: string; + description: string; + language: string | null; + noteCount: number; + ownerHandle: string | null; +} + +export function buildWikiCardSvg(data: WikiCardData): string { + const PAD = 60; + const INNER_PAD = 64; + const innerX = PAD; + const innerY = PAD; + const innerW = OG_CARD_WIDTH - PAD * 2; + const innerH = OG_CARD_HEIGHT - PAD * 2; + + const contentX = innerX + INNER_PAD; + const contentRight = innerX + innerW - INNER_PAD; + + const wikiNameTruncated = truncate(data.name, 28); + const descLines = wrapText(data.description ?? "", 50, 3); + + const langBadge = data.language + ? ` + + ${escapeHtml(data.language.toUpperCase())} + ` + : ""; + + const descY = data.language ? 320 : 280; + const descSvg = descLines + .map( + (line, i) => + `${escapeHtml(line)}`, + ) + .join(""); + + const footerY = OG_CARD_HEIGHT - PAD - INNER_PAD - 18; + const noteLabel = `${data.noteCount} ${data.noteCount === 1 ? "note" : "notes"}`; + + const ownerBlock = data.ownerHandle + ? `${escapeHtml(data.ownerHandle)} + + + ` + : ""; + + return ` + + + ${brandingMark({ x: contentX, y: innerY + 32 })} + ${escapeHtml(wikiNameTruncated)} + ${langBadge} + ${descSvg} + + ${escapeHtml(noteLabel)} + ${ownerBlock} + `; +} + +export function buildHomeCardSvg(): string { + const PAD = 60; + const innerX = PAD; + const innerY = PAD; + const innerW = OG_CARD_WIDTH - PAD * 2; + const innerH = OG_CARD_HEIGHT - PAD * 2; + + const centerX = OG_CARD_WIDTH / 2; + const logoSize = 160; + const logoY = innerY + 100; + + return ` + + + ${LICHEN_LOGO_SVG(logoSize, PALETTE.accent)} + Lichen + Shared knowledge, owned by you + lichen.wiki · built on ATProto + `; +} + +/** + * Fetch a Bluesky-CDN avatar and shape it into a circular PNG sized for the + * card footer. Returns null on any failure so the card still renders without + * the avatar (a placeholder circle is drawn into the SVG underneath). + */ +async function fetchAvatarCircle( + url: string, + diameter: number, +): Promise { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(3000) }); + if (!res.ok) return null; + const bytes = Buffer.from(await res.arrayBuffer()); + const resized = await sharp(bytes) + .resize(diameter, diameter, { fit: "cover" }) + .png() + .toBuffer(); + const mask = Buffer.from( + ``, + ); + return await sharp(resized) + .composite([{ input: mask, blend: "dest-in" }]) + .png() + .toBuffer(); + } catch { + return null; + } +} + +/** + * Render an OG card SVG to PNG, optionally compositing a circular avatar at + * the wiki card's owner-avatar slot. + */ +export async function renderCardPng( + svg: string, + avatar?: { url: string; diameter: number; left: number; top: number } | null, +): Promise { + const base = sharp(Buffer.from(svg)).png(); + if (!avatar) return await base.toBuffer(); + const avatarPng = await fetchAvatarCircle(avatar.url, avatar.diameter); + if (!avatarPng) return await base.toBuffer(); + return await base + .composite([{ input: avatarPng, top: avatar.top, left: avatar.left }]) + .toBuffer(); +} + +/** + * Pixel position of the wiki card avatar circle (matches the placeholder + * circle drawn in buildWikiCardSvg: g.translate(contentRight-72, footerY-36) + * + circle cx=36 cy=24 r=30 → top-left at (contentRight-66, footerY-42)). + */ +export function wikiCardAvatarSlot(): { + diameter: number; + left: number; + top: number; +} { + const PAD = 60; + const INNER_PAD = 64; + const contentRight = OG_CARD_WIDTH - PAD - INNER_PAD; + const footerY = OG_CARD_HEIGHT - PAD - INNER_PAD - 18; + return { + diameter: 60, + left: contentRight - 66, + top: footerY - 42, + }; +} diff --git a/src/server/app.ts b/src/server/app.ts index 9823b84..103f4ae 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -11,6 +11,7 @@ import { homeRoute } from "./routes/home.ts"; import { localeRoutes } from "./routes/locale.ts"; import { membershipRoutes } from "./routes/membership.ts"; 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 { themeRoutes } from "./routes/theme.ts"; @@ -46,6 +47,7 @@ export function buildApp() { .use(localeRoutes) .use(themeRoutes) .use(profileRoutes) + .use(ogRoutes) .use(homeRoute) .use(exploreRoutes) .use(searchRoutes) diff --git a/src/server/db/types.ts b/src/server/db/types.ts index 264c88e..4d5c551 100644 --- a/src/server/db/types.ts +++ b/src/server/db/types.ts @@ -1,3 +1,8 @@ +// Mirror the CHECK constraints in src/server/db/schema.ts so callers can rely +// on these unions when wiring the wiki theme into views without re-narrowing. +export type WikiThemeMode = "reader" | "enforce"; +export type WikiThemeName = "light" | "dark"; + export interface WikiRow { slug: string; did: string; @@ -5,8 +10,8 @@ export interface WikiRow { visibility: string; language: string; description: string; - theme_mode: string; - theme: string; + theme_mode: WikiThemeMode; + theme: WikiThemeName; at_uri: string; created_at: string; updated_at: string; diff --git a/src/server/routes/home.ts b/src/server/routes/home.ts index 447a50d..783d0d2 100644 --- a/src/server/routes/home.ts +++ b/src/server/routes/home.ts @@ -1,4 +1,5 @@ import { Elysia } from "elysia"; +import { getAtprotoEnv } from "../../atproto/env.ts"; import { resolveRequestContext } from "../../lib/access.ts"; import { LIMITS } from "../../lib/limits.ts"; import { htmlResponse } from "../../lib/response.ts"; @@ -16,11 +17,14 @@ export const homeRoute = new Elysia().get("/", async ({ request }) => { offset: 0, }); const languages = getWikiLanguages(); + const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; return htmlResponse( homePage(wikis, languages, { session: ctx.session, locale: ctx.locale, userTheme: ctx.userTheme, + ogUrl: `${publicUrl}/`, + ogImage: `${publicUrl}/og/home.png`, }), { edgeCacheSeconds: ctx.session ? 0 : 60 }, ); diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 9304e53..37c2f44 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -145,6 +145,11 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; const canonicalUrl = `${publicUrl}${noteUrl(params.wikiSlug, params.noteSlug)}`; const shareHtml = shareOnBlueskyButton(data.note.title, canonicalUrl, msg); + // Notes inherit the parent wiki's OG card — no per-note image rendered. + const ogImage = `${publicUrl}/og/wiki/${params.wikiSlug}`; + const ogDescription = ctx.wiki.description + ? { ogDescription: ctx.wiki.description } + : {}; const cacheable = !ctx.session && ctx.wiki.visibility === "public" ? 60 : 0; @@ -167,6 +172,8 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) shareHtml, ogTitle: `${data.note.title} - ${ctx.wiki.name}`, ogUrl: canonicalUrl, + ogImage, + ...ogDescription, wikiThemeMode: ctx.wiki.theme_mode, wikiTheme: ctx.wiki.theme, }, diff --git a/src/server/routes/og.ts b/src/server/routes/og.ts new file mode 100644 index 0000000..21564ba --- /dev/null +++ b/src/server/routes/og.ts @@ -0,0 +1,52 @@ +import { Elysia } from "elysia"; +import { + buildHomeCardSvg, + buildWikiCardSvg, + renderCardPng, + wikiCardAvatarSlot, +} from "../../lib/og-card.ts"; +import { resolveProfile } from "../../lib/profile.ts"; +import { getSidebarNotes, getWiki } from "../db/queries/index.ts"; + +const PNG_HEADERS = { + "Content-Type": "image/png", + // 1h server cache + Cloudflare CDN in front means most requests never hit + // this handler. Bluesky/Twitter cache the image themselves on top of that. + "Cache-Control": "public, max-age=3600", +} as const; + +export const ogRoutes = new Elysia({ prefix: "/og" }) + .get("/home.png", async () => { + const png = await renderCardPng(buildHomeCardSvg()); + return new Response(png, { headers: PNG_HEADERS }); + }) + // No `.png` suffix on the route param — Elysia would treat it as part of + // the param name. Content-Type header tells consumers what they got. + .get("/wiki/:wikiSlug", async ({ params }) => { + const wiki = getWiki(params.wikiSlug); + if (!wiki) { + return new Response("Wiki not found", { status: 404 }); + } + const profile = await resolveProfile(wiki.did); + const noteCount = getSidebarNotes(wiki.slug).length; + const svg = buildWikiCardSvg({ + name: wiki.name, + description: wiki.description, + language: wiki.language || null, + noteCount, + ownerHandle: profile.handle, + }); + const slot = wikiCardAvatarSlot(); + const png = await renderCardPng( + svg, + profile.avatar + ? { + url: profile.avatar, + diameter: slot.diameter, + left: slot.left, + top: slot.top, + } + : null, + ); + return new Response(png, { headers: PNG_HEADERS }); + }); diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index 9a21230..72afd24 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -186,8 +186,12 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); const msg = t(ctx.locale); const formData = await request.formData(); - const themeMode = (formData.get("theme_mode") as string | null) ?? "reader"; - const theme = (formData.get("theme") as string | null) ?? "light"; + // Single dropdown sends "default" (= reader mode) or a preset name + // (= enforce that preset). Falls back to keeping the current theme key + // when in default so the value isn't lost on later toggles. + const choice = (formData.get("theme_choice") as string | null) ?? "default"; + const themeMode = choice === "default" ? "reader" : "enforce"; + const theme = choice === "default" ? ctx.wiki.theme : choice; try { await setWikiThemeAction(ctx, { themeMode, theme }, msg); @@ -291,13 +295,17 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) const msg = t(ctx.locale); const bmHtml = resolveBookmarkHtml(ctx.did, ctx.wiki.at_uri, msg); + const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; + const ogImage = `${publicUrl}/og/wiki/${params.wikiSlug}`; + const ogDescription = ctx.wiki.description + ? { ogDescription: ctx.wiki.description } + : {}; + if (homeData) { const { html, hasViz, hasMath } = renderMarkdown( homeData.current.content, params.wikiSlug, ); - const publicUrl = - getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; const canonicalUrl = `${publicUrl}${noteUrl(params.wikiSlug, "home")}`; const shareHtml = shareOnBlueskyButton( homeData.note.title, @@ -321,8 +329,10 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) accessLevel: ctx.access, bookmarkHtml: bmHtml, shareHtml, - ogTitle: `${homeData.note.title} - ${ctx.wiki.name}`, + ogTitle: ctx.wiki.name, ogUrl: canonicalUrl, + ogImage, + ...ogDescription, wikiThemeMode: ctx.wiki.theme_mode, wikiTheme: ctx.wiki.theme, }, @@ -343,6 +353,10 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) userTheme: ctx.userTheme, accessLevel: ctx.access, bookmarkHtml: bmHtml, + ogTitle: ctx.wiki.name, + ogUrl: `${publicUrl}${wikiUrl(ctx.wiki.slug)}`, + ogImage, + ...ogDescription, wikiThemeMode: ctx.wiki.theme_mode, wikiTheme: ctx.wiki.theme, }, diff --git a/src/views/icons.ts b/src/views/icons.ts index 29a69bf..cfedd1c 100644 --- a/src/views/icons.ts +++ b/src/views/icons.ts @@ -8,4 +8,7 @@ export const ICONS = { check: ``, download: ``, theme: ``, + // Inlined so fill="currentColor" lets the nav logo follow theme accent. + // Geometry kept identical to /public/logo.svg (favicon) so the brand mark stays consistent. + logo: ``, } as const; diff --git a/src/views/layout.ts b/src/views/layout.ts index 2f0d745..e49a50c 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -10,9 +10,12 @@ import { sidebarButtonClass, sidebarLinkClass, THEME, + type ThemeName, themeRootStyle, USER_THEMES, type UserTheme, + type WikiThemeMode, + wikiThemeStyleAttr, } from "./theme/index.ts"; const LOCALE_LABELS: Record = { @@ -23,7 +26,7 @@ const LOCALE_LABELS: Record = { export interface LayoutOptions { scripts?: string[]; stylesheets?: string[]; - session?: { did: string; handle: string } | null; + session?: { did: string; handle: string; avatar?: string | null } | null; wikiName?: string; wikiSlug?: string; sidebarNotes?: { slug: string; title: string }[]; @@ -36,9 +39,44 @@ export interface LayoutOptions { shareHtml?: string; ogTitle?: string; ogUrl?: string; + ogDescription?: string; + ogImage?: string; + wikiThemeMode?: WikiThemeMode; + wikiTheme?: ThemeName; enableSearchShortcut?: boolean; } +// Defaults so even pages without per-page overrides emit a usable Bluesky/Twitter card. +const OG_DEFAULT_DESCRIPTION = + "Build a knowledge base with friends — wikis owned by you, built on ATProto."; + +function renderOpenGraph( + options: LayoutOptions | undefined, + title: string, +): string { + // og:image and og:url need absolute URLs. We derive the origin from the + // per-page ogUrl when provided; otherwise we don't know the request origin + // here, so we emit a relative path and trust most consumers to resolve it. + const ogUrl = options?.ogUrl; + const origin = ogUrl ? new URL(ogUrl).origin : ""; + const ogImage = options?.ogImage ? options.ogImage : `${origin}/og/home.png`; + const ogTitle = options?.ogTitle ?? options?.wikiName ?? title; + const ogDescription = options?.ogDescription ?? OG_DEFAULT_DESCRIPTION; + const urlTag = ogUrl + ? `` + : ""; + return ` + + + + + ${urlTag} + + + + `; +} + function renderSearchButton(locale: Locale = "en"): string { const msg = t(locale); return `
${ICONS.user} ${msg.profile.myWikis} @@ -200,15 +243,7 @@ export function layout( : "Lichen", )} - ${ - options?.ogUrl && options.ogTitle - ? ` - - - - ` - : "" - } + ${renderOpenGraph(options, title)} ${extraStyles} @@ -219,18 +254,7 @@ ${extraScripts}