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/LICENSE +21 -0
- package/README.md +36 -0
- package/components/Tags.astro +36 -0
- package/components/Twitter.astro +11 -0
- package/components/Youtube.astro +11 -0
- package/index.ts +125 -0
- package/libs/fs.ts +37 -0
- package/libs/html.ts +18 -0
- package/libs/integration.ts +21 -0
- package/libs/markdown.ts +43 -0
- package/libs/obsidian.ts +225 -0
- package/libs/path.ts +39 -0
- package/libs/plugin.ts +8 -0
- package/libs/rehype.ts +74 -0
- package/libs/remark.ts +670 -0
- package/libs/starlight.ts +263 -0
- package/overrides/PageTitle.astro +14 -0
- package/package.json +77 -0
- package/schema.ts +8 -0
- package/styles.css +42 -0
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
|
+
}
|