Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109/** * Markdown heading extraction, for resolving `[[Note#Heading]]` anchors and for * offering headings in the `[[` typeahead. * * A line scan rather than a Lezer walk, deliberately. `syntaxTree(state)` is * parsed viewport-first and can be truncated on a long document — and forcing * it with `ensureSyntaxTree` blocks the thread and may still time out. A scan * is O(lines) with no failure mode. It also means one implementation serves * every caller, including completion for a note that is *not* open, where there * is a raw string off disk and no `EditorState` at all. * * Two known gaps, both matching what the editor already renders: * - **Setext headings** (`Title` over `===`) are not recognised, because * `headingDecoration.ts` does not render them as headings either. Accepting * them here would let a link resolve to something that displays as plain text. * - **YAML front matter** is not understood, so a `#` line inside a leading * `---` block reads as a heading. The app has no front-matter support at all * yet; this should be revisited when it does. */
/** A heading found in a document. */export type DocHeading = { /** Heading text with markers, any closing `#`s and surrounding space removed. */ text: string; /** 1–6. */ level: number; /** 0-based line index. */ line: number;};
/** * Up to three leading spaces (four would make it an indented code block), 1–6 * `#`, then either end-of-line or a space before the text. Requiring that space * is what stops `#hashtag` being read as a heading. */const ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/;
/** A closing run of `#`s, as in `## Title ##`, which is not part of the text. */const CLOSING_SEQUENCE = /[ \t]+#+[ \t]*$/;
/** ``` or ~~~ opening/closing a fenced code block. */const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
export function extractHeadings(lines: Iterable<string>): DocHeading[] { const out: DocHeading[] = []; let lineNumber = -1; // The fence currently open: its character and length. A fence closes only on // the same character, at least as long, and carrying no info string. let openFence: { char: string; length: number } | null = null;
for (const rawLine of lines) { lineNumber++; const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
const fence = FENCE.exec(line); if (fence) { const [, marker, rest] = fence; const char = marker[0]; if (!openFence) { openFence = { char, length: marker.length }; } else if ( char === openFence.char && marker.length >= openFence.length && rest.trim() === '' ) { openFence = null; } continue; } if (openFence) continue;
const match = ATX.exec(line); if (!match) continue; const text = (match[2] ?? '').replace(CLOSING_SEQUENCE, '').trim(); // `##` with nothing after it names nothing, so it cannot be linked to. if (text === '') continue;
out.push({ text, level: match[1].length, line: lineNumber }); }
return out;}
/** * Comparison key for anchors: case- and whitespace-insensitive. * * Case-folding mirrors how note names resolve (`wikiLinkIndex`'s * `byStemLower`); collapsing whitespace forgives `[[Note# Heading]]`. */export function normalizeAnchor(text: string): string { return text.trim().replace(/\s+/g, ' ').toLowerCase();}
/** * The heading an anchor refers to, or null. * * Duplicates resolve to the first in document order — the same rule the * typeahead uses when it drops later duplicates from the list, so what you can * pick is always what you will reach. */export function findHeading( headings: readonly DocHeading[], anchor: string,): DocHeading | null { const key = normalizeAnchor(anchor); if (key === '') return null; return headings.find((h) => normalizeAnchor(h.text) === key) ?? null;}