// src/editor/footnote-insertion.ts /** * Pure transform for the footnote-insertion command + toolbar action. * * Pieces: * - `pickNextIntegerFootnoteId(text)`: scans existing refs AND defs and * returns the lowest unused positive integer id. Named footnotes * (`[^foo]`) are ignored — they form a separate namespace. Refs/defs * inside fenced or inline code blocks are skipped (so a tutorial * example in a code block doesn't reserve real ids). * - `findFootnotesSection(text)`: returns `{ sectionStart, sectionEnd }` * marking the body of the LAST `## Footnotes` heading that is NOT * inside a code block, or null. Tolerates CRLF line endings. * - `isInsideFootnoteDef(text, offset)`: detects whether `offset` falls * within any `[^id]: …` def block (used to guard the toolbar/command * against nesting refs inside def bodies). * - `insertFootnote(text, selStart, selEnd)`: the headline transform. * Returns a `TextTransformResult` whose `changes` is a single * `ChangeSpec` or a `ChangeSpec[]` so all edits land in one * transaction. */ import type { ChangeSpec } from '@codemirror/state' import { FOOTNOTE_REF_RE, FOOTNOTE_DEF_LINE_RE, FOOTNOTE_CONTINUATION_RE, buildCodeRanges, isInRanges, } from '../services/metadata/extract-metadata' import type { TextTransformResult } from './source-formatting' const POSITIVE_INTEGER_RE = /^[1-9]\d*$/ /** * Scan refs AND defs for integer-only ids and return the lowest unused * positive integer. Named ids (`[^foo]`) and leading-zero ids (`[^01]`) * are filtered out. Refs/defs whose offsets fall inside fenced or inline * code blocks are SKIPPED — a tutorial example inside `` ``` `` no longer * reserves an integer id (review #3). */ export function pickNextIntegerFootnoteId(text: string): number { const used = new Set() const codeRanges = buildCodeRanges(text) // Re-instantiate from .source: the exported regexes carry /g, which // is stateful between matchAll calls. const refRe = new RegExp(FOOTNOTE_REF_RE.source, 'g') for (const m of text.matchAll(refRe)) { if (!isInRanges(m.index, codeRanges)) { const id = m[1] if (POSITIVE_INTEGER_RE.test(id)) used.add(Number(id)) } } const defRe = new RegExp(FOOTNOTE_DEF_LINE_RE.source, 'gm') for (const m of text.matchAll(defRe)) { if (!isInRanges(m.index, codeRanges)) { const id = m[1] if (POSITIVE_INTEGER_RE.test(id)) used.add(Number(id)) } } let candidate = 1 while (used.has(candidate)) candidate++ return candidate } /** * Locate the LAST `## Footnotes` heading line in the document and return * the byte range of the section's BODY (everything between the heading * line and the next heading of equal-or-lower level, or EOF). * * Returns null if no `## Footnotes` heading exists OUTSIDE a fenced or * inline code block (review #3 — silently splicing defs into a code * block on a tutorial doc is a real corruption bug; we now skip * code-block-internal headings entirely). * * Tolerates CRLF line endings (review #2): the heading regex permits * an optional `\r` before line end, and the section-start advance skips * a trailing `\r\n` rather than just `\n`. * * `sectionStart`: offset of the first character of the line AFTER the * heading line. (If the heading is on the last line with no trailing * newline, this equals `text.length`.) * `sectionEnd`: offset where the section's body ENDS — defined as the * start of the next h1 or h2 line (`/^#{1,2}[ \t]/m`), or `text.length` * if no such terminator exists. h3+ headings DO NOT terminate the * section (they're sub-sections within Footnotes). * * Why the LAST occurrence: if a doc somehow has two `## Footnotes` * sections, newer defs cluster nearer the end of the doc — pick the * later section so appends land where the user expects. */ export interface FootnotesSection { sectionStart: number sectionEnd: number } // `\r?$` rather than `$` so the heading regex matches `## Footnotes\r\n` // under CRLF. The `m` flag makes `$` match before `\n` only; without the // optional `\r`, a trailing `\r` would prevent the match. const FOOTNOTES_HEADING_RE = /^##[ \t]+footnotes[ \t]*\r?$/gim const NEXT_TERMINATOR_RE = /^#{1,2}[ \t]/gm export function findFootnotesSection(text: string): FootnotesSection | null { const codeRanges = buildCodeRanges(text) const re = new RegExp(FOOTNOTES_HEADING_RE.source, 'gim') let lastMatch: RegExpExecArray | null = null let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { // Skip headings inside code blocks (review #3). if (isInRanges(m.index, codeRanges)) continue lastMatch = m } if (!lastMatch) return null // sectionStart: skip past the heading line's terminating newline (if // any). The match's `\r?` captured the `\r` if present, so the heading // text spans up to but not including `\n`. Advance past the `\n` too. let sectionStart = lastMatch.index + lastMatch[0].length if (text.charCodeAt(sectionStart) === 0x0a /* \n */) sectionStart += 1 // sectionEnd: walk forward looking for the next h1/h2 heading line. // Use a fresh stateful regex starting at sectionStart. const term = new RegExp(NEXT_TERMINATOR_RE.source, 'gm') term.lastIndex = sectionStart // Skip terminators that fall inside code blocks too. let next: RegExpExecArray | null = null let nextCandidate: RegExpExecArray | null while ((nextCandidate = term.exec(text)) !== null) { if (isInRanges(nextCandidate.index, codeRanges)) continue next = nextCandidate break } const sectionEnd = next ? next.index : text.length return { sectionStart, sectionEnd } } /** * Detect whether `offset` falls within any `[^id]: …` def block. Used * by the toolbar/command surface to no-op (with a Notice) when the user * tries to insert a footnote with the cursor inside another footnote's * body — see D8. * * Implementation mirrors `extract-metadata.ts`'s footnote pass: line-grammar * walk that absorbs continuation lines indented with 4+ spaces or one tab. * For our purpose we only need the start/end offsets, not the parsed body. * * Imports `FOOTNOTE_CONTINUATION_RE` from `extract-metadata.ts` (review * #9 — was previously a local duplicate). */ export function isInsideFootnoteDef(text: string, offset: number): boolean { const defRe = new RegExp(FOOTNOTE_DEF_LINE_RE.source, 'gm') let m: RegExpExecArray | null while ((m = defRe.exec(text)) !== null) { const startOffset = m.index // Walk forward to find this def's end, absorbing continuation lines. // Find the end of the first line. let lineEnd = text.indexOf('\n', startOffset) if (lineEnd === -1) lineEnd = text.length let endOffset = lineEnd let cursor = lineEnd + 1 while (cursor < text.length) { let nextLineEnd = text.indexOf('\n', cursor) if (nextLineEnd === -1) nextLineEnd = text.length const lineContent = text.slice(cursor, nextLineEnd) if (lineContent === '') break if (!FOOTNOTE_CONTINUATION_RE.test(lineContent)) break endOffset = nextLineEnd cursor = nextLineEnd + 1 } // Round-3 M1: predicate uses INCLUSIVE bounds on both sides — // `offset >= startOffset && offset <= endOffset`. The cursor sitting // at the offset of `[` (column 0 of the def line) IS on the def // line; reporting false there would let `insertFootnote` splice a // fresh `[^N]` ref at that offset, producing `[^N][^id]: body`, // corrupting the def line. To insert ABOVE the def block, users // place the cursor on the preceding blank line — where this // predicate correctly returns false (offset < startOffset). if (offset >= startOffset && offset <= endOffset) return true } return false } /** * Headline transform: insert a footnote ref at (or wrapping) the * selection, plus a def-stub appended at the END of the existing * `## Footnotes` section (or a freshly-created one at end-of-doc). * * Returns `null` if the cursor is inside an existing footnote def body * (callers should show a Notice and not dispatch). See D8. * * Single CM6 dispatch — `result.changes` is a `ChangeSpec | ChangeSpec[]`. * * Cursor lands at the END of the inserted def body: `[^N]: |`. * * Selection handling: * - Reverse selections (anchor > head) are normalized at entry. * - Multi-line selections collapse `\n` (or `\r\n`) to a single space * to keep the def body well-formed (D10). */ export function insertFootnote( text: string, selectionStart: number, selectionEnd: number, ): TextTransformResult | null { // D9: normalize reverse selection. if (selectionStart > selectionEnd) { [selectionStart, selectionEnd] = [selectionEnd, selectionStart] } // D8: guardrail against nesting refs inside def bodies. if (isInsideFootnoteDef(text, selectionStart) || isInsideFootnoteDef(text, selectionEnd)) { return null } const id = pickNextIntegerFootnoteId(text) const ref = `[^${id}]` const rawBody = text.slice(selectionStart, selectionEnd) // D10: collapse newlines to single spaces so def-body is well-formed. const body = rawBody.replace(/\r?\n/g, ' ') // Def-stub: `[^N]: ` with NO trailing newline. const defStub = `[^${id}]: ${body}` const section = findFootnotesSection(text) // Net change in doc length contributed by the inline ref insert. const refDelta = ref.length - (selectionEnd - selectionStart) if (section !== null) { // CASE A: Footnotes section exists. Insert at section END (NOT // text.length) so content after the section is preserved. let insertPos = section.sectionEnd // Walk back over trailing newlines that belong to the section. while (insertPos > section.sectionStart && text.charCodeAt(insertPos - 1) === 0x0a) { insertPos -= 1 } let prefix: string if (insertPos === section.sectionStart) { // (i) Empty section. Compute prefix from the chars preceding the // section body (i.e. the heading line). `findFootnotesSection` // consumes at most ONE '\n' after the heading, so `preceding` // either ends with '\n' (heading followed by newline) or has no // trailing newline (heading at EOF). Two cases only. const preceding = text.slice(0, section.sectionStart) if (preceding.endsWith('\n')) { prefix = '\n' } else { // Heading at EOF without trailing newline (N6 case). prefix = '\n\n' } } else { // (ii) Non-empty section. Append on the next line. prefix = '\n' } const changes: ChangeSpec = [ { from: selectionStart, to: selectionEnd, insert: ref }, { from: insertPos, to: insertPos, insert: `${prefix}${defStub}` }, ] // Final cursor: end of the inserted def body. Compute carefully. // Three cases for the relationship between the ref insert range // [selectionStart, selectionEnd] and insertPos: // (a) selectionEnd <= insertPos: ref insert is BEFORE insertPos; // insertPos shifts by refDelta in the new doc. // (b) selectionStart >= insertPos: ref insert is AFTER insertPos // (cursor in Appendix, post-Footnotes — review #1); insertPos // does NOT shift; the def-stub insert lands first in new-doc // coords, so the cursor at end-of-def-stub is `insertPos + // prefix.length + defStub.length` regardless of refDelta. // (c) Otherwise the ref range straddles insertPos — impossible // here because `findFootnotesSection` returns a section that // cannot overlap the selection's intent (the guardrail // `isInsideFootnoteDef` already rejects selections that touch // def bodies; selection that straddles the section heading // isn't a meaningful user gesture and is treated by case (b) // semantics — the ref insert at selectionStart sits BEFORE // insertPos). const adjustedInsertPos = selectionEnd <= insertPos ? insertPos + refDelta : insertPos const finalCursor = adjustedInsertPos + prefix.length + defStub.length return { changes, selectionStart: finalCursor, selectionEnd: finalCursor, } } // CASE B: No Footnotes section. Create one at end-of-doc. if (selectionEnd === text.length) { // FUSE: collapse ref + section into one insert. The text immediately // preceding the new heading in the final doc is // `text.slice(0, selectionStart) + ref`, which is guaranteed to end // with `]` (the closing bracket of the ref) — i.e. NOT a newline. // So the lead-in between the ref and the heading is unconditionally // `'\n\n'` (review #6 — was previously a function call that always // returned `'\n\n'` in this branch; inline the constant). const leadIn = '\n\n' const sectionInsert = `${leadIn}## Footnotes\n\n${defStub}` const fused: ChangeSpec = { from: selectionStart, to: selectionEnd, insert: `${ref}${sectionInsert}`, } const finalCursor = selectionStart + ref.length + sectionInsert.length return { changes: fused, selectionStart: finalCursor, selectionEnd: finalCursor, } } // SPLIT: ref is mid-doc; section append is at end-of-doc. The lead-in // depends on what already terminates the doc. // SPLIT requires selectionEnd < text.length, so the document is non-empty. const computeLeadIn = (precedingText: string): string => { if (precedingText.endsWith('\n\n')) return '' if (precedingText.endsWith('\n')) return '\n' return '\n\n' } const leadIn = computeLeadIn(text) const sectionInsert = `${leadIn}## Footnotes\n\n${defStub}` const changes: ChangeSpec = [ { from: selectionStart, to: selectionEnd, insert: ref }, { from: text.length, to: text.length, insert: sectionInsert }, ] const finalCursor = text.length + refDelta + sectionInsert.length return { changes, selectionStart: finalCursor, selectionEnd: finalCursor, } }