返回 slidev
utils.ts
根目录 / packages / slidev / node / utils.ts
1 import type { ResolvedFontOptions, SourceSlideInfo } from '@slidev/types'
2 import type MarkdownExit from 'markdown-exit'
3 import type { Connect, GeneralImportGlobOptions } from 'vite'
4 import { createHash } from 'node:crypto'
5 import { mkdirSync, writeFileSync } from 'node:fs'
6 import { fileURLToPath } from 'node:url'
7 import { slash } from '@antfu/utils'
8 import { createJiti } from 'jiti'
9 import { dirname, join, relative, win32 } from 'pathe'
10 import YAML from 'yaml'
11 import { toAtFS } from './resolver'
12 import { isAllowedFile } from './vite/importGuard'
13
14 /**
15 * Whether `filePath` resolves inside any of `roots` (no `..` escape). Shared
16 * containment predicate reused by the snippet (`<<<`) and `src:` deck-file
17 * reads, and by the Vite slide-import guard (`isAllowedFile`).
18 */
19 export function isPathInsideRoots(filePath: string, roots: string[]): boolean {
20 return isAllowedFile(filePath, roots)
21 }
22
23 const RE_WHITESPACE_ONLY = /^\s*$/
24 const RE_QUOTED_STRING = /^(['"])(.*)\1$/
25 const RE_WHITESPACE = /\s+/g
26 const RE_WINDOWS_DRIVE = /^[A-Z]:\//i
27
28 type Token = ReturnType<MarkdownExit['parseInline']>[number]
29
30 type Jiti = ReturnType<typeof createJiti>
31 let jiti: Jiti | undefined
32 export function loadModule<T = unknown>(absolutePath: string): Promise<T> {
33 jiti ??= createJiti(fileURLToPath(import.meta.url), {
34 // Allows changes to take effect
35 moduleCache: false,
36 })
37 return jiti.import(absolutePath) as Promise<T>
38 }
39
40 export function stringifyMarkdownTokens(tokens: Token[]) {
41 return tokens.map(token => token.children
42 ?.filter(t => ['text', 'code_inline'].includes(t.type) && !t.content.match(RE_WHITESPACE_ONLY))
43 .map(t => t.content.trim())
44 .join(' '))
45 .filter(Boolean)
46 .join(' ')
47 }
48
49 const RE_WORD_CHARS_ONLY = /^[\w-]+$/
50 const RE_REGEXP_CHARS = /[.*+?^${}()|[\]\\]/g
51
52 export function applyNotesAutoRuby(md: string, notesAutoRuby: Record<string, string | undefined>) {
53 const keys = Object.keys(notesAutoRuby)
54 // Longest first, otherwise a shorter key shadows every key starting with it
55 .sort((a, b) => b.length - a.length)
56 // Add word boundaries to the keys when they are simple alphabets or numbers
57 .map(i => RE_WORD_CHARS_ONLY.test(i) ? `\\b${i}\\b` : i.replace(RE_REGEXP_CHARS, '\\$&'))
58
59 if (!keys.length)
60 return md
61
62 return md.replace(
63 new RegExp(`(${keys.join('|')})`, 'g'),
64 (match) => {
65 if (notesAutoRuby[match])
66 return `<ruby>${match}<rt>${notesAutoRuby[match]}</rt></ruby>`
67 return match
68 },
69 )
70 }
71
72 export function generateFontParams(options: ResolvedFontOptions) {
73 const weights = options.weights
74 .flatMap(i => options.italic ? [`0,${i}`, `1,${i}`] : [`${i}`])
75 .sort()
76 .join(';')
77 const fontParams = options.webfonts
78 .map(i => `family=${i.replace(RE_QUOTED_STRING, '$1').replace(RE_WHITESPACE, '+')}:${options.italic ? 'ital,' : ''}wght@${weights}`)
79 .join('&')
80 return fontParams
81 }
82
83 export function generateGoogleFontsUrl(options: ResolvedFontOptions) {
84 return `https://fonts.googleapis.com/css2?${generateFontParams(options)}&display=swap`
85 }
86
87 export function generateCoollabsFontsUrl(options: ResolvedFontOptions) {
88 return `https://api.fonts.coollabs.io/fonts?${generateFontParams(options)}&display=swap`
89 }
90
91 /**
92 * Update frontmatter patch and preserve the comments
93 */
94 export function updateFrontmatterPatch(source: SourceSlideInfo, frontmatter: Record<string, any>) {
95 let doc = source.frontmatterDoc
96 if (!doc) {
97 source.frontmatterStyle = 'frontmatter'
98 source.frontmatterDoc = doc = new YAML.Document({})
99 }
100 for (const [key, value] of Object.entries(frontmatter)) {
101 source.frontmatter[key] = value
102 if (value == null) {
103 doc.delete(key)
104 }
105 else {
106 const valueNode = doc.createNode(value)
107 let found = false
108 YAML.visit(doc.contents, {
109 Pair(_key, node, path) {
110 if (path.length === 1 && YAML.isScalar(node.key) && node.key.value === key) {
111 node.value = valueNode
112 found = true
113 return YAML.visit.BREAK
114 }
115 },
116 })
117 if (!found) {
118 if (!YAML.isMap(doc.contents))
119 doc.contents = doc.createNode({})
120 doc.contents.add(
121 doc.createPair(key, valueNode),
122 )
123 }
124 }
125 }
126 }
127
128 export function getBodyJson(req: Connect.IncomingMessage) {
129 return new Promise<any>((resolve, reject) => {
130 let body = ''
131 req.on('data', chunk => body += chunk)
132 req.on('error', reject)
133 req.on('end', () => {
134 try {
135 resolve(JSON.parse(body) || {})
136 }
137 catch (e) {
138 reject(e)
139 }
140 })
141 })
142 }
143
144 function getImportGlobRelativePath(from: string, to: string) {
145 const normalizedFrom = slash(from)
146 const normalizedTo = slash(to)
147 return slash(
148 RE_WINDOWS_DRIVE.test(normalizedFrom) || RE_WINDOWS_DRIVE.test(normalizedTo)
149 ? win32.relative(normalizedFrom, normalizedTo)
150 : relative(normalizedFrom, normalizedTo),
151 )
152 }
153
154 function resolveImportGlobProxyModule(proxyBase: string, content: string) {
155 const hash = createHash('sha256').update(content).digest('hex').slice(0, 10)
156 return `${proxyBase}.${hash}.ts`
157 }
158
159 export function createMakeAbsoluteImportGlob(baseRoot: string) {
160 const proxyModules = new Map<string, string>()
161 const proxyBasename = 'node_modules/.slidev/virtual/import-glob'
162 const proxyBase = slash(join(baseRoot, proxyBasename))
163
164 return function makeAbsoluteImportGlob(
165 globs: string[],
166 options: Partial<GeneralImportGlobOptions> = {},
167 ) {
168 // Vite does not treat /@slidev/* as a real filesystem importer. Emit
169 // import.meta.glob from a proxy file so Vite resolves imports from disk.
170 const content = `export default ${makeAbsoluteImportGlobExpression(dirname(proxyBase), globs, options)}\n`
171 const proxyModule = resolveImportGlobProxyModule(proxyBase, content)
172 if (proxyModules.get(proxyModule) !== content) {
173 mkdirSync(dirname(proxyModule), { recursive: true })
174 writeFileSync(proxyModule, content, 'utf-8')
175 proxyModules.set(proxyModule, content)
176 }
177 return toAtFS(proxyModule)
178 }
179 }
180
181 export type MakeAbsoluteImportGlob = ReturnType<typeof createMakeAbsoluteImportGlob>
182
183 function makeAbsoluteImportGlobExpression(
184 self: string,
185 globs: string[],
186 options: Partial<GeneralImportGlobOptions> = {},
187 ) {
188 const relativeGlobs = globs.map((glob) => {
189 const relativeGlob = getImportGlobRelativePath(self, glob)
190 return !relativeGlob.startsWith('.') && !RE_WINDOWS_DRIVE.test(relativeGlob)
191 ? `./${relativeGlob}`
192 : relativeGlob
193 })
194 const opts: GeneralImportGlobOptions = {
195 eager: true,
196 exhaustive: true,
197 ...options,
198 }
199 return `import.meta.glob(${JSON.stringify(relativeGlobs)}, ${JSON.stringify(opts)})`
200 }
201
201 lines TYPESCRIPT