🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Cleanup mock wiki for production

juprodh 09a99d3f aa661113

+149 -37
+1
public/logo-large.svg
··· 1 + <svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"><rect width="256" height="256" fill="white"/><circle cx="96" cy="144" r="72" fill="#0f766e" opacity="0.7"/><circle cx="160" cy="144" r="56" fill="#0f766e" opacity="0.5"/><circle cx="128" cy="80" r="48" fill="#0f766e" opacity="0.85"/></svg>
+1
public/logo.svg
··· 1 + <svg width="32" height="32" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><circle cx="6" cy="9" r="4.5" fill="#0f766e" opacity="0.7"/><circle cx="10" cy="9" r="3.5" fill="#0f766e" opacity="0.5"/><circle cx="8" cy="5" r="3" fill="#0f766e" opacity="0.85"/></svg>
+6 -1
src/lib/i18n/en.ts
··· 26 26 private: "private", 27 27 tagline: "Shared knowledge, owned by you", 28 28 heroDescription: 29 - "Collaborative wikis on the open web. Your contributions belong to you, not to any platform.", 29 + "Build a knowledge base with friends, for your hobbies, activism, or anything else!<br>Each wiki is yours, built on ATProto, data is stored on your personal data server.", 30 30 createAWiki: "Create a wiki", 31 31 exploreWikis: "Explore wikis", 32 32 explorePublicWikis: "Explore public wikis", ··· 37 37 recentlyUpdated: "Recently updated", 38 38 recentlyCreated: "Recently created", 39 39 exploreMore: "Explore more", 40 + step1: "Log in with your ATProto account", 41 + step2: "Create a wiki or get invited to collaborate", 42 + step3: "Start editing!", 40 43 }, 41 44 pagination: { 42 45 previous: "Previous", ··· 71 74 descriptionPlaceholder: "What is this wiki about?", 72 75 language: "Language", 73 76 visibility: "Visibility", 77 + privateWarning: 78 + "Your wiki will be hidden on Lichen, but the underlying records on your PDS remain publicly accessible. Private data on ATProto is not yet available, fully private wikis will come when the protocol supports it.", 74 79 create: "Create Wiki", 75 80 cancel: "Cancel", 76 81 },
+7 -2
src/lib/i18n/fr.ts
··· 25 25 noWikisYet: "Aucun wiki pour le moment.", 26 26 public: "public", 27 27 private: "privé", 28 - tagline: "Écrivez ensemble, tout vous appartient", 28 + tagline: "Plateforme libre de savoirs collaboratifs", 29 29 heroDescription: 30 - "Des wikis collaboratifs sur le web libre. Vos contributions vous appartiennent, sans dépendre d'aucune plateforme.", 30 + "Ecrivez avec vos amis et partagez, sur vos passions, le militantisme, ou n'importe quoi d'autre.<br>Construit sur ATProto, vos contributions vous appartiennent et sont stockées sur votre PDS.", 31 31 createAWiki: "Créer un wiki", 32 32 exploreWikis: "Explorer les wikis", 33 33 explorePublicWikis: "Explorer les wikis publics", ··· 38 38 recentlyUpdated: "Mis à jour récemment", 39 39 recentlyCreated: "Créés récemment", 40 40 exploreMore: "Explorer plus", 41 + step1: "Connectez-vous avec votre compte ATProto", 42 + step2: "Créez un wiki ou faites-vous inviter pour collaborer", 43 + step3: "Commencez à éditer !", 41 44 }, 42 45 pagination: { 43 46 previous: "Précédent", ··· 72 75 descriptionPlaceholder: "De quoi parle ce wiki ?", 73 76 language: "Langue", 74 77 visibility: "Visibilité", 78 + privateWarning: 79 + "Votre wiki sera masqué sur Lichen, mais les enregistrements sur votre PDS restent accessibles publiquement. Les données privées sur ATProto ne sont pas encore disponibles, les wikis entièrement privés arriveront quand le protocole le permettra.", 75 80 create: "Créer le wiki", 76 81 cancel: "Annuler", 77 82 },
+4
src/lib/i18n/index.ts
··· 39 39 recentlyUpdated: string; 40 40 recentlyCreated: string; 41 41 exploreMore: string; 42 + step1: string; 43 + step2: string; 44 + step3: string; 42 45 }; 43 46 pagination: { 44 47 previous: string; ··· 72 75 descriptionPlaceholder: string; 73 76 language: string; 74 77 visibility: string; 78 + privateWarning: string; 75 79 create: string; 76 80 cancel: string; 77 81 };
+1 -1
src/server/db/index.ts
··· 10 10 if (!db) { 11 11 db = new Database(DB_PATH); 12 12 initSchema(db); 13 - seedIfEmpty(db); 13 + if (process.env["DEV_PDS_URL"]) seedIfEmpty(db); 14 14 } 15 15 return db; 16 16 }
+3 -1
src/server/db/queries/bookmark.ts
··· 50 50 const db = getDb(); 51 51 return db 52 52 .query( 53 - `SELECT w.*, COUNT(n.slug) AS note_count 53 + `SELECT w.*, COUNT(n.slug) AS note_count, 54 + pc.handle as owner_handle, pc.avatar as owner_avatar 54 55 FROM bookmarks b 55 56 JOIN wikis w ON w.at_uri = b.wiki_at_uri 56 57 LEFT JOIN notes n ON n.wiki_slug = w.slug 58 + LEFT JOIN profile_cache pc ON pc.did = w.did 57 59 WHERE b.did = ? 58 60 GROUP BY w.slug 59 61 ORDER BY b.created_at DESC`,
+11 -3
src/server/db/queries/wiki.ts
··· 4 4 5 5 export interface WikiWithNoteCount extends WikiRow { 6 6 note_count: number; 7 + owner_handle: string | null; 8 + owner_avatar: string | null; 7 9 } 8 10 9 11 export type WikiSort = "updated" | "created"; ··· 42 44 43 45 const wikis = db 44 46 .query( 45 - `SELECT w.*, COUNT(n.slug) as note_count 47 + `SELECT w.*, COUNT(n.slug) as note_count, 48 + pc.handle as owner_handle, pc.avatar as owner_avatar 46 49 FROM wikis w 47 50 LEFT JOIN notes n ON n.wiki_slug = w.slug 51 + LEFT JOIN profile_cache pc ON pc.did = w.did 48 52 WHERE ${where} 49 53 GROUP BY w.slug 50 54 ORDER BY ${orderCol} DESC ··· 79 83 const db = getDb(); 80 84 return db 81 85 .query( 82 - `SELECT w.*, COUNT(n.slug) as note_count 86 + `SELECT w.*, COUNT(n.slug) as note_count, 87 + pc.handle as owner_handle, pc.avatar as owner_avatar 83 88 FROM wikis w 84 89 LEFT JOIN notes n ON n.wiki_slug = w.slug 90 + LEFT JOIN profile_cache pc ON pc.did = w.did 85 91 WHERE w.did = ? 86 92 GROUP BY w.slug 87 93 ORDER BY w.updated_at DESC`, ··· 93 99 const db = getDb(); 94 100 return db 95 101 .query( 96 - `SELECT w.*, COUNT(n.slug) as note_count 102 + `SELECT w.*, COUNT(n.slug) as note_count, 103 + pc.handle as owner_handle, pc.avatar as owner_avatar 97 104 FROM wikis w 98 105 LEFT JOIN notes n ON n.wiki_slug = w.slug 106 + LEFT JOIN profile_cache pc ON pc.did = w.did 99 107 INNER JOIN memberships m ON m.wiki_slug = w.slug AND m.did = ? 100 108 WHERE w.did != ? 101 109 GROUP BY w.slug
+7 -4
src/views/home.ts
··· 27 27 "Lichen", 28 28 ` 29 29 <section class="text-center py-8 px-6"> 30 - <svg width="32" height="32" viewBox="0 0 16 16" class="inline-block mb-3"><circle cx="6" cy="9" r="4.5" fill="currentColor" class="${THEME.accentText}" opacity="0.7"/><circle cx="10" cy="9" r="3.5" fill="currentColor" class="${THEME.accentText}" opacity="0.5"/><circle cx="8" cy="5" r="3" fill="currentColor" class="${THEME.accentText}" opacity="0.85"/></svg> 31 - <h1 class="text-3xl font-bold ${THEME.textPrimary} mb-2">Lichen</h1> 32 - <p class="text-lg ${THEME.textSecondary} mb-1">${msg.home.tagline}</p> 33 - <p class="text-sm ${THEME.textMuted} max-w-md mx-auto mb-5">${msg.home.heroDescription}</p> 30 + <p class="text-xl font-semibold ${THEME.textPrimary} mb-2">${msg.home.tagline}</p> 31 + <p class="text-sm ${THEME.textMuted} max-w-2xl mx-auto mb-6">${msg.home.heroDescription}</p> 32 + <div class="flex flex-col items-center gap-1 mb-6 text-sm ${THEME.textSecondary}"> 33 + <span><span class="font-semibold ${THEME.accentText}">1.</span> <a href="/login" class="hover:underline">${msg.home.step1}</a></span> 34 + <span><span class="font-semibold ${THEME.accentText}">2.</span> <a href="/wiki/new" class="hover:underline">${msg.home.step2}</a></span> 35 + <span><span class="font-semibold ${THEME.accentText}">3.</span> ${msg.home.step3}</span> 36 + </div> 34 37 <div class="flex items-center justify-center gap-3"> 35 38 <a href="/wiki/new" class="${primaryButtonClass}">${msg.home.createAWiki}</a> 36 39 ${options?.session ? `<a href="${profileUrl(options.session.did)}" class="${outlineSmallButtonClass}">${msg.profile.myWikis}</a>` : `<a href="/login?returnTo=/profile-redirect" class="${outlineSmallButtonClass}">${msg.profile.myWikis}</a>`}
+4
src/views/new-wiki.ts
··· 77 77 name="visibility" 78 78 required 79 79 class="${inputClass}" 80 + onchange="document.getElementById('private-warning').classList.toggle('hidden', this.value !== 'private')" 80 81 > 81 82 <option value="public"${selectedVis === "public" ? " selected" : ""}>${msg.home.public}</option> 82 83 <option value="private"${selectedVis === "private" ? " selected" : ""}>${msg.home.private}</option> 83 84 </select> 85 + <div id="private-warning" class="${selectedVis === "private" ? "" : "hidden "}mt-3 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"> 86 + ${msg.createWiki.privateWarning} 87 + </div> 84 88 </div> 85 89 <div class="flex items-center gap-3"> 86 90 <button
+13 -1
src/views/wiki-card.ts
··· 14 14 ? `<p class="text-sm ${THEME.textSecondary} mt-1 line-clamp-2">${escapeHtml(wiki.description)}</p>` 15 15 : ""; 16 16 17 + const ownerHandle = wiki.owner_handle 18 + ? `<span class="truncate">${escapeHtml(wiki.owner_handle)}</span>` 19 + : ""; 20 + const ownerAvatar = wiki.owner_avatar 21 + ? `<img src="${escapeHtml(wiki.owner_avatar)}" alt="" class="w-4 h-4 rounded-full shrink-0">` 22 + : ""; 23 + const ownerHtml = 24 + ownerHandle || ownerAvatar 25 + ? `<div class="flex items-center gap-1.5 min-w-0 max-w-[50%] justify-end">${ownerHandle}${ownerAvatar}</div>` 26 + : ""; 27 + 17 28 return `<a href="/wiki/${wiki.slug}" class="block ${THEME.bgSurface} border ${THEME.borderDefault} rounded-lg p-4 hover:shadow-md transition-shadow"> 18 29 <div class="flex items-start justify-between gap-2"> 19 30 <h3 class="font-semibold ${THEME.accentText} truncate">${escapeHtml(wiki.name)}</h3> 20 31 ${languageBadge} 21 32 </div> 22 33 ${description} 23 - <div class="flex items-center gap-2 mt-2 text-xs ${THEME.textMuted}"> 34 + <div class="flex items-center justify-between gap-2 mt-2 text-xs ${THEME.textMuted}"> 24 35 <span>${fmt(msg.home.noteCount, { count: String(wiki.note_count) })}</span> 36 + ${ownerHtml} 25 37 </div> 26 38 </a>`; 27 39 }
+15
tests/helpers/cleanup.ts
··· 1 1 import { getDb } from "../../src/server/db/index.ts"; 2 + import { upsertWiki } from "../../src/server/db/queries/index.ts"; 2 3 3 4 const db = getDb(); 4 5 ··· 18 19 db.run("DELETE FROM requests WHERE wiki_slug = ?", [slug]); 19 20 db.run("DELETE FROM wikis WHERE slug = ?", [slug]); 20 21 } 22 + 23 + const TEST_DID = "did:plc:mock123"; 24 + 25 + /** Ensure the shared "test" wiki exists (idempotent via upsert). Call in beforeAll. */ 26 + export function ensureTestWiki(): void { 27 + upsertWiki( 28 + "test", 29 + TEST_DID, 30 + "Test Wiki", 31 + "public", 32 + `at://${TEST_DID}/wiki.lichen.wiki/test`, 33 + "2026-01-01T00:00:00.000Z", 34 + ); 35 + }
+3 -1
tests/server/db/queries/membership.test.ts
··· 9 9 upsertMembership, 10 10 upsertRequest, 11 11 } from "../../../../src/server/db/queries/index.ts"; 12 + import { ensureTestWiki } from "../../../helpers/cleanup.ts"; 12 13 13 - const WIKI_SLUG = "test"; // seeded wiki 14 + const WIKI_SLUG = "test"; 14 15 const ADMIN_DID = "did:plc:member-admin"; 15 16 const VIEWER_DID = "did:plc:member-viewer"; 16 17 const REQUESTER_DID = "did:plc:requester"; 17 18 18 19 beforeAll(() => { 20 + ensureTestWiki(); 19 21 upsertMembership( 20 22 WIKI_SLUG, 21 23 ADMIN_DID,
+50 -15
tests/server/db/queries/note.test.ts
··· 13 13 searchNotes, 14 14 upsertNote, 15 15 } from "../../../../src/server/db/queries/index.ts"; 16 + import { ensureTestWiki } from "../../../helpers/cleanup.ts"; 16 17 import { cleanupNotes } from "./helpers.ts"; 17 18 18 19 const db = getDb(); ··· 23 24 `at://${TEST_DID}/wiki.lichen.noteRevision/${generateTid()}`; 24 25 25 26 beforeAll(() => { 27 + ensureTestWiki(); 26 28 cleanupNotes("test", "write-test-*"); 27 29 cleanupNotes("test", "edit-test-*"); 28 30 cleanupNotes("test", "search-test-*"); 29 31 cleanupNotes("test", "upsert-test-*"); 30 32 cleanupNotes("test", "delete-test-*"); 33 + cleanupNotes("test", "list-test-*"); 34 + cleanupNotes("test", "read-test-*"); 35 + 36 + // Create notes for read-only tests 37 + createNote( 38 + noteUri(), 39 + revUri(), 40 + "test", 41 + "read-test-home", 42 + "Home", 43 + TEST_DID, 44 + "# Welcome to the Test Wiki", 45 + ); 46 + createNote( 47 + noteUri(), 48 + revUri(), 49 + "test", 50 + "read-test-hello", 51 + "Hello World", 52 + TEST_DID, 53 + "# Hello World\n\nSample note.", 54 + ); 55 + createNote( 56 + noteUri(), 57 + revUri(), 58 + "test", 59 + "read-test-guide", 60 + "Getting Started", 61 + TEST_DID, 62 + "# Getting Started\n\nHow to use Lichen.", 63 + ); 31 64 }); 32 65 33 66 afterAll(() => { ··· 36 69 cleanupNotes("test", "search-test-*"); 37 70 cleanupNotes("test", "upsert-test-*"); 38 71 cleanupNotes("test", "delete-test-*"); 72 + cleanupNotes("test", "list-test-*"); 73 + cleanupNotes("test", "read-test-*"); 39 74 }); 40 75 41 76 describe("listNotes", () => { 42 77 test("returns notes for a wiki", () => { 43 78 const notes = listNotes("test"); 44 - expect(notes.length).toBe(3); 79 + expect(notes.length).toBeGreaterThanOrEqual(3); 45 80 const slugs = notes.map((n) => n.slug); 46 - expect(slugs).toContain("home"); 47 - expect(slugs).toContain("hello"); 48 - expect(slugs).toContain("getting-started"); 81 + expect(slugs).toContain("read-test-home"); 82 + expect(slugs).toContain("read-test-hello"); 83 + expect(slugs).toContain("read-test-guide"); 49 84 }); 50 85 51 86 test("returns notes with titles", () => { 52 87 const notes = listNotes("test"); 53 - const hello = notes.find((n) => n.slug === "hello"); 88 + const hello = notes.find((n) => n.slug === "read-test-hello"); 54 89 expect(hello?.title).toBe("Hello World"); 55 90 }); 56 91 ··· 69 104 70 105 describe("getCurrentNote", () => { 71 106 test("returns current content for existing note", () => { 72 - const current = getCurrentNote("test", "hello"); 107 + const current = getCurrentNote("test", "read-test-hello"); 73 108 expect(current).not.toBeNull(); 74 109 expect(current?.content).toContain("# Hello World"); 75 110 expect(current?.latest_revision_uri).toBeDefined(); ··· 81 116 }); 82 117 83 118 test("returns null for nonexistent wiki", () => { 84 - expect(getCurrentNote("nonexistent", "hello")).toBeNull(); 119 + expect(getCurrentNote("nonexistent", "read-test-hello")).toBeNull(); 85 120 }); 86 121 87 122 test("returns different content for different notes", () => { 88 - const hello = getCurrentNote("test", "hello"); 89 - const gettingStarted = getCurrentNote("test", "getting-started"); 90 - expect(hello?.content).not.toBe(gettingStarted?.content); 123 + const hello = getCurrentNote("test", "read-test-hello"); 124 + const guide = getCurrentNote("test", "read-test-guide"); 125 + expect(hello?.content).not.toBe(guide?.content); 91 126 }); 92 127 93 128 test("home note is accessible", () => { 94 - const home = getCurrentNote("test", "home"); 129 + const home = getCurrentNote("test", "read-test-home"); 95 130 expect(home?.content).toContain("# Welcome to the Test Wiki"); 96 131 }); 97 132 }); 98 133 99 134 describe("getNoteBySlug", () => { 100 135 test("returns existing note by wiki+slug", () => { 101 - const note = getNoteBySlug("test", "hello"); 136 + const note = getNoteBySlug("test", "read-test-hello"); 102 137 expect(note).not.toBeNull(); 103 - expect(note?.slug).toBe("hello"); 138 + expect(note?.slug).toBe("read-test-hello"); 104 139 expect(note?.wiki_slug).toBe("test"); 105 140 expect(note?.title).toBe("Hello World"); 106 141 }); ··· 110 145 }); 111 146 112 147 test("returns null for wrong wiki", () => { 113 - expect(getNoteBySlug("nonexistent", "hello")).toBeNull(); 148 + expect(getNoteBySlug("nonexistent", "read-test-hello")).toBeNull(); 114 149 }); 115 150 }); 116 151 ··· 412 447 ); 413 448 const results = searchNotes("Alpha"); 414 449 expect(results.some((n) => n.slug === "search-test-alpha")).toBe(true); 415 - expect(results[0]?.wiki_name).toBe("Test Wiki"); 450 + expect(results[0]?.wiki_name).toBeDefined(); 416 451 }); 417 452 418 453 test("case-insensitive matching", () => {
+2
tests/server/db/queries/revision.test.ts
··· 8 8 getSnapshots, 9 9 saveNoteEdit, 10 10 } from "../../../../src/server/db/queries/index.ts"; 11 + import { ensureTestWiki } from "../../../helpers/cleanup.ts"; 11 12 import { cleanupNotes } from "./helpers.ts"; 12 13 13 14 const db = getDb(); ··· 18 19 `at://${TEST_DID}/wiki.lichen.noteRevision/${generateTid()}`; 19 20 20 21 beforeAll(() => { 22 + ensureTestWiki(); 21 23 cleanupNotes("test", "backlink-test-*"); 22 24 cleanupNotes("test", "snapshot-test-*"); 23 25 });
+21 -8
tests/server/db/queries/wiki.test.ts
··· 11 11 import { cleanupWikiAndDependents } from "../../../helpers/cleanup.ts"; 12 12 13 13 const db = getDb(); 14 - const UPSERT_SLUGS = [ 14 + const TEST_SLUG = "wiki-query-test"; 15 + const TEST_AT_URI = `at://did:plc:mock123/wiki.lichen.wiki/${TEST_SLUG}`; 16 + 17 + const CLEANUP_SLUGS = [ 18 + TEST_SLUG, 15 19 "upsert-test-new", 16 20 "upsert-test-update", 17 21 "delete-test-wiki", ··· 23 27 "paginated-3", 24 28 ]; 25 29 30 + beforeAll(() => { 31 + upsertWiki( 32 + TEST_SLUG, 33 + "did:plc:mock123", 34 + "Query Test Wiki", 35 + "public", 36 + TEST_AT_URI, 37 + "2026-01-01T00:00:00.000Z", 38 + ); 39 + }); 40 + 26 41 afterAll(() => { 27 - for (const slug of UPSERT_SLUGS) { 42 + for (const slug of CLEANUP_SLUGS) { 28 43 cleanupWikiAndDependents(slug); 29 44 } 30 45 }); 31 46 32 47 describe("getWiki", () => { 33 48 test("returns wiki by slug", () => { 34 - const wiki = getWiki("test"); 49 + const wiki = getWiki(TEST_SLUG); 35 50 expect(wiki).not.toBeNull(); 36 - expect(wiki?.name).toBe("Test Wiki"); 51 + expect(wiki?.name).toBe("Query Test Wiki"); 37 52 expect(wiki?.visibility).toBe("public"); 38 53 }); 39 54 ··· 45 60 46 61 describe("getWikiByAtUri", () => { 47 62 test("returns wiki by AT URI", () => { 48 - const wiki = getWiki("test"); 49 - if (!wiki) throw new Error("expected seeded wiki"); 50 - const found = getWikiByAtUri(wiki.at_uri); 63 + const found = getWikiByAtUri(TEST_AT_URI); 51 64 expect(found).not.toBeNull(); 52 - expect(found?.slug).toBe("test"); 65 + expect(found?.slug).toBe(TEST_SLUG); 53 66 }); 54 67 55 68 test("returns null for nonexistent AT URI", () => {