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.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/app/components/CanvasMount.tsx +62 -0
- package/app/components/CodeWrapToggle.tsx +78 -0
- package/app/components/FindOnPage.tsx +224 -0
- package/app/components/MobileBottomBar.tsx +93 -0
- package/app/components/MobileProjectsPanel.tsx +113 -0
- package/app/components/PageFloatingMenu.tsx +224 -0
- package/app/components/ProjectSwitcher.tsx +124 -0
- package/app/components/Search.tsx +930 -0
- package/app/components/ShortcutsHelp.tsx +113 -0
- package/app/components/Sidebar.tsx +1049 -0
- package/app/components/TabBar.tsx +227 -0
- package/app/components/Toc.tsx +129 -0
- package/app/components/TopBar.tsx +74 -0
- package/app/components/theme-toggle.tsx +71 -0
- package/app/components/ui/button.tsx +56 -0
- package/app/components/ui/card.tsx +55 -0
- package/app/components/ui/dropdown-menu.tsx +156 -0
- package/app/components/ui/input.tsx +21 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +155 -0
- package/app/generated/site.ts +19 -0
- package/app/generated/slots.ts +10 -0
- package/app/generated/theme.generated.css +60 -0
- package/app/lib/config/config.server.ts +50 -0
- package/app/lib/config/defaults.ts +120 -0
- package/app/lib/config/load.ts +82 -0
- package/app/lib/config/schema.ts +131 -0
- package/app/lib/config/site.ts +43 -0
- package/app/lib/content.server.ts +105 -0
- package/app/lib/projects.ts +86 -0
- package/app/lib/sidebar.server.ts +113 -0
- package/app/lib/site.ts +27 -0
- package/app/lib/slots.tsx +33 -0
- package/app/lib/tabs.tsx +128 -0
- package/app/lib/useKeyboardShortcuts.ts +149 -0
- package/app/lib/utils.ts +17 -0
- package/app/root.tsx +171 -0
- package/app/routes/$.tsx +158 -0
- package/app/routes/_index.tsx +60 -0
- package/app/styles/app.css +461 -0
- package/app/styles/obsidian.css +83 -0
- package/app/styles/tailwind.css +227 -0
- package/cli.js +119 -0
- package/components.json +21 -0
- package/dist/config.mjs +87 -0
- package/dist/generate-content.mjs +1665 -0
- package/package.json +112 -0
- package/scripts/build-search-index.ts +129 -0
- package/scripts/canonical.ts +34 -0
- package/scripts/canvas-to-md.ts +73 -0
- package/scripts/compile.ts +242 -0
- package/scripts/emit-config.ts +163 -0
- package/scripts/generate-content.ts +197 -0
- package/scripts/obsidian/files.ts +222 -0
- package/scripts/obsidian/fs.ts +34 -0
- package/scripts/obsidian/generate.ts +36 -0
- package/scripts/obsidian/html.ts +17 -0
- package/scripts/obsidian/logger.ts +10 -0
- package/scripts/obsidian/markdown.ts +56 -0
- package/scripts/obsidian/obsidian.ts +229 -0
- package/scripts/obsidian/path.ts +60 -0
- package/scripts/obsidian/rehype.ts +60 -0
- package/scripts/obsidian/remark.ts +712 -0
- package/scripts/obsidian/types.ts +31 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|