starlight-obsidian 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/libs/remark.ts ADDED
@@ -0,0 +1,670 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import twitterMatcher from '@astro-community/astro-embed-twitter/matcher'
5
+ import youtubeMatcher from '@astro-community/astro-embed-youtube/matcher'
6
+ import { toHtml } from 'hast-util-to-html'
7
+ import isAbsoluteUrl from 'is-absolute-url'
8
+ import type { BlockContent, Blockquote, Code, Html, Image, Link, Parent, Root, RootContent } from 'mdast'
9
+ import { findAndReplace } from 'mdast-util-find-and-replace'
10
+ import { toHast } from 'mdast-util-to-hast'
11
+ import { customAlphabet } from 'nanoid'
12
+ import { CONTINUE, SKIP, visit } from 'unist-util-visit'
13
+ import type { VFile } from 'vfile'
14
+ import yaml from 'yaml'
15
+
16
+ import type { StarlightObsidianConfig } from '..'
17
+
18
+ import { transformHtmlToString } from './html'
19
+ import { transformMarkdownToAST } from './markdown'
20
+ import {
21
+ getObsidianRelativePath,
22
+ isObsidianFile,
23
+ isObsidianBlockAnchor,
24
+ parseObsidianFrontmatter,
25
+ slugifyObsidianAnchor,
26
+ slugifyObsidianPath,
27
+ type ObsidianFrontmatter,
28
+ type Vault,
29
+ type VaultFile,
30
+ } from './obsidian'
31
+ import { extractPathAndAnchor, getExtension, isAnchor } from './path'
32
+ import { getStarlightCalloutType, isAssetFile } from './starlight'
33
+
34
+ const generateAssetImportId = customAlphabet('abcdefghijklmnopqrstuvwxyz', 6)
35
+
36
+ const highlightReplacementRegex = /==(?<highlight>(?:(?!==).)+)==/g
37
+ const commentReplacementRegex = /%%(?<comment>(?:(?!%%).)+)%%/gs
38
+ const wikilinkReplacementRegex = /!?\[\[(?<url>(?:(?![[\]|]).)+)(?:\|(?<maybeText>(?:(?![[\]]).)+))?]]/g
39
+ const tagReplacementRegex = /(?:^|\s)#(?<tag>[\w/-]+)/g
40
+ const calloutRegex = /^\[!(?<type>\w+)][+-]? ?(?<title>.*)$/
41
+ const imageSizeRegex = /^(?<altText>.*)\|(?:(?<widthOnly>\d+)|(?:(?<width>\d+)x(?<height>\d+)))$/
42
+
43
+ const asideDelimiter = ':::'
44
+
45
+ export function remarkStarlightObsidian() {
46
+ return async function transformer(tree: Root, file: VFile) {
47
+ const obsidianFrontmatter = getObsidianFrontmatter(tree)
48
+
49
+ if (obsidianFrontmatter && obsidianFrontmatter.publish === false) {
50
+ file.data.skip = true
51
+ return
52
+ }
53
+
54
+ handleReplacements(tree, file)
55
+ await handleMermaid(tree, file)
56
+
57
+ visit(tree, (node, index, parent) => {
58
+ const context: VisitorContext = { file, index, parent }
59
+
60
+ switch (node.type) {
61
+ case 'math':
62
+ case 'inlineMath': {
63
+ return handleMath(context)
64
+ }
65
+ case 'link': {
66
+ return handleLinks(node, context)
67
+ }
68
+ case 'image': {
69
+ return handleImages(node, context)
70
+ }
71
+ case 'blockquote': {
72
+ return handleBlockquotes(node, context)
73
+ }
74
+ default: {
75
+ return CONTINUE
76
+ }
77
+ }
78
+ })
79
+
80
+ handleFrontmatter(tree, file, obsidianFrontmatter)
81
+ handleImports(tree, file)
82
+ }
83
+ }
84
+
85
+ function getObsidianFrontmatter(tree: Root) {
86
+ // The frontmatter is always at the root of the tree.
87
+ for (const node of tree.children) {
88
+ if (node.type !== 'yaml') {
89
+ continue
90
+ }
91
+
92
+ const obsidianFrontmatter = parseObsidianFrontmatter(node.value)
93
+
94
+ if (obsidianFrontmatter) {
95
+ return obsidianFrontmatter
96
+ }
97
+ }
98
+
99
+ return
100
+ }
101
+
102
+ function handleFrontmatter(tree: Root, file: VFile, obsidianFrontmatter?: ObsidianFrontmatter) {
103
+ let hasFrontmatter = false
104
+
105
+ // The frontmatter is always at the root of the tree.
106
+ for (const node of tree.children) {
107
+ if (node.type !== 'yaml') {
108
+ continue
109
+ }
110
+
111
+ node.value = getFrontmatterNodeValue(file, obsidianFrontmatter)
112
+ hasFrontmatter = true
113
+
114
+ if (obsidianFrontmatter?.aliases && obsidianFrontmatter.aliases.length > 0) {
115
+ file.data.aliases = obsidianFrontmatter.aliases
116
+ }
117
+
118
+ break
119
+ }
120
+
121
+ if (!hasFrontmatter) {
122
+ tree.children.unshift({ type: 'yaml', value: getFrontmatterNodeValue(file) })
123
+ }
124
+ }
125
+
126
+ function handleImports(tree: Root, file: VFile) {
127
+ if (
128
+ !file.data.includeTwitterComponent &&
129
+ !file.data.includeYoutubeComponent &&
130
+ (!file.data.assetImports || file.data.assetImports.length === 0)
131
+ ) {
132
+ return
133
+ }
134
+
135
+ file.data.isMdx = true
136
+
137
+ const imports: Html[] = []
138
+
139
+ if (file.data.includeTwitterComponent) {
140
+ imports.push(createMdxNode(`import Twitter from 'starlight-obsidian/components/Twitter.astro'`))
141
+ }
142
+
143
+ if (file.data.includeYoutubeComponent) {
144
+ imports.push(createMdxNode(`import Youtube from 'starlight-obsidian/components/Youtube.astro'`))
145
+ }
146
+
147
+ if (file.data.assetImports) {
148
+ imports.push(
149
+ createMdxNode(`import { Image } from 'astro:assets'`),
150
+ ...file.data.assetImports.map(([id, path]) => createMdxNode(`import ${id} from '${path}'`)),
151
+ )
152
+ }
153
+
154
+ tree.children.splice(1, 0, ...imports)
155
+ }
156
+
157
+ function handleReplacements(tree: Root, file: VFile) {
158
+ findAndReplace(tree, [
159
+ [
160
+ highlightReplacementRegex,
161
+ (_match: string, highlight: string) => ({
162
+ type: 'html',
163
+ value: `<mark class="sl-obs-highlight">${highlight}</mark>`,
164
+ }),
165
+ ],
166
+ [commentReplacementRegex, null],
167
+ [
168
+ wikilinkReplacementRegex,
169
+ (match: string, url: string, maybeText?: string) => {
170
+ ensureTransformContext(file)
171
+
172
+ let fileUrl: string
173
+ let text = maybeText ?? url
174
+
175
+ if (isAnchor(url)) {
176
+ fileUrl = slugifyObsidianAnchor(url)
177
+ text = maybeText ?? url.slice(isObsidianBlockAnchor(url) ? 2 : 1)
178
+ } else {
179
+ const [urlPath, urlAnchor] = extractPathAndAnchor(url)
180
+
181
+ switch (file.data.vault.options.linkFormat) {
182
+ case 'relative': {
183
+ fileUrl = getFileUrl(file.data.output, getRelativeFilePath(file, urlPath), urlAnchor)
184
+ break
185
+ }
186
+ case 'absolute':
187
+ case 'shortest': {
188
+ const matchingFile = file.data.files.find(
189
+ (vaultFile) => vaultFile.isEqualStem(urlPath) || vaultFile.isEqualFileName(urlPath),
190
+ )
191
+
192
+ fileUrl = getFileUrl(
193
+ file.data.output,
194
+ matchingFile ? getFilePathFromVaultFile(matchingFile, urlPath) : urlPath,
195
+ urlAnchor,
196
+ )
197
+ break
198
+ }
199
+ }
200
+ }
201
+
202
+ if (match.startsWith('!')) {
203
+ const isMarkdown = isMarkdownFile(url, file)
204
+
205
+ return {
206
+ type: 'image',
207
+ url: isMarkdown ? url : fileUrl,
208
+ alt: text,
209
+ data: { isAssetResolved: !isMarkdown },
210
+ }
211
+ }
212
+
213
+ return {
214
+ children: [{ type: 'text', value: text }],
215
+ type: 'link',
216
+ url: fileUrl,
217
+ }
218
+ },
219
+ ],
220
+ [
221
+ tagReplacementRegex,
222
+ (_match: string, tag: string) => {
223
+ // Tags with only numbers are not valid.
224
+ // https://help.obsidian.md/Editing+and+formatting/Tags#Tag%20format
225
+ if (/^\d+$/.test(tag)) {
226
+ return false
227
+ }
228
+
229
+ return {
230
+ type: 'html',
231
+ value: ` <span class="sl-obs-tag">#${tag}</span>`,
232
+ }
233
+ },
234
+ ],
235
+ ])
236
+ }
237
+
238
+ function handleMath({ file }: VisitorContext) {
239
+ file.data.includeKatexStyles = true
240
+ return SKIP
241
+ }
242
+
243
+ function handleLinks(node: Link, { file }: VisitorContext) {
244
+ ensureTransformContext(file)
245
+
246
+ if (file.data.vault.options.linkSyntax === 'wikilink' || isAbsoluteUrl(node.url) || !file.dirname) {
247
+ return SKIP
248
+ }
249
+
250
+ if (isAnchor(node.url)) {
251
+ node.url = slugifyObsidianAnchor(node.url)
252
+ return SKIP
253
+ }
254
+
255
+ const url = path.basename(decodeURIComponent(node.url))
256
+ const [urlPath, urlAnchor] = extractPathAndAnchor(url)
257
+ const matchingFile = file.data.files.find((vaultFile) => vaultFile.isEqualFileName(urlPath))
258
+
259
+ if (!matchingFile) {
260
+ return SKIP
261
+ }
262
+
263
+ switch (file.data.vault.options.linkFormat) {
264
+ case 'relative': {
265
+ node.url = getFileUrl(file.data.output, getRelativeFilePath(file, node.url), urlAnchor)
266
+ break
267
+ }
268
+ case 'absolute':
269
+ case 'shortest': {
270
+ node.url = getFileUrl(file.data.output, getFilePathFromVaultFile(matchingFile, node.url), urlAnchor)
271
+ break
272
+ }
273
+ }
274
+
275
+ return SKIP
276
+ }
277
+
278
+ function handleImages(node: Image, context: VisitorContext) {
279
+ const { file } = context
280
+
281
+ ensureTransformContext(file)
282
+
283
+ if (!file.dirname) {
284
+ return SKIP
285
+ }
286
+
287
+ if (isAbsoluteUrl(node.url)) {
288
+ const isExternalEmbed = handleExternalEmbeds(node, context)
289
+
290
+ if (isExternalEmbed) {
291
+ return SKIP
292
+ }
293
+
294
+ if (isObsidianFile(node.url, 'image')) {
295
+ handleImagesWithSize(node, context, 'external')
296
+ }
297
+
298
+ return SKIP
299
+ }
300
+
301
+ if (isMarkdownFile(node.url, file)) {
302
+ replaceNode(context, getMarkdownFileNode(file, node.url))
303
+ return SKIP
304
+ }
305
+
306
+ let fileUrl = node.url
307
+
308
+ if (!node.data?.isAssetResolved) {
309
+ switch (file.data.vault.options.linkFormat) {
310
+ case 'relative': {
311
+ fileUrl = getFileUrl(file.data.output, getRelativeFilePath(file, node.url))
312
+ break
313
+ }
314
+ case 'absolute': {
315
+ fileUrl = getFileUrl(file.data.output, slugifyObsidianPath(node.url))
316
+ break
317
+ }
318
+ case 'shortest': {
319
+ const url = path.basename(decodeURIComponent(node.url))
320
+ const [urlPath] = extractPathAndAnchor(url)
321
+ const matchingFile = file.data.files.find((vaultFile) => vaultFile.isEqualFileName(urlPath))
322
+
323
+ if (!matchingFile) {
324
+ break
325
+ }
326
+
327
+ fileUrl = getFileUrl(file.data.output, getFilePathFromVaultFile(matchingFile, node.url))
328
+ break
329
+ }
330
+ }
331
+ }
332
+
333
+ if (isCustomFile(node.url)) {
334
+ replaceNode(context, getCustomFileNode(fileUrl))
335
+
336
+ return SKIP
337
+ }
338
+
339
+ node.url = isAssetFile(fileUrl) ? getAssetPath(file, fileUrl) : fileUrl
340
+
341
+ if (isAssetFile(node.url)) {
342
+ handleImagesWithSize(node, context, 'asset')
343
+ }
344
+
345
+ return SKIP
346
+ }
347
+
348
+ function handleBlockquotes(node: Blockquote, context: VisitorContext) {
349
+ const [firstChild, ...otherChildren] = node.children
350
+
351
+ if (firstChild?.type !== 'paragraph') {
352
+ return SKIP
353
+ }
354
+
355
+ const [firstGrandChild, ...otherGrandChildren] = firstChild.children
356
+
357
+ if (firstGrandChild?.type !== 'text') {
358
+ return SKIP
359
+ }
360
+
361
+ const [firstLine, ...otherLines] = firstGrandChild.value.split('\n')
362
+
363
+ if (!firstLine) {
364
+ return SKIP
365
+ }
366
+
367
+ const match = firstLine.match(calloutRegex)
368
+
369
+ const { title, type } = match?.groups ?? {}
370
+
371
+ if (!match || !type) {
372
+ return SKIP
373
+ }
374
+
375
+ const asideTitle = title && title.length > 0 ? `[${title.trim()}]` : ''
376
+
377
+ const aside: RootContent[] = [
378
+ {
379
+ type: 'paragraph',
380
+ children: [
381
+ {
382
+ type: 'html',
383
+ value: `${asideDelimiter}${getStarlightCalloutType(type)}${asideTitle}\n${otherLines.join('\n')}`,
384
+ },
385
+ ...otherGrandChildren,
386
+ ...(otherChildren.length === 0 ? [{ type: 'html', value: `\n${asideDelimiter}` } satisfies RootContent] : []),
387
+ ],
388
+ },
389
+ ]
390
+
391
+ if (otherChildren.length > 0) {
392
+ aside.push(...otherChildren, { type: 'html', value: asideDelimiter })
393
+ }
394
+
395
+ replaceNode(context, aside)
396
+
397
+ return CONTINUE
398
+ }
399
+
400
+ async function handleMermaid(tree: Root, file: VFile) {
401
+ const mermaidNodes: [node: Code, context: VisitorContext][] = []
402
+
403
+ visit(tree, 'code', (node, index, parent) => {
404
+ if (node.lang === 'mermaid') {
405
+ mermaidNodes.push([node, { file, index, parent }])
406
+ return SKIP
407
+ }
408
+
409
+ return CONTINUE
410
+ })
411
+
412
+ await Promise.all(
413
+ mermaidNodes.map(async ([node, context]) => {
414
+ const html = toHtml(toHast(node))
415
+ const processedHtml = await transformHtmlToString(html)
416
+
417
+ replaceNode(context, { type: 'html', value: processedHtml })
418
+ }),
419
+ )
420
+ }
421
+
422
+ function getFrontmatterNodeValue(file: VFile, obsidianFrontmatter?: ObsidianFrontmatter) {
423
+ const frontmatter: Frontmatter = {
424
+ title: file.stem,
425
+ editUrl: false,
426
+ }
427
+
428
+ if (file.data.includeKatexStyles) {
429
+ frontmatter.head = [
430
+ {
431
+ tag: 'link',
432
+ attrs: {
433
+ rel: 'stylesheet',
434
+ href: 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css',
435
+ },
436
+ },
437
+ ]
438
+ }
439
+
440
+ const ogImage = obsidianFrontmatter?.cover ?? obsidianFrontmatter?.image
441
+
442
+ if (ogImage && isAbsoluteUrl(ogImage)) {
443
+ if (!frontmatter.head) {
444
+ frontmatter.head = []
445
+ }
446
+
447
+ frontmatter.head.push(
448
+ { tag: 'meta', attrs: { property: 'og:image', content: ogImage } },
449
+ { tag: 'meta', attrs: { name: 'twitter:image', content: ogImage } },
450
+ )
451
+ }
452
+
453
+ if (obsidianFrontmatter?.description && obsidianFrontmatter.description.length > 0) {
454
+ frontmatter.description = obsidianFrontmatter.description
455
+ }
456
+
457
+ if (obsidianFrontmatter?.permalink && obsidianFrontmatter.permalink.length > 0) {
458
+ frontmatter.slug = obsidianFrontmatter.permalink
459
+ }
460
+
461
+ if (obsidianFrontmatter?.tags && obsidianFrontmatter.tags.length > 0) {
462
+ frontmatter.tags = obsidianFrontmatter.tags
463
+ }
464
+
465
+ return yaml.stringify(frontmatter).trim()
466
+ }
467
+
468
+ function getFileUrl(output: StarlightObsidianConfig['output'], filePath: string, anchor?: string) {
469
+ return `${path.posix.join(path.posix.sep, output, slugifyObsidianPath(filePath))}${slugifyObsidianAnchor(anchor ?? '')}`
470
+ }
471
+
472
+ function getRelativeFilePath(file: VFile, relativePath: string) {
473
+ ensureTransformContext(file)
474
+
475
+ return path.posix.join(getObsidianRelativePath(file.data.vault, file.dirname), relativePath)
476
+ }
477
+
478
+ function getAssetPath(file: VFile, relativePath: string) {
479
+ ensureTransformContext(file)
480
+
481
+ return path.posix.join('../../..', path.relative(file.dirname, file.data.vault.path), 'assets', relativePath)
482
+ }
483
+
484
+ function getFilePathFromVaultFile(vaultFile: VaultFile, url: string) {
485
+ return vaultFile.uniqueFileName ? vaultFile.slug : slugifyObsidianPath(url)
486
+ }
487
+
488
+ function isMarkdownFile(filePath: string, file: VFile) {
489
+ return (
490
+ (file.data.vault?.options.linkSyntax === 'markdown' && filePath.endsWith('.md')) ||
491
+ getExtension(filePath).length === 0
492
+ )
493
+ }
494
+
495
+ function handleExternalEmbeds(node: Image, context: VisitorContext) {
496
+ const twitterId = twitterMatcher(node.url)
497
+ const youtubeId = youtubeMatcher(node.url)
498
+
499
+ if (!twitterId && !youtubeId) {
500
+ return false
501
+ }
502
+
503
+ const type = twitterId ? 'twitter' : 'youtube'
504
+ const id = twitterId ?? youtubeId
505
+ const component = type === 'twitter' ? 'Twitter' : 'Youtube'
506
+
507
+ if (type === 'twitter') {
508
+ context.file.data.includeTwitterComponent = true
509
+ } else {
510
+ context.file.data.includeYoutubeComponent = true
511
+ }
512
+
513
+ replaceNode(context, createMdxNode(`<${component} id="${id}" />`))
514
+
515
+ return true
516
+ }
517
+
518
+ function handleImagesWithSize(node: Image, context: VisitorContext, type: 'asset' | 'external') {
519
+ if (!node.alt) {
520
+ return
521
+ }
522
+
523
+ const match = node.alt.match(imageSizeRegex)
524
+ const { altText, width, widthOnly, height } = match?.groups ?? {}
525
+
526
+ if (widthOnly === undefined && width === undefined) {
527
+ return
528
+ }
529
+
530
+ const imgWidth = widthOnly ?? width
531
+ const imgHeight = height ?? 'auto'
532
+ // Workaround Starlight `auto` height default style.
533
+ const imgStyle = height === undefined ? '' : ` style="height: ${height}px !important;"`
534
+
535
+ if (type === 'external') {
536
+ replaceNode(context, {
537
+ type: 'html',
538
+ value: `<img src="${node.url}" alt="${altText}" width="${imgWidth}" height="${imgHeight}"${imgStyle} />`,
539
+ })
540
+ } else {
541
+ const importId = generateAssetImportId()
542
+
543
+ if (!context.file.data.assetImports) {
544
+ context.file.data.assetImports = []
545
+ }
546
+
547
+ context.file.data.assetImports.push([importId, node.url])
548
+
549
+ replaceNode(
550
+ context,
551
+ createMdxNode(
552
+ `<Image src={${importId}} alt="${altText}" width="${imgWidth}" height="${imgHeight}"${imgStyle} />`,
553
+ ),
554
+ )
555
+ }
556
+ }
557
+
558
+ // Custom file nodes are replaced by a custom HTML node, e.g. an audio player for audio files, etc.
559
+ function isCustomFile(filePath: string) {
560
+ return isObsidianFile(filePath) && !isObsidianFile(filePath, 'image')
561
+ }
562
+
563
+ function getCustomFileNode(filePath: string): RootContent {
564
+ if (isObsidianFile(filePath, 'audio')) {
565
+ return {
566
+ type: 'html',
567
+ value: `<audio class="sl-obs-embed-audio" controls src="${filePath}"></audio>`,
568
+ }
569
+ } else if (isObsidianFile(filePath, 'video')) {
570
+ return {
571
+ type: 'html',
572
+ value: `<video class="sl-obs-embed-video" controls src="${filePath}"></video>`,
573
+ }
574
+ }
575
+
576
+ return {
577
+ type: 'html',
578
+ value: `<iframe class="sl-obs-embed-pdf" src="${filePath}"></iframe>`,
579
+ }
580
+ }
581
+
582
+ function getMarkdownFileNode(file: VFile, fileUrl: string): RootContent {
583
+ ensureTransformContext(file)
584
+
585
+ const fileExt = file.data.vault.options.linkSyntax === 'wikilink' ? '.md' : ''
586
+ const filePath = decodeURIComponent(
587
+ file.data.vault.options.linkFormat === 'relative' ? getRelativeFilePath(file, fileUrl) : fileUrl,
588
+ )
589
+ const url = path.posix.join(path.posix.sep, `${filePath}${fileExt}`)
590
+
591
+ const matchingFile = file.data.files.find((vaultFile) => vaultFile.path === url)
592
+
593
+ if (!matchingFile) {
594
+ return { type: 'text', value: '' }
595
+ }
596
+
597
+ const content = fs.readFileSync(matchingFile.fsPath, 'utf8')
598
+ const root = transformMarkdownToAST(matchingFile.fsPath, content, file.data)
599
+
600
+ return {
601
+ type: 'blockquote',
602
+ children: [
603
+ {
604
+ type: 'html',
605
+ value: `<strong>${matchingFile.stem}</strong>`,
606
+ },
607
+ ...(root.children as BlockContent[]),
608
+ ],
609
+ }
610
+ }
611
+
612
+ function replaceNode({ index, parent }: VisitorContext, replacement: RootContent | RootContent[]) {
613
+ if (!parent || index === undefined) {
614
+ return
615
+ }
616
+
617
+ parent.children.splice(index, 1, ...(Array.isArray(replacement) ? replacement : [replacement]))
618
+ }
619
+
620
+ // We are using `Html` node instead of real MDX nodes because we are not using `remark-mdx` due to the fact that it
621
+ // makes the parsing step way more strict. During our inital testing round, we found out that a few users had pretty
622
+ // poorly formatted Markdown files (usually the result of various Obisidian migration tools) and we wanted to make sure
623
+ // that they could still use Starlight Obsidian.
624
+ function createMdxNode(value: string): Html {
625
+ return { type: 'html', value }
626
+ }
627
+
628
+ function ensureTransformContext(file: VFile): asserts file is VFile & { data: TransformContext; dirname: string } {
629
+ if (!file.dirname || !file.data.files || file.data.output === undefined || !file.data.vault) {
630
+ throw new Error('Invalid transform context.')
631
+ }
632
+ }
633
+
634
+ export interface TransformContext {
635
+ aliases?: string[]
636
+ assetImports?: [id: string, path: string][]
637
+ files: VaultFile[]
638
+ includeKatexStyles?: boolean
639
+ includeTwitterComponent?: boolean
640
+ includeYoutubeComponent?: boolean
641
+ isMdx?: true
642
+ output: StarlightObsidianConfig['output']
643
+ skip?: true
644
+ vault: Vault
645
+ }
646
+
647
+ interface VisitorContext {
648
+ file: VFile
649
+ index: number | undefined
650
+ parent: Parent | undefined
651
+ }
652
+
653
+ interface Frontmatter {
654
+ title: string | undefined
655
+ description?: string
656
+ editUrl: false
657
+ slug?: string
658
+ tags?: string[]
659
+ head?: { tag: string; attrs: Record<string, string> }[]
660
+ }
661
+
662
+ declare module 'vfile' {
663
+ interface DataMap extends TransformContext {}
664
+ }
665
+
666
+ declare module 'unist' {
667
+ interface Data {
668
+ isAssetResolved?: boolean
669
+ }
670
+ }