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,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
|
+
}
|