fix: restore syntax highlighting inside Obsidian callouts (#205)

* fix: restore syntax highlighting inside Obsidian callouts

The callout wrapper adds extra indentation/prefix characters that
shifted the document offsets, causing the editor extension to miss
SQLSeal codeblocks. Updated range detection to account for callout
context so highlighting works correctly inside collapsed and expanded
callouts.

* chore: refactoring code to reuse the same function in tests and app
This commit is contained in:
Kacper 2026-03-29 10:43:53 +01:00 committed by GitHub
parent f57e0f5a00
commit d9cb4d5fea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 211 additions and 29 deletions

View file

@ -0,0 +1,5 @@
---
"sqlseal": patch
---
fixed syntax highlighting in callouts

View file

@ -0,0 +1,78 @@
export interface CodeBlockMatch {
startIndex: number
content: string
/** Characters stripped from the start of each content line (e.g. "> " in callouts) */
linePrefix?: string
}
/**
* Extract sqlseal code blocks from markdown text, including those inside callouts.
*
* This function handles both regular code blocks and those inside Obsidian callouts
* (which are prefixed with "> " on each line). For callout blocks, the prefix is
* stripped from content lines so the SQL parser can process them cleanly.
*
* @param text - The raw markdown text to search
* @returns Array of code block matches with their content and position information
*/
export function extractCodeBlocks(text: string): CodeBlockMatch[] {
// Parsing — match code blocks with optional callout prefix (e.g. "> ") on fence lines.
// The prefix is captured so positions can be mapped back to the raw document.
const codeBlockRegex = /^([ \t]*(?:> )*)```(sqlseal)\n([\s\S]*?)^[ \t]*(?:> )*```/gm
let match
const results: CodeBlockMatch[] = []
while ((match = codeBlockRegex.exec(text)) !== null) {
const fencePrefix = match[1] || ''
const langTag = match[2]
const rawContent = match[3]
const blockStart = match.index
const langTagEnd = blockStart + fencePrefix.length + 3 + langTag.length // prefix + ``` + langTag
const contentStart = langTagEnd + 1 // +1 for the newline after the opening fence
if (!fencePrefix) {
results.push({ content: rawContent, startIndex: contentStart })
} else {
// Strip the callout prefix from each content line so the grammar can parse it cleanly.
const strippedLines = rawContent.split('\n').map(line =>
line.startsWith(fencePrefix) ? line.slice(fencePrefix.length) : line
)
results.push({
content: strippedLines.join('\n'),
startIndex: contentStart,
linePrefix: fencePrefix
})
}
}
return results
}
/**
* Map a position in stripped content back to the raw document position.
*
* For callout blocks, each line has a prefix (e.g. "> ") that was removed before
* parsing. This function restores that offset: for a position on line N, it adds
* (N+1) * prefixLen to account for all the stripped prefixes up to that point.
*
* Formula: rawPos = startIndex + strippedPos + (lineNum + 1) * prefixLen
* where lineNum = count of \n characters in content before posInContent
*
* @param content - The stripped content (after prefix removal)
* @param linePrefix - The prefix that was stripped (e.g. "> "), empty string if none
* @param startIndex - Starting position of the content in the raw document
* @param posInContent - Position within the stripped content
* @returns Position in the raw document
*/
export function toDocPos(
content: string,
linePrefix: string,
startIndex: number,
posInContent: number
): number {
if (!linePrefix) return startIndex + posInContent
const prefixLen = linePrefix.length
const lineCount = (content.slice(0, posInContent).match(/\n/g) || []).length
return startIndex + posInContent + (lineCount + 1) * prefixLen
}

View file

@ -0,0 +1,121 @@
/**
* Tests for the callout syntax-highlighting fix (main-ow0).
*
* SQLSealViewPlugin is not imported here because it pulls in Obsidian/CodeMirror
* globals that don't exist in Node. Instead, we test the pure logic functions
* that were extracted for testability:
*
* 1. extractCodeBlocks() - The regex that finds sqlseal blocks (including inside callouts)
* and the prefix-stripping that cleans "> " from each content line
* 2. toDocPos() - The formula that maps stripped positions back to raw doc positions
*/
import { extractCodeBlocks, toDocPos } from './codeBlockExtraction';
// --------------------------------------------------------------------------
// Tests
// --------------------------------------------------------------------------
describe('callout code block detection (getCodeBlocks logic)', () => {
describe('regular (non-callout) blocks', () => {
it('finds a simple sqlseal block', () => {
const text = '```sqlseal\nSELECT * FROM files\n```\n'
const blocks = extractCodeBlocks(text)
expect(blocks).toHaveLength(1)
expect(blocks[0].content).toBe('SELECT * FROM files\n')
expect(blocks[0].linePrefix).toBeUndefined()
})
it('sets startIndex to char 11 ("```sqlseal\\n" = 11 chars)', () => {
const text = '```sqlseal\nSELECT 1\n```\n'
expect(extractCodeBlocks(text)[0].startIndex).toBe(11)
})
it('returns empty array when there are no sqlseal blocks', () => {
expect(extractCodeBlocks('no blocks here')).toHaveLength(0)
expect(extractCodeBlocks('```sql\nSELECT 1\n```')).toHaveLength(0)
})
it('finds multiple blocks', () => {
const text = '```sqlseal\nSELECT 1\n```\n\ntext\n\n```sqlseal\nSELECT 2\n```\n'
expect(extractCodeBlocks(text)).toHaveLength(2)
})
})
describe('callout blocks (bug: were invisible to the highlighter)', () => {
it('finds a sqlseal block inside a callout', () => {
// Before fix: regex /```(sqlseal)\n([\s\S]*?)```/g did not match
// when opening fence was preceded by "> "
const text = '> ```sqlseal\n> SELECT * FROM files\n> ```\n'
const blocks = extractCodeBlocks(text)
expect(blocks).toHaveLength(1)
})
it('strips "> " prefix from each content line', () => {
const text = '> ```sqlseal\n> SELECT * FROM files\n> ```\n'
const blocks = extractCodeBlocks(text)
// Grammar must receive clean SQL, not "> SELECT * FROM files"
expect(blocks[0].content).toContain('SELECT * FROM files')
expect(blocks[0].content).not.toContain('> ')
})
it('sets linePrefix to "> "', () => {
const text = '> ```sqlseal\n> SELECT 1\n> ```\n'
expect(extractCodeBlocks(text)[0].linePrefix).toBe('> ')
})
it('sets startIndex to first raw char of content (after "> ```sqlseal\\n")', () => {
// "> "(2) + "```"(3) + "sqlseal"(7) + "\n"(1) = 13
const text = '> ```sqlseal\n> SELECT 1\n> ```\n'
expect(extractCodeBlocks(text)[0].startIndex).toBe(13)
})
it('correctly strips multi-line callout content', () => {
const text = '> ```sqlseal\n> LINE 1\n> LINE 2\n> ```\n'
const blocks = extractCodeBlocks(text)
expect(blocks[0].content).toBe('LINE 1\nLINE 2\n')
})
})
})
describe('toDocPos() — maps stripped positions to raw document positions', () => {
/**
* For a callout block, each line in the raw document has a "> " prefix (2 chars)
* that was stripped before grammar parsing. When applying decorations we must add
* those chars back.
*
* Formula: rawPos = startIndex + strippedPos + (lineNum + 1) * prefixLen
* where lineNum = count of \n characters in strippedContent before strippedPos
*/
it('maps position 0 on line 0 (S of SELECT)', () => {
// "> ```sqlseal\n" = 13 chars → contentStart = 13
// raw line: "> SELECT * FROM files\n" → S is at raw[15] = 13 + 2
// formula: 13 + 0 + (0+1)*2 = 15
expect(toDocPos('SELECT * FROM files\n', '> ', 13, 0)).toBe(15)
})
it('maps end of SELECT keyword (pos 6) on line 0', () => {
// formula: 13 + 6 + (0+1)*2 = 21
expect(toDocPos('SELECT * FROM files\n', '> ', 13, 6)).toBe(21)
})
it('maps start of second line (after first \\n)', () => {
// strippedContent = "LINE1\nLINE2\n", pos=6 = L of LINE2, lineNum=1
// formula: 13 + 6 + (1+1)*2 = 23
expect(toDocPos('LINE1\nLINE2\n', '> ', 13, 6)).toBe(23)
})
it('maps the \\n at end of line 0', () => {
// "SELECT\n", pos=6 = \n, lineNum=0
// formula: 13 + 6 + (0+1)*2 = 21
// raw line "> SELECT\n" has \n at index 13+2+6 = 21 ✓
expect(toDocPos('SELECT\n', '> ', 13, 6)).toBe(21)
})
it('uses prefix length correctly for longer prefixes', () => {
// nested callout prefix "> > " (4 chars)
// pos=0, lineNum=0: 13 + 0 + (0+1)*4 = 17
expect(toDocPos('SELECT 1\n', '> > ', 13, 0)).toBe(17)
})
})

View file

@ -16,11 +16,7 @@ import { Decorator, highlighterOperation } from '../grammar/highlighterOperation
import { FilePathWidget } from './widgets/FilePathWidget';
import { RendererRegistry } from '../../editor/renderer/rendererRegistry';
import { SQLSealLangDefinition } from '../../editor/parser';
interface CodeBlockMatch {
startIndex: number,
content: string
}
import { extractCodeBlocks, toDocPos, CodeBlockMatch } from './codeBlockExtraction';
const markDecorations = {
blockFlag: Decoration.mark({ class: 'cm-sqlseal-block-flag' }),
@ -83,21 +79,7 @@ export class SQLSealViewPlugin implements PluginValue {
}]
}
// Parsing
const codeBlockRegex = /```(sqlseal)\n([\s\S]*?)```/g;
let match;
let results: CodeBlockMatch[] = []
while ((match = codeBlockRegex.exec(text)) !== null) {
const blockStart = match.index;
const langTagEnd = blockStart + match[1].length + 3;
const sqlContent = match[2];
const contentStart = langTagEnd + 1;
results.push({
content: sqlContent,
startIndex: contentStart
})
}
return results
return extractCodeBlocks(text)
}
decorateFilename(dec: Decorator, { content, startIndex }: CodeBlockMatch) {
@ -123,9 +105,10 @@ export class SQLSealViewPlugin implements PluginValue {
}
privateDecorateCodeblock(codeblockMatch: CodeBlockMatch): Array<Range<Decoration>> {
const { content, startIndex } = codeblockMatch
const { content, startIndex, linePrefix } = codeblockMatch
const decorations = this.parseWithGrammar(content);
return (decorations || []).flatMap(dec => {
return (decorations || []).flatMap(dec => {
switch (dec.type) {
case 'filename':
return this.decorateFilename(dec, codeblockMatch)
@ -133,8 +116,8 @@ export class SQLSealViewPlugin implements PluginValue {
const decoration = markDecorations[dec.type as keyof typeof markDecorations];
if (decoration) {
return decoration.range(
startIndex + dec.start,
startIndex + dec.end
toDocPos(content, linePrefix || '', startIndex, dec.start),
toDocPos(content, linePrefix || '', startIndex, dec.end)
)
} else {
return []
@ -144,11 +127,6 @@ export class SQLSealViewPlugin implements PluginValue {
}
private buildDecorations(view: EditorView): DecorationSet {
const builder: Array<Range<Decoration>> = [];
// const text = view.state.doc.toString();
// const codeBlockRegex = /```(sqlseal)\n([\s\S]*?)```/g;
// let match;
const results = this.getCodeBlocks(view)
const decorators = results.flatMap(r => this.privateDecorateCodeblock(r))