🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Add first version of link preview

juprodh c7af7022 1a33f8d1

+478 -15
+1
bun.lock
··· 10 10 "@atproto/jwk-jose": "^0.1.0", 11 11 "@atproto/oauth-client-node": "^0.1.0", 12 12 "@atproto/sync": "^0.1.40", 13 + "@codemirror/autocomplete": "^6.20.1", 13 14 "@codemirror/lang-markdown": "^6.5.0", 14 15 "@codemirror/state": "^6.0.0", 15 16 "@elysiajs/static": "^1.4.7",
+1
package.json
··· 54 54 "@atproto/jwk-jose": "^0.1.0", 55 55 "@atproto/oauth-client-node": "^0.1.0", 56 56 "@atproto/sync": "^0.1.40", 57 + "@codemirror/autocomplete": "^6.20.1", 57 58 "@codemirror/lang-markdown": "^6.5.0", 58 59 "@codemirror/state": "^6.0.0", 59 60 "@elysiajs/static": "^1.4.7",
+8
public/editor/editor.ts
··· 1 + import { autocompletion } from "@codemirror/autocomplete"; 1 2 import { markdown } from "@codemirror/lang-markdown"; 2 3 import { EditorState } from "@codemirror/state"; 3 4 import { basicSetup, EditorView } from "codemirror"; ··· 5 6 import { renderPreview } from "./preview.ts"; 6 7 import { createToolbar } from "./toolbar.ts"; 7 8 import { showToast, syncBlobMetadata, uploadImage } from "./upload.ts"; 9 + import { wikilinkCompletion } from "./wikilink-autocomplete.ts"; 8 10 9 11 let activeView: EditorView | null = null; 10 12 ··· 76 78 fileInput.accept = "image/jpeg,image/png,image/gif,image/webp"; 77 79 fileInput.style.display = "none"; 78 80 81 + const wikiSlug = textarea.dataset["wikiSlug"]; 82 + const autocompleteExt = wikiSlug 83 + ? [autocompletion({ override: [wikilinkCompletion(wikiSlug)] })] 84 + : []; 85 + 79 86 const view = new EditorView({ 80 87 state: EditorState.create({ 81 88 doc: textarea.value, 82 89 extensions: [ 83 90 basicSetup, 84 91 markdown(), 92 + ...autocompleteExt, 85 93 EditorView.lineWrapping, 86 94 EditorView.updateListener.of((update) => { 87 95 if (update.docChanged) {
+51
src/lib/headings.ts
··· 1 + import { slugify } from "./slug.ts"; 2 + 3 + interface Heading { 4 + text: string; 5 + level: number; 6 + anchor: string; 7 + } 8 + 9 + /** 10 + * Allocate an anchor for `text`, disambiguating repeats by suffixing -1, -2, ... 11 + * `seen` tracks how many times each base slug has been used so far in the doc. 12 + */ 13 + export function makeAnchor(text: string, seen: Map<string, number>): string { 14 + const base = slugify(text); 15 + if (!base) return base; 16 + const n = seen.get(base) ?? 0; 17 + seen.set(base, n + 1); 18 + return n === 0 ? base : `${base}-${n}`; 19 + } 20 + 21 + const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/; 22 + const FENCE_RE = /^(`{3,}|~{3,})/; 23 + 24 + /** Extract ATX headings from markdown, skipping fenced code blocks. */ 25 + export function extractHeadings(md: string): Heading[] { 26 + const out: Heading[] = []; 27 + const seen = new Map<string, number>(); 28 + let fence: string | null = null; 29 + for (const line of md.split("\n")) { 30 + const fenceMatch = line.match(FENCE_RE); 31 + if (fenceMatch) { 32 + const marker = fenceMatch[1]?.[0]; 33 + if (fence === null) { 34 + fence = marker ?? null; 35 + } else if (marker === fence) { 36 + fence = null; 37 + } 38 + continue; 39 + } 40 + if (fence !== null) continue; 41 + const m = line.match(HEADING_RE); 42 + if (!m) continue; 43 + const level = m[1]?.length ?? 0; 44 + const text = m[2]?.trim() ?? ""; 45 + if (!text) continue; 46 + const anchor = makeAnchor(text, seen); 47 + if (!anchor) continue; 48 + out.push({ text, level, anchor }); 49 + } 50 + return out; 51 + }
+6 -2
src/lib/markdown.ts
··· 1 1 import MarkdownIt from "markdown-it"; 2 + import { headingAnchorPlugin } from "./markdown/heading-anchor-plugin.ts"; 2 3 import { type KatexPluginEnv, katexPlugin } from "./markdown/katex-plugin.ts"; 3 4 import { 5 + parseWikilink, 4 6 type WikilinkEnv, 5 7 wikilinkPlugin, 6 8 } from "./markdown/wikilink-plugin.ts"; ··· 54 56 } 55 57 56 58 md.use(wikilinkPlugin); 59 + md.use(headingAnchorPlugin); 57 60 md.use(vizPlugin); 58 61 md.use(katexPlugin, { throwOnError: false }); 59 62 md.use(youtubePlugin); ··· 69 72 70 73 /** 71 74 * Extract unique wikilink target slugs from markdown content. 72 - * Matches [[slug]] and [[slug|label]], returns deduplicated trimmed slugs. 75 + * Matches [[slug]], [[slug|label]], and [[slug#heading]] — returns deduplicated 76 + * trimmed slugs (heading suffix dropped, since backlinks are per-note). 73 77 */ 74 78 export function extractWikilinks(content: string): string[] { 75 79 const seen = new Set<string>(); ··· 79 83 const closeIdx = content.indexOf("]]", pos + 2); 80 84 if (closeIdx === -1) break; 81 85 const inner = content.slice(pos + 2, closeIdx); 82 - const slug = (inner.includes("|") ? inner.split("|")[0] : inner)?.trim(); 86 + const { slug } = parseWikilink(inner); 83 87 if (slug) { 84 88 seen.add(slug); 85 89 }
+25
src/lib/markdown/heading-anchor-plugin.ts
··· 1 + import type MarkdownIt from "markdown-it"; 2 + import { makeAnchor } from "../headings.ts"; 3 + 4 + /** 5 + * Adds an `id` attribute to every rendered heading, slugified from its text. 6 + * Matches the anchor scheme used by the wikilink plugin so `[[slug#heading]]` 7 + * lands on the right element. 8 + */ 9 + export function headingAnchorPlugin(mdi: MarkdownIt): void { 10 + mdi.core.ruler.push("heading_anchor", (state) => { 11 + const seen = new Map<string, number>(); 12 + const tokens = state.tokens; 13 + for (let i = 0; i < tokens.length; i++) { 14 + const open = tokens[i]; 15 + if (open?.type !== "heading_open") continue; 16 + const inline = tokens[i + 1]; 17 + if (!inline || inline.type !== "inline") continue; 18 + const text = inline.content.trim(); 19 + if (!text) continue; 20 + const anchor = makeAnchor(text, seen); 21 + if (!anchor) continue; 22 + open.attrSet("id", anchor); 23 + } 24 + }); 25 + }
+35
src/server/routes/wiki.ts
··· 12 12 ImportError, 13 13 ValidationError, 14 14 } from "../../lib/errors.ts"; 15 + import { extractHeadings } from "../../lib/headings.ts"; 15 16 import { t } from "../../lib/i18n/index.ts"; 16 17 import { exportWikiZip } from "../../lib/import-export/export.ts"; 17 18 import { importWikiAction } from "../../lib/import-export/import.ts"; ··· 32 33 import { shareOnBlueskyButton } from "../../views/share.ts"; 33 34 import { wikiPage } from "../../views/wiki.ts"; 34 35 import { 36 + getCurrentNote, 35 37 getNoteWithCurrent, 36 38 getSidebarNotes, 37 39 listMembers, 38 40 listRequests, 39 41 } from "../db/queries/index.ts"; 42 + 43 + const LINK_SUGGEST_LIMIT = 20; 40 44 41 45 async function loadSettingsData(wikiSlug: string) { 42 46 const members = listMembers(wikiSlug); ··· 200 204 } 201 205 await deleteWikiAction(ctx); 202 206 return redirect("/"); 207 + }) 208 + .get("/:wikiSlug/-/link-suggest", async ({ params, request, query }) => { 209 + await resolveWikiContext(request, params.wikiSlug, "read"); 210 + const q = ((query["q"] as string | undefined) ?? "").toLowerCase().trim(); 211 + const noteSlug = (query["note"] as string | undefined) ?? null; 212 + 213 + if (noteSlug) { 214 + const current = getCurrentNote(params.wikiSlug, noteSlug); 215 + if (!current) { 216 + return Response.json({ items: [] }); 217 + } 218 + const headings = extractHeadings(current.content); 219 + const filtered = q 220 + ? headings.filter((h) => h.text.toLowerCase().includes(q)) 221 + : headings; 222 + return Response.json({ 223 + items: filtered.slice(0, LINK_SUGGEST_LIMIT), 224 + }); 225 + } 226 + 227 + const notes = getSidebarNotes(params.wikiSlug); 228 + const filtered = q 229 + ? notes.filter( 230 + (n) => 231 + n.title.toLowerCase().includes(q) || 232 + n.slug.toLowerCase().includes(q), 233 + ) 234 + : notes; 235 + return Response.json({ 236 + items: filtered.slice(0, LINK_SUGGEST_LIMIT), 237 + }); 203 238 }) 204 239 .get("/:wikiSlug/-/export", async ({ params, request }) => { 205 240 await resolveWikiContext(request, params.wikiSlug, "read");
+1
src/views/edit-note.ts
··· 60 60 id="content" 61 61 name="content" 62 62 rows="24" 63 + data-wiki-slug="${escapeHtml(wikiSlug)}" 63 64 class="${inputClass} h-full ${THEME.fontMono} md:resize-none" 64 65 >${escapeHtml(currentContent)}</textarea> 65 66 </div>
+1
src/views/new-note.ts
··· 88 88 id="content" 89 89 name="content" 90 90 rows="16" 91 + data-wiki-slug="${escapeHtml(wikiSlug)}" 91 92 class="${inputClass} ${THEME.fontMono}" 92 93 placeholder="${msg.editor.newNotePlaceholder}" 93 94 >${escapedContent}</textarea>
+65
tests/lib/headings.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { extractHeadings, makeAnchor } from "../../src/lib/headings.ts"; 3 + 4 + describe("makeAnchor", () => { 5 + test("slugifies plain text", () => { 6 + const seen = new Map<string, number>(); 7 + expect(makeAnchor("Hello World", seen)).toBe("hello-world"); 8 + }); 9 + 10 + test("suffixes repeats with -1, -2, ...", () => { 11 + const seen = new Map<string, number>(); 12 + expect(makeAnchor("Intro", seen)).toBe("intro"); 13 + expect(makeAnchor("Intro", seen)).toBe("intro-1"); 14 + expect(makeAnchor("Intro", seen)).toBe("intro-2"); 15 + }); 16 + 17 + test("returns empty for text that slugifies to empty", () => { 18 + const seen = new Map<string, number>(); 19 + expect(makeAnchor(" ", seen)).toBe(""); 20 + }); 21 + }); 22 + 23 + describe("extractHeadings", () => { 24 + test("returns empty for no headings", () => { 25 + expect(extractHeadings("just paragraphs\n\nmore text")).toEqual([]); 26 + expect(extractHeadings("")).toEqual([]); 27 + }); 28 + 29 + test("captures level, text, and anchor", () => { 30 + const md = "# One\n\n## Two\n\n### Three"; 31 + expect(extractHeadings(md)).toEqual([ 32 + { text: "One", level: 1, anchor: "one" }, 33 + { text: "Two", level: 2, anchor: "two" }, 34 + { text: "Three", level: 3, anchor: "three" }, 35 + ]); 36 + }); 37 + 38 + test("strips trailing ATX closing hashes", () => { 39 + expect(extractHeadings("# Title ##")[0]?.text).toBe("Title"); 40 + }); 41 + 42 + test("requires a space after the hashes", () => { 43 + expect(extractHeadings("#notaheading")).toEqual([]); 44 + }); 45 + 46 + test("ignores headings inside fenced code blocks", () => { 47 + const md = "# Real\n\n```\n# Fake\n```\n\n## AlsoReal"; 48 + const out = extractHeadings(md); 49 + expect(out.map((h) => h.text)).toEqual(["Real", "AlsoReal"]); 50 + }); 51 + 52 + test("handles tilde fences", () => { 53 + const md = "~~~\n# Fake\n~~~\n# Real"; 54 + expect(extractHeadings(md).map((h) => h.text)).toEqual(["Real"]); 55 + }); 56 + 57 + test("disambiguates duplicate anchors", () => { 58 + const md = "# Intro\n\n## Intro\n\n### Intro"; 59 + expect(extractHeadings(md).map((h) => h.anchor)).toEqual([ 60 + "intro", 61 + "intro-1", 62 + "intro-2", 63 + ]); 64 + }); 65 + });
+36
tests/lib/markdown.test.ts
··· 110 110 expect(html).not.toContain("<img"); 111 111 expect(html).toContain("&lt;img"); 112 112 }); 113 + 114 + test("renders [[slug#heading]] with anchor fragment and default label", () => { 115 + const { html } = renderMarkdown("see [[my-page#My Heading]]", WIKI); 116 + expect(html).toContain('href="/wiki/test-wiki/my-page#my-heading"'); 117 + expect(html).toContain(">my-page#My Heading<"); 118 + }); 119 + 120 + test("renders [[slug#heading|label]] with custom label", () => { 121 + const { html } = renderMarkdown("see [[my-page#Intro|Jump]]", WIKI); 122 + expect(html).toContain('href="/wiki/test-wiki/my-page#intro"'); 123 + expect(html).toContain(">Jump<"); 124 + }); 125 + }); 126 + 127 + describe("heading anchors", () => { 128 + test("attaches id to rendered headings", () => { 129 + const { html } = renderMarkdown("# Hello World\n\n## Another One"); 130 + expect(html).toContain('<h1 id="hello-world">'); 131 + expect(html).toContain('<h2 id="another-one">'); 132 + }); 133 + 134 + test("disambiguates duplicate heading anchors", () => { 135 + const { html } = renderMarkdown("# Intro\n\n## Intro\n\n### Intro"); 136 + expect(html).toContain('<h1 id="intro">'); 137 + expect(html).toContain('<h2 id="intro-1">'); 138 + expect(html).toContain('<h3 id="intro-2">'); 139 + }); 113 140 }); 114 141 115 142 describe("extractWikilinks", () => { ··· 144 171 test("handles wikilinks across multiple lines", () => { 145 172 const result = extractWikilinks("line1 [[a]]\nline2 [[b]]"); 146 173 expect(result).toEqual(["a", "b"]); 174 + }); 175 + 176 + test("strips #heading suffix when recording the slug", () => { 177 + expect(extractWikilinks("jump to [[my-page#Section]]")).toEqual([ 178 + "my-page", 179 + ]); 180 + expect(extractWikilinks("[[a#one]] and [[a#two]] and [[a]]")).toEqual([ 181 + "a", 182 + ]); 147 183 }); 148 184 });