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,163 @@
1
+ /**
2
+ * Serialize the resolved config for the running app.
3
+ *
4
+ * Emits two artifacts under the manifest dir (`app/generated`):
5
+ * - `config.json` — the full resolved config (theme, ui, projects). Read at
6
+ * runtime via `fs` in `app/lib/config/config.server.ts`. Never imported, so it
7
+ * carries server-only detail (theme CSS tokens) safely.
8
+ * - `site.ts` — a plain-literal module (client-safe subset) IMPORTED by the app,
9
+ * so Vite bundles projects/branding/ui strings into both client and server.
10
+ * This is what lets client components read projects synchronously.
11
+ *
12
+ * Per-project `landing` is defaulted here (not in `loadConfig`) because it needs
13
+ * the compiled doc index: when a project doesn't pin a landing URL, we use the
14
+ * first doc under that project as its landing page.
15
+ */
16
+ import fs from 'node:fs/promises'
17
+ import path from 'node:path'
18
+
19
+ import type { DocsConfig } from '../app/lib/config/schema.ts'
20
+ import type { GeneratedSite, SiteProject } from '../app/lib/config/site.ts'
21
+
22
+ // Mirrors `GENERAL_PROJECT_ID` in app/lib/projects.ts. Inlined (not imported) so
23
+ // this build script never pulls in the app module, which imports the Vite-only
24
+ // `~/generated/site` alias that doesn't resolve under the plain-Node runner.
25
+ const GENERAL_PROJECT_ID = 'general'
26
+
27
+ interface IndexEntry {
28
+ id: string
29
+ }
30
+
31
+ interface EmitArgs {
32
+ config: DocsConfig
33
+ manifestDir: string
34
+ /** The doc index (already sorted), used to derive default landing URLs. */
35
+ index: IndexEntry[]
36
+ logger: { info(m: string): void }
37
+ }
38
+
39
+ /** The overridable slot names, in the order they appear in the generated module. */
40
+ const SLOT_NAMES = ['Home', 'DocPage', 'TopBar', 'Toc'] as const
41
+
42
+ /**
43
+ * Render the generated `slots.ts`: import each configured override component by a
44
+ * path relative to the manifest dir (so Vite resolves it from the user's
45
+ * project), and export every slot as the override or `null` (= engine default).
46
+ */
47
+ function renderSlots(components: Record<string, string | undefined>, manifestDir: string): string {
48
+ const imports: string[] = []
49
+ const exports: string[] = []
50
+ for (const name of SLOT_NAMES) {
51
+ const src = components[name]
52
+ if (src) {
53
+ // Resolve the user's path (relative to cwd) to an import specifier
54
+ // relative to the manifest dir; ensure it starts with './' or '../'.
55
+ let rel = path.relative(manifestDir, path.resolve(process.cwd(), src)).replace(/\\/g, '/')
56
+ if (!rel.startsWith('.')) rel = `./${rel}`
57
+ imports.push(`import ${name}Override from '${rel}'`)
58
+ exports.push(`export const ${name} = ${name}Override`)
59
+ } else {
60
+ exports.push(`export const ${name}: ComponentType<any> | null = null`)
61
+ }
62
+ }
63
+ return (
64
+ `// AUTO-GENERATED by scripts/generate-content.ts — DO NOT EDIT.\n` +
65
+ `import type { ComponentType } from 'react'\n` +
66
+ (imports.length ? imports.join('\n') + '\n' : '') +
67
+ `\n` +
68
+ exports.join('\n') +
69
+ `\n`
70
+ )
71
+ }
72
+
73
+ /** Render the resolved theme token maps into a `:root` + `.dark` CSS block. */
74
+ function renderThemeCss(colors: { light: Record<string, string>; dark: Record<string, string> }): string {
75
+ const block = (selector: string, vars: Record<string, string>) => {
76
+ const lines = Object.entries(vars).map(([k, v]) => `\t${k}: ${v};`)
77
+ return `${selector} {\n${lines.join('\n')}\n}`
78
+ }
79
+ return (
80
+ `/* AUTO-GENERATED by scripts/generate-content.ts from docs.config.ts theme. DO NOT EDIT. */\n` +
81
+ `${block(':root', colors.light)}\n\n` +
82
+ `${block('.dark', colors.dark)}\n`
83
+ )
84
+ }
85
+
86
+ /** First doc id whose project (first segment) is `projectId`, or null. */
87
+ function firstDocOfProject(index: IndexEntry[], projectId: string): string | null {
88
+ for (const e of index) {
89
+ if ((e.id.split('/')[0] ?? '') === projectId) return e.id
90
+ }
91
+ return null
92
+ }
93
+
94
+ /** First doc id that belongs to no known project (the general bucket), or null. */
95
+ function firstGeneralDoc(index: IndexEntry[], projectIds: Set<string>): string | null {
96
+ for (const e of index) {
97
+ if (!projectIds.has(e.id.split('/')[0] ?? '')) return e.id
98
+ }
99
+ return null
100
+ }
101
+
102
+ export async function emitGeneratedConfig({ config, manifestDir, index, logger }: EmitArgs): Promise<void> {
103
+ const projectIds = new Set(config.projects.map((p) => p.id))
104
+
105
+ // Resolve each project's landing URL: the authored value, else its first doc.
106
+ const projects: SiteProject[] = config.projects.map((p) => {
107
+ const first = firstDocOfProject(index, p.id)
108
+ const landing = p.landing ?? (first ? `/${first}/` : '/')
109
+ return {
110
+ id: p.id,
111
+ name: p.name,
112
+ logo: p.logo ?? `/projects/${p.id}.svg`,
113
+ landing,
114
+ description: p.description,
115
+ }
116
+ })
117
+
118
+ const generalHasDocs = config.general.enabled && firstGeneralDoc(index, projectIds) !== null
119
+ const site: GeneratedSite = {
120
+ site: {
121
+ title: config.site.title,
122
+ description: config.site.description,
123
+ lang: config.site.lang,
124
+ favicon: config.site.favicon,
125
+ logo: config.site.logo,
126
+ defaultTheme: config.site.defaultTheme,
127
+ },
128
+ projects,
129
+ general: {
130
+ enabled: generalHasDocs,
131
+ id: GENERAL_PROJECT_ID,
132
+ name: config.general.name,
133
+ logo: config.general.logo,
134
+ description: config.general.description,
135
+ },
136
+ ui: config.ui,
137
+ }
138
+
139
+ // Full config (server-only) — includes resolved landings so the server agrees
140
+ // with the client `site.ts` view.
141
+ const fullConfig = { ...config, projects: config.projects.map((p, i) => ({ ...p, landing: projects[i].landing })) }
142
+ await fs.writeFile(path.join(manifestDir, 'config.json'), JSON.stringify(fullConfig))
143
+
144
+ // Client-safe literals module. Pretty-printed so a human can diff it.
145
+ const siteModule =
146
+ `// AUTO-GENERATED by scripts/generate-content.ts — DO NOT EDIT.\n` +
147
+ `import type { GeneratedSite } from '~/lib/config/site'\n\n` +
148
+ `export const SITE: GeneratedSite = ${JSON.stringify(site, null, '\t')}\n`
149
+ await fs.writeFile(path.join(manifestDir, 'site.ts'), siteModule)
150
+
151
+ // Theme CSS: the resolved theme already has defaults merged with the user's
152
+ // overrides, so emit the full :root/.dark token blocks. Imported AFTER
153
+ // tailwind.css in root.tsx, so these win; the `--sl-*` bridge in tailwind.css
154
+ // (which references these vars) keeps generated markdown tracking the theme.
155
+ await fs.writeFile(path.join(manifestDir, 'theme.generated.css'), renderThemeCss(config.theme.colors))
156
+
157
+ // Slot overrides: rewrite the generated slots module to import any user
158
+ // override components (by path, resolved from the user's project) and fall the
159
+ // rest back to null (= engine default). See app/lib/slots.tsx.
160
+ await fs.writeFile(path.join(manifestDir, 'slots.ts'), renderSlots(config.components, manifestDir))
161
+
162
+ logger.info(`Emitted config.json + site.ts + theme.generated.css + slots.ts (${projects.length} project(s)${generalHasDocs ? ' + general' : ''}).`)
163
+ }
@@ -0,0 +1,197 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { generateObsidian } from './obsidian/generate.ts'
4
+ import { generateCanvas } from './canvas-to-md.ts'
5
+ import { compileDir, type CompiledDoc } from './compile.ts'
6
+ import { buildSearchIndex } from './build-search-index.ts'
7
+ import { buildIdToCanonicalUrl } from './canonical.ts'
8
+ import type { Logger } from './obsidian/logger.ts'
9
+ import { loadConfig } from '../app/lib/config/load.ts'
10
+ import type { DocsConfig } from '../app/lib/config/schema.ts'
11
+ import { emitGeneratedConfig } from './emit-config.ts'
12
+
13
+ const logger: Logger = {
14
+ info: (m) => console.log(` ${m}`),
15
+ warn: (m) => console.warn(` ⚠ ${m}`),
16
+ error: (m) => console.error(` ✖ ${m}`),
17
+ }
18
+
19
+ // All build artifacts live under the USER's cwd (not inside the engine package),
20
+ // matching the runtime contract in `app/lib/content.server.ts` + `config.server.ts`.
21
+ const CWD = process.cwd()
22
+ const CONTENT_ROOT = path.resolve(CWD, 'content')
23
+ const PUBLIC_ROOT = path.resolve(CWD, 'public')
24
+ const MANIFEST_DIR = path.resolve(CWD, 'app/generated')
25
+ const OUTPUT_ROOTS = { content: CONTENT_ROOT, public: PUBLIC_ROOT }
26
+
27
+ /**
28
+ * Derive the ingestion work-lists from the resolved config. Each project (and the
29
+ * optional `general` bucket, when it has a `source`) becomes one vault; projects
30
+ * flagged `canvas` additionally feed the canvas pass. `output` is the project id
31
+ * — the first id segment of every doc it produces — except the general bucket,
32
+ * whose docs sit at the root (output `.`).
33
+ */
34
+ function buildWorkLists(config: DocsConfig) {
35
+ const vaults = config.projects.map((p) => ({ vault: p.source, output: p.id, ignore: p.ignore }))
36
+ const canvas = config.projects
37
+ .filter((p) => p.canvas)
38
+ .map((p) => ({ vault: p.source, output: p.id }))
39
+
40
+ if (config.general.enabled && config.general.source) {
41
+ // General docs live at the root: output '.' writes straight into content/.
42
+ vaults.push({ vault: config.general.source, output: '.', ignore: config.general.ignore })
43
+ }
44
+ return { vaults, canvas }
45
+ }
46
+
47
+ /**
48
+ * Rewrite internal links in every doc's HTML to canonical (permalink) URLs.
49
+ *
50
+ * Wikilink hrefs are emitted by the Obsidian pass as percent-encoded file-path
51
+ * URLs without a trailing slash, e.g. `/krista/%D0%B3.../%D0%BA...`. We decode
52
+ * each href, match it against a doc id, and if that doc's canonical URL differs
53
+ * from its file-path URL (i.e. it has a permalink) we swap the href in place.
54
+ * Any anchor (#heading) is preserved. Mutates `docs[].html`; returns the count.
55
+ */
56
+ function rewriteContentLinks(docs: CompiledDoc[], canonicalUrl: Map<string, string>): number {
57
+ let count = 0
58
+ for (const d of docs) {
59
+ d.html = d.html.replace(/href="(\/[^"#]+)(#[^"]*)?"/g, (whole, rawPath: string, anchor?: string) => {
60
+ // Decode and normalize to an id (no leading/trailing slash) for lookup.
61
+ let decoded: string
62
+ try {
63
+ decoded = decodeURIComponent(rawPath)
64
+ } catch {
65
+ return whole // malformed escape — leave untouched
66
+ }
67
+ const id = decoded.replace(/^\/+|\/+$/g, '')
68
+ const canonical = canonicalUrl.get(id)
69
+ // Only rewrite when the target is a known doc WITH a permalink (its
70
+ // canonical URL differs from the default file-path form).
71
+ if (!canonical || canonical === `/${id}/`) return whole
72
+ count++
73
+ return `href="${canonical}${anchor ?? ''}"`
74
+ })
75
+ }
76
+ return count
77
+ }
78
+
79
+ async function main() {
80
+ const start = performance.now()
81
+ console.log('▶ Generating content from Obsidian vaults…')
82
+
83
+ // 0. Load the user's docs.config.ts (resolved + validated) from cwd.
84
+ const config = await loadConfig(CWD)
85
+ const { vaults, canvas } = buildWorkLists(config)
86
+
87
+ // 1. Clean previous content output (asset/public cleanup is handled per-vault).
88
+ await fs.rm(CONTENT_ROOT, { recursive: true, force: true })
89
+
90
+ // 2. Ingest each vault → content/<output>/*.md (+ assets to public/<output>).
91
+ // Run the general bucket (output '.') first so its root-level write lands
92
+ // before project subdirs (it skips per-vault cleanup to avoid wiping them).
93
+ const orderedVaults = [...vaults].sort((a, b) => (a.output === '.' ? -1 : b.output === '.' ? 1 : 0))
94
+ for (const v of orderedVaults) {
95
+ await generateObsidian(v, logger, OUTPUT_ROOTS)
96
+ }
97
+
98
+ // 3. Convert .canvas files → content/<output>/*.md
99
+ for (const c of canvas) {
100
+ await generateCanvas({ ...c, contentRoot: CONTENT_ROOT }, logger)
101
+ }
102
+
103
+ // 4. Compile every markdown page → HTML + headings + frontmatter
104
+ const docs = await compileDir(CONTENT_ROOT, logger)
105
+
106
+ // 4b. Rewrite in-content links to canonical URLs. Wikilinks are resolved to
107
+ // file-path hrefs (/{id}/) during the per-vault Obsidian pass, before the
108
+ // permalink map exists. Now that every doc is compiled we know which docs
109
+ // have permalinks, so we rewrite any internal href pointing at such a doc
110
+ // to its permalink — internal links then skip the file-path→permalink 301.
111
+ const canonicalUrl = buildIdToCanonicalUrl(docs)
112
+ const rewrites = rewriteContentLinks(docs, canonicalUrl)
113
+ if (rewrites > 0) {
114
+ logger.info(`Rewrote ${rewrites} in-content link(s) to permalinks.`)
115
+ }
116
+
117
+ // 5. Emit manifest. One index file (lightweight, for the sidebar/routing) and
118
+ // one HTML file per doc (loaded by the route loader on demand).
119
+ await fs.rm(MANIFEST_DIR, { recursive: true, force: true })
120
+ await fs.mkdir(path.join(MANIFEST_DIR, 'docs'), { recursive: true })
121
+
122
+ const index = docs
123
+ .map((d) => ({
124
+ id: d.id,
125
+ title: (d.frontmatter.title as string | undefined) ?? null,
126
+ draft: d.frontmatter.draft === true,
127
+ tableOfContents: d.frontmatter.tableOfContents !== false,
128
+ tags: (d.frontmatter.tags as string[] | undefined) ?? [],
129
+ isCanvas: d.html.includes('data-canvas-mount'),
130
+ }))
131
+ .sort((a, b) => a.id.localeCompare(b.id, config.site.lang))
132
+
133
+ await fs.writeFile(path.join(MANIFEST_DIR, 'index.json'), JSON.stringify(index))
134
+
135
+ // Permalink map: a doc may pin a stable URL via `permalink` in its Obsidian
136
+ // frontmatter (carried through to the generated frontmatter under the same
137
+ // key). This URL is independent of the file name, so renames never break it.
138
+ // We store
139
+ // permalink → id; the route loader serves the doc at the permalink and
140
+ // redirects the file-path URL to it (the permalink is the canonical URL).
141
+ //
142
+ // Permalinks are PROJECT-SCOPED: the key is prefixed with the doc's project
143
+ // (the first id segment, same rule as getProjectIdForDoc). So `permalink:
144
+ // /abc/123` in a krista doc is served at /krista/abc/123/, and a same-named
145
+ // permalink in another project (krista-partners/abc/123) never collides.
146
+ const permalinks: Record<string, string> = {}
147
+ for (const d of docs) {
148
+ const raw = d.frontmatter.permalink
149
+ if (typeof raw !== 'string' || raw.trim() === '') continue
150
+ const rel = raw.trim().replace(/^\/+|\/+$/g, '') // normalize: no leading/trailing slashes
151
+ if (!rel) continue
152
+ const project = d.id.split('/')[0] // doc's project = first id segment
153
+ const key = `${project}/${rel}`
154
+ if (permalinks[key] && permalinks[key] !== d.id) {
155
+ logger.warn(`Duplicate permalink "${raw}" in project "${project}" on ${d.id} (already used by ${permalinks[key]}); keeping the first.`)
156
+ continue
157
+ }
158
+ permalinks[key] = d.id
159
+ }
160
+ await fs.writeFile(path.join(MANIFEST_DIR, 'permalinks.json'), JSON.stringify(permalinks))
161
+ if (Object.keys(permalinks).length > 0) {
162
+ logger.info(`Registered ${Object.keys(permalinks).length} permalink(s).`)
163
+ }
164
+
165
+ // One JSON per doc, mirroring the content directory structure so individual
166
+ // path segments stay within the filesystem's 255-byte filename limit (the
167
+ // Cyrillic ids percent-encode to very long single strings otherwise).
168
+ await Promise.all(
169
+ docs.map(async (d) => {
170
+ const outPath = path.join(MANIFEST_DIR, 'docs', `${d.id}.json`)
171
+ await fs.mkdir(path.dirname(outPath), { recursive: true })
172
+ await fs.writeFile(
173
+ outPath,
174
+ JSON.stringify({ id: d.id, frontmatter: d.frontmatter, headings: d.headings, html: d.html }),
175
+ )
176
+ }),
177
+ )
178
+
179
+ // 6. Build the Pagefind search index from the compiled docs → public/pagefind
180
+ await buildSearchIndex(docs, canonicalUrl, logger, {
181
+ outputPath: path.join(PUBLIC_ROOT, 'pagefind'),
182
+ lang: config.site.lang,
183
+ })
184
+
185
+ // 7. Emit the resolved config so the running app can read it without ever
186
+ // importing the user's TS config: `config.json` (full, read via fs in
187
+ // .server code) + `site.ts` (client-safe literals, bundled by Vite). The
188
+ // index drives per-project `landing` defaults (first doc of each project).
189
+ await emitGeneratedConfig({ config, manifestDir: MANIFEST_DIR, index, logger })
190
+
191
+ console.log(`✔ Done: ${docs.length} pages in ${Math.round(performance.now() - start)}ms`)
192
+ }
193
+
194
+ main().catch((err) => {
195
+ console.error(err)
196
+ process.exit(1)
197
+ })
@@ -0,0 +1,222 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import type { Logger } from './logger.ts'
5
+
6
+ import type { ObsidianConfig } from './types.ts'
7
+
8
+ import { copyFile, ensureDirectory, removeDirectory } from './fs.ts'
9
+ import { transformMarkdownToString } from './markdown.ts'
10
+ import { getObsidianVaultFiles, isObsidianFile, type ObsidianFrontmatter, type Vault, type VaultFile } from './obsidian.ts'
11
+ import { getExtension, stripLeadingAndTrailingSlashes } from './path.ts'
12
+
13
+ // Output roots. Assets and non-markdown files are served statically from the
14
+ // public dir; markdown pages live under the content dir and are compiled to HTML
15
+ // by the content-generation script. Defaults reproduce the original layout
16
+ // (`content/` + `public/` relative to cwd); the generator passes resolved,
17
+ // cwd-absolute roots via `OutputRoots` so the engine also works from node_modules.
18
+ export interface OutputRoots {
19
+ /** Where compiled markdown lands, e.g. `<cwd>/content`. */
20
+ content: string
21
+ /** Where assets + non-markdown files land (served statically), e.g. `<cwd>/public`. */
22
+ public: string
23
+ }
24
+
25
+ const DEFAULT_OUTPUT_ROOTS: OutputRoots = { content: 'content', public: 'public' }
26
+
27
+ const calloutTypeMap: Record<string, string> = {
28
+ note: 'note',
29
+ abstract: 'tip',
30
+ summary: 'tip',
31
+ tldr: 'tip',
32
+ info: 'note',
33
+ todo: 'note',
34
+ tip: 'tip',
35
+ hint: 'tip',
36
+ important: 'tip',
37
+ success: 'note',
38
+ check: 'note',
39
+ done: 'note',
40
+ question: 'caution',
41
+ help: 'caution',
42
+ faq: 'caution',
43
+ warning: 'caution',
44
+ caution: 'caution',
45
+ attention: 'caution',
46
+ failure: 'danger',
47
+ fail: 'danger',
48
+ missing: 'danger',
49
+ danger: 'danger',
50
+ error: 'danger',
51
+ bug: 'danger',
52
+ example: 'tip',
53
+ quote: 'note',
54
+ cite: 'note',
55
+ }
56
+
57
+ export function getCalloutType(obsidianCalloutType: string): string {
58
+ return calloutTypeMap[obsidianCalloutType] ?? 'note'
59
+ }
60
+
61
+ export function isAssetFile(filePath: string): boolean {
62
+ return getExtension(filePath) !== '.bmp' && isObsidianFile(filePath, 'image')
63
+ }
64
+
65
+ export async function addObsidianFiles(
66
+ config: ObsidianConfig,
67
+ vault: Vault,
68
+ obsidianPaths: string[],
69
+ logger: Logger,
70
+ roots: OutputRoots = DEFAULT_OUTPUT_ROOTS,
71
+ ) {
72
+ const outputPaths = getOutputPaths(config, roots)
73
+
74
+ // The general/no-project bucket (output '.') writes into the content/public
75
+ // ROOTS, intermixed with project subdirs. Cleaning those roots here would wipe
76
+ // sibling projects' output, so skip per-vault cleanup for it — the orchestrator
77
+ // clears the content root once upfront, and general's loose assets are few.
78
+ if (stripLeadingAndTrailingSlashes(config.output) !== '.') {
79
+ await cleanOutputPaths(outputPaths)
80
+ }
81
+
82
+ const vaultFiles = getObsidianVaultFiles(vault, obsidianPaths)
83
+
84
+ const results = await Promise.allSettled(
85
+ vaultFiles.map(async (vaultFile) => {
86
+ await (vaultFile.type === 'asset'
87
+ ? addAsset(outputPaths, vaultFile)
88
+ : vaultFile.type === 'file'
89
+ ? addFile(outputPaths, vaultFile)
90
+ : addContent(config, vault, outputPaths, vaultFiles, vaultFile))
91
+ }),
92
+ )
93
+
94
+ let didFail = false
95
+
96
+ for (const result of results) {
97
+ if (result.status === 'rejected') {
98
+ didFail = true
99
+ logger.error(result.reason instanceof Error ? result.reason.message : String(result.reason))
100
+ }
101
+ }
102
+
103
+ if (didFail) {
104
+ throw new Error('Failed to generate some pages. See the error(s) above for more information.')
105
+ }
106
+ }
107
+
108
+ async function addContent(
109
+ config: ObsidianConfig,
110
+ vault: Vault,
111
+ outputPaths: OutputPaths,
112
+ vaultFiles: VaultFile[],
113
+ vaultFile: VaultFile,
114
+ ) {
115
+ try {
116
+ const obsidianContent = await fs.readFile(vaultFile.fsPath, 'utf8')
117
+ const {
118
+ content,
119
+ aliases,
120
+ skip,
121
+ type,
122
+ } = await transformMarkdownToString(vaultFile.fsPath, obsidianContent, {
123
+ files: vaultFiles,
124
+ copyFrontmatter: config.copyFrontmatter,
125
+ output: config.output,
126
+ singleDollarTextMath: config.math.singleDollarTextMath,
127
+ vault,
128
+ })
129
+
130
+ if (skip) {
131
+ return
132
+ }
133
+
134
+ const outputPath = path.join(
135
+ outputPaths.content,
136
+ type === 'markdown' ? vaultFile.path : vaultFile.path.replace(/\.md$/, '.mdx'),
137
+ )
138
+ const outputDirPath = path.dirname(outputPath)
139
+
140
+ await ensureDirectory(outputDirPath)
141
+ await fs.writeFile(outputPath, content)
142
+
143
+ if (aliases) {
144
+ for (const alias of aliases) {
145
+ await addAlias(config, outputPaths, vaultFile, alias)
146
+ }
147
+ }
148
+ } catch (error) {
149
+ throwVaultFileError(error, vaultFile)
150
+ }
151
+ }
152
+
153
+ async function addFile(outputPaths: OutputPaths, vaultFile: VaultFile) {
154
+ try {
155
+ await copyFile(vaultFile.fsPath, path.join(outputPaths.file, vaultFile.slug))
156
+ } catch (error) {
157
+ throwVaultFileError(error, vaultFile)
158
+ }
159
+ }
160
+
161
+ async function addAsset(outputPaths: OutputPaths, vaultFile: VaultFile) {
162
+ try {
163
+ await copyFile(vaultFile.fsPath, path.join(outputPaths.asset, vaultFile.slug))
164
+ } catch (error) {
165
+ throwVaultFileError(error, vaultFile)
166
+ }
167
+ }
168
+
169
+ async function addAlias(
170
+ config: ObsidianConfig,
171
+ outputPaths: OutputPaths,
172
+ vaultFile: VaultFile,
173
+ alias: string,
174
+ ) {
175
+ const htmlPath = path.join(outputPaths.file, path.dirname(vaultFile.path), alias, 'index.html')
176
+ const htmlDirPath = path.dirname(htmlPath)
177
+
178
+ const to = path.posix.join(path.posix.sep, config.output, vaultFile.slug)
179
+ const from = path.posix.join(path.dirname(to), alias)
180
+
181
+ await ensureDirectory(htmlDirPath)
182
+
183
+ await fs.writeFile(
184
+ htmlPath,
185
+ `<!doctype html>
186
+ <html lang="en">
187
+ <head>
188
+ <title>${vaultFile.stem}</title>
189
+ <meta http-equiv="refresh" content="0;url=${to}">
190
+ <meta name="robots" content="noindex">
191
+ <link rel="canonical" href="${to}">
192
+ </head>
193
+ <body>
194
+ <a href="${to}">Redirecting from <code>${from}</code> to <code>${to}</code></a>
195
+ </body>
196
+ </html>`,
197
+ )
198
+ }
199
+
200
+ function getOutputPaths(config: ObsidianConfig, roots: OutputRoots): OutputPaths {
201
+ return {
202
+ asset: path.join(roots.public, config.output),
203
+ content: path.join(roots.content, config.output),
204
+ file: path.join(roots.public, config.output),
205
+ }
206
+ }
207
+
208
+ async function cleanOutputPaths(outputPaths: OutputPaths) {
209
+ await removeDirectory(outputPaths.asset)
210
+ await removeDirectory(outputPaths.content)
211
+ await removeDirectory(outputPaths.file)
212
+ }
213
+
214
+ function throwVaultFileError(error: unknown, vaultFile: VaultFile): never {
215
+ throw new Error(`${vaultFile.path} — ${error instanceof Error ? error.message : String(error)}`, { cause: error })
216
+ }
217
+
218
+ interface OutputPaths {
219
+ asset: string
220
+ content: string
221
+ file: string
222
+ }
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ export async function isDirectory(path: string) {
5
+ try {
6
+ const stats = await fs.stat(path)
7
+ return stats.isDirectory()
8
+ } catch {
9
+ return false
10
+ }
11
+ }
12
+
13
+ export async function isFile(path: string) {
14
+ try {
15
+ const stats = await fs.stat(path)
16
+ return stats.isFile()
17
+ } catch {
18
+ return false
19
+ }
20
+ }
21
+
22
+ export function ensureDirectory(path: string) {
23
+ return fs.mkdir(path, { recursive: true })
24
+ }
25
+
26
+ export function removeDirectory(path: string) {
27
+ return fs.rm(path, { force: true, recursive: true })
28
+ }
29
+
30
+ export async function copyFile(sourcePath: string, destinationPath: string) {
31
+ const dirPath = path.dirname(destinationPath)
32
+ await ensureDirectory(dirPath)
33
+ return fs.copyFile(sourcePath, destinationPath)
34
+ }
@@ -0,0 +1,36 @@
1
+ import { obsidianConfigSchema, type ObsidianUserConfig } from './types.ts'
2
+ import { getObsidianPaths, getVault } from './obsidian.ts'
3
+ import { addObsidianFiles, type OutputRoots } from './files.ts'
4
+ import type { Logger } from './logger.ts'
5
+
6
+ /**
7
+ * Plain-Node replacement for the Astro `obsidian()` integration. Reads an
8
+ * Obsidian vault, runs the remark transform pipeline over every note, and
9
+ * writes the resulting markdown into `content/<output>` plus copies assets
10
+ * and embedded files into `public/<output>`.
11
+ *
12
+ * (The Astro version wired remark/rehype plugins into Astro's markdown config
13
+ * via `updateConfig`; here the full markdown -> HTML compilation happens later
14
+ * in generate-content.ts, so this step only emits the transformed markdown.)
15
+ */
16
+ export async function generateObsidian(userConfig: ObsidianUserConfig, logger: Logger, roots?: OutputRoots): Promise<void> {
17
+ const parsed = obsidianConfigSchema.safeParse(userConfig)
18
+ if (!parsed.success) {
19
+ throw new Error(`Invalid obsidian configuration:\n\n${JSON.stringify(parsed.error.format(), null, 2)}`)
20
+ }
21
+ const config = parsed.data
22
+
23
+ if (config.skipGeneration) {
24
+ logger.warn(`Skipping generation for '${config.output}' (skipGeneration enabled).`)
25
+ return
26
+ }
27
+
28
+ const start = performance.now()
29
+ logger.info(`Generating pages from Obsidian vault '${config.vault}'…`)
30
+
31
+ const vault = await getVault(config)
32
+ const obsidianPaths = await getObsidianPaths(vault, config.ignore)
33
+ await addObsidianFiles(config, vault, obsidianPaths, logger, roots)
34
+
35
+ logger.info(`Generated '${config.output}' in ${Math.round(performance.now() - start)}ms.`)
36
+ }
@@ -0,0 +1,17 @@
1
+ import { rehype } from 'rehype'
2
+ import rehypeMermaid from 'rehype-mermaid'
3
+
4
+ const processor = rehype()
5
+ .data('settings', {
6
+ fragment: true,
7
+ closeSelfClosing: true,
8
+ })
9
+ .use(rehypeMermaid, {
10
+ dark: true,
11
+ strategy: 'img-svg',
12
+ })
13
+
14
+ export async function transformHtmlToString(html: string) {
15
+ const file = await processor.process(html)
16
+ return String(file)
17
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Minimal logger interface used by the content-generation pipeline.
3
+ * Replaces Astro's `AstroIntegrationLogger`. Satisfied by `console` or a
4
+ * thin wrapper (see scripts/generate-content.ts).
5
+ */
6
+ export interface Logger {
7
+ info(message: string): void
8
+ warn(message: string): void
9
+ error(message: string): void
10
+ }