diff --git a/src/node/markdown/plugins/snippet.ts b/src/node/markdown/plugins/snippet.ts index 576e56c7..99b9da8f 100644 --- a/src/node/markdown/plugins/snippet.ts +++ b/src/node/markdown/plugins/snippet.ts @@ -4,6 +4,11 @@ import fs from 'node:fs' import path from 'node:path' import type { MarkdownEnv } from '../../shared' +type FenceRenderer = NonNullable +type SnippetToken = ReturnType[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, 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) }