返回 slidev
snippet.ts
根目录 / packages / slidev / node / syntax / snippet.ts
1 import type { ResolvedSlidevOptions, SlideInfo } from '@slidev/types'
2 import type { MarkdownExit } from 'markdown-exit'
3 import fs from 'node:fs'
4 import { slash } from '@antfu/utils'
5 import { yellow } from 'ansis'
6 import lz from 'lz-string'
7 import path from 'pathe'
8 import { isPathInsideRoots } from '../utils'
9 import { regexSlideSourceId } from '../vite/common'
10 import { monacoWriterWhitelist } from '../vite/monacoWrite'
11
12 const RE_NEWLINE = /\r?\n/
13
14 function dedent(text: string): string {
15 const lines = text.split('\n')
16
17 const minIndentLength = lines.reduce((acc, line) => {
18 for (let i = 0; i < line.length; i++) {
19 if (line[i] !== ' ' && line[i] !== '\t')
20 return Math.min(i, acc)
21 }
22 return acc
23 }, Number.POSITIVE_INFINITY)
24
25 if (minIndentLength < Number.POSITIVE_INFINITY)
26 return lines.map(x => x.slice(minIndentLength)).join('\n')
27
28 return text
29 }
30
31 /* eslint-disable regexp/no-super-linear-backtracking */
32 const markers = [
33 {
34 start: /^\s*\/\/\s*#?region\b\s*(.*?)\s*$/,
35 end: /^\s*\/\/\s*#?endregion\b\s*(.*?)\s*$/,
36 },
37 {
38 start: /^\s*<!--\s*#?region\b\s*(.*?)\s*-->/,
39 end: /^\s*<!--\s*#?endregion\b\s*(.*?)\s*-->/,
40 },
41 {
42 start: /^\s*\/\*\s*#region\b\s*(.*?)\s*\*\//,
43 end: /^\s*\/\*\s*#endregion\b\s*(.*?)\s*\*\//,
44 },
45 {
46 start: /^\s*#[rR]egion\b\s*(.*?)\s*$/,
47 end: /^\s*#[eE]nd ?[rR]egion\b\s*(.*?)\s*$/,
48 },
49 {
50 start: /^\s*#\s*#?region\b\s*(.*?)\s*$/,
51 end: /^\s*#\s*#?endregion\b\s*(.*?)\s*$/,
52 },
53 {
54 start: /^\s*(?:--|::|@?REM)\s*#region\b\s*(.*?)\s*$/,
55 end: /^\s*(?:--|::|@?REM)\s*#endregion\b\s*(.*?)\s*$/,
56 },
57 {
58 start: /^\s*#pragma\s+region\b\s*(.*?)\s*$/,
59 end: /^\s*#pragma\s+endregion\b\s*(.*?)\s*$/,
60 },
61 {
62 start: /^\s*\(\*\s*#region\b\s*(.*?)\s*\*\)/,
63 end: /^\s*\(\*\s*#endregion\b\s*(.*?)\s*\*\)/,
64 },
65 ]
66 /* eslint-enable regexp/no-super-linear-backtracking */
67
68 function findRegion(lines: Array<string>, regionName: string) {
69 let chosen: { re: (typeof markers)[number], start: number } | null = null
70 // find the regex pair for a start marker that matches the given region name
71 for (let i = 0; i < lines.length; i++) {
72 for (const re of markers) {
73 if (re.start.exec(lines[i])?.[1] === regionName) {
74 chosen = { re, start: i + 1 }
75 break
76 }
77 }
78 if (chosen)
79 break
80 }
81 if (!chosen)
82 return null
83
84 let counter = 1
85 // scan the rest of the lines to find the matching end marker, handling nested markers
86 for (let i = chosen.start; i < lines.length; i++) {
87 // check for an inner start marker for the same region
88 if (chosen.re.start.exec(lines[i])?.[1] === regionName) {
89 counter++
90 continue
91 }
92 // check for an end marker for the same region
93 const endRegion = chosen.re.end.exec(lines[i])?.[1]
94 // allow empty region name on the end marker as a fallback
95 if (endRegion === regionName || endRegion === '') {
96 if (--counter === 0) {
97 return {
98 ...chosen,
99 end: i,
100 }
101 }
102 }
103 }
104
105 return null
106 }
107
108 // eslint-disable-next-line regexp/no-super-linear-backtracking
109 export const RE_SNIPPET_IMPORT = /^<<<[ \t]*(\S.*?)(#[\w-]+)?[ \t]*(?:[ \t](\S+?))?[ \t]*(\{.*)?$/
110
111 export function resolveSnippetImport(lineText: string, userRoot: string, slide: SlideInfo, allowedRoots: string[] = [userRoot]) {
112 const match = lineText.trimStart().match(RE_SNIPPET_IMPORT)
113 if (!match)
114 return null
115
116 let [, filepath = '', regionName = '', lang = '', meta = ''] = match
117 const dir = path.dirname(slide.source.filepath)
118 const src = slash(
119 filepath.startsWith('@/')
120 ? path.resolve(userRoot, filepath.slice(2))
121 : path.resolve(dir, filepath),
122 )
123
124 lang = lang.trim() || path.extname(filepath).slice(1)
125 meta = meta.trim()
126
127 if (!isPathInsideRoots(src, allowedRoots)) {
128 throw new Error(`Code snippet path escapes the project root: ${src}`)
129 }
130
131 const isAFile = fs.existsSync(src) && fs.statSync(src).isFile()
132 if (!isAFile) {
133 throw new Error(`Code snippet path not found: ${src}`)
134 }
135
136 let content = fs.readFileSync(src, 'utf8')
137
138 if (regionName) {
139 const lines = content.split(RE_NEWLINE)
140 const region = findRegion(lines, regionName.slice(1))
141 if (region) {
142 content = dedent(
143 lines
144 .slice(region.start, region.end)
145 .filter(l => !(region.re.start.test(l) || region.re.end.test(l)))
146 .join('\n'),
147 )
148 }
149 }
150
151 return { content, filepath, lang, meta, src }
152 }
153
154 export default function MarkdownItSnippet(md: MarkdownExit, { userRoot, userWorkspaceRoot, roots, data: { watchFiles, slides } }: ResolvedSlidevOptions) {
155 const allowedRoots = [...new Set([userWorkspaceRoot, userRoot, ...(roots ?? [])].filter(Boolean))]
156 md.block.ruler.before('fence', 'snippet_import', (state, startLine, _endLine, silent) => {
157 const pos = state.bMarks[startLine] + state.tShift[startLine]
158 const max = state.eMarks[startLine]
159
160 const lineText = state.src.slice(pos, max)
161 const match = lineText.match(RE_SNIPPET_IMPORT)
162
163 if (!match)
164 return false
165 if (silent)
166 return true
167
168 const slideNo = state.env.id?.match(regexSlideSourceId)
169 const slide = slideNo ? slides[slideNo[1] - 1] : null
170
171 if (!slide) {
172 console.warn(yellow(`[markdown-it-snippet] Snippet syntax is not supported in ${state.env.id || 'unknown source'}. Skipped.`))
173 return false
174 }
175
176 const snippet = resolveSnippetImport(lineText, userRoot, slide, allowedRoots)
177 if (!snippet)
178 return false
179
180 const { content, filepath, src } = snippet
181 let { lang, meta } = snippet
182
183 if (meta.includes('{monaco-write}')) {
184 monacoWriterWhitelist.add(filepath)
185 lang = lang.trim()
186 meta = meta.replace('{monaco-write}', '').trim() || '{}'
187 const safeFilepath = JSON.stringify(filepath).slice(1, -1)
188 const encoded = lz.compressToBase64(content)
189
190 const token = state.push('html_block', '', 0)
191 token.content = `<Monaco writable="${safeFilepath}" code-lz="${encoded}" lang="${lang}" v-bind="${meta}" />\n`
192 token.map = [startLine, startLine + 1]
193 }
194 else {
195 watchFiles[src] ??= new Set()
196 watchFiles[src].add(slide.index)
197
198 const token = state.push('fence', 'code', 0)
199 token.info = `${lang} ${meta}`.trim()
200 token.content = content.endsWith('\n') ? content : `${content}\n`
201 token.map = [startLine, startLine + 1]
202 }
203
204 state.line = startLine + 1
205 return true
206 }, { alt: ['paragraph', 'reference', 'blockquote', 'list'] })
207 }
208
208 lines TYPESCRIPT