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
package/package.json
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cantip",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Config-driven Remix documentation site engine — ingest Obsidian/markdown vaults, build an SSR docs site. \"How (to)\" in Kyrgyz.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"documentation",
|
|
8
|
+
"docs",
|
|
9
|
+
"remix",
|
|
10
|
+
"obsidian",
|
|
11
|
+
"markdown",
|
|
12
|
+
"static-site",
|
|
13
|
+
"ssr",
|
|
14
|
+
"docs-site"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Sedokina",
|
|
18
|
+
"homepage": "https://github.com/Sedokina/cantip#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/Sedokina/cantip.git",
|
|
22
|
+
"directory": "packages/cantip"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/Sedokina/cantip/issues"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"cantip": "./cli.js"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
"./config": {
|
|
32
|
+
"types": "./app/lib/config/schema.ts",
|
|
33
|
+
"default": "./dist/config.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./vite": "./vite.config.ts",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"app",
|
|
40
|
+
"scripts",
|
|
41
|
+
"dist",
|
|
42
|
+
"cli.js",
|
|
43
|
+
"vite.config.ts",
|
|
44
|
+
"components.json",
|
|
45
|
+
"LICENSE",
|
|
46
|
+
"README.md"
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "node build.js",
|
|
50
|
+
"prepublishOnly": "node build.js",
|
|
51
|
+
"typecheck": "tsc --noEmit"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@headless-tree/core": "^1.7.0",
|
|
55
|
+
"@headless-tree/react": "^1.7.0",
|
|
56
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
57
|
+
"@remix-run/dev": "^2.15.0",
|
|
58
|
+
"@remix-run/node": "^2.15.0",
|
|
59
|
+
"@remix-run/react": "^2.15.0",
|
|
60
|
+
"@remix-run/serve": "^2.15.0",
|
|
61
|
+
"@tailwindcss/typography": "^0.5.19",
|
|
62
|
+
"@tailwindcss/vite": "^4.3.0",
|
|
63
|
+
"class-variance-authority": "^0.7.1",
|
|
64
|
+
"clsx": "^2.1.1",
|
|
65
|
+
"decode-uri-component": "^0.4.1",
|
|
66
|
+
"github-slugger": "^2.0.0",
|
|
67
|
+
"globby": "^16.2.0",
|
|
68
|
+
"hast-util-to-html": "^9.0.5",
|
|
69
|
+
"is-absolute-url": "^5.0.0",
|
|
70
|
+
"isbot": "^5.1.0",
|
|
71
|
+
"json-canvas-viewer": "^4.3.2",
|
|
72
|
+
"katex": "^0.16.11",
|
|
73
|
+
"lucide-react": "^1.17.0",
|
|
74
|
+
"mdast-util-find-and-replace": "^3.0.2",
|
|
75
|
+
"mdast-util-from-markdown": "^2.0.3",
|
|
76
|
+
"mdast-util-to-hast": "^13.2.1",
|
|
77
|
+
"nanoid": "^5.1.11",
|
|
78
|
+
"pagefind": "^1.5.2",
|
|
79
|
+
"react": "^18.3.1",
|
|
80
|
+
"react-dom": "^18.3.1",
|
|
81
|
+
"react-sticky-box": "^2.0.5",
|
|
82
|
+
"rehype": "^13.0.2",
|
|
83
|
+
"rehype-katex": "^7.0.1",
|
|
84
|
+
"rehype-mermaid": "^2.1.0",
|
|
85
|
+
"rehype-stringify": "^10.0.1",
|
|
86
|
+
"remark": "^15.0.1",
|
|
87
|
+
"remark-breaks": "^4.0.0",
|
|
88
|
+
"remark-frontmatter": "^5.0.0",
|
|
89
|
+
"remark-gfm": "^4.0.1",
|
|
90
|
+
"remark-math": "^6.0.0",
|
|
91
|
+
"remark-parse": "^11.0.0",
|
|
92
|
+
"remark-rehype": "^11.1.1",
|
|
93
|
+
"tailwind-merge": "^3.6.0",
|
|
94
|
+
"tailwindcss": "^4.3.0",
|
|
95
|
+
"tinyglobby": "^0.2.10",
|
|
96
|
+
"tw-animate-css": "^1.4.0",
|
|
97
|
+
"unified": "^11.0.5",
|
|
98
|
+
"unist-util-visit": "^5.1.0",
|
|
99
|
+
"vfile": "^6.0.3",
|
|
100
|
+
"vite": "^5.4.0",
|
|
101
|
+
"yaml": "^2.9.0",
|
|
102
|
+
"zod": "^4.3.6"
|
|
103
|
+
},
|
|
104
|
+
"devDependencies": {
|
|
105
|
+
"@types/react": "^18.3.1",
|
|
106
|
+
"@types/react-dom": "^18.3.1",
|
|
107
|
+
"typescript": "^5.6.0"
|
|
108
|
+
},
|
|
109
|
+
"engines": {
|
|
110
|
+
"node": ">=20"
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { CompiledDoc } from './compile.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a Pagefind search index from the compiled docs and write it to
|
|
5
|
+
* `public/pagefind/` so it ships as a static asset served at `/pagefind/`.
|
|
6
|
+
*
|
|
7
|
+
* The app is SSR with no static HTML on disk (content lives as HTML strings in
|
|
8
|
+
* the per-doc manifest), so Pagefind's directory-crawling mode has nothing to
|
|
9
|
+
* index. Instead we feed each doc's stored HTML to the Node Indexing API via
|
|
10
|
+
* `addHTMLFile`, which lets Pagefind extract headings, excerpts and apply its
|
|
11
|
+
* heading-weighted ranking — far richer results than indexing plain text.
|
|
12
|
+
*
|
|
13
|
+
* `writeFiles` emits the whole Pagefind runtime here too: `pagefind.js` (search
|
|
14
|
+
* API) plus `pagefind-ui.js` / `pagefind-ui.css` (the prebuilt UI used in the
|
|
15
|
+
* top/bottom bars).
|
|
16
|
+
*/
|
|
17
|
+
export interface SearchIndexOptions {
|
|
18
|
+
/** Absolute output dir for the Pagefind index, e.g. `<cwd>/public/pagefind`. */
|
|
19
|
+
outputPath: string
|
|
20
|
+
/** Site language for Pagefind tokenisation/stemming (`forceLanguage`). */
|
|
21
|
+
lang: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function buildSearchIndex(
|
|
25
|
+
docs: CompiledDoc[],
|
|
26
|
+
canonicalUrl: Map<string, string>,
|
|
27
|
+
logger: { info(m: string): void; warn(m: string): void },
|
|
28
|
+
options: SearchIndexOptions = { outputPath: 'public/pagefind', lang: 'ru' },
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
// Imported lazily so a missing/optional native binary only fails the search
|
|
31
|
+
// step, not the whole content generation.
|
|
32
|
+
const { createIndex } = await import('pagefind')
|
|
33
|
+
|
|
34
|
+
const { index, errors } = await createIndex({
|
|
35
|
+
// Set the default site language so tokenisation and stemming are correct.
|
|
36
|
+
// Pagefind still detects per-page `lang` if present.
|
|
37
|
+
forceLanguage: options.lang,
|
|
38
|
+
})
|
|
39
|
+
if (!index) {
|
|
40
|
+
logger.warn(`Pagefind index could not be created: ${errors.join('; ')}`)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let indexed = 0
|
|
45
|
+
let skipped = 0
|
|
46
|
+
for (const doc of docs) {
|
|
47
|
+
// Drafts are not routable; canvas pages have no prose (their "HTML" is a
|
|
48
|
+
// JSON blob in a <script> mount) — nothing useful to search.
|
|
49
|
+
if (doc.frontmatter.draft === true) {
|
|
50
|
+
skipped++
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (doc.html.includes('data-canvas-mount')) {
|
|
54
|
+
skipped++
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const title =
|
|
59
|
+
(doc.frontmatter.title as string | undefined) ??
|
|
60
|
+
doc.id.split('/').pop()?.replace(/-/g, ' ') ??
|
|
61
|
+
doc.id
|
|
62
|
+
|
|
63
|
+
// Canonical URL: the doc's permalink when it has one, else its file-path
|
|
64
|
+
// URL `/{id}/`. Indexing the canonical URL means a clicked search result
|
|
65
|
+
// lands on the permalink directly (no file-path → permalink 301).
|
|
66
|
+
const url = canonicalUrl.get(doc.id) ?? `/${doc.id}/`
|
|
67
|
+
|
|
68
|
+
// Directory filters, derived from the slugified id (already matches URLs).
|
|
69
|
+
// `project` is the top-level dir (e.g. "krista") used to default-scope the
|
|
70
|
+
// search to the project the reader is currently in. `dir` is tagged with
|
|
71
|
+
// EVERY ancestor directory prefix of the page, so a filter on any directory
|
|
72
|
+
// — at any nesting depth — matches every page beneath it (WebStorm's
|
|
73
|
+
// "search in directory" behaviour). e.g. "krista/требования/заказы/x" is
|
|
74
|
+
// tagged dir: ["krista", "krista/требования", "krista/требования/заказы"].
|
|
75
|
+
//
|
|
76
|
+
// The Node API's addHTMLFile takes filters only via in-content HTML
|
|
77
|
+
// attributes (data-pagefind-filter), not a `filters` field — so we emit
|
|
78
|
+
// hidden tagging elements. Repeating the `dir` filter on several elements
|
|
79
|
+
// records it as a multi-value filter (one value per ancestor directory).
|
|
80
|
+
const segments = doc.id.split('/')
|
|
81
|
+
const project = segments[0]
|
|
82
|
+
const dirSegments = segments.slice(0, -1) // drop the file slug itself
|
|
83
|
+
const dirPrefixes = dirSegments.map((_, i) => dirSegments.slice(0, i + 1).join('/'))
|
|
84
|
+
const filterTags =
|
|
85
|
+
`<span data-pagefind-filter="project" style="display:none">${escapeHtml(project)}</span>` +
|
|
86
|
+
dirPrefixes
|
|
87
|
+
.map(
|
|
88
|
+
(d) =>
|
|
89
|
+
`<span data-pagefind-filter="dir" style="display:none">${escapeHtml(d)}</span>`,
|
|
90
|
+
)
|
|
91
|
+
.join('')
|
|
92
|
+
|
|
93
|
+
// Wrap the body so Pagefind sees a full document with a heading it can use
|
|
94
|
+
// as the result title; `data-pagefind-body` scopes indexing to this region.
|
|
95
|
+
const content = `<!DOCTYPE html><html lang="${escapeHtml(options.lang)}"><head><title>${escapeHtml(
|
|
96
|
+
title,
|
|
97
|
+
)}</title></head><body><main data-pagefind-body>${filterTags}<h1>${escapeHtml(
|
|
98
|
+
title,
|
|
99
|
+
)}</h1>${doc.html}</main></body></html>`
|
|
100
|
+
|
|
101
|
+
const { errors: fileErrors } = await index.addHTMLFile({
|
|
102
|
+
url,
|
|
103
|
+
content,
|
|
104
|
+
})
|
|
105
|
+
if (fileErrors.length) {
|
|
106
|
+
logger.warn(`Pagefind failed on ${doc.id}: ${fileErrors.join('; ')}`)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
indexed++
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { errors: writeErrors } = await index.writeFiles({
|
|
113
|
+
outputPath: options.outputPath,
|
|
114
|
+
})
|
|
115
|
+
if (writeErrors.length) {
|
|
116
|
+
logger.warn(`Pagefind writeFiles errors: ${writeErrors.join('; ')}`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await index.deleteIndex()
|
|
120
|
+
logger.info(`Pagefind: indexed ${indexed} pages (skipped ${skipped}) → ${options.outputPath}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function escapeHtml(s: string): string {
|
|
124
|
+
return s
|
|
125
|
+
.replace(/&/g, '&')
|
|
126
|
+
.replace(/</g, '<')
|
|
127
|
+
.replace(/>/g, '>')
|
|
128
|
+
.replace(/"/g, '"')
|
|
129
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CompiledDoc } from './compile.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical-URL resolution shared by every place that emits a link to a doc
|
|
5
|
+
* (in-content wikilinks, the sidebar, the search index). A doc's canonical URL
|
|
6
|
+
* is its `permalink` (project-scoped, set in frontmatter) when it has one, else
|
|
7
|
+
* its file-path URL `/{id}/`. Linking to the canonical URL means internal links
|
|
8
|
+
* skip the file-path → permalink 301 redirect.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Project a doc belongs to: its first id segment (mirrors getProjectIdForDoc). */
|
|
12
|
+
function projectOf(id: string): string {
|
|
13
|
+
return id.split('/')[0] ?? ''
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Map every doc id to its canonical URL. Docs with a `permalink` frontmatter
|
|
18
|
+
* field get the project-scoped permalink URL; all others map to `/{id}/`.
|
|
19
|
+
*/
|
|
20
|
+
export function buildIdToCanonicalUrl(docs: CompiledDoc[]): Map<string, string> {
|
|
21
|
+
const map = new Map<string, string>()
|
|
22
|
+
for (const d of docs) {
|
|
23
|
+
const raw = d.frontmatter.permalink
|
|
24
|
+
if (typeof raw === 'string' && raw.trim() !== '') {
|
|
25
|
+
const rel = raw.trim().replace(/^\/+|\/+$/g, '')
|
|
26
|
+
if (rel) {
|
|
27
|
+
map.set(d.id, `/${projectOf(d.id)}/${rel}/`)
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
map.set(d.id, `/${d.id}/`)
|
|
32
|
+
}
|
|
33
|
+
return map
|
|
34
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { glob } from 'tinyglobby';
|
|
4
|
+
|
|
5
|
+
import type { Logger } from './obsidian/logger.ts';
|
|
6
|
+
|
|
7
|
+
export interface CanvasToMdOptions {
|
|
8
|
+
/** Absolute or cwd-relative path to the Obsidian vault. */
|
|
9
|
+
vault: string;
|
|
10
|
+
/** Output section name (e.g. 'krista'); files are written under `content/<output>`. */
|
|
11
|
+
output: string;
|
|
12
|
+
/** Directory that `content/<output>` lives under. Defaults to 'content'. */
|
|
13
|
+
contentRoot?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function escapeYamlString(s: string): string {
|
|
17
|
+
if (/[:"'#\[\]{}&*!|>%@`]/.test(s) || s.includes('\n')) {
|
|
18
|
+
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
19
|
+
}
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function generateCanvasFile(
|
|
24
|
+
vaultDir: string,
|
|
25
|
+
outDir: string,
|
|
26
|
+
relPath: string,
|
|
27
|
+
log: (msg: string) => void,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const absPath = path.join(vaultDir, relPath);
|
|
30
|
+
const contents = await fs.readFile(absPath, 'utf-8');
|
|
31
|
+
|
|
32
|
+
let canvasObj: unknown;
|
|
33
|
+
try {
|
|
34
|
+
canvasObj = JSON.parse(contents);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
log(`Invalid JSON in ${relPath}: ${(err as Error).message}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const title = path.basename(relPath, '.canvas');
|
|
41
|
+
const safeJson = JSON.stringify(canvasObj).replace(/</g, '\\u003c');
|
|
42
|
+
|
|
43
|
+
const md = [
|
|
44
|
+
'---',
|
|
45
|
+
`title: ${escapeYamlString(title)}`,
|
|
46
|
+
'tableOfContents: false',
|
|
47
|
+
'---',
|
|
48
|
+
'',
|
|
49
|
+
`<div class="canvas-container not-content" data-canvas-mount><script type="application/json">${safeJson}</script></div>`,
|
|
50
|
+
'',
|
|
51
|
+
].join('\n');
|
|
52
|
+
|
|
53
|
+
const mdRelPath = relPath.replace(/\.canvas$/, '.md');
|
|
54
|
+
const outPath = path.join(outDir, mdRelPath);
|
|
55
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
56
|
+
await fs.writeFile(outPath, md, 'utf-8');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Convert every `.canvas` file in a vault to a markdown page containing a
|
|
61
|
+
* client-mountable canvas container. Plain Node — no Astro integration hooks.
|
|
62
|
+
*/
|
|
63
|
+
export async function generateCanvas(options: CanvasToMdOptions, logger: Logger): Promise<void> {
|
|
64
|
+
const { vault, output, contentRoot = 'content' } = options;
|
|
65
|
+
const vaultDir = path.resolve(vault);
|
|
66
|
+
const outDir = path.resolve(contentRoot, output);
|
|
67
|
+
|
|
68
|
+
const entries = await glob('**/[^_]*.canvas', { cwd: vaultDir });
|
|
69
|
+
await Promise.all(entries.map((relPath) => generateCanvasFile(vaultDir, outDir, relPath, (m) => logger.info(m))));
|
|
70
|
+
if (entries.length > 0) {
|
|
71
|
+
logger.info(`Generated ${entries.length} canvas page(s) for '${output}'`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { unified } from 'unified'
|
|
4
|
+
import remarkParse from 'remark-parse'
|
|
5
|
+
import remarkGfm from 'remark-gfm'
|
|
6
|
+
import remarkBreaks from 'remark-breaks'
|
|
7
|
+
import remarkMath from 'remark-math'
|
|
8
|
+
import remarkFrontmatter from 'remark-frontmatter'
|
|
9
|
+
import remarkRehype from 'remark-rehype'
|
|
10
|
+
import rehypeKatex from 'rehype-katex'
|
|
11
|
+
import rehypeStringify from 'rehype-stringify'
|
|
12
|
+
import { visit } from 'unist-util-visit'
|
|
13
|
+
import { slug as slugify } from 'github-slugger'
|
|
14
|
+
import yaml from 'yaml'
|
|
15
|
+
import { slugifyObsidianPath } from './obsidian/obsidian.ts'
|
|
16
|
+
import type { Root as MdastRoot } from 'mdast'
|
|
17
|
+
import type { Root as HastRoot, Element } from 'hast'
|
|
18
|
+
|
|
19
|
+
export interface Heading {
|
|
20
|
+
depth: number
|
|
21
|
+
slug: string
|
|
22
|
+
text: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CompiledDoc {
|
|
26
|
+
/** Route id, e.g. "krista/глоссарий/коллекция" (no leading slash, no extension). */
|
|
27
|
+
id: string
|
|
28
|
+
frontmatter: Record<string, unknown>
|
|
29
|
+
headings: Heading[]
|
|
30
|
+
html: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function extractFrontmatter(tree: MdastRoot): Record<string, unknown> {
|
|
34
|
+
for (const node of tree.children) {
|
|
35
|
+
if (node.type === 'yaml') {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = yaml.parse((node as { value: string }).value)
|
|
38
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
39
|
+
} catch {
|
|
40
|
+
return {}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function nodeText(node: Element): string {
|
|
48
|
+
let out = ''
|
|
49
|
+
visit(node, 'text', (t: { value: string }) => {
|
|
50
|
+
out += t.value
|
|
51
|
+
})
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* rehype plugin: assign slug ids to headings (matching Astro/github-slugger
|
|
57
|
+
* behaviour) and collect them into `file.data.headings` for the TOC.
|
|
58
|
+
*/
|
|
59
|
+
function rehypeHeadings() {
|
|
60
|
+
return (tree: HastRoot, file: { data: Record<string, unknown> }) => {
|
|
61
|
+
const headings: Heading[] = []
|
|
62
|
+
const seen = new Map<string, number>()
|
|
63
|
+
visit(tree, 'element', (node: Element) => {
|
|
64
|
+
const m = /^h([1-6])$/.exec(node.tagName)
|
|
65
|
+
if (!m) return
|
|
66
|
+
const depth = Number(m[1])
|
|
67
|
+
const text = nodeText(node).trim()
|
|
68
|
+
let id = slugify(text)
|
|
69
|
+
// de-duplicate ids the way github-slugger does within a doc
|
|
70
|
+
if (seen.has(id)) {
|
|
71
|
+
const n = seen.get(id)! + 1
|
|
72
|
+
seen.set(id, n)
|
|
73
|
+
id = `${id}-${n}`
|
|
74
|
+
} else {
|
|
75
|
+
seen.set(id, 0)
|
|
76
|
+
}
|
|
77
|
+
node.properties = node.properties ?? {}
|
|
78
|
+
if (!node.properties.id) node.properties.id = id
|
|
79
|
+
headings.push({ depth, slug: String(node.properties.id), text })
|
|
80
|
+
})
|
|
81
|
+
file.data.headings = headings
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* rehype plugin: hoist `has-blank-before` from a `<code>` up to its `<pre>`.
|
|
87
|
+
*
|
|
88
|
+
* `remarkBlankLineGaps` attaches the class via mdast `hProperties`, which for a
|
|
89
|
+
* fenced code block lands on the inner `<code>` element — but the block-level
|
|
90
|
+
* box (and the `margin-block: 0` reset it must override) lives on the wrapping
|
|
91
|
+
* `<pre>`. A class on `<code>` therefore produces no gap. Move it to the `<pre>`
|
|
92
|
+
* so the marker sits on the element CSS actually keys the gap on.
|
|
93
|
+
*/
|
|
94
|
+
function rehypeHoistBlankBeforeToPre() {
|
|
95
|
+
return (tree: HastRoot) => {
|
|
96
|
+
visit(tree, 'element', (node: Element) => {
|
|
97
|
+
if (node.tagName !== 'pre') return
|
|
98
|
+
const code = node.children.find(
|
|
99
|
+
(c): c is Element => c.type === 'element' && c.tagName === 'code',
|
|
100
|
+
)
|
|
101
|
+
if (!code) return
|
|
102
|
+
const codeCls = code.properties?.className
|
|
103
|
+
if (!Array.isArray(codeCls) || !codeCls.includes('has-blank-before')) return
|
|
104
|
+
// Drop the marker from <code> and add it to <pre>.
|
|
105
|
+
const remaining = codeCls.filter((c) => c !== 'has-blank-before')
|
|
106
|
+
if (remaining.length > 0) code.properties!.className = remaining
|
|
107
|
+
else delete code.properties!.className
|
|
108
|
+
node.properties = node.properties ?? {}
|
|
109
|
+
const preCls = node.properties.className
|
|
110
|
+
node.properties.className = Array.isArray(preCls)
|
|
111
|
+
? [...preCls, 'has-blank-before']
|
|
112
|
+
: ['has-blank-before']
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* remark plugin: attach `.has-blank-before` to every block the author separated
|
|
119
|
+
* from its previous sibling with a blank line. Blank-line separation is the only
|
|
120
|
+
* thing that earns a vertical gap; the gap itself lives entirely in CSS, keyed on
|
|
121
|
+
* `.has-blank-before`.
|
|
122
|
+
*
|
|
123
|
+
* This plugin does NOT read source positions — it can't. The obsidian generator
|
|
124
|
+
* round-trips the document through remark-stringify, which renormalises
|
|
125
|
+
* blank-line spacing throughout (it drifts in 86% of files), so positions on the
|
|
126
|
+
* markdown we compile here no longer reflect what the author wrote. Instead the
|
|
127
|
+
* generator records the author's gaps at the source — where positions are
|
|
128
|
+
* pristine — by inserting a `<!--blank-gap-->` comment (see BLANK_GAP_MARKER in
|
|
129
|
+
* `scripts/obsidian/remark.ts`) before each gapped block. Here we simply honour
|
|
130
|
+
* those markers: the block following a marker gets the class, and the marker is
|
|
131
|
+
* stripped so it never reaches the HTML. Markers before raw `html` blocks aren't
|
|
132
|
+
* emitted (callouts/canvas bring their own margins), so none are read for them.
|
|
133
|
+
*
|
|
134
|
+
* The class is attached through mdast `data.hProperties.className` so that
|
|
135
|
+
* remark-rehype renders it onto the resulting element.
|
|
136
|
+
*/
|
|
137
|
+
const BLANK_GAP_MARKER = '<!--blank-gap-->'
|
|
138
|
+
|
|
139
|
+
function remarkBlankLineGaps() {
|
|
140
|
+
type Parent = { children?: BlockNode[] }
|
|
141
|
+
type BlockNode = {
|
|
142
|
+
type: string
|
|
143
|
+
value?: string
|
|
144
|
+
data?: { hProperties?: { className?: string[] } }
|
|
145
|
+
children?: BlockNode[]
|
|
146
|
+
}
|
|
147
|
+
const isGapMarker = (n: BlockNode | undefined) =>
|
|
148
|
+
n?.type === 'html' && n.value?.trim() === BLANK_GAP_MARKER
|
|
149
|
+
function walk(parent: Parent) {
|
|
150
|
+
const children = parent.children
|
|
151
|
+
if (!children) return
|
|
152
|
+
// Strip gap markers, remembering which block each preceded so we can tag it
|
|
153
|
+
// without leaving the comment behind. Iterate backwards so splices are safe.
|
|
154
|
+
const gapped = new Set<BlockNode>()
|
|
155
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
156
|
+
if (isGapMarker(children[i])) {
|
|
157
|
+
const next = children[i + 1]
|
|
158
|
+
if (next) gapped.add(next)
|
|
159
|
+
children.splice(i, 1)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const node of children) {
|
|
163
|
+
if (gapped.has(node)) {
|
|
164
|
+
node.data ??= {}
|
|
165
|
+
node.data.hProperties ??= {}
|
|
166
|
+
const cls = node.data.hProperties.className ?? []
|
|
167
|
+
cls.push('has-blank-before')
|
|
168
|
+
node.data.hProperties.className = cls
|
|
169
|
+
}
|
|
170
|
+
walk(node)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return (tree: MdastRoot) => {
|
|
174
|
+
walk(tree as unknown as Parent)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const processor = unified()
|
|
179
|
+
.use(remarkParse)
|
|
180
|
+
.use(remarkFrontmatter)
|
|
181
|
+
.use(remarkGfm)
|
|
182
|
+
// Obsidian renders a single newline as a line break (a "hard break"); the
|
|
183
|
+
// vault was authored against that behaviour (e.g. multi-line blockquotes
|
|
184
|
+
// without trailing spaces). Default CommonMark collapses single newlines to
|
|
185
|
+
// spaces — remark-breaks restores the Obsidian/GitHub line-break semantics.
|
|
186
|
+
.use(remarkBreaks)
|
|
187
|
+
// Mark blocks preceded by a blank line so CSS can apply the inter-block gap
|
|
188
|
+
// ONLY there (see `.has-blank-before` in app.css), instead of a blanket
|
|
189
|
+
// adjacent-sibling margin. Must run BEFORE remarkRehype (needs mdast
|
|
190
|
+
// positions) and after remarkBreaks (which doesn't add/remove blocks).
|
|
191
|
+
.use(remarkBlankLineGaps)
|
|
192
|
+
.use(remarkMath, { singleDollarTextMath: true })
|
|
193
|
+
// allowDangerousHtml: the generated markdown contains raw HTML (callouts,
|
|
194
|
+
// <img>, canvas containers) produced by the obsidian remark pipeline.
|
|
195
|
+
.use(remarkRehype, { allowDangerousHtml: true })
|
|
196
|
+
.use(rehypeKatex)
|
|
197
|
+
// remarkBlankLineGaps puts the gap marker on the inner <code>; move it up to
|
|
198
|
+
// the <pre> so the inter-block gap actually applies (see plugin doc above).
|
|
199
|
+
.use(rehypeHoistBlankBeforeToPre)
|
|
200
|
+
.use(rehypeHeadings)
|
|
201
|
+
.use(rehypeStringify, { allowDangerousHtml: true })
|
|
202
|
+
|
|
203
|
+
/** Compile a single markdown string to HTML + headings + frontmatter. */
|
|
204
|
+
export async function compileMarkdown(markdown: string): Promise<Omit<CompiledDoc, 'id'>> {
|
|
205
|
+
const mdast = processor.parse(markdown) as MdastRoot
|
|
206
|
+
const frontmatter = extractFrontmatter(mdast)
|
|
207
|
+
const file = await processor.process(markdown)
|
|
208
|
+
return {
|
|
209
|
+
frontmatter,
|
|
210
|
+
headings: (file.data.headings as Heading[]) ?? [],
|
|
211
|
+
html: String(file),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Walk a content directory and compile every .md file into the manifest. */
|
|
216
|
+
export async function compileDir(contentRoot: string, logger: { info(m: string): void }): Promise<CompiledDoc[]> {
|
|
217
|
+
const docs: CompiledDoc[] = []
|
|
218
|
+
|
|
219
|
+
async function walk(dir: string): Promise<void> {
|
|
220
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
221
|
+
await Promise.all(
|
|
222
|
+
entries.map(async (entry) => {
|
|
223
|
+
const full = path.join(dir, entry.name)
|
|
224
|
+
if (entry.isDirectory()) {
|
|
225
|
+
await walk(full)
|
|
226
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
227
|
+
const raw = await fs.readFile(full, 'utf8')
|
|
228
|
+
const compiled = await compileMarkdown(raw)
|
|
229
|
+
const rel = path.relative(contentRoot, full).replace(/\\/g, '/').replace(/\.md$/, '')
|
|
230
|
+
// Slugify the id with the same logic used to generate internal link
|
|
231
|
+
// hrefs (lowercase, slugified per segment) so routes match wikilinks.
|
|
232
|
+
const id = slugifyObsidianPath(rel)
|
|
233
|
+
docs.push({ id, ...compiled })
|
|
234
|
+
}
|
|
235
|
+
}),
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await walk(contentRoot)
|
|
240
|
+
logger.info(`Compiled ${docs.length} markdown page(s) to HTML.`)
|
|
241
|
+
return docs
|
|
242
|
+
}
|