refactor(markdown): restructure snippet plugin

Split the block parser and fence renderer into factories, extract
region extraction, and type the snippet token instead of ts-ignoring
its src field.

Reading the file now handles ENOENT/EISDIR explicitly: a missing
snippet previously threw from statSync before the 'path not found'
message could render, so that branch was unreachable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5342/head
Divyansh Singh 1 month ago
parent dc98a2b9ac
commit 9a76017302

@ -4,6 +4,11 @@ import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import type { MarkdownEnv } from '../../shared' import type { MarkdownEnv } from '../../shared'
type FenceRenderer = NonNullable<MarkdownItAsync['renderer']['rules']['fence']>
type SnippetToken = ReturnType<Parameters<RuleBlock>[0]['push']> & {
src?: [path: string, regionName: string]
}
/** /**
* raw path format: "/path/to/file.extension#region {meta} [title]" * raw path format: "/path/to/file.extension#region {meta} [title]"
* where #region, {meta} and [title] are optional * where #region, {meta} and [title] are optional
@ -18,40 +23,7 @@ import type { MarkdownEnv } from '../../shared'
export const rawPathRegexp = export const rawPathRegexp =
/^(.+?(?:(?:\.([a-z0-9]+))?))(?:(#[\w-]+))?(?: ?(?:{(\d+(?:[,-]\d+)*)? ?(\S+)? ?(\S+)?}))? ?(?:\[(.+)\])?$/ /^(.+?(?:(?:\.([a-z0-9]+))?))(?:(#[\w-]+))?(?: ?(?:{(\d+(?:[,-]\d+)*)? ?(\S+)? ?(\S+)?}))? ?(?:\[(.+)\])?$/
export function rawPathToToken(rawPath: string) { const regionMarkers = [
const [
filepath = '',
extension = '',
region = '',
lines = '',
lang = '',
attrs = '',
rawTitle = ''
] = (rawPathRegexp.exec(rawPath) || []).slice(1)
const title = rawTitle || filepath.split('/').pop() || ''
return { filepath, extension, region, lines, lang, attrs, title }
}
export function dedent(text: string): string {
const lines = text.split('\n')
const minIndentLength = lines.reduce((acc, line) => {
for (let i = 0; i < line.length; i++) {
if (line[i] !== ' ' && line[i] !== '\t') return Math.min(i, acc)
}
return acc
}, Infinity)
if (minIndentLength < Infinity) {
return lines.map((x) => x.slice(minIndentLength)).join('\n')
}
return text
}
const markers = [
{ {
start: /^\s*\/\/\s*#?region\b\s*(.*?)\s*$/, start: /^\s*\/\/\s*#?region\b\s*(.*?)\s*$/,
end: /^\s*\/\/\s*#?endregion\b\s*(.*?)\s*$/ end: /^\s*\/\/\s*#?endregion\b\s*(.*?)\s*$/
@ -86,60 +58,102 @@ const markers = [
} }
] ]
const snippetMarker = '<<<'
export function rawPathToToken(rawPath: string) {
const [
filepath = '',
extension = '',
region = '',
lines = '',
lang = '',
attrs = '',
rawTitle = ''
] = (rawPathRegexp.exec(rawPath) || []).slice(1)
const title = rawTitle || filepath.split('/').pop() || ''
return { filepath, extension, region, lines, lang, attrs, title }
}
export function dedent(text: string): string {
const lines = text.split('\n')
const minIndentLength = lines.reduce((acc, line) => {
for (let i = 0; i < line.length; i++) {
if (line[i] !== ' ' && line[i] !== '\t') return Math.min(i, acc)
}
return acc
}, Infinity)
if (minIndentLength < Infinity) {
return lines.map((x) => x.slice(minIndentLength)).join('\n')
}
return text
}
export function findRegion(lines: Array<string>, regionName: string) { export function findRegion(lines: Array<string>, regionName: string) {
let chosen: { re: (typeof markers)[number]; start: number } | null = null let regionStart: {
re: (typeof regionMarkers)[number]
start: number
} | null = null
// find the regex pair for a start marker that matches the given region name // find the regex pair for a start marker that matches the given region name
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
for (const re of markers) { for (const marker of regionMarkers) {
if (re.start.exec(lines[i])?.[1] === regionName) { if (marker.start.exec(lines[i])?.[1] === regionName) {
chosen = { re, start: i + 1 } regionStart = { re: marker, start: i + 1 }
break break
} }
} }
if (chosen) break if (regionStart) break
} }
if (!chosen) return null if (!regionStart) return null
let counter = 1 let depth = 1
// scan the rest of the lines to find the matching end marker, handling nested markers // scan the rest of the lines to find the matching end marker,
for (let i = chosen.start; i < lines.length; i++) { // handling nested markers with the same region name
for (let i = regionStart.start; i < lines.length; i++) {
// check for an inner start marker for the same region // check for an inner start marker for the same region
if (chosen.re.start.exec(lines[i])?.[1] === regionName) { if (regionStart.re.start.exec(lines[i])?.[1] === regionName) {
counter++ depth++
continue continue
} }
// check for an end marker for the same region // check for an end marker for the same region
const endRegion = chosen.re.end.exec(lines[i])?.[1] const endRegion = regionStart.re.end.exec(lines[i])?.[1]
// allow empty region name on the end marker as a fallback // allow empty region name on the end marker as a fallback
if (endRegion === regionName || endRegion === '') { if (endRegion === regionName || endRegion === '') {
if (--counter === 0) return { ...chosen, end: i } if (--depth === 0) return { ...regionStart, end: i }
} }
} }
return null return null
} }
export const snippetPlugin = (md: MarkdownItAsync, srcDir: string) => { export function snippetPlugin(md: MarkdownItAsync, srcDir: string) {
const parser: RuleBlock = (state, startLine, endLine, silent) => { const renderFence = md.renderer.rules.fence!
const CH = '<'.charCodeAt(0) md.renderer.rules.fence = createSnippetRenderer(renderFence)
md.block.ruler.before('fence', 'snippet', createSnippetParser(srcDir))
}
function createSnippetParser(srcDir: string): RuleBlock {
return (state, startLine, _endLine, silent) => {
const pos = state.bMarks[startLine] + state.tShift[startLine] const pos = state.bMarks[startLine] + state.tShift[startLine]
const max = state.eMarks[startLine] const max = state.eMarks[startLine]
// if it's indented more than 3 spaces, it should be a code block // if it's indented more than 3 spaces, it should be a code block
if (state.sCount[startLine] - state.blkIndent >= 4) { if (
state.sCount[startLine] - state.blkIndent >= 4 ||
pos + snippetMarker.length > max ||
!state.src.startsWith(snippetMarker, pos)
) {
return false return false
} }
for (let i = 0; i < 3; ++i) { if (silent) return true
const ch = state.src.charCodeAt(pos + i)
if (ch !== CH || pos + i >= max) return false
}
if (silent) {
return true
}
const start = pos + 3 const start = pos + snippetMarker.length
const end = state.skipSpacesBack(max, pos) const end = state.skipSpacesBack(max, pos)
const rawPath = state.src const rawPath = state.src
@ -153,64 +167,72 @@ export const snippetPlugin = (md: MarkdownItAsync, srcDir: string) => {
state.line = startLine + 1 state.line = startLine + 1
const token = state.push('fence', 'code', 0) const token = state.push('fence', 'code', 0) as SnippetToken
token.info = `${lang || extension}${lines ? `{${lines}}` : ''}${ token.info = `${lang || extension}${lines ? `{${lines}}` : ''}${
title ? `[${title}]` : '' title ? `[${title}]` : ''
} ${attrs ?? ''}` } ${attrs}`
const { realPath, path: _path } = state.env as MarkdownEnv const { realPath, path: _path } = state.env as MarkdownEnv
const resolvedPath = path.resolve(path.dirname(realPath ?? _path), filepath) const resolvedPath = path.resolve(path.dirname(realPath ?? _path), filepath)
// @ts-ignore
token.src = [resolvedPath, region.slice(1)] token.src = [resolvedPath, region.slice(1)]
token.markup = '```' token.markup = '```'
token.map = [startLine, startLine + 1] token.map = [startLine, startLine + 1]
return true return true
} }
const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => {
const [tokens, idx, , { includes }] = args
const token = tokens[idx]
// @ts-ignore
const [src, regionName] = token.src ?? []
if (!src) return fence(...args)
if (includes) {
includes.push(src)
} }
const isAFile = fs.statSync(src).isFile() function getFileOrError(src: string): { content: string; error?: string } {
if (!fs.existsSync(src) || !isAFile) { try {
token.content = isAFile const content = fs.readFileSync(src, 'utf8').replace(/\r\n/g, '\n')
? `Code snippet path not found: ${src}` return { content }
: `Invalid code snippet option` } catch (error) {
token.info = '' switch ((error as NodeJS.ErrnoException).code) {
return fence(...args) case 'ENOENT':
return { content: '', error: `Code snippet path not found: ${src}` }
case 'EISDIR':
return { content: '', error: 'Invalid code snippet option' }
default:
throw error
}
}
} }
let content = fs.readFileSync(src, 'utf8').replace(/\r\n/g, '\n') function extractRegion(content: string, regionName: string): string {
if (!regionName) return content
if (regionName) {
const lines = content.split('\n') const lines = content.split('\n')
const region = findRegion(lines, regionName) const region = findRegion(lines, regionName)
if (region) { if (!region) return content
content = dedent(
return dedent(
lines lines
.slice(region.start, region.end) .slice(region.start, region.end)
.filter((l) => !(region.re.start.test(l) || region.re.end.test(l))) .filter((l) => !(region.re.start.test(l) || region.re.end.test(l)))
.join('\n') .join('\n')
) )
} }
}
token.content = content function createSnippetRenderer(renderFence: FenceRenderer): FenceRenderer {
return fence(...args) return (...args) => {
const [tokens, idx, , { includes }] = args
const token = tokens[idx] as SnippetToken
const [src, regionName = ''] = token.src ?? []
if (!src) return renderFence(...args)
includes?.push(src)
const { content, error } = getFileOrError(src)
if (error) {
token.content = error
token.info = ''
return renderFence(...args)
} }
md.block.ruler.before('fence', 'snippet', parser) token.content = extractRegion(content, regionName)
return renderFence(...args)
}
} }

Loading…
Cancel
Save