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 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]"
* where #region, {meta} and [title] are optional
@ -18,40 +23,7 @@ import type { MarkdownEnv } from '../../shared'
export const rawPathRegexp =
/^(.+?(?:(?:\.([a-z0-9]+))?))(?:(#[\w-]+))?(?: ?(?:{(\d+(?:[,-]\d+)*)? ?(\S+)? ?(\S+)?}))? ?(?:\[(.+)\])?$/
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
}
const markers = [
const regionMarkers = [
{
start: /^\s*\/\/\s*#?region\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) {
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
for (let i = 0; i < lines.length; i++) {
for (const re of markers) {
if (re.start.exec(lines[i])?.[1] === regionName) {
chosen = { re, start: i + 1 }
for (const marker of regionMarkers) {
if (marker.start.exec(lines[i])?.[1] === regionName) {
regionStart = { re: marker, start: i + 1 }
break
}
}
if (chosen) break
if (regionStart) break
}
if (!chosen) return null
if (!regionStart) return null
let counter = 1
// scan the rest of the lines to find the matching end marker, handling nested markers
for (let i = chosen.start; i < lines.length; i++) {
let depth = 1
// scan the rest of the lines to find the matching end marker,
// 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
if (chosen.re.start.exec(lines[i])?.[1] === regionName) {
counter++
if (regionStart.re.start.exec(lines[i])?.[1] === regionName) {
depth++
continue
}
// 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
if (endRegion === regionName || endRegion === '') {
if (--counter === 0) return { ...chosen, end: i }
if (--depth === 0) return { ...regionStart, end: i }
}
}
return null
}
export const snippetPlugin = (md: MarkdownItAsync, srcDir: string) => {
const parser: RuleBlock = (state, startLine, endLine, silent) => {
const CH = '<'.charCodeAt(0)
export function snippetPlugin(md: MarkdownItAsync, srcDir: string) {
const renderFence = md.renderer.rules.fence!
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 max = state.eMarks[startLine]
// 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
}
for (let i = 0; i < 3; ++i) {
const ch = state.src.charCodeAt(pos + i)
if (ch !== CH || pos + i >= max) return false
}
if (silent) return true
if (silent) {
return true
}
const start = pos + 3
const start = pos + snippetMarker.length
const end = state.skipSpacesBack(max, pos)
const rawPath = state.src
@ -153,64 +167,72 @@ export const snippetPlugin = (md: MarkdownItAsync, srcDir: string) => {
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}}` : ''}${
title ? `[${title}]` : ''
} ${attrs ?? ''}`
} ${attrs}`
const { realPath, path: _path } = state.env as MarkdownEnv
const resolvedPath = path.resolve(path.dirname(realPath ?? _path), filepath)
// @ts-ignore
token.src = [resolvedPath, region.slice(1)]
token.markup = '```'
token.map = [startLine, startLine + 1]
return true
}
}
function getFileOrError(src: string): { content: string; error?: string } {
try {
const content = fs.readFileSync(src, 'utf8').replace(/\r\n/g, '\n')
return { content }
} catch (error) {
switch ((error as NodeJS.ErrnoException).code) {
case 'ENOENT':
return { content: '', error: `Code snippet path not found: ${src}` }
case 'EISDIR':
return { content: '', error: 'Invalid code snippet option' }
default:
throw error
}
}
}
const fence = md.renderer.rules.fence!
function extractRegion(content: string, regionName: string): string {
if (!regionName) return content
md.renderer.rules.fence = (...args) => {
const [tokens, idx, , { includes }] = args
const token = tokens[idx]
// @ts-ignore
const [src, regionName] = token.src ?? []
const lines = content.split('\n')
const region = findRegion(lines, regionName)
if (!src) return fence(...args)
if (!region) return content
if (includes) {
includes.push(src)
}
return dedent(
lines
.slice(region.start, region.end)
.filter((l) => !(region.re.start.test(l) || region.re.end.test(l)))
.join('\n')
)
}
const isAFile = fs.statSync(src).isFile()
if (!fs.existsSync(src) || !isAFile) {
token.content = isAFile
? `Code snippet path not found: ${src}`
: `Invalid code snippet option`
token.info = ''
return fence(...args)
}
function createSnippetRenderer(renderFence: FenceRenderer): FenceRenderer {
return (...args) => {
const [tokens, idx, , { includes }] = args
const token = tokens[idx] as SnippetToken
const [src, regionName = ''] = token.src ?? []
let content = fs.readFileSync(src, 'utf8').replace(/\r\n/g, '\n')
if (!src) return renderFence(...args)
if (regionName) {
const lines = content.split('\n')
const region = findRegion(lines, regionName)
includes?.push(src)
if (region) {
content = dedent(
lines
.slice(region.start, region.end)
.filter((l) => !(region.re.start.test(l) || region.re.end.test(l)))
.join('\n')
)
}
const { content, error } = getFileOrError(src)
if (error) {
token.content = error
token.info = ''
return renderFence(...args)
}
token.content = content
return fence(...args)
token.content = extractRegion(content, regionName)
return renderFence(...args)
}
md.block.ruler.before('fence', 'snippet', parser)
}

Loading…
Cancel
Save