Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153import { describe, it, expect } from 'vitest';import { syntaxTree } from '@codemirror/language';
import { makeState } from '../editors/automerge/livePreview/testSupport';import { extractHeadings, findHeading, normalizeAnchor } from './headings';
const headings = (doc: string) => extractHeadings(doc.split('\n'));
describe('extractHeadings', () => { it('reads all six levels', () => { const doc = ['# a', '## b', '### c', '#### d', '##### e', '###### f'].join( '\n', ); expect(headings(doc).map((h) => h.level)).toEqual([1, 2, 3, 4, 5, 6]); });
it('records text and 0-based line index', () => { expect(headings('intro\n\n## Second bit\nbody')).toEqual([ { text: 'Second bit', level: 2, line: 2 }, ]); });
it('requires a space, so `#hashtag` is not a heading', () => { expect(headings('#hashtag')).toEqual([]); });
it('ignores a heading with no text', () => { expect(headings('##\n### ')).toEqual([]); });
it('stops at six `#`', () => { expect(headings('####### seven')).toEqual([]); });
it('strips a closing sequence', () => { expect(headings('## Title ##')[0].text).toBe('Title'); expect(headings('## Title #########')[0].text).toBe('Title'); });
it('keeps a `#` that is part of the text', () => { expect(headings('## C# notes')[0].text).toBe('C# notes'); });
it('allows up to three leading spaces but not four', () => { expect(headings(' ### indented')[0].text).toBe('indented'); expect(headings(' ### code block')).toEqual([]); });
it('tolerates CRLF', () => { expect(headings('# a\r\n## b\r')).toEqual([ { text: 'a', level: 1, line: 0 }, { text: 'b', level: 2, line: 1 }, ]); });
describe('fenced code', () => { it('ignores headings inside a backtick fence', () => { expect(headings('```\n# not a heading\n```\n# real')).toEqual([ { text: 'real', level: 1, line: 3 }, ]); });
it('ignores headings inside a tilde fence', () => { expect(headings('~~~\n# nope\n~~~')).toEqual([]); });
it('does not let a tilde fence close a backtick fence', () => { expect(headings('```\n~~~\n# still inside\n```\n# out')).toEqual([ { text: 'out', level: 1, line: 4 }, ]); });
it('requires the closing fence to be at least as long', () => { expect(headings('````\n```\n# still inside\n````\n# out')).toEqual([ { text: 'out', level: 1, line: 4 }, ]); });
it('does not close on a fence carrying an info string', () => { expect(headings('```\n```ts\n# still inside\n```\n# out')).toEqual([ { text: 'out', level: 1, line: 4 }, ]); });
it('handles a fence opened with an info string', () => { expect(headings('```ts\n# nope\n```\n# out')).toEqual([ { text: 'out', level: 1, line: 3 }, ]); }); });});
/** * The scan is a second heading implementation alongside the Lezer grammar the * editor renders with. This is the test that stops the two drifting: for a * broad corpus, both must find headings on exactly the same lines. */describe('parity with the markdown parser the editor uses', () => { const CORPUS = [ '# one\ntext\n## two\n### three', 'intro\n\n#hashtag not a heading\n\n## real one', '```\n# fenced\n```\n# after', ' ### three spaces\n #### four spaces', '## closing ##\n## trailing spaces ', '# a\n\n> quoted text\n\n## b\n- list\n## c', '~~~js\n# in tilde fence\n~~~\n# out', '## C# and **bold**\ntext', 'para\n# h1\npara\n###### h6\npara', '````\n```\n# nested fence\n````\n# after nested', ];
const parserHeadingLines = (doc: string): number[] => { const state = makeState(doc, 0); const lines: number[] = []; syntaxTree(state).iterate({ enter(node) { if (!/^ATXHeading[1-6]$/.test(node.name)) return; lines.push(state.doc.lineAt(node.from).number - 1); }, }); return lines; };
for (const doc of CORPUS) { it(`agrees on: ${JSON.stringify(doc.slice(0, 32))}…`, () => { expect(headings(doc).map((h) => h.line)).toEqual(parserHeadingLines(doc)); }); }});
describe('normalizeAnchor', () => { it('folds case and collapses whitespace', () => { expect(normalizeAnchor(' The Big One ')).toBe('the big one'); });});
describe('findHeading', () => { const hs = headings('# Alpha\n## Beta\n### Alpha');
it('matches case- and whitespace-insensitively', () => { expect(findHeading(hs, ' beta ')).toMatchObject({ line: 1 }); });
it('resolves a duplicate to the first in document order', () => { expect(findHeading(hs, 'Alpha')).toMatchObject({ line: 0, level: 1 }); });
it('returns null for no match and for an empty anchor', () => { expect(findHeading(hs, 'Gamma')).toBeNull(); expect(findHeading(hs, ' ')).toBeNull(); });});