cantip 0.1.0

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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/app/components/CanvasMount.tsx +62 -0
  4. package/app/components/CodeWrapToggle.tsx +78 -0
  5. package/app/components/FindOnPage.tsx +224 -0
  6. package/app/components/MobileBottomBar.tsx +93 -0
  7. package/app/components/MobileProjectsPanel.tsx +113 -0
  8. package/app/components/PageFloatingMenu.tsx +224 -0
  9. package/app/components/ProjectSwitcher.tsx +124 -0
  10. package/app/components/Search.tsx +930 -0
  11. package/app/components/ShortcutsHelp.tsx +113 -0
  12. package/app/components/Sidebar.tsx +1049 -0
  13. package/app/components/TabBar.tsx +227 -0
  14. package/app/components/Toc.tsx +129 -0
  15. package/app/components/TopBar.tsx +74 -0
  16. package/app/components/theme-toggle.tsx +71 -0
  17. package/app/components/ui/button.tsx +56 -0
  18. package/app/components/ui/card.tsx +55 -0
  19. package/app/components/ui/dropdown-menu.tsx +156 -0
  20. package/app/components/ui/input.tsx +21 -0
  21. package/app/entry.client.tsx +12 -0
  22. package/app/entry.server.tsx +155 -0
  23. package/app/generated/site.ts +19 -0
  24. package/app/generated/slots.ts +10 -0
  25. package/app/generated/theme.generated.css +60 -0
  26. package/app/lib/config/config.server.ts +50 -0
  27. package/app/lib/config/defaults.ts +120 -0
  28. package/app/lib/config/load.ts +82 -0
  29. package/app/lib/config/schema.ts +131 -0
  30. package/app/lib/config/site.ts +43 -0
  31. package/app/lib/content.server.ts +105 -0
  32. package/app/lib/projects.ts +86 -0
  33. package/app/lib/sidebar.server.ts +113 -0
  34. package/app/lib/site.ts +27 -0
  35. package/app/lib/slots.tsx +33 -0
  36. package/app/lib/tabs.tsx +128 -0
  37. package/app/lib/useKeyboardShortcuts.ts +149 -0
  38. package/app/lib/utils.ts +17 -0
  39. package/app/root.tsx +171 -0
  40. package/app/routes/$.tsx +158 -0
  41. package/app/routes/_index.tsx +60 -0
  42. package/app/styles/app.css +461 -0
  43. package/app/styles/obsidian.css +83 -0
  44. package/app/styles/tailwind.css +227 -0
  45. package/cli.js +119 -0
  46. package/components.json +21 -0
  47. package/dist/config.mjs +87 -0
  48. package/dist/generate-content.mjs +1665 -0
  49. package/package.json +112 -0
  50. package/scripts/build-search-index.ts +129 -0
  51. package/scripts/canonical.ts +34 -0
  52. package/scripts/canvas-to-md.ts +73 -0
  53. package/scripts/compile.ts +242 -0
  54. package/scripts/emit-config.ts +163 -0
  55. package/scripts/generate-content.ts +197 -0
  56. package/scripts/obsidian/files.ts +222 -0
  57. package/scripts/obsidian/fs.ts +34 -0
  58. package/scripts/obsidian/generate.ts +36 -0
  59. package/scripts/obsidian/html.ts +17 -0
  60. package/scripts/obsidian/logger.ts +10 -0
  61. package/scripts/obsidian/markdown.ts +56 -0
  62. package/scripts/obsidian/obsidian.ts +229 -0
  63. package/scripts/obsidian/path.ts +60 -0
  64. package/scripts/obsidian/rehype.ts +60 -0
  65. package/scripts/obsidian/remark.ts +712 -0
  66. package/scripts/obsidian/types.ts +31 -0
  67. package/vite.config.ts +62 -0
@@ -0,0 +1,712 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { toHtml } from 'hast-util-to-html'
5
+ import isAbsoluteUrl from 'is-absolute-url'
6
+ import type {
7
+ BlockContent,
8
+ Blockquote,
9
+ Code,
10
+ Html,
11
+ Image,
12
+ Link,
13
+ Parent,
14
+ PhrasingContent,
15
+ Root,
16
+ RootContent,
17
+ } from 'mdast'
18
+ import { findAndReplace } from 'mdast-util-find-and-replace'
19
+ import { toHast } from 'mdast-util-to-hast'
20
+ import { customAlphabet } from 'nanoid'
21
+ import { CONTINUE, EXIT, SKIP, visit } from 'unist-util-visit'
22
+ import type { VFile } from 'vfile'
23
+ import yaml from 'yaml'
24
+
25
+ import type { ObsidianConfig } from './types.ts'
26
+
27
+ import { transformHtmlToString } from './html.ts'
28
+ import { transformMarkdownToAST } from './markdown.ts'
29
+ import {
30
+ getObsidianRelativePath,
31
+ isObsidianFile,
32
+ isObsidianBlockAnchor,
33
+ parseObsidianFrontmatter,
34
+ slugifyObsidianAnchor,
35
+ slugifyObsidianPath,
36
+ type ObsidianFrontmatter,
37
+ type Vault,
38
+ type VaultFile,
39
+ } from './obsidian.ts'
40
+ import { extractPathAndAnchor, getExtension, isAnchor } from './path.ts'
41
+ import { getCalloutType, isAssetFile } from './files.ts'
42
+
43
+ const generateAssetImportId = customAlphabet('abcdefghijklmnopqrstuvwxyz', 6)
44
+
45
+ const highlightReplacementRegex = /==(?<highlight>(?:(?!==).)+)==/g
46
+ const commentReplacementRegex = /%%(?<comment>(?:(?!%%).)+)%%/gs
47
+ const wikilinkReplacementRegex = /!?\[\[(?<url>(?:(?![[\]|]).)+)(?:\|(?<maybeText>(?:(?![[\]]).)+))?]]/g
48
+ const tagReplacementRegex = /(?:^|\s)#(?<tag>[\w/-]+)/g
49
+ const calloutRegex = /^\[!(?<type>\w+)][+-]? ?(?<title>.*)$/
50
+ const imageSizeRegex = /^(?:(?<altText>.*)\|)?(?:(?<widthOnly>\d+)|(?:(?<width>\d+)x(?<height>\d+)))$/
51
+ const mdxNonClosingVoidElementRegex = /<(?<tag>br|hr)(?<attrs>[^/>]*)>/g
52
+
53
+ export function remarkObsidian() {
54
+ return async function transformer(tree: Root, file: VFile) {
55
+ const obsidianFrontmatter = getObsidianFrontmatter(tree)
56
+
57
+ if (obsidianFrontmatter && obsidianFrontmatter.publish === false) {
58
+ file.data.skip = true
59
+ return
60
+ }
61
+
62
+ // Record where the AUTHOR left blank lines between blocks, while mdast
63
+ // positions still reflect the original source. This must run FIRST: the
64
+ // handlers below mutate the tree (and remark-stringify, applied after this
65
+ // whole transformer, renormalises blank-line spacing throughout — see the
66
+ // 86%-of-files drift this caused). The markers it inserts survive stringify
67
+ // and are the sole source of truth for `.has-blank-before` at compile time.
68
+ markBlankGaps(tree)
69
+
70
+ handleReplacements(tree, file)
71
+ await handleMermaid(tree, file)
72
+ await handleImagesAndNoteEmbeds(tree, file)
73
+
74
+ visit(tree, (node, index, parent) => {
75
+ const context: VisitorContext = { file, index, parent }
76
+
77
+ switch (node.type) {
78
+ case 'math':
79
+ case 'inlineMath': {
80
+ return handleMath(context)
81
+ }
82
+ case 'link': {
83
+ return handleLinks(node, context)
84
+ }
85
+ case 'blockquote': {
86
+ return handleBlockquotes(node, context)
87
+ }
88
+ default: {
89
+ return CONTINUE
90
+ }
91
+ }
92
+ })
93
+
94
+ handleFrontmatter(tree, file, obsidianFrontmatter)
95
+ handleImports(tree, file)
96
+
97
+ if (file.data.isMdx) {
98
+ closeVoidElements(tree)
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Marker comment emitted before any block the author separated from its previous
105
+ * sibling with a blank line in the SOURCE. remark-stringify (run after this
106
+ * transformer) rewrites blank-line spacing throughout the document — adding gaps
107
+ * before lists, around html blocks, etc. — so source line positions can't be
108
+ * read back at compile time. We capture the author's gaps HERE, where positions
109
+ * are pristine, and `scripts/compile.ts` reads + strips this marker as the only
110
+ * source of truth for `.has-blank-before`. The marker survives stringify before
111
+ * every block type (heading/list/blockquote/code/table/hr/paragraph).
112
+ */
113
+ export const BLANK_GAP_MARKER = '<!--blank-gap-->'
114
+
115
+ /**
116
+ * Containers whose direct children are flow content, where inserting an `html`
117
+ * comment sibling is valid markdown. We ONLY recurse into these. Crucially we do
118
+ * NOT descend into `table`/`tableRow`/`tableCell` (an html node among table rows
119
+ * corrupts the table and crashes remark-stringify) or `list` (its children are
120
+ * `listItem`s, not flow content) — we step into each `listItem` instead.
121
+ */
122
+ const FLOW_PARENTS = new Set(['root', 'blockquote', 'listItem'])
123
+
124
+ /**
125
+ * Insert BLANK_GAP_MARKER before every block whose previous sibling was separated
126
+ * by a blank line in source (gap >= 2). `html` targets are skipped to match the
127
+ * compile-time policy: raw obsidian blocks (callouts/canvas/embeds) bring their
128
+ * own margins. Recurses through flow-content containers (see FLOW_PARENTS) so
129
+ * blocks nested in blockquotes/list items are covered too.
130
+ */
131
+ function markBlankGaps(tree: Root) {
132
+ function walk(parent: Parent) {
133
+ const children = parent.children
134
+ for (let i = 0; i < children.length; i++) {
135
+ const node = children[i]
136
+ const prev = children[i - 1]
137
+ if (
138
+ prev &&
139
+ node.type !== 'html' &&
140
+ prev.position &&
141
+ node.position &&
142
+ node.position.start.line - prev.position.end.line >= 2
143
+ ) {
144
+ children.splice(i, 0, { type: 'html', value: BLANK_GAP_MARKER } as Html)
145
+ i++ // skip the marker we just inserted
146
+ }
147
+ // Recurse only into list items so we step over the `list` wrapper itself.
148
+ if (node.type === 'list') {
149
+ for (const item of (node as Parent).children) walk(item as Parent)
150
+ } else if (FLOW_PARENTS.has(node.type) && 'children' in node) {
151
+ walk(node as Parent)
152
+ }
153
+ }
154
+ }
155
+ walk(tree)
156
+ }
157
+
158
+ function getObsidianFrontmatter(tree: Root) {
159
+ for (const node of tree.children) {
160
+ if (node.type !== 'yaml') {
161
+ continue
162
+ }
163
+ const obsidianFrontmatter = parseObsidianFrontmatter(node.value)
164
+ if (obsidianFrontmatter) {
165
+ return obsidianFrontmatter
166
+ }
167
+ }
168
+ return
169
+ }
170
+
171
+ function handleFrontmatter(tree: Root, file: VFile, obsidianFrontmatter?: ObsidianFrontmatter) {
172
+ if (file.data.embedded) {
173
+ for (const [index, node] of tree.children.entries()) {
174
+ if (node.type !== 'yaml') {
175
+ continue
176
+ }
177
+ tree.children.splice(index, 1)
178
+ break
179
+ }
180
+ return
181
+ }
182
+
183
+ let hasFrontmatter = false
184
+
185
+ for (const node of tree.children) {
186
+ if (node.type !== 'yaml') {
187
+ continue
188
+ }
189
+ node.value = getFrontmatterNodeValue(file, obsidianFrontmatter)
190
+ hasFrontmatter = true
191
+
192
+ if (obsidianFrontmatter?.aliases && obsidianFrontmatter.aliases.length > 0) {
193
+ file.data.aliases = obsidianFrontmatter.aliases
194
+ }
195
+ break
196
+ }
197
+
198
+ if (!hasFrontmatter) {
199
+ tree.children.unshift({ type: 'yaml', value: getFrontmatterNodeValue(file) })
200
+ }
201
+ }
202
+
203
+ function handleImports(_tree: Root, _file: VFile) {
204
+ // No-op since the Astro migration: images are emitted as plain <img> tags
205
+ // referencing assets copied to `public/`, so no MDX `import` statements
206
+ // (previously `import { Image } from 'astro:assets'`) are ever needed.
207
+ }
208
+
209
+ function handleReplacements(tree: Root, file: VFile) {
210
+ findAndReplace(tree, [
211
+ [
212
+ highlightReplacementRegex,
213
+ (_match: string, highlight: string) => ({
214
+ type: 'html',
215
+ value: `<mark class="obs-highlight">${highlight}</mark>`,
216
+ }),
217
+ ],
218
+ [commentReplacementRegex, null],
219
+ [
220
+ wikilinkReplacementRegex,
221
+ (match: string, url: string, maybeText?: string) => {
222
+ ensureTransformContext(file)
223
+
224
+ let fileUrl: string
225
+ let text = maybeText ?? url
226
+
227
+ if (isAnchor(url)) {
228
+ fileUrl = slugifyObsidianAnchor(url)
229
+ text = maybeText ?? url.slice(isObsidianBlockAnchor(url) ? 2 : 1)
230
+ } else {
231
+ const [urlPath, urlAnchor] = extractPathAndAnchor(url)
232
+
233
+ switch (file.data.vault.options.linkFormat) {
234
+ case 'relative': {
235
+ fileUrl = getFileUrl(file.data.output, getRelativeFilePath(file, urlPath), urlAnchor)
236
+ break
237
+ }
238
+ case 'absolute':
239
+ case 'shortest': {
240
+ const matchingFile = file.data.files.find(
241
+ (vaultFile) => vaultFile.isEqualStem(urlPath) || vaultFile.isEqualFileName(urlPath),
242
+ )
243
+
244
+ fileUrl = getFileUrl(
245
+ file.data.output,
246
+ matchingFile ? getFilePathFromVaultFile(matchingFile, urlPath) : urlPath,
247
+ urlAnchor,
248
+ )
249
+ break
250
+ }
251
+ }
252
+ }
253
+
254
+ if (match.startsWith('!')) {
255
+ const isMarkdown = isMarkdownFile(url, file)
256
+
257
+ return {
258
+ type: 'image',
259
+ url: isMarkdown ? url : fileUrl,
260
+ alt: text,
261
+ data: { isAssetResolved: !isMarkdown },
262
+ }
263
+ }
264
+
265
+ return {
266
+ children: [{ type: 'text', value: text }],
267
+ type: 'link',
268
+ url: fileUrl,
269
+ }
270
+ },
271
+ ],
272
+ [
273
+ tagReplacementRegex,
274
+ (_match: string, tag: string) => {
275
+ if (/^\d+$/.test(tag)) {
276
+ return false
277
+ }
278
+
279
+ return {
280
+ type: 'html',
281
+ value: ` <span class="obs-tag">#${tag}</span>`,
282
+ }
283
+ },
284
+ ],
285
+ ])
286
+ }
287
+
288
+ function handleMath({ file }: VisitorContext) {
289
+ file.data.includeKatexStyles = true
290
+ return SKIP
291
+ }
292
+
293
+ function handleLinks(node: Link, { file }: VisitorContext) {
294
+ ensureTransformContext(file)
295
+
296
+ if (file.data.vault.options.linkSyntax === 'wikilink' || isAbsoluteUrl(node.url) || !file.dirname) {
297
+ return SKIP
298
+ }
299
+
300
+ if (isAnchor(node.url)) {
301
+ node.url = slugifyObsidianAnchor(node.url)
302
+ return SKIP
303
+ }
304
+
305
+ const url = path.basename(decodeURIComponent(node.url))
306
+ const [urlPath, urlAnchor] = extractPathAndAnchor(url)
307
+ const matchingFile = file.data.files.find((vaultFile) => vaultFile.isEqualFileName(urlPath))
308
+
309
+ if (!matchingFile) {
310
+ return SKIP
311
+ }
312
+
313
+ switch (file.data.vault.options.linkFormat) {
314
+ case 'relative': {
315
+ node.url = getFileUrl(file.data.output, getRelativeFilePath(file, node.url), urlAnchor)
316
+ break
317
+ }
318
+ case 'absolute':
319
+ case 'shortest': {
320
+ node.url = getFileUrl(file.data.output, getFilePathFromVaultFile(matchingFile, node.url), urlAnchor)
321
+ break
322
+ }
323
+ }
324
+
325
+ return SKIP
326
+ }
327
+
328
+ async function handleImages(node: Image, context: VisitorContext) {
329
+ const { file } = context
330
+
331
+ ensureTransformContext(file)
332
+
333
+ if (!file.dirname) {
334
+ return SKIP
335
+ }
336
+
337
+ if (isAbsoluteUrl(node.url)) {
338
+ if (isObsidianFile(node.url, 'image')) {
339
+ handleImagesWithSize(node, context, 'external')
340
+ }
341
+ return SKIP
342
+ }
343
+
344
+ if (isMarkdownFile(node.url, file)) {
345
+ replaceNode(context, await getMarkdownFileNode(file, node.url))
346
+ return SKIP
347
+ }
348
+
349
+ let fileUrl = node.url
350
+
351
+ if (!node.data?.isAssetResolved) {
352
+ switch (file.data.vault.options.linkFormat) {
353
+ case 'relative': {
354
+ fileUrl = getFileUrl(file.data.output, getRelativeFilePath(file, node.url))
355
+ break
356
+ }
357
+ case 'absolute': {
358
+ fileUrl = getFileUrl(file.data.output, slugifyObsidianPath(node.url))
359
+ break
360
+ }
361
+ case 'shortest': {
362
+ const url = path.basename(decodeURIComponent(node.url))
363
+ const [urlPath] = extractPathAndAnchor(url)
364
+ const matchingFile = file.data.files.find((vaultFile) => vaultFile.isEqualFileName(urlPath))
365
+
366
+ if (!matchingFile) {
367
+ break
368
+ }
369
+
370
+ fileUrl = getFileUrl(file.data.output, getFilePathFromVaultFile(matchingFile, node.url))
371
+ break
372
+ }
373
+ }
374
+ }
375
+
376
+ if (isCustomFile(node.url)) {
377
+ replaceNode(context, getCustomFileNode(fileUrl))
378
+ return SKIP
379
+ }
380
+
381
+ // Assets are copied to `public/<output>/<slug>` and served from the web root,
382
+ // so the URL produced by getFileUrl (`/<output>/<slug>`) is already correct.
383
+ // (Astro used getAssetPath to produce a relative `../../assets/...` path for its
384
+ // asset pipeline; with a plain static `public/` dir we keep the absolute URL.)
385
+ node.url = fileUrl
386
+
387
+ if (isAssetFile(node.url)) {
388
+ handleImagesWithSize(node, context, 'asset')
389
+ }
390
+
391
+ return SKIP
392
+ }
393
+
394
+ function handleBlockquotes(node: Blockquote, context: VisitorContext) {
395
+ const [firstChild, ...otherChildren] = node.children
396
+
397
+ if (firstChild?.type !== 'paragraph') {
398
+ return SKIP
399
+ }
400
+
401
+ const [firstGrandChild, ...otherGrandChildren] = firstChild.children
402
+
403
+ if (firstGrandChild?.type !== 'text') {
404
+ return SKIP
405
+ }
406
+
407
+ const [firstLine, ...otherLines] = firstGrandChild.value.split(/\r?\n/)
408
+
409
+ if (!firstLine) {
410
+ return SKIP
411
+ }
412
+
413
+ const match = calloutRegex.exec(firstLine)
414
+ const { title, type } = match?.groups ?? {}
415
+
416
+ if (!match || !type) {
417
+ return SKIP
418
+ }
419
+
420
+ const calloutType = getCalloutType(type)
421
+ const calloutTitle = title?.trim() || type.charAt(0).toUpperCase() + type.slice(1)
422
+ const escapedTitle = calloutTitle.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
423
+
424
+ const openTag = `<div class="callout callout-${calloutType}"><p class="callout-title">${escapedTitle}</p>`
425
+
426
+ const contentChildren: PhrasingContent[] = []
427
+ if (otherLines.length > 0) {
428
+ contentChildren.push({ type: 'text', value: otherLines.join('\n') })
429
+ }
430
+ contentChildren.push(...otherGrandChildren)
431
+
432
+ const aside: RootContent[] = [
433
+ { type: 'html', value: openTag },
434
+ ]
435
+
436
+ if (contentChildren.length > 0) {
437
+ aside.push({
438
+ type: 'paragraph',
439
+ children: contentChildren,
440
+ })
441
+ }
442
+
443
+ aside.push(...otherChildren)
444
+ aside.push({ type: 'html', value: '</div>' })
445
+
446
+ replaceNode(context, aside)
447
+
448
+ return CONTINUE
449
+ }
450
+
451
+ async function handleMermaid(tree: Root, file: VFile) {
452
+ const mermaidNodes: [node: Code, context: VisitorContext][] = []
453
+
454
+ visit(tree, 'code', (node, index, parent) => {
455
+ if (node.lang === 'mermaid') {
456
+ mermaidNodes.push([node, { file, index, parent }])
457
+ return SKIP
458
+ }
459
+ return CONTINUE
460
+ })
461
+
462
+ await Promise.all(
463
+ mermaidNodes.map(async ([node, context]) => {
464
+ const html = toHtml(toHast(node))
465
+ const processedHtml = await transformHtmlToString(html)
466
+ replaceNode(context, { type: 'html', value: processedHtml })
467
+ }),
468
+ )
469
+ }
470
+
471
+ async function handleImagesAndNoteEmbeds(tree: Root, file: VFile) {
472
+ const imageNodes: [node: Image, context: VisitorContext][] = []
473
+
474
+ visit(tree, 'image', (node, index, parent) => {
475
+ imageNodes.push([node, { file, index, parent }])
476
+ return SKIP
477
+ })
478
+
479
+ await Promise.all(
480
+ imageNodes.map(async ([node, context]) => {
481
+ await handleImages(node, context)
482
+ }),
483
+ )
484
+ }
485
+
486
+ function getFrontmatterNodeValue(file: VFile, obsidianFrontmatter?: ObsidianFrontmatter) {
487
+ let frontmatter: Record<string, unknown> = {
488
+ title: file.stem,
489
+ }
490
+
491
+ if (obsidianFrontmatter && file.data.copyFrontmatter) {
492
+ const { cover, image, description, permalink, tags, publish, aliases, ...restFrontmatter } =
493
+ obsidianFrontmatter.raw
494
+ frontmatter = { ...frontmatter, ...restFrontmatter }
495
+ }
496
+
497
+ if (obsidianFrontmatter?.description && obsidianFrontmatter.description.length > 0) {
498
+ frontmatter.description = obsidianFrontmatter.description
499
+ }
500
+
501
+ if (obsidianFrontmatter?.permalink && obsidianFrontmatter.permalink.length > 0) {
502
+ frontmatter.permalink = obsidianFrontmatter.permalink
503
+ }
504
+
505
+ if (obsidianFrontmatter?.tags && obsidianFrontmatter.tags.length > 0) {
506
+ frontmatter.tags = obsidianFrontmatter.tags
507
+ }
508
+
509
+ const { title, ...frontmatterWithoutTitle } = frontmatter
510
+
511
+ let result = yaml.stringify({ title }, { version: '1.1' })
512
+ if (Object.keys(frontmatterWithoutTitle).length > 0) {
513
+ result += yaml.stringify(frontmatterWithoutTitle)
514
+ }
515
+ return result.trim()
516
+ }
517
+
518
+ function getFileUrl(output: ObsidianConfig['output'], filePath: string, anchor?: string) {
519
+ return `${path.posix.join(path.posix.sep, output, slugifyObsidianPath(filePath))}${slugifyObsidianAnchor(anchor ?? '')}`
520
+ }
521
+
522
+ function getRelativeFilePath(file: VFile, relativePath: string) {
523
+ ensureTransformContext(file)
524
+ return path.posix.join(getObsidianRelativePath(file.data.vault, file.dirname), relativePath)
525
+ }
526
+
527
+ function getAssetPath(file: VFile, relativePath: string) {
528
+ ensureTransformContext(file)
529
+ return path.posix.join('../../..', path.posix.relative(file.dirname, file.data.vault.path), 'assets', relativePath)
530
+ }
531
+
532
+ function getFilePathFromVaultFile(vaultFile: VaultFile, url: string) {
533
+ return vaultFile.uniqueFileName ? vaultFile.slug : slugifyObsidianPath(url)
534
+ }
535
+
536
+ function isMarkdownFile(filePath: string, file: VFile) {
537
+ return (
538
+ (file.data.vault?.options.linkSyntax === 'markdown' && filePath.endsWith('.md')) ||
539
+ getExtension(filePath).length === 0
540
+ )
541
+ }
542
+
543
+ function handleImagesWithSize(node: Image, context: VisitorContext, type: 'asset' | 'external') {
544
+ if (!node.alt) {
545
+ return
546
+ }
547
+
548
+ const match = imageSizeRegex.exec(node.alt)
549
+ const { altText, width, widthOnly, height } = match?.groups ?? {}
550
+
551
+ if (widthOnly === undefined && width === undefined) {
552
+ return
553
+ }
554
+
555
+ const imgAltText = altText ?? ''
556
+ const imgWidth = widthOnly ?? width
557
+ const imgHeight = height ?? 'auto'
558
+ const imgStyle = height === undefined ? '' : ` style="height: ${height}px !important;"`
559
+
560
+ // Both local (asset) and external images become a plain <img>. Local assets are
561
+ // copied to `public/<output>/` and referenced by their absolute web URL, so there
562
+ // is no longer any need for Astro's MDX `<Image>` import machinery.
563
+ replaceNode(context, {
564
+ type: 'html',
565
+ value: `<img src="${node.url}" alt="${imgAltText}" width="${imgWidth}" height="${imgHeight}"${imgStyle} />`,
566
+ })
567
+ }
568
+
569
+ function isCustomFile(filePath: string) {
570
+ return isObsidianFile(filePath) && !isObsidianFile(filePath, 'image')
571
+ }
572
+
573
+ function getCustomFileNode(filePath: string): RootContent {
574
+ if (isObsidianFile(filePath, 'audio')) {
575
+ return {
576
+ type: 'html',
577
+ value: `<audio class="obs-embed-audio" controls src="${filePath}"></audio>`,
578
+ }
579
+ } else if (isObsidianFile(filePath, 'video')) {
580
+ return {
581
+ type: 'html',
582
+ value: `<video class="obs-embed-video" controls src="${filePath}"></video>`,
583
+ }
584
+ }
585
+
586
+ return {
587
+ type: 'html',
588
+ value: `<iframe class="obs-embed-pdf" src="${filePath}"></iframe>`,
589
+ }
590
+ }
591
+
592
+ async function getMarkdownFileNode(file: VFile, fileUrl: string): Promise<RootContent> {
593
+ ensureTransformContext(file)
594
+
595
+ const [fileName, ...anchorSegments] = fileUrl.split('#')
596
+ const fileAnchor = anchorSegments.join('#')
597
+ const fileExt = file.data.vault.options.linkSyntax === 'wikilink' ? '.md' : ''
598
+ const filePath = decodeURIComponent(
599
+ file.data.vault.options.linkFormat === 'relative'
600
+ ? getRelativeFilePath(file, fileName ?? fileUrl)
601
+ : (fileName ?? fileUrl),
602
+ )
603
+ const url = path.posix.join(path.posix.sep, `${filePath}${fileExt}`)
604
+ const matchingFile = file.data.files.find(
605
+ (vaultFile) => vaultFile.path === url || vaultFile.isEqualStem(filePath) || vaultFile.isEqualFileName(filePath),
606
+ )
607
+
608
+ if (!matchingFile) {
609
+ return { type: 'text', value: '' }
610
+ }
611
+
612
+ const content = fs.readFileSync(matchingFile.fsPath, 'utf8')
613
+ const root = await transformMarkdownToAST(matchingFile.fsPath, content, { ...file.data, embedded: true })
614
+
615
+ if (fileAnchor) {
616
+ root.children = extractMarkdownSection(root, fileAnchor)
617
+ }
618
+
619
+ return {
620
+ type: 'blockquote',
621
+ children: [
622
+ {
623
+ type: 'html',
624
+ value: `<strong>${matchingFile.stem}</strong>`,
625
+ },
626
+ ...(root.children as BlockContent[]),
627
+ ],
628
+ }
629
+ }
630
+
631
+ function replaceNode({ index, parent }: VisitorContext, replacement: RootContent | RootContent[]) {
632
+ if (!parent || index === undefined) {
633
+ return
634
+ }
635
+ parent.children.splice(index, 1, ...(Array.isArray(replacement) ? replacement : [replacement]))
636
+ }
637
+
638
+ function extractMarkdownSection(root: Root, sectionAnchor: string) {
639
+ const children: Root['children'] = []
640
+
641
+ visit(root, (node, index, parent) => {
642
+ switch (node.type) {
643
+ case 'heading': {
644
+ if (!parent || index === undefined) return CONTINUE
645
+ const headingText = node.children.find((child) => child.type === 'text')?.value
646
+ if (headingText !== sectionAnchor) return CONTINUE
647
+
648
+ children.push(node)
649
+
650
+ let nextNode = parent.children[index + 1]
651
+ while (nextNode && (nextNode.type !== 'heading' || nextNode.depth > node.depth)) {
652
+ children.push(nextNode)
653
+ nextNode = parent.children[index + children.length]
654
+ }
655
+
656
+ return EXIT
657
+ }
658
+ default: {
659
+ return CONTINUE
660
+ }
661
+ }
662
+ })
663
+
664
+ return children
665
+ }
666
+
667
+ function createMdxNode(value: string): Html {
668
+ return { type: 'html', value }
669
+ }
670
+
671
+ function closeVoidElements(tree: Root) {
672
+ visit(tree, 'html', (node) => {
673
+ node.value = node.value.replaceAll(mdxNonClosingVoidElementRegex, '<$<tag>$<attrs>/>')
674
+ return SKIP
675
+ })
676
+ }
677
+
678
+ function ensureTransformContext(file: VFile): asserts file is VFile & { data: TransformContext; dirname: string } {
679
+ if (!file.dirname || !file.data.files || file.data.output === undefined || !file.data.vault) {
680
+ throw new Error('Invalid transform context.')
681
+ }
682
+ }
683
+
684
+ export interface TransformContext {
685
+ aliases?: string[]
686
+ assetImports?: [id: string, path: string][]
687
+ copyFrontmatter: boolean
688
+ embedded?: boolean
689
+ files: VaultFile[]
690
+ includeKatexStyles?: boolean
691
+ isMdx?: true
692
+ output: ObsidianConfig['output']
693
+ singleDollarTextMath: ObsidianConfig['math']['singleDollarTextMath']
694
+ skip?: true
695
+ vault: Vault
696
+ }
697
+
698
+ interface VisitorContext {
699
+ file: VFile
700
+ index: number | undefined
701
+ parent: Parent | undefined
702
+ }
703
+
704
+ declare module 'vfile' {
705
+ interface DataMap extends TransformContext {}
706
+ }
707
+
708
+ declare module 'unist' {
709
+ interface Data {
710
+ isAssetResolved?: boolean
711
+ }
712
+ }