···11+import type { Completion, CompletionSource } from "@codemirror/autocomplete";
22+33+interface NoteItem {
44+ slug: string;
55+ title: string;
66+}
77+88+interface HeadingItem {
99+ text: string;
1010+ anchor: string;
1111+ level: number;
1212+}
1313+1414+/** Matches the text inside an unclosed `[[` up to the cursor. */
1515+const TRIGGER_RE = /\[\[([^[\]\n]*)$/;
1616+1717+/**
1818+ * CodeMirror autocomplete source: triggers inside `[[...`. Returns note titles
1919+ * by default; after the user types `#`, returns headings for the named note.
2020+ */
2121+export function wikilinkCompletion(wikiSlug: string): CompletionSource {
2222+ const endpoint = `/wiki/${encodeURIComponent(wikiSlug)}/-/link-suggest`;
2323+2424+ return async (context) => {
2525+ const match = context.matchBefore(TRIGGER_RE);
2626+ if (!match) return null;
2727+ const inside = match.text.slice(2);
2828+2929+ // Explicit trigger keeps the popup open even when `inside` is empty.
3030+ if (!inside && !context.explicit) return null;
3131+3232+ const from = match.from + 2;
3333+ const hashIdx = inside.indexOf("#");
3434+3535+ if (hashIdx !== -1) {
3636+ const noteSlug = inside.slice(0, hashIdx).trim();
3737+ const headingQuery = inside.slice(hashIdx + 1);
3838+ if (!noteSlug) return null;
3939+ const url = `${endpoint}?note=${encodeURIComponent(noteSlug)}&q=${encodeURIComponent(headingQuery)}`;
4040+ const items = await fetchItems<HeadingItem>(url, context.aborted);
4141+ if (!items) return null;
4242+ const options: Completion[] = items.map((h) => ({
4343+ label: `${"#".repeat(Math.min(h.level, 3))} ${h.text}`,
4444+ apply: `${noteSlug}#${h.text}]]`,
4545+ type: "property",
4646+ }));
4747+ return { from, to: context.pos, options, validFor: /^[^[\]\n]*$/ };
4848+ }
4949+5050+ const url = `${endpoint}?q=${encodeURIComponent(inside)}`;
5151+ const items = await fetchItems<NoteItem>(url, context.aborted);
5252+ if (!items) return null;
5353+ const options: Completion[] = items.map((n) => ({
5454+ label: n.title,
5555+ detail: n.slug,
5656+ apply: `${n.slug}]]`,
5757+ type: "text",
5858+ }));
5959+ return { from, to: context.pos, options, validFor: /^[^[\]\n#]*$/ };
6060+ };
6161+}
6262+6363+async function fetchItems<T>(
6464+ url: string,
6565+ aborted: boolean,
6666+): Promise<T[] | null> {
6767+ if (aborted) return null;
6868+ try {
6969+ const res = await fetch(url, { credentials: "same-origin" });
7070+ if (!res.ok) return [];
7171+ const data = (await res.json()) as { items?: T[] };
7272+ return data.items ?? [];
7373+ } catch {
7474+ return null;
7575+ }
7676+}
+51
src/lib/headings.ts
···11+import { slugify } from "./slug.ts";
22+33+interface Heading {
44+ text: string;
55+ level: number;
66+ anchor: string;
77+}
88+99+/**
1010+ * Allocate an anchor for `text`, disambiguating repeats by suffixing -1, -2, ...
1111+ * `seen` tracks how many times each base slug has been used so far in the doc.
1212+ */
1313+export function makeAnchor(text: string, seen: Map<string, number>): string {
1414+ const base = slugify(text);
1515+ if (!base) return base;
1616+ const n = seen.get(base) ?? 0;
1717+ seen.set(base, n + 1);
1818+ return n === 0 ? base : `${base}-${n}`;
1919+}
2020+2121+const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
2222+const FENCE_RE = /^(`{3,}|~{3,})/;
2323+2424+/** Extract ATX headings from markdown, skipping fenced code blocks. */
2525+export function extractHeadings(md: string): Heading[] {
2626+ const out: Heading[] = [];
2727+ const seen = new Map<string, number>();
2828+ let fence: string | null = null;
2929+ for (const line of md.split("\n")) {
3030+ const fenceMatch = line.match(FENCE_RE);
3131+ if (fenceMatch) {
3232+ const marker = fenceMatch[1]?.[0];
3333+ if (fence === null) {
3434+ fence = marker ?? null;
3535+ } else if (marker === fence) {
3636+ fence = null;
3737+ }
3838+ continue;
3939+ }
4040+ if (fence !== null) continue;
4141+ const m = line.match(HEADING_RE);
4242+ if (!m) continue;
4343+ const level = m[1]?.length ?? 0;
4444+ const text = m[2]?.trim() ?? "";
4545+ if (!text) continue;
4646+ const anchor = makeAnchor(text, seen);
4747+ if (!anchor) continue;
4848+ out.push({ text, level, anchor });
4949+ }
5050+ return out;
5151+}
+6-2
src/lib/markdown.ts
···11import MarkdownIt from "markdown-it";
22+import { headingAnchorPlugin } from "./markdown/heading-anchor-plugin.ts";
23import { type KatexPluginEnv, katexPlugin } from "./markdown/katex-plugin.ts";
34import {
55+ parseWikilink,
46 type WikilinkEnv,
57 wikilinkPlugin,
68} from "./markdown/wikilink-plugin.ts";
···5456}
55575658md.use(wikilinkPlugin);
5959+md.use(headingAnchorPlugin);
5760md.use(vizPlugin);
5861md.use(katexPlugin, { throwOnError: false });
5962md.use(youtubePlugin);
···69727073/**
7174 * Extract unique wikilink target slugs from markdown content.
7272- * Matches [[slug]] and [[slug|label]], returns deduplicated trimmed slugs.
7575+ * Matches [[slug]], [[slug|label]], and [[slug#heading]] — returns deduplicated
7676+ * trimmed slugs (heading suffix dropped, since backlinks are per-note).
7377 */
7478export function extractWikilinks(content: string): string[] {
7579 const seen = new Set<string>();
···7983 const closeIdx = content.indexOf("]]", pos + 2);
8084 if (closeIdx === -1) break;
8185 const inner = content.slice(pos + 2, closeIdx);
8282- const slug = (inner.includes("|") ? inner.split("|")[0] : inner)?.trim();
8686+ const { slug } = parseWikilink(inner);
8387 if (slug) {
8488 seen.add(slug);
8589 }
+25
src/lib/markdown/heading-anchor-plugin.ts
···11+import type MarkdownIt from "markdown-it";
22+import { makeAnchor } from "../headings.ts";
33+44+/**
55+ * Adds an `id` attribute to every rendered heading, slugified from its text.
66+ * Matches the anchor scheme used by the wikilink plugin so `[[slug#heading]]`
77+ * lands on the right element.
88+ */
99+export function headingAnchorPlugin(mdi: MarkdownIt): void {
1010+ mdi.core.ruler.push("heading_anchor", (state) => {
1111+ const seen = new Map<string, number>();
1212+ const tokens = state.tokens;
1313+ for (let i = 0; i < tokens.length; i++) {
1414+ const open = tokens[i];
1515+ if (open?.type !== "heading_open") continue;
1616+ const inline = tokens[i + 1];
1717+ if (!inline || inline.type !== "inline") continue;
1818+ const text = inline.content.trim();
1919+ if (!text) continue;
2020+ const anchor = makeAnchor(text, seen);
2121+ if (!anchor) continue;
2222+ open.attrSet("id", anchor);
2323+ }
2424+ });
2525+}