🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Solve theming issues

juprodh 49328e89 927cba8c

+439 -198
+1 -1
deploy/Caddyfile
··· 14 14 # Static assets served from disk 15 15 handle /public/* { 16 16 uri strip_prefix /public 17 - root * /var/www/lichen/public 17 + root * /opt/lichen/public 18 18 file_server 19 19 20 20 header Cache-Control "public, max-age=31536000, immutable"
+8 -2
src/atproto/session.ts
··· 8 8 export interface Session { 9 9 did: string; 10 10 handle: string; 11 + avatar?: string | null; 11 12 oauthSession?: OAuthSession; 12 13 } 13 14 ··· 25 26 try { 26 27 const oauthSession = await client.restore(did as Did); 27 28 const profile = await resolveProfile(did); 28 - return { did, handle: profile.handle ?? did, oauthSession }; 29 + return { 30 + did, 31 + handle: profile.handle ?? did, 32 + avatar: profile.avatar, 33 + oauthSession, 34 + }; 29 35 } catch { 30 36 return null; 31 37 } ··· 48 54 const account = Object.values(accounts).find((a) => a.did === did); 49 55 if (!account) return null; 50 56 51 - return { did, handle: account.handle }; 57 + return { did, handle: account.handle, avatar: null }; 52 58 } 53 59 54 60 /**
+2 -6
src/lib/i18n/en.ts
··· 141 141 save: "Save changes", 142 142 detailsSaved: "Wiki details saved.", 143 143 theme: "Theme", 144 - themeModeLabel: "Theme behavior", 145 - themeReader: "Reader's choice", 146 - themeReaderHint: "Visitors see the wiki in their preferred theme.", 147 - themeEnforce: "Enforce a theme", 148 - themeEnforceHint: "All visitors see the wiki in the theme you pick below.", 149 - themePresetLabel: "Preset", 144 + themeChoiceLabel: "Theme", 145 + themeReaderDefault: "User's default", 150 146 themeSaved: "Theme saved.", 151 147 dangerZone: "Delete this wiki", 152 148 deleteWikiDescription:
+2 -7
src/lib/i18n/fr.ts
··· 143 143 save: "Enregistrer les modifications", 144 144 detailsSaved: "Détails du wiki enregistrés.", 145 145 theme: "Thème", 146 - themeModeLabel: "Comportement du thème", 147 - themeReader: "Choix du lecteur", 148 - themeReaderHint: "Les visiteurs voient le wiki dans leur thème préféré.", 149 - themeEnforce: "Imposer un thème", 150 - themeEnforceHint: 151 - "Tous les visiteurs voient le wiki dans le thème choisi ci-dessous.", 152 - themePresetLabel: "Préréglage", 146 + themeChoiceLabel: "Thème", 147 + themeReaderDefault: "Choix du lecteur", 153 148 themeSaved: "Thème enregistré.", 154 149 dangerZone: "Supprimer ce wiki", 155 150 deleteWikiDescription:
+2 -6
src/lib/i18n/index.ts
··· 141 141 save: string; 142 142 detailsSaved: string; 143 143 theme: string; 144 - themeModeLabel: string; 145 - themeReader: string; 146 - themeReaderHint: string; 147 - themeEnforce: string; 148 - themeEnforceHint: string; 149 - themePresetLabel: string; 144 + themeChoiceLabel: string; 145 + themeReaderDefault: string; 150 146 themeSaved: string; 151 147 dangerZone: string; 152 148 deleteWikiDescription: string;
+231
src/lib/og-card.ts
··· 1 + import sharp from "sharp"; 2 + import { escapeHtml } from "./html.ts"; 3 + 4 + const OG_CARD_WIDTH = 1200; 5 + const OG_CARD_HEIGHT = 630; 6 + 7 + // OG cards always render against the light palette so previews look the same 8 + // in every Bluesky/Discord/Twitter feed regardless of the viewer's device theme. 9 + const PALETTE = { 10 + bg: "#fafaf9", 11 + surface: "#ffffff", 12 + border: "#e7e5e4", 13 + text: "#1c1917", 14 + textSecondary: "#44403c", 15 + textMuted: "#78716c", 16 + accent: "#0f766e", 17 + accentSoft: "#f0fdfa", 18 + }; 19 + 20 + const FONT_STACK = 21 + "system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif"; 22 + 23 + // Geometry copied verbatim from public/logo.svg so the card mark matches the 24 + // favicon and nav logo. Re-centred to 0,0 with a viewBox the size of the path. 25 + const LICHEN_LOGO_SVG = (size: number, color: string): string => 26 + `<svg width="${size}" height="${size}" viewBox="0 0 134.92644 128.42101" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.1,0,0,1.1,301.47096,350.25297)"><path fill="${color}" d="m -228.35257,-262.99296 c -19.02978,5.17331 5.71835,53.17295 -8.81529,55.61911 -13.95583,2.34891 0.99206,-24.53241 -15.5475,-43.46739 -15.47347,-6.08381 -20.6167,-0.72715 -21.15703,-6.95845 -1.3108,-15.1168 3.95665,-3.71429 19.79401,-8.66631 10.82433,-3.38455 8.09155,-17.08945 3.51308,-19.68708 -14.62332,-8.29661 -0.4486,-21.17177 10.05655,-4.9252 4.71302,9.40824 16.36423,20.32455 18.35849,1.52382 1.60655,-15.14555 2.81668,-16.44355 8.53544,-24.19918 5.90154,-8.00349 25.08044,-4.44133 20.58215,1.31991 -5.02364,6.43406 -7.98538,3.21247 -14.05766,18.33414 -12.75512,31.76381 6.97901,29.69424 13.71761,37.34873 4.34654,4.93731 7.24489,-0.30345 10.876,-6.12677 8,-12 7.33137,-12.59361 13.33137,-20.59361 6,-6 3.48697,-14.27094 11.48697,-8.27094 2.14738,1.61054 8.33559,11.3398 5.57936,13.31991 -7.65995,1.7579 -6.25355,1.42931 -12.25355,11.42931 -6,10 -12,22 -16,30 4,6 3.7579,16.36888 5.7579,28.36888 -6.36045,10.47209 -14.25746,-0.3673 -24.05766,6.32268 -6.32302,4.31633 -20.20337,-14.76624 -11.82391,-14.37787 28.84239,1.33675 17.02084,-2.92172 15.35708,-20.34251 -10.88292,-10.19367 -16.95709,-23.80355 -33.23341,-25.97118 z"/></g></svg>`; 27 + 28 + /** 29 + * Greedy word-wrap on character count. Approximate but works fine for sans-serif 30 + * at the sizes we use (a real metric-aware wrapper would need font loading and 31 + * isn't worth the complexity for an OG card). 32 + */ 33 + function wrapText( 34 + text: string, 35 + maxCharsPerLine: number, 36 + maxLines: number, 37 + ): string[] { 38 + const words = text.split(/\s+/).filter(Boolean); 39 + const lines: string[] = []; 40 + let current = ""; 41 + for (const word of words) { 42 + const candidate = current ? `${current} ${word}` : word; 43 + if (candidate.length <= maxCharsPerLine) { 44 + current = candidate; 45 + continue; 46 + } 47 + if (current) lines.push(current); 48 + current = word; 49 + if (lines.length >= maxLines) break; 50 + } 51 + if (current && lines.length < maxLines) lines.push(current); 52 + if (lines.length === maxLines) { 53 + const last = lines[maxLines - 1] ?? ""; 54 + const remainingWords = words.slice( 55 + lines.flatMap((l) => l.split(/\s+/)).length, 56 + ); 57 + if (remainingWords.length > 0) { 58 + const truncated = 59 + last.length > maxCharsPerLine - 1 60 + ? `${last.slice(0, maxCharsPerLine - 1)}…` 61 + : `${last}…`; 62 + lines[maxLines - 1] = truncated; 63 + } 64 + } 65 + return lines; 66 + } 67 + 68 + function truncate(text: string, maxChars: number): string { 69 + return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text; 70 + } 71 + 72 + interface BrandingArgs { 73 + x: number; 74 + y: number; 75 + } 76 + 77 + function brandingMark({ x, y }: BrandingArgs): string { 78 + const logoSize = 36; 79 + return `<g transform="translate(${x} ${y})"> 80 + ${LICHEN_LOGO_SVG(logoSize, PALETTE.accent)} 81 + <text x="${logoSize + 12}" y="${logoSize / 2 + 9}" font-family="${FONT_STACK}" font-size="26" font-weight="600" fill="${PALETTE.accent}">Lichen</text> 82 + </g>`; 83 + } 84 + 85 + interface WikiCardData { 86 + name: string; 87 + description: string; 88 + language: string | null; 89 + noteCount: number; 90 + ownerHandle: string | null; 91 + } 92 + 93 + export function buildWikiCardSvg(data: WikiCardData): string { 94 + const PAD = 60; 95 + const INNER_PAD = 64; 96 + const innerX = PAD; 97 + const innerY = PAD; 98 + const innerW = OG_CARD_WIDTH - PAD * 2; 99 + const innerH = OG_CARD_HEIGHT - PAD * 2; 100 + 101 + const contentX = innerX + INNER_PAD; 102 + const contentRight = innerX + innerW - INNER_PAD; 103 + 104 + const wikiNameTruncated = truncate(data.name, 28); 105 + const descLines = wrapText(data.description ?? "", 50, 3); 106 + 107 + const langBadge = data.language 108 + ? `<g transform="translate(${contentX} 240)"> 109 + <rect x="0" y="0" width="${data.language.length * 14 + 28}" height="38" rx="19" fill="${PALETTE.border}"/> 110 + <text x="14" y="25" font-family="${FONT_STACK}" font-size="20" font-weight="600" fill="${PALETTE.textSecondary}">${escapeHtml(data.language.toUpperCase())}</text> 111 + </g>` 112 + : ""; 113 + 114 + const descY = data.language ? 320 : 280; 115 + const descSvg = descLines 116 + .map( 117 + (line, i) => 118 + `<text x="${contentX}" y="${descY + i * 44}" font-family="${FONT_STACK}" font-size="30" fill="${PALETTE.textSecondary}">${escapeHtml(line)}</text>`, 119 + ) 120 + .join(""); 121 + 122 + const footerY = OG_CARD_HEIGHT - PAD - INNER_PAD - 18; 123 + const noteLabel = `${data.noteCount} ${data.noteCount === 1 ? "note" : "notes"}`; 124 + 125 + const ownerBlock = data.ownerHandle 126 + ? `<text x="${contentRight - 84}" y="${footerY}" font-family="${FONT_STACK}" font-size="24" fill="${PALETTE.textMuted}" text-anchor="end">${escapeHtml(data.ownerHandle)}</text> 127 + <g transform="translate(${contentRight - 72} ${footerY - 36})"> 128 + <circle cx="36" cy="24" r="30" fill="${PALETTE.border}"/> 129 + </g>` 130 + : ""; 131 + 132 + return `<svg xmlns="http://www.w3.org/2000/svg" width="${OG_CARD_WIDTH}" height="${OG_CARD_HEIGHT}" viewBox="0 0 ${OG_CARD_WIDTH} ${OG_CARD_HEIGHT}"> 133 + <rect width="${OG_CARD_WIDTH}" height="${OG_CARD_HEIGHT}" fill="${PALETTE.bg}"/> 134 + <rect x="${innerX}" y="${innerY}" width="${innerW}" height="${innerH}" rx="24" fill="${PALETTE.surface}" stroke="${PALETTE.border}" stroke-width="2"/> 135 + ${brandingMark({ x: contentX, y: innerY + 32 })} 136 + <text x="${contentX}" y="200" font-family="${FONT_STACK}" font-size="72" font-weight="700" fill="${PALETTE.accent}">${escapeHtml(wikiNameTruncated)}</text> 137 + ${langBadge} 138 + ${descSvg} 139 + <line x1="${contentX}" y1="${footerY - 56}" x2="${contentRight}" y2="${footerY - 56}" stroke="${PALETTE.border}" stroke-width="1"/> 140 + <text x="${contentX}" y="${footerY}" font-family="${FONT_STACK}" font-size="24" fill="${PALETTE.textMuted}">${escapeHtml(noteLabel)}</text> 141 + ${ownerBlock} 142 + </svg>`; 143 + } 144 + 145 + export function buildHomeCardSvg(): string { 146 + const PAD = 60; 147 + const innerX = PAD; 148 + const innerY = PAD; 149 + const innerW = OG_CARD_WIDTH - PAD * 2; 150 + const innerH = OG_CARD_HEIGHT - PAD * 2; 151 + 152 + const centerX = OG_CARD_WIDTH / 2; 153 + const logoSize = 160; 154 + const logoY = innerY + 100; 155 + 156 + return `<svg xmlns="http://www.w3.org/2000/svg" width="${OG_CARD_WIDTH}" height="${OG_CARD_HEIGHT}" viewBox="0 0 ${OG_CARD_WIDTH} ${OG_CARD_HEIGHT}"> 157 + <rect width="${OG_CARD_WIDTH}" height="${OG_CARD_HEIGHT}" fill="${PALETTE.bg}"/> 158 + <rect x="${innerX}" y="${innerY}" width="${innerW}" height="${innerH}" rx="24" fill="${PALETTE.surface}" stroke="${PALETTE.border}" stroke-width="2"/> 159 + <g transform="translate(${centerX - logoSize / 2} ${logoY})">${LICHEN_LOGO_SVG(logoSize, PALETTE.accent)}</g> 160 + <text x="${centerX}" y="${logoY + logoSize + 90}" font-family="${FONT_STACK}" font-size="96" font-weight="700" fill="${PALETTE.accent}" text-anchor="middle">Lichen</text> 161 + <text x="${centerX}" y="${logoY + logoSize + 156}" font-family="${FONT_STACK}" font-size="32" fill="${PALETTE.textSecondary}" text-anchor="middle">Shared knowledge, owned by you</text> 162 + <text x="${centerX}" y="${OG_CARD_HEIGHT - PAD - 36}" font-family="${FONT_STACK}" font-size="22" fill="${PALETTE.textMuted}" text-anchor="middle">lichen.wiki · built on ATProto</text> 163 + </svg>`; 164 + } 165 + 166 + /** 167 + * Fetch a Bluesky-CDN avatar and shape it into a circular PNG sized for the 168 + * card footer. Returns null on any failure so the card still renders without 169 + * the avatar (a placeholder circle is drawn into the SVG underneath). 170 + */ 171 + async function fetchAvatarCircle( 172 + url: string, 173 + diameter: number, 174 + ): Promise<Buffer | null> { 175 + try { 176 + const res = await fetch(url, { signal: AbortSignal.timeout(3000) }); 177 + if (!res.ok) return null; 178 + const bytes = Buffer.from(await res.arrayBuffer()); 179 + const resized = await sharp(bytes) 180 + .resize(diameter, diameter, { fit: "cover" }) 181 + .png() 182 + .toBuffer(); 183 + const mask = Buffer.from( 184 + `<svg width="${diameter}" height="${diameter}" xmlns="http://www.w3.org/2000/svg"><circle cx="${diameter / 2}" cy="${diameter / 2}" r="${diameter / 2}" fill="white"/></svg>`, 185 + ); 186 + return await sharp(resized) 187 + .composite([{ input: mask, blend: "dest-in" }]) 188 + .png() 189 + .toBuffer(); 190 + } catch { 191 + return null; 192 + } 193 + } 194 + 195 + /** 196 + * Render an OG card SVG to PNG, optionally compositing a circular avatar at 197 + * the wiki card's owner-avatar slot. 198 + */ 199 + export async function renderCardPng( 200 + svg: string, 201 + avatar?: { url: string; diameter: number; left: number; top: number } | null, 202 + ): Promise<Buffer> { 203 + const base = sharp(Buffer.from(svg)).png(); 204 + if (!avatar) return await base.toBuffer(); 205 + const avatarPng = await fetchAvatarCircle(avatar.url, avatar.diameter); 206 + if (!avatarPng) return await base.toBuffer(); 207 + return await base 208 + .composite([{ input: avatarPng, top: avatar.top, left: avatar.left }]) 209 + .toBuffer(); 210 + } 211 + 212 + /** 213 + * Pixel position of the wiki card avatar circle (matches the placeholder 214 + * circle drawn in buildWikiCardSvg: g.translate(contentRight-72, footerY-36) 215 + * + circle cx=36 cy=24 r=30 → top-left at (contentRight-66, footerY-42)). 216 + */ 217 + export function wikiCardAvatarSlot(): { 218 + diameter: number; 219 + left: number; 220 + top: number; 221 + } { 222 + const PAD = 60; 223 + const INNER_PAD = 64; 224 + const contentRight = OG_CARD_WIDTH - PAD - INNER_PAD; 225 + const footerY = OG_CARD_HEIGHT - PAD - INNER_PAD - 18; 226 + return { 227 + diameter: 60, 228 + left: contentRight - 66, 229 + top: footerY - 42, 230 + }; 231 + }
+2
src/server/app.ts
··· 11 11 import { localeRoutes } from "./routes/locale.ts"; 12 12 import { membershipRoutes } from "./routes/membership.ts"; 13 13 import { noteRoutes } from "./routes/note.ts"; 14 + import { ogRoutes } from "./routes/og.ts"; 14 15 import { profileRoutes } from "./routes/profile.ts"; 15 16 import { searchRoutes } from "./routes/search.ts"; 16 17 import { themeRoutes } from "./routes/theme.ts"; ··· 46 47 .use(localeRoutes) 47 48 .use(themeRoutes) 48 49 .use(profileRoutes) 50 + .use(ogRoutes) 49 51 .use(homeRoute) 50 52 .use(exploreRoutes) 51 53 .use(searchRoutes)
+7 -2
src/server/db/types.ts
··· 1 + // Mirror the CHECK constraints in src/server/db/schema.ts so callers can rely 2 + // on these unions when wiring the wiki theme into views without re-narrowing. 3 + export type WikiThemeMode = "reader" | "enforce"; 4 + export type WikiThemeName = "light" | "dark"; 5 + 1 6 export interface WikiRow { 2 7 slug: string; 3 8 did: string; ··· 5 10 visibility: string; 6 11 language: string; 7 12 description: string; 8 - theme_mode: string; 9 - theme: string; 13 + theme_mode: WikiThemeMode; 14 + theme: WikiThemeName; 10 15 at_uri: string; 11 16 created_at: string; 12 17 updated_at: string;
+4
src/server/routes/home.ts
··· 1 1 import { Elysia } from "elysia"; 2 + import { getAtprotoEnv } from "../../atproto/env.ts"; 2 3 import { resolveRequestContext } from "../../lib/access.ts"; 3 4 import { LIMITS } from "../../lib/limits.ts"; 4 5 import { htmlResponse } from "../../lib/response.ts"; ··· 16 17 offset: 0, 17 18 }); 18 19 const languages = getWikiLanguages(); 20 + const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; 19 21 return htmlResponse( 20 22 homePage(wikis, languages, { 21 23 session: ctx.session, 22 24 locale: ctx.locale, 23 25 userTheme: ctx.userTheme, 26 + ogUrl: `${publicUrl}/`, 27 + ogImage: `${publicUrl}/og/home.png`, 24 28 }), 25 29 { edgeCacheSeconds: ctx.session ? 0 : 60 }, 26 30 );
+7
src/server/routes/note.ts
··· 145 145 const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; 146 146 const canonicalUrl = `${publicUrl}${noteUrl(params.wikiSlug, params.noteSlug)}`; 147 147 const shareHtml = shareOnBlueskyButton(data.note.title, canonicalUrl, msg); 148 + // Notes inherit the parent wiki's OG card — no per-note image rendered. 149 + const ogImage = `${publicUrl}/og/wiki/${params.wikiSlug}`; 150 + const ogDescription = ctx.wiki.description 151 + ? { ogDescription: ctx.wiki.description } 152 + : {}; 148 153 149 154 const cacheable = !ctx.session && ctx.wiki.visibility === "public" ? 60 : 0; 150 155 ··· 167 172 shareHtml, 168 173 ogTitle: `${data.note.title} - ${ctx.wiki.name}`, 169 174 ogUrl: canonicalUrl, 175 + ogImage, 176 + ...ogDescription, 170 177 wikiThemeMode: ctx.wiki.theme_mode, 171 178 wikiTheme: ctx.wiki.theme, 172 179 },
+52
src/server/routes/og.ts
··· 1 + import { Elysia } from "elysia"; 2 + import { 3 + buildHomeCardSvg, 4 + buildWikiCardSvg, 5 + renderCardPng, 6 + wikiCardAvatarSlot, 7 + } from "../../lib/og-card.ts"; 8 + import { resolveProfile } from "../../lib/profile.ts"; 9 + import { getSidebarNotes, getWiki } from "../db/queries/index.ts"; 10 + 11 + const PNG_HEADERS = { 12 + "Content-Type": "image/png", 13 + // 1h server cache + Cloudflare CDN in front means most requests never hit 14 + // this handler. Bluesky/Twitter cache the image themselves on top of that. 15 + "Cache-Control": "public, max-age=3600", 16 + } as const; 17 + 18 + export const ogRoutes = new Elysia({ prefix: "/og" }) 19 + .get("/home.png", async () => { 20 + const png = await renderCardPng(buildHomeCardSvg()); 21 + return new Response(png, { headers: PNG_HEADERS }); 22 + }) 23 + // No `.png` suffix on the route param — Elysia would treat it as part of 24 + // the param name. Content-Type header tells consumers what they got. 25 + .get("/wiki/:wikiSlug", async ({ params }) => { 26 + const wiki = getWiki(params.wikiSlug); 27 + if (!wiki) { 28 + return new Response("Wiki not found", { status: 404 }); 29 + } 30 + const profile = await resolveProfile(wiki.did); 31 + const noteCount = getSidebarNotes(wiki.slug).length; 32 + const svg = buildWikiCardSvg({ 33 + name: wiki.name, 34 + description: wiki.description, 35 + language: wiki.language || null, 36 + noteCount, 37 + ownerHandle: profile.handle, 38 + }); 39 + const slot = wikiCardAvatarSlot(); 40 + const png = await renderCardPng( 41 + svg, 42 + profile.avatar 43 + ? { 44 + url: profile.avatar, 45 + diameter: slot.diameter, 46 + left: slot.left, 47 + top: slot.top, 48 + } 49 + : null, 50 + ); 51 + return new Response(png, { headers: PNG_HEADERS }); 52 + });
+19 -5
src/server/routes/wiki.ts
··· 186 186 const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 187 187 const msg = t(ctx.locale); 188 188 const formData = await request.formData(); 189 - const themeMode = (formData.get("theme_mode") as string | null) ?? "reader"; 190 - const theme = (formData.get("theme") as string | null) ?? "light"; 189 + // Single dropdown sends "default" (= reader mode) or a preset name 190 + // (= enforce that preset). Falls back to keeping the current theme key 191 + // when in default so the value isn't lost on later toggles. 192 + const choice = (formData.get("theme_choice") as string | null) ?? "default"; 193 + const themeMode = choice === "default" ? "reader" : "enforce"; 194 + const theme = choice === "default" ? ctx.wiki.theme : choice; 191 195 192 196 try { 193 197 await setWikiThemeAction(ctx, { themeMode, theme }, msg); ··· 291 295 const msg = t(ctx.locale); 292 296 const bmHtml = resolveBookmarkHtml(ctx.did, ctx.wiki.at_uri, msg); 293 297 298 + const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; 299 + const ogImage = `${publicUrl}/og/wiki/${params.wikiSlug}`; 300 + const ogDescription = ctx.wiki.description 301 + ? { ogDescription: ctx.wiki.description } 302 + : {}; 303 + 294 304 if (homeData) { 295 305 const { html, hasViz, hasMath } = renderMarkdown( 296 306 homeData.current.content, 297 307 params.wikiSlug, 298 308 ); 299 - const publicUrl = 300 - getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; 301 309 const canonicalUrl = `${publicUrl}${noteUrl(params.wikiSlug, "home")}`; 302 310 const shareHtml = shareOnBlueskyButton( 303 311 homeData.note.title, ··· 321 329 accessLevel: ctx.access, 322 330 bookmarkHtml: bmHtml, 323 331 shareHtml, 324 - ogTitle: `${homeData.note.title} - ${ctx.wiki.name}`, 332 + ogTitle: ctx.wiki.name, 325 333 ogUrl: canonicalUrl, 334 + ogImage, 335 + ...ogDescription, 326 336 wikiThemeMode: ctx.wiki.theme_mode, 327 337 wikiTheme: ctx.wiki.theme, 328 338 }, ··· 343 353 userTheme: ctx.userTheme, 344 354 accessLevel: ctx.access, 345 355 bookmarkHtml: bmHtml, 356 + ogTitle: ctx.wiki.name, 357 + ogUrl: `${publicUrl}${wikiUrl(ctx.wiki.slug)}`, 358 + ogImage, 359 + ...ogDescription, 346 360 wikiThemeMode: ctx.wiki.theme_mode, 347 361 wikiTheme: ctx.wiki.theme, 348 362 },
+3
src/views/icons.ts
··· 8 8 check: `<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`, 9 9 download: `<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`, 10 10 theme: `<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.39 5.39 0 0 1-4.4 2.26 5.4 5.4 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"/></svg>`, 11 + // Inlined so fill="currentColor" lets the nav logo follow theme accent. 12 + // Geometry kept identical to /public/logo.svg (favicon) so the brand mark stays consistent. 13 + logo: `<svg class="shrink-0" width="22" height="22" viewBox="0 0 134.92644 128.42101" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g transform="matrix(1.1,0,0,1.1,301.47096,350.25297)"><path fill="currentColor" d="m -228.35257,-262.99296 c -19.02978,5.17331 5.71835,53.17295 -8.81529,55.61911 -13.95583,2.34891 0.99206,-24.53241 -15.5475,-43.46739 -15.47347,-6.08381 -20.6167,-0.72715 -21.15703,-6.95845 -1.3108,-15.1168 3.95665,-3.71429 19.79401,-8.66631 10.82433,-3.38455 8.09155,-17.08945 3.51308,-19.68708 -14.62332,-8.29661 -0.4486,-21.17177 10.05655,-4.9252 4.71302,9.40824 16.36423,20.32455 18.35849,1.52382 1.60655,-15.14555 2.81668,-16.44355 8.53544,-24.19918 5.90154,-8.00349 25.08044,-4.44133 20.58215,1.31991 -5.02364,6.43406 -7.98538,3.21247 -14.05766,18.33414 -12.75512,31.76381 6.97901,29.69424 13.71761,37.34873 4.34654,4.93731 7.24489,-0.30345 10.876,-6.12677 8,-12 7.33137,-12.59361 13.33137,-20.59361 6,-6 3.48697,-14.27094 11.48697,-8.27094 2.14738,1.61054 8.33559,11.3398 5.57936,13.31991 -7.65995,1.7579 -6.25355,1.42931 -12.25355,11.42931 -6,10 -12,22 -16,30 4,6 3.7579,16.36888 5.7579,28.36888 -6.36045,10.47209 -14.25746,-0.3673 -24.05766,6.32268 -6.32302,4.31633 -20.20337,-14.76624 -11.82391,-14.37787 28.84239,1.33675 17.02084,-2.92172 15.35708,-20.34251 -10.88292,-10.19367 -16.95709,-23.80355 -33.23341,-25.97118 z"/></g></svg>`, 11 14 } as const;
+49 -25
src/views/layout.ts
··· 10 10 sidebarButtonClass, 11 11 sidebarLinkClass, 12 12 THEME, 13 + type ThemeName, 13 14 themeRootStyle, 14 15 USER_THEMES, 15 16 type UserTheme, 17 + type WikiThemeMode, 18 + wikiThemeStyleAttr, 16 19 } from "./theme/index.ts"; 17 20 18 21 const LOCALE_LABELS: Record<string, string> = { ··· 23 26 export interface LayoutOptions { 24 27 scripts?: string[]; 25 28 stylesheets?: string[]; 26 - session?: { did: string; handle: string } | null; 29 + session?: { did: string; handle: string; avatar?: string | null } | null; 27 30 wikiName?: string; 28 31 wikiSlug?: string; 29 32 sidebarNotes?: { slug: string; title: string }[]; ··· 36 39 shareHtml?: string; 37 40 ogTitle?: string; 38 41 ogUrl?: string; 42 + ogDescription?: string; 43 + ogImage?: string; 44 + wikiThemeMode?: WikiThemeMode; 45 + wikiTheme?: ThemeName; 39 46 enableSearchShortcut?: boolean; 40 47 } 41 48 49 + // Defaults so even pages without per-page overrides emit a usable Bluesky/Twitter card. 50 + const OG_DEFAULT_DESCRIPTION = 51 + "Build a knowledge base with friends — wikis owned by you, built on ATProto."; 52 + 53 + function renderOpenGraph( 54 + options: LayoutOptions | undefined, 55 + title: string, 56 + ): string { 57 + // og:image and og:url need absolute URLs. We derive the origin from the 58 + // per-page ogUrl when provided; otherwise we don't know the request origin 59 + // here, so we emit a relative path and trust most consumers to resolve it. 60 + const ogUrl = options?.ogUrl; 61 + const origin = ogUrl ? new URL(ogUrl).origin : ""; 62 + const ogImage = options?.ogImage ? options.ogImage : `${origin}/og/home.png`; 63 + const ogTitle = options?.ogTitle ?? options?.wikiName ?? title; 64 + const ogDescription = options?.ogDescription ?? OG_DEFAULT_DESCRIPTION; 65 + const urlTag = ogUrl 66 + ? `<meta property="og:url" content="${escapeHtml(ogUrl)}">` 67 + : ""; 68 + return `<meta property="og:type" content="website"> 69 + <meta property="og:site_name" content="Lichen"> 70 + <meta property="og:title" content="${escapeHtml(ogTitle)}"> 71 + <meta property="og:description" content="${escapeHtml(ogDescription)}"> 72 + <meta property="og:image" content="${escapeHtml(ogImage)}"> 73 + ${urlTag} 74 + <meta name="twitter:card" content="summary_large_image"> 75 + <meta name="twitter:title" content="${escapeHtml(ogTitle)}"> 76 + <meta name="twitter:description" content="${escapeHtml(ogDescription)}"> 77 + <meta name="twitter:image" content="${escapeHtml(ogImage)}">`; 78 + } 79 + 42 80 function renderSearchButton(locale: Locale = "en"): string { 43 81 const msg = t(locale); 44 82 return `<button type="button" onclick="document.getElementById('search-modal').showModal();document.getElementById('search-input').focus();" ··· 172 210 </div> 173 211 </div>`; 174 212 213 + // Bluesky's `avatar` URL is already a CDN-hosted ~256px JPEG, so the 214 + // browser scales it down for the 22px display — no extra resizing needed. 215 + const avatarBadge = session?.avatar 216 + ? `<img src="${escapeHtml(session.avatar)}" alt="" width="22" height="22" class="w-[22px] h-[22px] rounded-full shrink-0 object-cover"/>` 217 + : ICONS.user; 175 218 const profileOrLogin = session 176 219 ? `<div class="relative" id="profile-picker"> 177 220 <button type="button" onclick="document.getElementById('profile-menu').classList.toggle('hidden')" class="${navBtnClass}" aria-label="${msg.profile.myWikis}"> 178 - ${ICONS.user} 221 + ${avatarBadge} 179 222 </button> 180 223 <div id="profile-menu" class="${dropdownClass}"> 181 224 <a href="${profileUrl(session.did)}" class="${dropdownItemClass}">${ICONS.user} <span>${msg.profile.myWikis}</span></a> ··· 200 243 : "Lichen", 201 244 )}</title> 202 245 <link rel="icon" type="image/svg+xml" href="/public/logo.svg"> 203 - ${ 204 - options?.ogUrl && options.ogTitle 205 - ? `<meta property="og:type" content="article"> 206 - <meta property="og:title" content="${escapeHtml(options.ogTitle)}"> 207 - <meta property="og:url" content="${escapeHtml(options.ogUrl)}"> 208 - <meta property="og:image" content="${escapeHtml(new URL("/public/logo.svg", options.ogUrl).toString())}"> 209 - <meta name="twitter:card" content="summary">` 210 - : "" 211 - } 246 + ${renderOpenGraph(options, title)} 212 247 <link rel="stylesheet" href="/public/dist.css"> 213 248 <style>${themeRootStyle(userTheme)}</style> 214 249 ${extraStyles} ··· 219 254 <nav class="shrink-0 sticky top-0 z-20 ${THEME.bgSurface} border-b ${THEME.border}"> 220 255 <div class="max-w-6xl mx-auto px-6 py-3 flex items-center justify-between gap-4"> 221 256 <div class="flex items-center gap-2 min-w-0"> 222 - <a href="/" class="flex items-center gap-2 shrink-0"> 223 - <img 224 - src="/public/logo.svg" 225 - width="22" 226 - height="22" 227 - fetchpriority="high" 228 - decoding="async" 229 - alt="" 230 - class="shrink-0" 231 - /> 232 - <span class="text-lg font-semibold ${THEME.accentText}">Lichen</span> 233 - </a> 257 + <a href="/" class="flex items-center gap-2 shrink-0 ${THEME.accentText}">${ICONS.logo}<span class="text-lg font-semibold">Lichen</span></a> 234 258 ${options?.wikiName ? `<span class="${THEME.textMuted}">/</span><a href="/wiki/${options.wikiSlug}" class="text-lg font-medium ${THEME.textSecondary} hover:text-[var(--accent)] truncate">${escapeHtml(options.wikiName)}</a>` : ""} 235 259 </div> 236 260 <div class="flex items-center gap-2 shrink-0"> ··· 292 316 window._htmxToastTimeout = setTimeout(() => toast.classList.add('hidden'), 3000); 293 317 }); 294 318 </script> 295 - <div class="flex-1 flex flex-col"> 319 + <div class="flex-1 flex flex-col" style="${wikiThemeStyleAttr(options?.wikiThemeMode, options?.wikiTheme)}"> 296 320 ${ 297 321 options?.sidebarNotes 298 322 ? renderWithSidebar( ··· 305 329 <footer class="shrink-0 sticky bottom-0 z-10 ${THEME.bg} border-t ${THEME.borderSubtle} py-4 text-center text-xs ${THEME.textMuted}"> 306 330 <a href="https://atproto.com" target="_blank" rel="noopener noreferrer" class="hover:underline">${msg.home.builtOnAtproto}</a> 307 331 <span class="mx-2">&middot;</span> 308 - <a href="https://tangled.org/juprodh.bsky.social/lichen.wiki" target="_blank" rel="noopener noreferrer" class="hover:underline">${msg.home.sourceCode}</a> 332 + <a href="https://tangled.sh/juprodh.me/lichen.wiki" target="_blank" rel="noopener noreferrer" class="hover:underline">${msg.home.sourceCode}</a> 309 333 </footer> 310 334 <script data-goatcounter="https://stats.lichen.wiki/count" async src="//stats.lichen.wiki/count.js"></script> 311 335 <script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "d6116b6075fb4242b756ff05acfd16b0"}'></script>
+3 -11
src/views/note.ts
··· 1 1 import { type LayoutOptions, layout } from "./layout.ts"; 2 - import { wrapWikiContent } from "./theme/index.ts"; 3 2 4 3 export function notePage( 5 4 wikiName: string, 6 5 wikiSlug: string, 7 6 noteTitle: string, 8 7 renderedHtml: string, 9 - options?: LayoutOptions & { wikiThemeMode?: string; wikiTheme?: string }, 8 + options?: LayoutOptions, 10 9 wikiLanguage?: string, 11 10 ): string { 12 11 const langAttr = wikiLanguage ? ` lang="${wikiLanguage}"` : ""; 13 - const content = wrapWikiContent( 14 - ` 12 + const content = ` 15 13 <article class="prose max-w-none"${langAttr}> 16 14 ${renderedHtml} 17 15 </article> 18 - `, 19 - { 20 - wikiThemeMode: 21 - options?.wikiThemeMode === "enforce" ? "enforce" : "reader", 22 - wikiTheme: options?.wikiTheme === "dark" ? "dark" : "light", 23 - }, 24 - ); 16 + `; 25 17 return layout(noteTitle, content, { 26 18 ...options, 27 19 wikiName,
+20 -74
src/views/settings.ts
··· 17 17 interface SettingsPageOptions extends LayoutOptions { 18 18 wikiDid: string; 19 19 wikiDescription: string; 20 - wikiThemeMode: string; 21 - wikiTheme: string; 20 + wikiThemeMode: NonNullable<LayoutOptions["wikiThemeMode"]>; 21 + wikiTheme: NonNullable<LayoutOptions["wikiTheme"]>; 22 22 error?: string; 23 23 detailsSaved?: boolean; 24 24 themeSaved?: boolean; ··· 263 263 locale: string, 264 264 ): string { 265 265 const msg = t(locale as "en" | "fr"); 266 - const isEnforce = wikiThemeMode === "enforce"; 267 - const themeLabels: Record<string, string> = { 266 + 267 + // Map (theme_mode, theme) → single dropdown value: 268 + // "reader" → "default" (visitor's chrome theme wins) 269 + // "enforce" → the preset key (light, dark, …) 270 + const currentChoice = wikiThemeMode === "enforce" ? wikiTheme : "default"; 271 + 272 + const presetLabels: Record<string, string> = { 268 273 light: msg.nav.themeLight, 269 274 dark: msg.nav.themeDark, 270 275 }; 271 - const themeOptions = (Object.keys(themes) as (keyof typeof themes)[]) 272 - .map( 276 + const themeNames = Object.keys(themes) as (keyof typeof themes)[]; 277 + const options = [ 278 + `<option value="default"${currentChoice === "default" ? " selected" : ""}>${msg.settings.themeReaderDefault}</option>`, 279 + ...themeNames.map( 273 280 (name) => 274 - `<option value="${name}"${name === wikiTheme ? " selected" : ""}>${themeLabels[name] ?? name}</option>`, 275 - ) 276 - .join(""); 281 + `<option value="${name}"${currentChoice === name ? " selected" : ""}>${presetLabels[name] ?? name}</option>`, 282 + ), 283 + ].join(""); 277 284 278 285 const savedBanner = themeSaved ? successBanner(msg.settings.themeSaved) : ""; 279 286 280 - const palettes = JSON.stringify( 281 - Object.fromEntries( 282 - (Object.keys(themes) as (keyof typeof themes)[]).map((name) => [ 283 - name, 284 - { 285 - bg: themes[name].bg, 286 - surface: themes[name].surface, 287 - accent: themes[name].accent, 288 - text: themes[name].text, 289 - }, 290 - ]), 291 - ), 292 - ); 293 - 294 - const swatchClass = `inline-block w-6 h-6 rounded border ${THEME.borderInput}`; 295 - const initialPalette = 296 - themes[wikiTheme as keyof typeof themes] ?? themes.light; 297 - 298 287 return `<section class="mb-10"> 299 288 <h2 class="text-lg font-semibold mb-4">${msg.settings.theme}</h2> 300 289 ${savedBanner} 301 290 <form method="POST" action="/wiki/${wikiSlug}/-/theme" class="flex flex-col gap-4 max-w-xl"> 302 - <fieldset class="flex flex-col gap-2"> 303 - <legend class="text-xs font-medium ${THEME.textMuted} mb-1">${msg.settings.themeModeLabel}</legend> 304 - <label class="flex items-start gap-2 text-sm"> 305 - <input type="radio" name="theme_mode" value="reader" class="mt-1" 306 - ${!isEnforce ? "checked" : ""} 307 - onchange="document.getElementById('theme-preset-select').disabled = true" /> 308 - <span> 309 - <span class="font-medium">${msg.settings.themeReader}</span> 310 - <span class="block ${THEME.textMuted} text-xs">${msg.settings.themeReaderHint}</span> 311 - </span> 312 - </label> 313 - <label class="flex items-start gap-2 text-sm"> 314 - <input type="radio" name="theme_mode" value="enforce" class="mt-1" 315 - ${isEnforce ? "checked" : ""} 316 - onchange="document.getElementById('theme-preset-select').disabled = false" /> 317 - <span> 318 - <span class="font-medium">${msg.settings.themeEnforce}</span> 319 - <span class="block ${THEME.textMuted} text-xs">${msg.settings.themeEnforceHint}</span> 320 - </span> 321 - </label> 322 - </fieldset> 323 291 <div class="flex flex-col gap-1"> 324 - <label for="theme-preset-select" class="text-xs font-medium ${THEME.textMuted}">${msg.settings.themePresetLabel}</label> 325 - <select id="theme-preset-select" name="theme" ${isEnforce ? "" : "disabled"} 326 - class="px-3 py-1.5 text-sm border ${THEME.borderInput} rounded focus:outline-none focus:border-[var(--accent-focus-input)] max-w-xs disabled:opacity-50" 327 - onchange="window.__updateThemePreview&&window.__updateThemePreview(this.value)"> 328 - ${themeOptions} 292 + <label for="theme-choice-select" class="text-xs font-medium ${THEME.textMuted}">${msg.settings.themeChoiceLabel}</label> 293 + <select id="theme-choice-select" name="theme_choice" 294 + class="px-3 py-1.5 text-sm border ${THEME.borderInput} rounded focus:outline-none focus:border-[var(--accent-focus-input)] max-w-xs"> 295 + ${options} 329 296 </select> 330 - <div id="theme-preview" class="flex items-center gap-2 mt-2" aria-hidden="true"> 331 - <span data-swatch="bg" class="${swatchClass}" style="background-color:${initialPalette.bg}" title="bg"></span> 332 - <span data-swatch="surface" class="${swatchClass}" style="background-color:${initialPalette.surface}" title="surface"></span> 333 - <span data-swatch="accent" class="${swatchClass}" style="background-color:${initialPalette.accent}" title="accent"></span> 334 - <span data-swatch="text" class="${swatchClass}" style="background-color:${initialPalette.text}" title="text"></span> 335 - </div> 336 297 </div> 337 298 <div> 338 299 <button type="submit" class="${primarySmallButtonClass}">${msg.settings.save}</button> 339 300 </div> 340 301 </form> 341 - <script> 342 - (function(){ 343 - var palettes = ${palettes}; 344 - window.__updateThemePreview = function(name){ 345 - var p = palettes[name]; 346 - if (!p) return; 347 - var preview = document.getElementById('theme-preview'); 348 - if (!preview) return; 349 - for (var key in p) { 350 - var swatch = preview.querySelector('[data-swatch="' + key + '"]'); 351 - if (swatch) swatch.style.backgroundColor = p[key]; 352 - } 353 - }; 354 - })(); 355 - </script> 356 302 </section>`; 357 303 } 358 304
+14 -16
src/views/theme/apply.ts
··· 1 - import { 2 - resolveTheme, 3 - type ThemeName, 4 - type UserTheme, 5 - type WikiThemeMode, 6 - } from "./resolve.ts"; 1 + import type { ThemeName, UserTheme, WikiThemeMode } from "./resolve.ts"; 7 2 import { type Theme, themes } from "./themes.ts"; 8 3 9 4 const kebab = (s: string): string => ··· 99 94 } 100 95 101 96 /** 102 - * Wraps wiki-content HTML in a div that overrides the chrome theme when the 103 - * wiki enforces its own. In reader mode the content inherits via CSS cascade 104 - * and no wrapper is emitted. Prose vars set on <body> reference our base 105 - * tokens, so they automatically follow the wiki's overrides in this scope. 97 + * Inline style content for the wiki-content area when the wiki enforces a 98 + * theme. Sets the theme tokens AND the wrapper's own background/color so the 99 + * area visibly switches palette (otherwise inner text follows the wiki theme 100 + * via CSS vars, but the wrapper background bleeds the user theme through). 101 + * 102 + * Returns "" in reader mode so the area inherits chrome theme via cascade. 106 103 */ 107 - export function wrapWikiContent( 108 - html: string, 109 - args: { wikiThemeMode?: WikiThemeMode; wikiTheme?: ThemeName } = {}, 104 + export function wikiThemeStyleAttr( 105 + wikiThemeMode: WikiThemeMode | undefined, 106 + wikiTheme: ThemeName | undefined, 110 107 ): string { 111 - const theme = resolveTheme({ scope: "wikiContent", ...args }); 112 - if (!theme) return html; 113 - return `<div style="${themeStyleAttr(theme)}">${html}</div>`; 108 + if (wikiThemeMode !== "enforce" || !wikiTheme) return ""; 109 + const theme = themes[wikiTheme]; 110 + if (!theme) return ""; 111 + return `${themeStyleAttr(theme)}; background-color: var(--bg); color: var(--text); color-scheme: ${wikiTheme};`; 114 112 }
+8 -2
src/views/theme/index.ts
··· 1 - export { themeRootStyle, wrapWikiContent } from "./apply.ts"; 2 - export { resolveUserTheme, USER_THEMES, type UserTheme } from "./resolve.ts"; 1 + export { themeRootStyle, wikiThemeStyleAttr } from "./apply.ts"; 2 + export { 3 + resolveUserTheme, 4 + type ThemeName, 5 + USER_THEMES, 6 + type UserTheme, 7 + type WikiThemeMode, 8 + } from "./resolve.ts"; 3 9 export { 4 10 dangerButtonClass, 5 11 dangerSmallButtonClass,
+1 -26
src/views/theme/resolve.ts
··· 1 - import { type Theme, themes } from "./themes.ts"; 1 + import type { themes } from "./themes.ts"; 2 2 3 3 export const USER_THEMES = ["light", "dark", "system"] as const; 4 4 export type UserTheme = (typeof USER_THEMES)[number]; ··· 12 12 13 13 /** Named theme keys present in `themes` (light/dark presets). */ 14 14 export type ThemeName = keyof typeof themes; 15 - 16 - type ThemeScope = "chrome" | "wikiContent"; 17 - 18 - type ResolveThemeArgs = { 19 - scope: ThemeScope; 20 - wikiThemeMode?: WikiThemeMode; 21 - wikiTheme?: ThemeName; 22 - }; 23 - 24 - /** 25 - * Returns the palette an element should apply via inline style, or null when 26 - * the element should inherit from its cascading parent. 27 - * 28 - * Chrome scope is always handled by the body <style> block (which natively 29 - * supports system preference via @media), so resolveTheme returns null for it. 30 - * Wiki-content scope returns the wiki's theme only when it enforces an 31 - * override; otherwise it inherits the chrome theme via cascade. 32 - */ 33 - export function resolveTheme(args: ResolveThemeArgs): Theme | null { 34 - if (args.scope === "chrome") return null; 35 - if (args.wikiThemeMode === "enforce" && args.wikiTheme) { 36 - return themes[args.wikiTheme]; 37 - } 38 - return null; 39 - } 40 15 41 16 export function resolveUserTheme( 42 17 cookieHeader: string | null | undefined,
+4 -15
src/views/wiki.ts
··· 1 1 import { canEdit } from "../lib/access.ts"; 2 2 import { t } from "../lib/i18n/index.ts"; 3 3 import { type LayoutOptions, layout } from "./layout.ts"; 4 - import { 5 - primarySmallButtonClass, 6 - THEME, 7 - wrapWikiContent, 8 - } from "./theme/index.ts"; 4 + import { primarySmallButtonClass, THEME } from "./theme/index.ts"; 9 5 10 6 export function wikiPage( 11 7 wikiName: string, 12 8 wikiSlug: string, 13 - options?: LayoutOptions & { wikiThemeMode?: string; wikiTheme?: string }, 9 + options?: LayoutOptions, 14 10 wikiLanguage?: string, 15 11 ): string { 16 12 const locale = options?.locale ?? "en"; ··· 34 30 ? `<a href="/wiki/${wikiSlug}/new" class="${primarySmallButtonClass}">${msg.wiki.newNote}</a>` 35 31 : ""; 36 32 37 - const content = wrapWikiContent( 38 - ` 33 + const content = ` 39 34 <h1 class="text-2xl font-bold mb-2">${wikiName}${langBadge}</h1> 40 35 <p class="${THEME.textMuted} mb-6">/${wikiSlug}</p> 41 36 <div class="flex items-center justify-between mb-3"> ··· 45 40 <ul class="space-y-2"> 46 41 ${noteList || `<li class="${THEME.textMuted}">${msg.wiki.noNotesYet}</li>`} 47 42 </ul> 48 - `, 49 - { 50 - wikiThemeMode: 51 - options?.wikiThemeMode === "enforce" ? "enforce" : "reader", 52 - wikiTheme: options?.wikiTheme === "dark" ? "dark" : "light", 53 - }, 54 - ); 43 + `; 55 44 56 45 return layout(wikiName, content, { 57 46 ...options,