@ -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 regionM arkers) {
if ( ma rk er . 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
}
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 ( )
if ( ! fs . existsSync ( src ) || ! isAFile ) {
token . content = isAFile
? ` Code snippet path not found: ${ src } `
: ` Invalid code snippet option `
token . info = ''
return fence ( . . . args )
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
}
}
}
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 region = findRegion ( lines , regionName )
if ( region ) {
content = dedent (
if ( ! region ) return content
return dedent (
lines
. slice ( region . start , region . end )
. filter ( ( l ) = > ! ( region . re . start . test ( l ) || region . re . end . test ( l ) ) )
. join ( '\n' )
)
}
}
token . content = content
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 ? ? [ ]
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 )
}
}