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,56 @@
1
+ import { fromMarkdown } from 'mdast-util-from-markdown'
2
+ import { remark } from 'remark'
3
+ import remarkFrontmatter from 'remark-frontmatter'
4
+ import remarkGfm from 'remark-gfm'
5
+ import remarkMath from 'remark-math'
6
+ import { VFile } from 'vfile'
7
+
8
+ import { remarkObsidian, type TransformContext } from './remark.ts'
9
+
10
+ let processor: ReturnType<typeof remark> | undefined
11
+
12
+ export async function transformMarkdownToString(
13
+ filePath: string,
14
+ markdown: string,
15
+ context: TransformContext,
16
+ ): Promise<TransformResult> {
17
+ const file = await getProcessor(context).process(getVFile(filePath, markdown, context))
18
+
19
+ return {
20
+ aliases: file.data.aliases,
21
+ content: String(file),
22
+ skip: file.data.skip === true,
23
+ type: file.data.isMdx === true ? 'mdx' : 'markdown',
24
+ }
25
+ }
26
+
27
+ export async function transformMarkdownToAST(filePath: string, markdown: string, context: TransformContext) {
28
+ const { content } = await transformMarkdownToString(filePath, markdown, context)
29
+ return fromMarkdown(content)
30
+ }
31
+
32
+ function getProcessor(context: TransformContext): ReturnType<typeof remark> {
33
+ processor ??= remark()
34
+ .data('settings', { resourceLink: true })
35
+ .use(remarkGfm)
36
+ .use(remarkMath, { singleDollarTextMath: context.singleDollarTextMath })
37
+ .use(remarkFrontmatter)
38
+ .use(remarkObsidian)
39
+
40
+ return processor
41
+ }
42
+
43
+ function getVFile(filePath: string, markdown: string, context: TransformContext) {
44
+ return new VFile({
45
+ data: { ...context },
46
+ path: filePath,
47
+ value: markdown,
48
+ })
49
+ }
50
+
51
+ interface TransformResult {
52
+ aliases: string[] | undefined
53
+ content: string
54
+ skip: boolean
55
+ type: 'markdown' | 'mdx'
56
+ }
@@ -0,0 +1,229 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { z } from 'zod'
5
+ import decodeUriComponent from 'decode-uri-component'
6
+ import { slug } from 'github-slugger'
7
+ import { globby } from 'globby'
8
+ import yaml from 'yaml'
9
+
10
+ import type { ObsidianConfig } from './types.ts'
11
+
12
+ import { isDirectory, isFile } from './fs.ts'
13
+ import { getExtension, isAnchor, slashify, slugifyPath, stripExtension } from './path.ts'
14
+ import { isAssetFile } from './files.ts'
15
+
16
+ const obsidianAppConfigSchema = z.object({
17
+ newLinkFormat: z.union([z.literal('absolute'), z.literal('relative'), z.literal('shortest')]).default('shortest'),
18
+ useMarkdownLinks: z.boolean().default(false),
19
+ })
20
+
21
+ const obsidianFrontmatterSchema = z.object({
22
+ aliases: z
23
+ .array(z.string())
24
+ .optional()
25
+ .nullable()
26
+ .transform((aliases) => aliases?.map((alias) => slug(alias))),
27
+ cover: z.string().optional().nullable(),
28
+ description: z.string().optional().nullable(),
29
+ image: z.string().optional().nullable(),
30
+ permalink: z.string().optional().nullable(),
31
+ publish: z
32
+ .union([z.boolean(), z.literal('true'), z.literal('false')])
33
+ .optional()
34
+ .nullable()
35
+ .transform((publish) => publish === undefined || publish === 'true' || publish === true),
36
+ tags: z.array(z.string()).optional().nullable(),
37
+ })
38
+
39
+ const imageFileFormats = new Set(['.avif', '.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp'])
40
+ const audioFileFormats = new Set(['.flac', '.m4a', '.mp3', '.wav', '.ogg', '.wav', '.3gp'])
41
+ const videoFileFormats = new Set(['.mkv', '.mov', '.mp4', '.ogv', '.webm'])
42
+ const otherFileFormats = new Set(['.pdf'])
43
+
44
+ const fileFormats = new Set([...imageFileFormats, ...audioFileFormats, ...videoFileFormats, ...otherFileFormats])
45
+
46
+ export async function getVault(config: ObsidianConfig): Promise<Vault> {
47
+ const vaultPath = path.resolve(config.vault)
48
+
49
+ if (!(await isDirectory(vaultPath))) {
50
+ throw new Error(`The provided vault path is not a directory.\n> Provided path: ${vaultPath}`)
51
+ }
52
+
53
+ // A real Obsidian vault carries `.obsidian/app.json`; a plain "loose content
54
+ // folder" (a supported input mode) does not. For the latter, fall back to
55
+ // Obsidian's own defaults (shortest wikilinks) instead of erroring, so users
56
+ // can point a project `source` at any directory of markdown.
57
+ const options = (await isVaultDirectory(config, vaultPath))
58
+ ? await getVaultOptions(config, vaultPath)
59
+ : { linkFormat: 'shortest' as const, linkSyntax: 'wikilink' as const }
60
+
61
+ return {
62
+ options,
63
+ path: slashify(vaultPath),
64
+ }
65
+ }
66
+
67
+ export function getObsidianPaths(vault: Vault, ignore: ObsidianConfig['ignore'] = []) {
68
+ return globby(['**/*.md', ...[...fileFormats].map((fileFormat) => `**/*${fileFormat}`)], {
69
+ absolute: true,
70
+ cwd: vault.path,
71
+ ignore,
72
+ })
73
+ }
74
+
75
+ export function getObsidianVaultFiles(vault: Vault, obsidianPaths: string[]): VaultFile[] {
76
+ const allFileNames = obsidianPaths.map((obsidianPath) => path.basename(obsidianPath))
77
+
78
+ return obsidianPaths.map((obsidianPath, index) => {
79
+ const baseFileName = allFileNames[index] as string
80
+ let fileName = baseFileName
81
+
82
+ const type = isAssetFile(fileName) ? 'asset' : isObsidianFile(fileName) ? 'file' : 'content'
83
+
84
+ if (type === 'asset') {
85
+ fileName = slugifyPath(fileName)
86
+ }
87
+
88
+ const filePath = getObsidianRelativePath(vault, obsidianPath)
89
+ const fileSlug = slugifyObsidianPath(filePath)
90
+
91
+ return createVaultFile({
92
+ fileName,
93
+ fsPath: obsidianPath,
94
+ path: type === 'asset' ? fileSlug : filePath,
95
+ slug: fileSlug,
96
+ stem: stripExtension(fileName),
97
+ type,
98
+ uniqueFileName: allFileNames.filter((currentFileName) => currentFileName === baseFileName).length === 1,
99
+ })
100
+ })
101
+ }
102
+
103
+ export function getObsidianRelativePath(vault: Vault, obsidianPath: string) {
104
+ return obsidianPath.replace(vault.path, '')
105
+ }
106
+
107
+ export function slugifyObsidianPath(obsidianPath: string) {
108
+ const segments = obsidianPath.split('/')
109
+
110
+ return segments
111
+ .map((segment, index) => {
112
+ const isLastSegment = index === segments.length - 1
113
+
114
+ if (!isLastSegment) {
115
+ return slug(decodeUriComponent(segment))
116
+ } else if (isObsidianFile(segment) && !isAssetFile(segment)) {
117
+ return decodeUriComponent(segment)
118
+ } else if (isAssetFile(segment)) {
119
+ return `${slug(decodeUriComponent(stripExtension(segment)))}${getExtension(segment)}`
120
+ }
121
+
122
+ return slug(decodeUriComponent(stripExtension(segment)))
123
+ })
124
+ .join('/')
125
+ }
126
+
127
+ export function slugifyObsidianAnchor(obsidianAnchor: string) {
128
+ if (obsidianAnchor.length === 0) {
129
+ return ''
130
+ }
131
+
132
+ let anchor = isAnchor(obsidianAnchor) ? obsidianAnchor.slice(1) : obsidianAnchor
133
+
134
+ if (isObsidianBlockAnchor(anchor)) {
135
+ anchor = anchor.replace('^', 'block-')
136
+ }
137
+
138
+ return `#${slug(decodeURIComponent(anchor))}`
139
+ }
140
+
141
+ export function isObsidianBlockAnchor(anchor: string) {
142
+ return anchor.startsWith('#^') || anchor.startsWith('^')
143
+ }
144
+
145
+ export function isObsidianFile(filePath: string, type?: 'image' | 'audio' | 'video' | 'other') {
146
+ const formats: Set<string> =
147
+ type === undefined
148
+ ? fileFormats
149
+ : type === 'image'
150
+ ? imageFileFormats
151
+ : type === 'audio'
152
+ ? audioFileFormats
153
+ : type === 'video'
154
+ ? videoFileFormats
155
+ : otherFileFormats
156
+
157
+ return formats.has(getExtension(filePath))
158
+ }
159
+
160
+ export function parseObsidianFrontmatter(content: string): ObsidianFrontmatter | undefined {
161
+ try {
162
+ const raw: unknown = yaml.parse(content)
163
+ return { ...obsidianFrontmatterSchema.parse(raw), raw: raw as ObsidianFrontmatter['raw'] }
164
+ } catch {
165
+ return
166
+ }
167
+ }
168
+
169
+ export function createVaultFile(baseVaultFile: BaseVaultFile) {
170
+ return {
171
+ ...baseVaultFile,
172
+ isEqualFileName(otherFileName: string) {
173
+ return (isAssetFile(otherFileName) ? slugifyPath(otherFileName) : otherFileName) === this.fileName
174
+ },
175
+ isEqualStem(otherStem: string) {
176
+ return (isAssetFile(otherStem) ? slugifyPath(otherStem) : otherStem) === this.stem
177
+ },
178
+ }
179
+ }
180
+
181
+ async function isVaultDirectory(config: ObsidianConfig, vaultPath: string) {
182
+ const configPath = path.join(vaultPath, config.configFolder)
183
+ return (await isDirectory(configPath)) && (await isFile(path.join(configPath, 'app.json')))
184
+ }
185
+
186
+ async function getVaultOptions(config: ObsidianConfig, vaultPath: string): Promise<VaultOptions> {
187
+ const appConfigPath = path.join(vaultPath, config.configFolder, 'app.json')
188
+
189
+ try {
190
+ const appConfigData = await fs.readFile(appConfigPath, 'utf8')
191
+ const appConfig = obsidianAppConfigSchema.parse(JSON.parse(appConfigData))
192
+
193
+ return {
194
+ linkFormat: appConfig.newLinkFormat,
195
+ linkSyntax: appConfig.useMarkdownLinks ? 'markdown' : 'wikilink',
196
+ }
197
+ } catch (error) {
198
+ throw new Error('Failed to read Obsidian vault app configuration.', { cause: error })
199
+ }
200
+ }
201
+
202
+ export interface Vault {
203
+ options: VaultOptions
204
+ path: string
205
+ }
206
+
207
+ interface VaultOptions {
208
+ linkFormat: 'absolute' | 'relative' | 'shortest'
209
+ linkSyntax: 'markdown' | 'wikilink'
210
+ }
211
+
212
+ interface BaseVaultFile {
213
+ fileName: string
214
+ fsPath: string
215
+ path: string
216
+ slug: string
217
+ stem: string
218
+ type: 'asset' | 'content' | 'file'
219
+ uniqueFileName: boolean
220
+ }
221
+
222
+ export interface VaultFile extends BaseVaultFile {
223
+ isEqualFileName: (otherFileName: string) => boolean
224
+ isEqualStem: (otherStem: string) => boolean
225
+ }
226
+
227
+ export type ObsidianFrontmatter = z.output<typeof obsidianFrontmatterSchema> & {
228
+ raw: Record<string | number, unknown>
229
+ }
@@ -0,0 +1,60 @@
1
+ import path from 'node:path'
2
+
3
+ import { slug } from 'github-slugger'
4
+
5
+ export function getExtension(filePath: string) {
6
+ return path.parse(filePath).ext
7
+ }
8
+
9
+ export function stripExtension(filePath: string) {
10
+ return path.parse(filePath).name
11
+ }
12
+
13
+ export function extractPathAndAnchor(filePathAndAnchor: string): [string, string | undefined] {
14
+ const [filePath, fileAnchor] = filePathAndAnchor.split('#', 2)
15
+ return [filePath as string, fileAnchor]
16
+ }
17
+
18
+ export function isAnchor(filePath: string): filePath is `#${string}` {
19
+ return filePath.startsWith('#')
20
+ }
21
+
22
+ export function slugifyPath(filePath: string) {
23
+ const segments = filePath.split('/')
24
+ return segments
25
+ .map((segment, index) => {
26
+ const isLastSegment = index === segments.length - 1
27
+ if (!isLastSegment) {
28
+ return slug(segment)
29
+ }
30
+ const parsedPath = path.parse(segment)
31
+ return `${slug(parsedPath.name)}${parsedPath.ext}`
32
+ })
33
+ .join('/')
34
+ }
35
+
36
+ export function slashify(filePath: string) {
37
+ const isExtendedLengthPath = filePath.startsWith('\\\\?\\')
38
+ if (isExtendedLengthPath) {
39
+ return filePath
40
+ }
41
+ return filePath.replaceAll('\\', '/')
42
+ }
43
+
44
+ export function osPath(filePath: string) {
45
+ return filePath.replaceAll('/', path.sep)
46
+ }
47
+
48
+ function stripLeadingSlash(href: string) {
49
+ if (href.startsWith('/')) href = href.slice(1)
50
+ return href
51
+ }
52
+
53
+ function stripTrailingSlash(href: string) {
54
+ if (href.endsWith('/')) href = href.slice(0, -1)
55
+ return href
56
+ }
57
+
58
+ export function stripLeadingAndTrailingSlashes(href: string): string {
59
+ return stripTrailingSlash(stripLeadingSlash(href))
60
+ }
@@ -0,0 +1,60 @@
1
+ import type { Element, ElementContent, Root } from 'hast'
2
+ import type { Literal } from 'mdast'
3
+ import { CONTINUE, SKIP, visit } from 'unist-util-visit'
4
+
5
+ const blockIdentifierRegex = /(?<identifier> *\^(?<name>[\w-]+))$/
6
+
7
+ export function rehypeObsidian() {
8
+ return function transformer(tree: Root) {
9
+ visit(tree, 'element', (node) => {
10
+ if (node.tagName === 'blockquote') {
11
+ const lastChild = node.children.at(-1)
12
+ if (
13
+ lastChild?.type !== 'element' ||
14
+ !(lastChild.tagName === 'p' || lastChild.tagName === 'ul' || lastChild.tagName === 'ol')
15
+ ) {
16
+ return CONTINUE
17
+ }
18
+ const lastGrandChild = lastChild.children.at(-1)
19
+ if (lastChild.tagName === 'p') {
20
+ return transformBlockIdentifier(node, lastGrandChild)
21
+ } else if (lastGrandChild?.type === 'element' && lastGrandChild.tagName === 'li') {
22
+ return transformBlockIdentifier(node, lastGrandChild.children.at(-1))
23
+ }
24
+ } else if (node.tagName === 'p' || node.tagName === 'li') {
25
+ return transformBlockIdentifier(node, node.children.at(-1))
26
+ }
27
+ return CONTINUE
28
+ })
29
+ }
30
+ }
31
+
32
+ function transformBlockIdentifier(reference: Element, node: ElementContent | undefined) {
33
+ if (!isNodeWithValue(node)) {
34
+ return CONTINUE
35
+ }
36
+ const identifier = getBlockIdentifer(node)
37
+ if (!identifier) {
38
+ return CONTINUE
39
+ }
40
+ node.value = node.value.slice(0, identifier.length * -1)
41
+ reference.properties = reference.properties ?? {}
42
+ reference.properties['id'] = `block-${identifier.name}`
43
+ return SKIP
44
+ }
45
+
46
+ function isNodeWithValue(node: ElementContent | undefined): node is NodeWithValue {
47
+ return node !== undefined && 'value' in node
48
+ }
49
+
50
+ function getBlockIdentifer(node: NodeWithValue): { length: number; name: string } | undefined {
51
+ const match = blockIdentifierRegex.exec(node.value)
52
+ const identifier = match?.groups?.['identifier']
53
+ const name = match?.groups?.['name']
54
+ if (!identifier || !name) {
55
+ return undefined
56
+ }
57
+ return { length: identifier.length, name }
58
+ }
59
+
60
+ type NodeWithValue = ElementContent & Literal