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
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, '&amp;')
126
+ .replace(/</g, '&lt;')
127
+ .replace(/>/g, '&gt;')
128
+ .replace(/"/g, '&quot;')
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
+ }