From 377150ebecd8d3ada35db1053dc00ac5f3dc89be Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 15 Apr 2026 16:59:11 +0200 Subject: [PATCH] make Asciidoc more advanced --- .../AdvancedEventLabMarkupToolbar.tsx | 284 +++++++++++++++++- .../AdvancedEventLab/markup-insert.ts | 17 ++ .../Note/AsciidocArticle/AsciidocArticle.tsx | 10 +- .../Note/MarkdownArticle/preprocessMarkup.ts | 12 +- src/i18n/locales/de.ts | 37 ++- src/i18n/locales/en.ts | 37 ++- src/lib/asciidoc-double-bracket-guard.ts | 35 +++ 7 files changed, 418 insertions(+), 14 deletions(-) create mode 100644 src/lib/asciidoc-double-bracket-guard.ts diff --git a/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx b/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx index a74b40d1..c5526fef 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx @@ -13,10 +13,13 @@ import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import type { EditorView } from '@codemirror/view' import { + Anchor, Braces, ChevronDown, Code2, + Film, Heading, + Hash, Image as ImageIcon, Link2, List, @@ -27,12 +30,18 @@ import { Sigma, Table2, Type, - ListTodo + ListTodo, + Volume2 } from 'lucide-react' import type { MutableRefObject } from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { labInsertRaw, labInsertSnippet, labWrapOrSnippet } from './markup-insert' +import { + labInsertRaw, + labInsertRawWithOptionalBlockLeadNl, + labInsertSnippet, + labWrapOrSnippet +} from './markup-insert' /** Languages for fenced / source blocks (labels are English; widely recognized by highlighters). */ const CODE_LANGUAGES = [ @@ -112,6 +121,17 @@ export function AdvancedEventLabMarkupToolbar({ fn(v) } + /** Contiguous document header per https://docs.asciidoctor.org/asciidoc/latest/document/header/ (no blank lines until after the last header line). */ + const adocInsertFullHeader = (titleLine: string) => { + run((v) => + labInsertRawWithOptionalBlockLeadNl( + v, + sliceRef, + `${titleLine}\n${t('Advanced lab tb adocHeaderAuthorLine')}\n${t('Advanced lab tb adocHeaderRevisionLine')}\n${t('Advanced lab tb adocHeaderAttrsBlock')}\n\n== ${t('Advanced lab tb sectionTitle')}\n` + ) + ) + } + const mdFence = (lang: string) => { run((v) => labInsertSnippet(v, sliceRef, `\`\`\`${lang}\n`, 'your code here', `\n\`\`\`\n`) @@ -584,11 +604,21 @@ export function AdvancedEventLabMarkupToolbar({ - + {t('Advanced lab tb adocTitlesHint')} - run((v) => labInsertRaw(v, sliceRef, `\n= ${t('Advanced lab tb documentTitle')}\n`))}> + + run((v) => + labInsertRawWithOptionalBlockLeadNl(v, sliceRef, `= ${t('Advanced lab tb documentTitle')}\n`) + ) + } + > {t('Advanced lab tb adocLevel0')} + adocInsertFullHeader(`= ${t('Advanced lab tb documentTitle')}`)}> + {t('Advanced lab tb adocLevel0WithHeader')} + + {( [ 'Advanced lab tb adocSection1', @@ -662,6 +692,24 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocImage')} + + run((v) => labInsertRaw(v, sliceRef, ' +\n'))}> + {t('Advanced lab tb adocLineBreak')} + + run((v) => labWrapOrSnippet(v, sliceRef, '^', 'sup'))}> + {t('Advanced lab tb adocSuperscript')} + + run((v) => labWrapOrSnippet(v, sliceRef, '~', 'sub'))}> + {t('Advanced lab tb adocSubscript')} + + run((v) => labInsertSnippet(v, sliceRef, '+++', 'raw or HTML', '+++'))} + > + {t('Advanced lab tb adocPassthrough')} + + run((v) => labInsertRaw(v, sliceRef, 'footnote:[Footnote text]'))}> + {t('Advanced lab tb adocFootnote')} + @@ -683,6 +731,20 @@ export function AdvancedEventLabMarkupToolbar({ run((v) => labInsertRaw(v, sliceRef, '\nterm:: definition line\n'))}> {t('Advanced lab tb adocLabeled')} + + + run((v) => + labInsertRaw( + v, + sliceRef, + '\n[start=4]\n. fourth item\n. fifth item\n' + ) + ) + } + > + {t('Advanced lab tb adocOrderedStart')} + @@ -759,6 +821,166 @@ export function AdvancedEventLabMarkupToolbar({ > {t('Advanced lab tb adocWarning')} + + run((v) => + labInsertSnippet( + v, + sliceRef, + '\n[IMPORTANT]\n====\n', + 'Important body', + '\n====\n' + ) + ) + } + > + {t('Advanced lab tb adocImportant')} + + + run((v) => + labInsertSnippet( + v, + sliceRef, + '\n[CAUTION]\n====\n', + 'Caution body', + '\n====\n' + ) + ) + } + > + {t('Advanced lab tb adocCaution')} + + + + run((v) => + labInsertSnippet( + v, + sliceRef, + '\n[example]\n====\n', + 'Example body', + '\n====\n' + ) + ) + } + > + {t('Advanced lab tb adocExampleBlock')} + + + run((v) => + labInsertSnippet(v, sliceRef, '\n****\n', 'Sidebar body', '\n****\n') + ) + } + > + {t('Advanced lab tb adocSidebar')} + + + run((v) => + labInsertSnippet( + v, + sliceRef, + '\n[listing]\n----\n', + 'Listing body (often line-oriented text)', + '\n----\n' + ) + ) + } + > + {t('Advanced lab tb adocListing')} + + + run((v) => + labInsertSnippet(v, sliceRef, '\n--\n', 'Open block body', '\n--\n') + ) + } + > + {t('Advanced lab tb adocOpenBlock')} + + + + + + + + + + {t('Advanced lab tb adocStructureHint')} + + run((v) => + labInsertRaw( + v, + sliceRef, + '\n|===\n|Column 1 |Column 2\n\n|Cell A |Cell B\n|===\n' + ) + ) + } + > + + {t('Advanced lab tb adocTable')} + + + run((v) => labInsertRawWithOptionalBlockLeadNl(v, sliceRef, '[#section-anchor]\n')) + } + > + + {t('Advanced lab tb adocAnchor')} + + + run((v) => + labInsertSnippet(v, sliceRef, '<>') + ) + } + > + + {t('Advanced lab tb adocXref')} + + + + run((v) => + labInsertRaw(v, sliceRef, '\nvideo::https://example.com/video.mp4[width=640]\n') + ) + } + > + + {t('Advanced lab tb adocVideo')} + + + run((v) => + labInsertRaw(v, sliceRef, '\naudio::https://example.com/audio.mp3[]\n') + ) + } + > + + {t('Advanced lab tb adocAudio')} + + + run((v) => labInsertRaw(v, sliceRef, '\n// Comment line\n'))}> + {t('Advanced lab tb adocComment')} + + run((v) => labInsertRaw(v, sliceRef, 'kbd:[Ctrl+T]'))}> + {t('Advanced lab tb adockbd')} + + run((v) => labInsertRaw(v, sliceRef, 'menu:View[Zoom > In]'))} + > + {t('Advanced lab tb adocMenu')} + + run((v) => labInsertRaw(v, sliceRef, 'btn:[OK]'))}> + {t('Advanced lab tb adocBtn')} + @@ -817,6 +1039,20 @@ export function AdvancedEventLabMarkupToolbar({ > {t('Advanced lab tb adocStemInline')} + run((v) => labInsertSnippet(v, sliceRef, 'latexmath:[', 'x^2 + y^2', ']'))} + > + {t('Advanced lab tb adocLatexmathInline')} + + + run((v) => + labInsertSnippet(v, sliceRef, '\n[stem]\n++++\n', 'E = mc^2', '\n++++\n') + ) + } + > + {t('Advanced lab tb adocStemBlock')} + run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\frac{a}{b}', ']'))} @@ -836,6 +1072,46 @@ export function AdvancedEventLabMarkupToolbar({ > {t('Advanced lab tb katexInt')} + + run((v) => + labInsertRaw( + v, + sliceRef, + '\n[stem]\n++++\n\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}\n++++\n' + ) + ) + } + > + {t('Advanced lab tb katexMatrix')} + + + run((v) => + labInsertRaw( + v, + sliceRef, + '\n[stem]\n++++\n\\begin{cases} x & x > 0 \\\\ -x & x \\le 0 \\end{cases}\n++++\n' + ) + ) + } + > + {t('Advanced lab tb katexCases')} + + + {t('Advanced lab tb mathGreek')} + run((v) => labInsertRaw(v, sliceRef, 'stem:[\\alpha]'))}> + {'\\alpha'} {t('Advanced lab tb greekAlpha')} + + run((v) => labInsertRaw(v, sliceRef, 'stem:[\\beta]'))}> + {'\\beta'} {t('Advanced lab tb greekBeta')} + + run((v) => labInsertRaw(v, sliceRef, 'stem:[\\pi]'))}> + {'\\pi'} {t('Advanced lab tb greekPi')} + + run((v) => labInsertRaw(v, sliceRef, 'stem:[\\infty]'))}> + {'\\infty'} {t('Advanced lab tb greekInfty')} + diff --git a/src/components/AdvancedEventLab/markup-insert.ts b/src/components/AdvancedEventLab/markup-insert.ts index 1b1f09d3..f6131240 100644 --- a/src/components/AdvancedEventLab/markup-insert.ts +++ b/src/components/AdvancedEventLab/markup-insert.ts @@ -43,6 +43,23 @@ export function labInsertRaw( labSyncSliceFromView(view, sliceRef) } +/** Like {@link labInsertRaw}, but skips a leading newline when the selection starts at document position 0 (avoids an empty first line). */ +export function labInsertRawWithOptionalBlockLeadNl( + view: EditorView, + sliceRef: { current: AdvancedEventLabSlice | null }, + body: string +) { + const sel = view.state.selection.main + const needsLeadNl = sel.from > 0 + const insert = needsLeadNl ? `\n${body}` : body + view.dispatch({ + changes: { from: sel.from, to: sel.to, insert }, + selection: EditorSelection.cursor(sel.from + insert.length) + }) + view.focus() + labSyncSliceFromView(view, sliceRef) +} + /** If there is a selection, wrap it; otherwise insert snippet with placeholder between delimiters. */ export function labWrapOrSnippet( view: EditorView, diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 8ef3a460..28c50dcb 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -36,6 +36,7 @@ import { NOSTR_ASCIIDOC_TEXT_NODE_REGEX, NOSTR_HTML_BECH32_RELAXED } from '@/lib/content-patterns' +import { shouldLeaveDoubleBracketForAsciidoctor } from '@/lib/asciidoc-double-bracket-guard' import logger from '@/lib/logger' import { extractBookMetadata } from '@/lib/bookstr-parser' import { ExtendedKind } from '@/constants' @@ -396,14 +397,17 @@ export default function AsciidocArticle({ // Then protect regular wikilinks by converting them to passthrough format // This prevents AsciiDoc from processing them and prevents URLs inside from being processed - content = content.replace(/\[\[([^\]]+)\]\]/g, (_match, linkContent) => { + content = content.replace(/\[\[([^\]]+)\]\]/g, (match, linkContent, offset) => { // Skip if this was already processed as a bookstr wikilink (shouldn't happen, but safety check) if (linkContent.startsWith('book::')) { - return _match + return match } // Skip citations - they're already processed above if (linkContent.startsWith('citation::')) { - return _match + return match + } + if (shouldLeaveDoubleBracketForAsciidoctor(content, offset, match.length, linkContent)) { + return match } // Convert to AsciiDoc passthrough format so it's preserved return `+++WIKILINK:${linkContent}+++` diff --git a/src/components/Note/MarkdownArticle/preprocessMarkup.ts b/src/components/Note/MarkdownArticle/preprocessMarkup.ts index 061e1977..821d0950 100644 --- a/src/components/Note/MarkdownArticle/preprocessMarkup.ts +++ b/src/components/Note/MarkdownArticle/preprocessMarkup.ts @@ -1,3 +1,4 @@ +import { shouldLeaveDoubleBracketForAsciidoctor } from '@/lib/asciidoc-double-bracket-guard' import { isImage, isVideo, isAudio } from '@/lib/url' import { URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants' import { isSpotifyOpenUrl } from '@/lib/spotify-url' @@ -137,10 +138,15 @@ export function preprocessAsciidocMediaLinks(content: string): string { }) // Fallback: protect regular wikilinks if they weren't processed yet - processed = processed.replace(/\[\[([^\]]+)\]\]/g, (_match, linkContent) => { - // Skip if this was already processed as a bookstr wikilink + processed = processed.replace(/\[\[([^\]]+)\]\]/g, (match, linkContent, offset) => { if (linkContent.startsWith('book::')) { - return _match + return match + } + if (linkContent.startsWith('citation::')) { + return match + } + if (shouldLeaveDoubleBracketForAsciidoctor(processed, offset, match.length, linkContent)) { + return match } return `+++WIKILINK:${linkContent}+++` }) diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 4b591e37..b355446a 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1047,10 +1047,16 @@ export default { 'Advanced lab tb greekInfty': 'Unendlich', 'Advanced lab tb hrTitle': 'Horizontale Linie', 'Advanced lab tb adocTitles': 'Titel', - 'Advanced lab tb adocTitlesHint': 'AsciiDoc: = Dokumenttitel, == … für Abschnitte.', + 'Advanced lab tb adocTitlesHint': + 'Dokumentkopf: keine Leerzeilen innerhalb des Kopfes; die erste Leerzeile beendet ihn (https://docs.asciidoctor.org/asciidoc/latest/document/header/).', 'Advanced lab tb documentTitle': 'Dokumenttitel', 'Advanced lab tb sectionTitle': 'Abschnittstitel', 'Advanced lab tb adocLevel0': 'Dokumenttitel (=)', + 'Advanced lab tb adocLevel0WithHeader': 'Minimaler Kopf (Titel, Autor, Revision, Beschreibung, Stichwörter, erster Abschnitt)', + 'Advanced lab tb adocHeaderAuthorLine': 'Autor Name ', + 'Advanced lab tb adocHeaderRevisionLine': '1.0, 2026-04-15: Entwurf', + 'Advanced lab tb adocHeaderAttrsBlock': + ':description: Kurze Zusammenfassung für Metadaten\n:keywords: stichwort-eins, stichwort-zwei, stichwort-drei', 'Advanced lab tb adocSection1': 'Abschnitt (==)', 'Advanced lab tb adocSection2': 'Unterabschnitt (===)', 'Advanced lab tb adocSection3': 'Ebene 4 (====)', @@ -1073,8 +1079,35 @@ export default { 'Advanced lab tb adocSource': 'Source / Listing', 'Advanced lab tb adocSourceHint': 'Fügt [source,Sprache] ein; Beispielcode ersetzen.', 'Advanced lab tb adocStem': 'STEM / LaTeX', - 'Advanced lab tb adocStemHint': 'stem:[…] (STEM in der Pipeline ggf. aktivieren).', + 'Advanced lab tb adocStemHint': + 'Imwald nutzt Asciidoctor mit stem: latexmath; Formeln werden zu \\(...\\) / \\[...\\] und mit KaTeX gerendert (nicht MathJax).', 'Advanced lab tb adocStemInline': 'Inline stem:[…]', + 'Advanced lab tb adocLatexmathInline': 'Inline latexmath:[…]', + 'Advanced lab tb adocStemBlock': 'Display-Formel ([stem] +++ … +++++)', + 'Advanced lab tb adocLineBreak': 'Zeilenumbruch (Leerzeichen + am Zeilenende)', + 'Advanced lab tb adocSubscript': 'Tiefgestellt (~text~)', + 'Advanced lab tb adocSuperscript': 'Hochgestellt (^text^)', + 'Advanced lab tb adocPassthrough': 'Inline-Passthrough (+++ … +++)', + 'Advanced lab tb adocFootnote': 'Fußnote (footnote:[…])', + 'Advanced lab tb adocImportant': 'IMPORTANT-Hinweis', + 'Advanced lab tb adocCaution': 'CAUTION-Hinweis', + 'Advanced lab tb adocExampleBlock': 'Beispielblock ([example])', + 'Advanced lab tb adocSidebar': 'Seitenleiste (**** … ****)', + 'Advanced lab tb adocListing': 'Listing-Block ([listing] + ----)', + 'Advanced lab tb adocOpenBlock': 'Open-Block (-- … --)', + 'Advanced lab tb adocStructure': 'Struktur & Medien', + 'Advanced lab tb adocStructureHint': + 'Tabellen, IDs/Anker ([#id] vor einem Block—kollidiert nicht mit [[Wikilink]]-Syntax), Querverweise, Medien (Asciidoctor-Blockmakros).', + 'Advanced lab tb adocTable': 'Tabelle (|=== …)', + 'Advanced lab tb adocAnchor': 'Block- oder Abschnitts-ID ([#id])', + 'Advanced lab tb adocXref': 'Querverweis (<>)', + 'Advanced lab tb adocVideo': 'Video (video::url[])', + 'Advanced lab tb adocAudio': 'Audio (audio::url[])', + 'Advanced lab tb adocOrderedStart': 'Nummerierte Liste mit [start=n]', + 'Advanced lab tb adocComment': 'Zeilenkommentar (//)', + 'Advanced lab tb adockbd': 'Tastatur (kbd:[…])', + 'Advanced lab tb adocMenu': 'Menüpfad (menu:…)', + 'Advanced lab tb adocBtn': 'Schaltfläche (btn:[…])', 'Advanced lab tb adocHrTitle': 'Thematic break (\'\'\')', Apply: 'Anwenden', Reset: 'Zurücksetzen', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e4bfd5e9..44f5a026 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1047,10 +1047,16 @@ export default { 'Advanced lab tb greekInfty': 'infinity', 'Advanced lab tb hrTitle': 'Horizontal rule', 'Advanced lab tb adocTitles': 'Titles', - 'Advanced lab tb adocTitlesHint': 'AsciiDoc uses = for the document title and == … for sections.', + 'Advanced lab tb adocTitlesHint': + 'Document header: no empty lines inside the header; the first blank line ends it (https://docs.asciidoctor.org/asciidoc/latest/document/header/).', 'Advanced lab tb documentTitle': 'Document title', 'Advanced lab tb sectionTitle': 'Section title', 'Advanced lab tb adocLevel0': 'Document title (=)', + 'Advanced lab tb adocLevel0WithHeader': 'Minimal header (title, author, revision, description, keywords, first section)', + 'Advanced lab tb adocHeaderAuthorLine': 'Author Name ', + 'Advanced lab tb adocHeaderRevisionLine': '1.0, 2026-04-15: Draft revision', + 'Advanced lab tb adocHeaderAttrsBlock': + ':description: Short document summary for metadata\n:keywords: keyword-one, keyword-two, keyword-three', 'Advanced lab tb adocSection1': 'Section (==)', 'Advanced lab tb adocSection2': 'Subsection (===)', 'Advanced lab tb adocSection3': 'Subsubsection (====)', @@ -1073,8 +1079,35 @@ export default { 'Advanced lab tb adocSource': 'Source / listing', 'Advanced lab tb adocSourceHint': 'Inserts a [source,lang] block; replace the sample code.', 'Advanced lab tb adocStem': 'STEM / LaTeX', - 'Advanced lab tb adocStemHint': 'Asciidoctor stem:[…] (enable stem in your pipeline if needed).', + 'Advanced lab tb adocStemHint': + 'Imwald runs Asciidoctor with stem: latexmath; math becomes \\(...\\) / \\[...\\] and is drawn with KaTeX (not MathJax).', 'Advanced lab tb adocStemInline': 'Inline stem:[…]', + 'Advanced lab tb adocLatexmathInline': 'Inline latexmath:[…]', + 'Advanced lab tb adocStemBlock': 'Display math ([stem] +++ … +++++)', + 'Advanced lab tb adocLineBreak': 'Hard line break (space + at line end)', + 'Advanced lab tb adocSubscript': 'Subscript (~text~)', + 'Advanced lab tb adocSuperscript': 'Superscript (^text^)', + 'Advanced lab tb adocPassthrough': 'Inline passthrough (+++ … +++)', + 'Advanced lab tb adocFootnote': 'Footnote (footnote:[…])', + 'Advanced lab tb adocImportant': 'IMPORTANT admonition', + 'Advanced lab tb adocCaution': 'CAUTION admonition', + 'Advanced lab tb adocExampleBlock': 'Example block ([example])', + 'Advanced lab tb adocSidebar': 'Sidebar (**** … ****)', + 'Advanced lab tb adocListing': 'Listing block ([listing] + ----)', + 'Advanced lab tb adocOpenBlock': 'Open block (-- … --)', + 'Advanced lab tb adocStructure': 'Structure & media', + 'Advanced lab tb adocStructureHint': + 'Tables, IDs/anchors ([#id] before a block—avoids [[wikilink]] syntax), cross-refs, media (Asciidoctor block macros).', + 'Advanced lab tb adocTable': 'Table (|=== …)', + 'Advanced lab tb adocAnchor': 'Block or section ID ([#id])', + 'Advanced lab tb adocXref': 'Cross reference (<>)', + 'Advanced lab tb adocVideo': 'Video (video::url[])', + 'Advanced lab tb adocAudio': 'Audio (audio::url[])', + 'Advanced lab tb adocOrderedStart': 'Ordered list with [start=n]', + 'Advanced lab tb adocComment': 'Line comment (//)', + 'Advanced lab tb adockbd': 'Keyboard (kbd:[…])', + 'Advanced lab tb adocMenu': 'Menu path (menu:…)', + 'Advanced lab tb adocBtn': 'Button (btn:[…])', 'Advanced lab tb adocHrTitle': 'Thematic break (\'\'\')', Apply: 'Apply', Reset: 'Reset', diff --git a/src/lib/asciidoc-double-bracket-guard.ts b/src/lib/asciidoc-double-bracket-guard.ts new file mode 100644 index 00000000..7374f346 --- /dev/null +++ b/src/lib/asciidoc-double-bracket-guard.ts @@ -0,0 +1,35 @@ +/** + * `[[inner]]` is both a wiki link (Nostr / bookstr) and valid AsciiDoc (block anchor, biblio id, etc.). + * When we rewrite every `[[…]]` to a wiki passthrough, AsciiDoc `[[id]]` anchors break; prefer `[#id]` before a block to avoid clashing with `[[wikilink]]`. + * + * Leave the original brackets for Asciidoctor when this looks like an AsciiDoc anchor, not a wiki slug. + * @see https://docs.asciidoctor.org/asciidoc/latest/syntax/ids/ + */ +export function shouldLeaveDoubleBracketForAsciidoctor( + fullSource: string, + matchIndex: number, + matchLength: number, + inner: string +): boolean { + const t = inner.trim() + if (!t) return false + + // Bibliographic / paired id (not wiki `target|display`) + if (t.includes(',') && !t.includes('|')) return true + + const lineStart = fullSource.lastIndexOf('\n', matchIndex - 1) + 1 + const lineEndIdx = fullSource.indexOf('\n', matchIndex + matchLength) + const lineEnd = lineEndIdx === -1 ? fullSource.length : lineEndIdx + const lineTrimmed = fullSource.slice(lineStart, lineEnd).trim() + const matchTrimmed = fullSource.slice(matchIndex, matchIndex + matchLength).trim() + if (lineTrimmed !== matchTrimmed) return false + + const after = fullSource.slice(matchIndex + matchLength) + if (!/^\s*\n(?:\s*\n)*={2,6}\s\S/.test(after)) return false + + // Lowercase slug + section ahead: legacy `[[id]]` block anchor (e.g. before == Intro); new content should use `[#id]`. + // Uppercase wiki slugs like [[NIP-54]] still go through wiki passthrough. + if (!/^[a-z][a-z0-9._-]*$/.test(t)) return false + + return true +}