@sullux/markdown-docs 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -6,33 +6,98 @@ Built on `@sullux/markdown-compiler` and `@sullux/markdown-html`, `@sullux/markd
6
6
 
7
7
  ## Core Features
8
8
 
9
- * **GitBook `SUMMARY.md` Support:** Automatically parses `SUMMARY.md` navigation lists into sidebar navigation menus (with auto-discovery fallback if `SUMMARY.md` is omitted).
10
- * **Zero Dependencies:** Pure Vanilla JS implementation with zero external packages.
9
+ * **GitBook `SUMMARY.md` Support:** Automatically parses `SUMMARY.md` navigation lists and `## Section` headers into sidebar navigation menus (with directory auto-discovery fallback if `SUMMARY.md` is omitted).
10
+ * **Zero Dependencies:** Pure Vanilla JS implementation using Node.js built-ins.
11
+ * **Declarative Site Configuration (`docs.yaml`):** Comprehensive YAML configuration file support for logos, themes, external links, favicons, and base URLs.
12
+ * **Dual Light/Dark Branding:** Supports dual logos (`logo.light`, `logo.dark`) and dual color schemes with native OS system dark mode detection and client-side manual toggle persistence.
11
13
  * **Client-Side Search:** Auto-generates a lightweight JSON search index and embedded client-side search UI.
12
- * **Responsive Layout:** Clean GitBook-inspired UI with collapsible sidebar navigation, dark/light theme toggle, and mobile support.
13
- * **Rich Component Support:** Supports all `@sullux/markdown-html` features including code syntax highlighting, callout boxes (`> [!NOTE]`, `{% hint %}`), GFM tables, and custom image dimensions.
14
- * **Static Asset Copying:** Automatically copies non-markdown assets (PNGs, SVGs, PDFs) directly to the output directory.
14
+ * **Responsive 3-Column Layout:** Sticky header with top search bar, left navigation sidebar, center content stream, and right page outline (TOC) with responsive slide-in drawers for mobile.
15
+ * **Rich Component Support:** Supports code syntax highlighting, callout boxes (`> [!NOTE]`, `{% hint %}`), GFM tables, and custom image dimensions.
16
+ * **Static Asset Copying:** Automatically copies non-Markdown assets (images, SVGs, PDFs) directly to the output directory.
15
17
 
16
- ## Installation & CLI Usage
18
+ ## CLI Usage
17
19
 
18
20
  ```bash
19
- # Global installation or via npx
20
- npm install -g @sullux/markdown-docs
21
+ # Generate site using default docs.yaml configuration in ./docs
22
+ markdown-docs -i ./docs -o ./_site
21
23
 
22
- # Generate site from a docs directory
23
- markdown-docs -i ./docs -o ./_site -t "Project Documentation"
24
+ # Build with a custom base URL for CI/CD environments
25
+ markdown-docs -i ./docs -o ./_site -b "/my-app-docs/"
24
26
  ```
25
27
 
26
28
  ### CLI Options
27
29
 
28
30
  | Flag | Long Flag | Description | Default |
29
31
  | :--- | :--- | :--- | :--- |
30
- | `-i` | `--input` | Input Markdown docs directory | Current working directory |
31
- | `-o` | `--output` | Output directory | `<input>/_site` |
32
- | `-t` | `--title` | Documentation site title | Directory name |
33
- | | `--baseUrl` | Base URL prefix for links | `""` |
32
+ | `-i` | `--input` | Path to input Markdown docs directory | Current working directory |
33
+ | `-o` | `--output` | Path to output build directory | `<input>/_site` |
34
+ | `-b` | `--base-url` | Base URL prefix for links and deployment | `""` |
35
+ | `-t` | `--title` | Site title override (overrides `docs.yaml`) | `title` in `docs.yaml` |
36
+ | `-c` | `--config` | Custom path to config file | `<input>/docs.yaml` |
34
37
  | `-h` | `--help` | Display CLI help menu | |
35
38
 
39
+ ---
40
+
41
+ ## Site Configuration (`docs.yaml`)
42
+
43
+ You can configure your documentation site by placing a `docs.yaml` (or `docs.yml` / `docs.json`) file in your input documentation directory.
44
+
45
+ ### Example `docs.yaml`
46
+
47
+ ```yaml
48
+ # Site Title (Optional: omit or leave empty for logo-only headers)
49
+ title: "BucketDB"
50
+
51
+ # Output directory (Optional: can also be passed via CLI)
52
+ output: "_site"
53
+
54
+ # Base URL prefix (Optional: useful for GitHub Pages or subdirectory hosting)
55
+ baseUrl: "/docs"
56
+
57
+ # Brand Logo (Supports single path/SVG, or dual light/dark images)
58
+ logo:
59
+ light: "assets/logo-light.svg"
60
+ dark: "assets/logo-dark.svg"
61
+
62
+ # Favicon Asset Path or URL
63
+ favicon: "assets/favicon.ico"
64
+
65
+ # External Header Navigation Links
66
+ links:
67
+ - title: "GitHub"
68
+ url: "https://github.com/sullux/coms"
69
+ - title: "API Spec"
70
+ url: "https://api.example.com"
71
+
72
+ # Theme Color Overrides
73
+ theme:
74
+ light:
75
+ bg: "#ffffff"
76
+ accent: "#2563eb"
77
+ codeBg: "#f8fafc"
78
+ codeText: "#0f172a"
79
+ dark:
80
+ bg: "#121316"
81
+ accent: "#3b82f6"
82
+ codeBg: "#0a0b0e"
83
+ codeText: "#f3f4f6"
84
+ ```
85
+
86
+ ### Configuration Options Reference
87
+
88
+ | Property | Type | Description | Default |
89
+ | :--- | :--- | :--- | :--- |
90
+ | `title` | `string` | Product or site name displayed in header & `<title>` tag. Leave empty (`""`) or omit when using a logo image containing the product name. | `""` |
91
+ | `output` | `string` | Relative or absolute path to output build directory. | `<input>/_site` |
92
+ | `baseUrl` | `string` | Base URL path prefix for hosting in subdirectories. | `""` |
93
+ | `logo` | `string \| object` | Asset path/SVG string, or `{ light: "...", dark: "..." }` object for automatic theme switching. | `""` |
94
+ | `favicon` | `string` | Asset path or URL to icon file. | Default book emoji (`📚`) |
95
+ | `links` | `array` | Header external links array `[{ title: "...", url: "..." }]`. | `[]` |
96
+ | `theme.light` | `object` | Light theme color overrides (`bg`, `accent`, `codeBg`, `codeText`). | Built-in light colors |
97
+ | `theme.dark` | `object` | Dark theme color overrides (`bg`, `accent`, `codeBg`, `codeText`). | Built-in dark colors |
98
+
99
+ ---
100
+
36
101
  ## Programmatic API Usage
37
102
 
38
103
  ```javascript
@@ -41,30 +106,12 @@ const { generateSite } = require('@sullux/markdown-docs')
41
106
  const result = generateSite({
42
107
  input: './docs',
43
108
  output: './dist',
44
- title: 'Sullux API Docs',
109
+ baseUrl: '/docs',
45
110
  })
46
111
 
47
112
  console.log(`Generated ${result.pageCount} pages at ${result.output}`)
48
113
  ```
49
114
 
50
- ## Folder Topography
51
-
52
- ```
53
- packages/markdown-docs/
54
- ├── bin/
55
- │ └── cli.js # Executable CLI entrypoint
56
- ├── lib/
57
- │ ├── config.js # Option and CLI argument parser
58
- │ ├── summary.js # SUMMARY.md parser and directory scanner
59
- │ ├── layout.js # Responsive HTML page template generator
60
- │ ├── theme.js # Embedded GitBook CSS styles
61
- │ ├── search.js # Client-side search index and JS script
62
- │ ├── assets.js # Static asset copy utility
63
- │ └── site.js # Site generation coordinator
64
- ├── index.js # Package API entrypoint
65
- └── package.json # Package manifest
66
- ```
67
-
68
115
  ## Running Unit Tests
69
116
 
70
117
  ```bash
package/bin/cli.js CHANGED
@@ -10,8 +10,9 @@ Usage: markdown-docs [options]
10
10
  Options:
11
11
  -i, --input <dir> Path to input Markdown docs directory (default: CWD)
12
12
  -o, --output <dir> Path to output directory (default: <input>/_site)
13
- -t, --title <title> Site title
14
- --baseUrl <url> Base URL prefix for links
13
+ -b, --base-url <url> Base URL prefix for build/deployment
14
+ -t, --title <title> Site title (overrides title in docs.yaml)
15
+ -c, --config <file> Path to config file (default: <input>/docs.yaml)
15
16
  -h, --help Show help documentation
16
17
  `)
17
18
  }
package/lib/config.js CHANGED
@@ -1,34 +1,66 @@
1
+ const fs = require('node:fs')
1
2
  const path = require('node:path')
3
+ const { parse } = require('@sullux/markdown-compiler')
4
+ const { parseYaml } = require('./yaml')
2
5
 
3
6
  const parseArgs = (args = []) => {
4
7
  const options = {}
5
8
  for (let i = 0; i < args.length; i++) {
6
9
  const arg = args[i]
7
- if (arg === '-i' || arg === '--input') {
8
- options.input = args[++i]
9
- } else if (arg === '-o' || arg === '--output') {
10
- options.output = args[++i]
11
- } else if (arg === '-t' || arg === '--title') {
12
- options.title = args[++i]
13
- } else if (arg === '--logo') {
14
- options.logo = args[++i]
15
- } else if (arg === '--baseUrl') {
16
- options.baseUrl = args[++i]
17
- } else if (arg === '-h' || arg === '--help') {
18
- options.help = true
19
- }
10
+ if (arg === '-i' || arg === '--input') options.input = args[++i]
11
+ else if (arg === '-o' || arg === '--output') options.output = args[++i]
12
+ else if (arg === '-b' || arg === '--base-url' || arg === '--baseUrl') options.baseUrl = args[++i]
13
+ else if (arg === '-t' || arg === '--title') options.title = args[++i]
14
+ else if (arg === '-c' || arg === '--config') options.config = args[++i]
15
+ else if (arg === '-h' || arg === '--help') options.help = true
20
16
  }
21
17
  return options
22
18
  }
23
19
 
20
+ const loadFileConfig = (inputDir, customConfigPath) => {
21
+ const configFile = customConfigPath ? path.resolve(customConfigPath) : null
22
+ const candidates = configFile ? [configFile] : [
23
+ path.join(inputDir, 'docs.yaml'),
24
+ path.join(inputDir, 'docs.yml'),
25
+ path.join(inputDir, 'docs.json'),
26
+ ]
27
+
28
+ for (const file of candidates) {
29
+ if (fs.existsSync(file)) {
30
+ try {
31
+ const content = fs.readFileSync(file, 'utf8')
32
+ if (file.endsWith('.json')) return JSON.parse(content)
33
+ return parseYaml(content)
34
+ } catch (e) {
35
+ return {}
36
+ }
37
+ }
38
+ }
39
+
40
+ const summaryPath = path.join(inputDir, 'SUMMARY.md')
41
+ if (fs.existsSync(summaryPath)) {
42
+ const ast = parse(fs.readFileSync(summaryPath, 'utf8'))
43
+ if (ast.frontmatter && Object.keys(ast.frontmatter).length > 0) {
44
+ return ast.frontmatter
45
+ }
46
+ }
47
+
48
+ return {}
49
+ }
50
+
24
51
  const normalizeConfig = (opts = {}) => {
25
52
  const input = path.resolve(opts.input || process.cwd())
26
- const output = path.resolve(opts.output || path.join(input, '_site'))
27
- const title = opts.title || path.basename(input)
28
- const baseUrl = opts.baseUrl || ''
29
- const logo = opts.logo || ''
53
+ const fileConfig = loadFileConfig(input, opts.config)
54
+
55
+ const output = path.resolve(opts.output || fileConfig.output || path.join(input, '_site'))
56
+ const title = opts.title !== undefined ? opts.title : (fileConfig.title !== undefined ? fileConfig.title : '')
57
+ const baseUrl = opts.baseUrl !== undefined ? opts.baseUrl : (fileConfig.baseUrl !== undefined ? fileConfig.baseUrl : '')
58
+ const logo = fileConfig.logo || opts.logo || ''
59
+ const favicon = fileConfig.favicon || opts.favicon || ''
60
+ const links = fileConfig.links || opts.links || []
61
+ const theme = { ...(fileConfig.theme || {}), ...(opts.theme || {}) }
30
62
 
31
- return { input, output, title, baseUrl, logo }
63
+ return { input, output, title, baseUrl, logo, favicon, links, theme }
32
64
  }
33
65
 
34
- module.exports = { parseArgs, normalizeConfig }
66
+ module.exports = { parseArgs, normalizeConfig, loadFileConfig }
package/lib/icons.js ADDED
@@ -0,0 +1,10 @@
1
+ const SVGS = {
2
+ hamburger: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>`,
3
+ pageIndex: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="5" x2="20" y2="5"/><line x1="4" y1="12" x2="4" y2="19"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/></svg>`,
4
+ close: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
5
+ sun: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`,
6
+ system: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`,
7
+ moon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`,
8
+ }
9
+
10
+ module.exports = { SVGS }
package/lib/layout.js CHANGED
@@ -1,53 +1,95 @@
1
+ const path = require('node:path')
1
2
  const { getCss } = require('./theme')
2
3
  const { getSearchScript } = require('./search')
4
+ const { SVGS } = require('./icons')
3
5
 
4
- const renderNavItems = (items, currentHref, baseUrl = '') => {
5
- if (!items || items.length === 0) return ''
6
- return `<ul class="nav-list">\n` + items.map((item) => {
7
- const isActive = item.href === currentHref ? ' active' : ''
8
- const itemHref = item.href ? `${baseUrl}${item.href.replace(/\.md$/, '.html')}` : '#'
9
- const subNav = item.children ? renderNavItems(item.children, currentHref, baseUrl) : ''
10
- return `<li class="nav-item">
11
- <a href="${itemHref}" class="nav-link${isActive}">${item.title}</a>
12
- ${subNav}
13
- </li>\n`
14
- }).join('') + `</ul>\n`
6
+ const calcRelativeHref = (currentHref, targetHref) => targetHref ? (path.relative(path.dirname(currentHref), targetHref.replace(/\.md$/, '.html')) || './') : '#'
7
+
8
+ const renderNavTree = (items, currentHref) => {
9
+ if (!items?.length) return ''
10
+ let html = '', group = []
11
+ const flushGroup = () => {
12
+ if (!group.length) return ''
13
+ const list = `<ul class="nav-list">\n` + group.map((item) => {
14
+ const active = item.href === currentHref ? ' active' : ''
15
+ const href = calcRelativeHref(currentHref, item.href)
16
+ const sub = item.children?.length ? renderNavTree(item.children, currentHref) : ''
17
+ return `<li class="nav-item"><a href="${href}" class="nav-link${active}">${item.title}</a>${sub ? `<div class="nav-sub">${sub}</div>` : ''}</li>\n`
18
+ }).join('') + `</ul>\n`
19
+ group = []
20
+ return list
21
+ }
22
+ for (const item of items) {
23
+ if (item.type === 'section') {
24
+ html += `${flushGroup()}<div class="nav-section-title">${item.title}</div>\n`
25
+ if (item.items?.length) html += renderNavTree(item.items, currentHref)
26
+ } else group.push(item)
27
+ }
28
+ return html + flushGroup()
29
+ }
30
+
31
+ const renderTocList = (toc) => toc?.length ? `<div class="toc-title">On this page</div>\n<ul class="toc-list">\n` + toc.map((i) => `<li class="toc-item level-${i.level}"><a href="#${i.id}" class="toc-link" onclick="closeAllDrawers()">${i.title}</a></li>`).join('') + `</ul>\n` : ''
32
+
33
+ const resolveAssetHref = (currentHref, assetPath) => (!assetPath || assetPath.startsWith('http') || assetPath.startsWith('<svg') || assetPath.startsWith('data:') || assetPath.startsWith('/')) ? assetPath : calcRelativeHref(currentHref, assetPath.replace(/^\.\//, ''))
34
+
35
+ const renderSingleLogo = (logo, modeClass, title, currentHref) => logo ? (typeof logo === 'string' && logo.startsWith('<svg') ? logo : `<img src="${resolveAssetHref(currentHref, logo)}" alt="${title || 'Logo'}" class="${modeClass ? `brand-logo ${modeClass}` : 'brand-logo'}" />`) : ''
36
+
37
+ const renderBrandLogo = (logo, title, currentHref) => {
38
+ const titleSpan = title ? `<span>${title}</span>` : ''
39
+ if (!logo) return title ? `📚 ${title}` : '📚'
40
+ if (typeof logo === 'string') return `${renderSingleLogo(logo, '', title, currentHref)}${titleSpan}`
41
+ if (typeof logo === 'object') return `${renderSingleLogo(logo.light, 'logo-light', title, currentHref)}${renderSingleLogo(logo.dark, 'logo-dark', title, currentHref)}${titleSpan}`
42
+ return title ? `📚 ${title}` : '📚'
15
43
  }
16
44
 
17
- const renderPageLayout = ({ title, navTree, contentHtml, currentHref, baseUrl = '' }) => {
18
- const sidebarNav = renderNavItems(navTree, currentHref, baseUrl)
19
-
20
- return `<!DOCTYPE html>
21
- <html lang="en">
22
- <head>
23
- <meta charset="UTF-8" />
24
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
25
- <title>${title}</title>
26
- <style>${getCss()}</style>
27
- </head>
28
- <body>
29
- <aside class="app-sidebar">
30
- <a href="${baseUrl}index.html" class="app-title">${title}</a>
31
- <div class="search-box">
32
- <input type="text" id="doc-search" class="search-input" placeholder="Search docs..." />
33
- <div id="search-results" class="search-results"></div>
34
- </div>
35
- <nav class="app-nav">
36
- ${sidebarNav}
37
- </nav>
38
- </aside>
39
- <main class="app-main">
40
- <header class="app-header">
41
- <span class="header-title">${title}</span>
42
- <button class="theme-toggle" onclick="document.body.toggleAttribute('data-theme', document.body.hasAttribute('data-theme') ? '' : 'dark')">Theme</button>
43
- </header>
44
- <article class="app-content">
45
- ${contentHtml}
46
- </article>
47
- </main>
45
+ const renderFavicon = (f) => f ? `<link rel="icon" href="${f}" />` : `<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📚</text></svg>">`
46
+
47
+ const renderHeaderLinks = (links = []) => links?.length ? `<div class="header-links">` + links.map((l) => `<a href="${l.url}" target="_blank" rel="noopener" class="header-link">${l.title} ↗</a>`).join('') + `</div>` : ''
48
+
49
+ const renderPageLayout = ({ title, siteTitle, navTree, toc, contentHtml, currentHref, logo, favicon, links, theme }) => {
50
+ const sidebarNav = renderNavTree(navTree, currentHref)
51
+ return `<!DOCTYPE html><html lang="en"><head>
52
+ <meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
53
+ <title>${title}</title>${renderFavicon(favicon)}<style>${getCss(theme)}</style>
54
+ <script>
55
+ function setThemeMode(m) {
56
+ try { localStorage.setItem('sullux-theme-mode', m); } catch(e) {}
57
+ if (m === 'light' || m === 'dark') document.documentElement.setAttribute('data-theme', m); else document.documentElement.removeAttribute('data-theme');
58
+ document.querySelectorAll('.theme-opt').forEach(function(b) { b.classList.toggle('active', b.getAttribute('data-mode') === m); });
59
+ }
60
+ function toggleDrawer(id) {
61
+ var d = document.getElementById(id), b = document.getElementById('drawer-backdrop'), open = d?.classList.contains('open');
62
+ closeAllDrawers(); if (!open && d && b) { d.classList.add('open'); b.classList.add('open'); }
63
+ }
64
+ function closeAllDrawers() { document.querySelectorAll('.app-sidebar, .app-toc, .drawer-backdrop').forEach(function(e) { e.classList.remove('open'); }); }
65
+ (function() {
66
+ var m = 'system'; try { m = localStorage.getItem('sullux-theme-mode') || 'system'; } catch(e) {}
67
+ if (m === 'light' || m === 'dark') document.documentElement.setAttribute('data-theme', m);
68
+ })();
69
+ window.addEventListener('DOMContentLoaded', function() { setThemeMode(localStorage.getItem('sullux-theme-mode') || 'system'); });
70
+ </script>
71
+ </head><body>
72
+ <header class="top-header">
73
+ <div class="header-left"><button class="header-btn hamburger-btn" onclick="toggleDrawer('sidebar-drawer')" aria-label="Toggle navigation">${SVGS.hamburger}</button><a href="${calcRelativeHref(currentHref, 'index.html')}" class="header-brand">${renderBrandLogo(logo, siteTitle, currentHref)}</a></div>
74
+ <div class="header-center"><div class="search-box"><input type="text" id="doc-search" class="search-input" placeholder="Search docs..." /><div id="search-results" class="search-results"></div></div></div>
75
+ <div class="header-right">${renderHeaderLinks(links)}<button class="header-btn page-index-btn" onclick="toggleDrawer('toc-drawer')" aria-label="Toggle page outline">${SVGS.pageIndex}</button></div>
76
+ </header>
77
+ <div class="app-container">
78
+ <aside id="sidebar-drawer" class="app-sidebar"><div class="drawer-header"><span class="drawer-title">Navigation</span><button class="drawer-close" onclick="closeAllDrawers()">${SVGS.close}</button></div><nav class="app-nav">${sidebarNav}</nav></aside>
79
+ <main class="app-main"><article class="app-article">${contentHtml}</article></main>
80
+ <aside id="toc-drawer" class="app-toc">
81
+ <div class="drawer-header"><span class="drawer-title">Page Outline</span><button class="drawer-close" onclick="closeAllDrawers()">${SVGS.close}</button></div>
82
+ ${renderTocList(toc)}
83
+ <div class="theme-picker">
84
+ <button class="theme-opt" data-mode="light" onclick="setThemeMode('light')" title="Light">${SVGS.sun}</button>
85
+ <button class="theme-opt" data-mode="system" onclick="setThemeMode('system')" title="System">${SVGS.system}</button>
86
+ <button class="theme-opt" data-mode="dark" onclick="setThemeMode('dark')" title="Dark">${SVGS.moon}</button>
87
+ </div>
88
+ </aside>
89
+ </div>
90
+ <div id="drawer-backdrop" class="drawer-backdrop" onclick="closeAllDrawers()"></div>
48
91
  ${getSearchScript()}
49
- </body>
50
- </html>`
92
+ </body></html>`
51
93
  }
52
94
 
53
- module.exports = { renderPageLayout }
95
+ module.exports = { renderPageLayout, calcRelativeHref }
package/lib/site.js CHANGED
@@ -1,69 +1,65 @@
1
1
  const fs = require('node:fs')
2
2
  const path = require('node:path')
3
+ const { parse } = require('@sullux/markdown-compiler')
3
4
  const { markdownToHtml } = require('@sullux/markdown-html')
4
5
  const { normalizeConfig } = require('./config')
5
6
  const { getNavigationTree } = require('./summary')
7
+ const { extractToc } = require('./toc')
6
8
  const { renderPageLayout } = require('./layout')
7
9
  const { copyAssets } = require('./assets')
8
10
 
9
11
  const collectMarkdownFiles = (dir, rootDir = dir) => {
10
12
  const files = []
11
- const entries = fs.readdirSync(dir, { withFileTypes: true })
12
-
13
- for (const entry of entries) {
13
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
14
14
  if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '_site') continue
15
15
  const fullPath = path.join(dir, entry.name)
16
-
17
- if (entry.isDirectory()) {
18
- files.push(...collectMarkdownFiles(fullPath, rootDir))
19
- } else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'SUMMARY.md') {
20
- const relPath = path.relative(rootDir, fullPath)
21
- files.push({ fullPath, relPath })
16
+ if (entry.isDirectory()) files.push(...collectMarkdownFiles(fullPath, rootDir))
17
+ else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'SUMMARY.md') {
18
+ files.push({ fullPath, relPath: path.relative(rootDir, fullPath) })
22
19
  }
23
20
  }
24
-
25
21
  return files
26
22
  }
27
23
 
24
+ const ensureIndexHtml = (outputDir, generatedPages) => {
25
+ if (generatedPages.some((p) => p === 'index.md')) return
26
+ const readmePath = path.join(outputDir, 'README.html'), indexPath = path.join(outputDir, 'index.html')
27
+ if (fs.existsSync(readmePath)) fs.copyFileSync(readmePath, indexPath)
28
+ else if (generatedPages.length > 0) {
29
+ const firstHtmlPath = path.join(outputDir, generatedPages[0].replace(/\.md$/, '.html'))
30
+ if (fs.existsSync(firstHtmlPath)) fs.copyFileSync(firstHtmlPath, indexPath)
31
+ }
32
+ }
33
+
28
34
  const generateSite = (options = {}) => {
29
35
  const config = normalizeConfig(options)
30
- if (!fs.existsSync(config.input)) {
31
- throw new Error(`Input directory does not exist: ${config.input}`)
32
- }
36
+ if (!fs.existsSync(config.input)) throw new Error(`Input directory does not exist: ${config.input}`)
33
37
 
34
38
  fs.mkdirSync(config.output, { recursive: true })
35
- const navTree = getNavigationTree(config.input)
36
- const mdFiles = collectMarkdownFiles(config.input)
37
- const searchIndex = []
39
+ const navTree = getNavigationTree(config.input), mdFiles = collectMarkdownFiles(config.input), searchIndex = []
38
40
 
39
41
  for (const file of mdFiles) {
40
42
  const rawMd = fs.readFileSync(file.fullPath, 'utf8')
41
- const contentHtml = markdownToHtml(rawMd)
42
-
43
- const relHtmlPath = file.relPath.replace(/\.md$/, '.html')
44
- const outHtmlPath = path.join(config.output, relHtmlPath)
43
+ const ast = parse(rawMd), toc = extractToc(ast), rawHtml = markdownToHtml(rawMd)
44
+ const contentHtml = rawHtml.replace(/href="([^":#]+)\.md(#.*?)?"/g, 'href="$1.html$2"')
45
+ const relHtmlPath = file.relPath.replace(/\.md$/, '.html'), outHtmlPath = path.join(config.output, relHtmlPath)
45
46
 
46
47
  fs.mkdirSync(path.dirname(outHtmlPath), { recursive: true })
47
48
 
48
- const pageTitle = `${file.relPath.replace(/\.md$/, '')} - ${config.title}`
49
+ const pageTitle = config.title ? `${file.relPath.replace(/\.md$/, '')} - ${config.title}` : file.relPath.replace(/\.md$/, '')
50
+
49
51
  const fullHtml = renderPageLayout({
50
- title: pageTitle,
51
- navTree,
52
- contentHtml,
53
- currentHref: file.relPath,
54
- baseUrl: config.baseUrl,
52
+ title: pageTitle, siteTitle: config.title,
53
+ navTree, toc, contentHtml, currentHref: file.relPath,
54
+ logo: config.logo, favicon: config.favicon, links: config.links, theme: config.theme,
55
55
  })
56
56
 
57
57
  fs.writeFileSync(outHtmlPath, fullHtml, 'utf8')
58
-
59
58
  const plainText = rawMd.replace(/[#*`_\[\]()\-]/g, ' ').replace(/\s+/g, ' ').trim()
60
- searchIndex.push({
61
- title: file.relPath.replace(/\.md$/, ''),
62
- href: relHtmlPath,
63
- content: plainText.slice(0, 300),
64
- })
59
+ searchIndex.push({ title: file.relPath.replace(/\.md$/, ''), href: relHtmlPath, content: plainText.slice(0, 300) })
65
60
  }
66
61
 
62
+ ensureIndexHtml(config.output, mdFiles.map((f) => f.relPath))
67
63
  fs.writeFileSync(path.join(config.output, 'search-index.json'), JSON.stringify(searchIndex, null, 2))
68
64
  copyAssets(config.input, config.output)
69
65
 
package/lib/summary.js CHANGED
@@ -4,22 +4,33 @@ const { parse } = require('@sullux/markdown-compiler')
4
4
 
5
5
  const parseSummaryMd = (content) => {
6
6
  const ast = parse(content)
7
- const items = []
7
+ const nav = []
8
+ let currentSection = null
8
9
 
9
10
  for (const block of ast.blocks) {
10
- if (block.type === 'bulletList' || block.type === 'orderedList') {
11
+ if (block.type === 'header' && block.level > 1) {
12
+ const sectionTitle = block.children ? block.children.map((c) => c.value || '').join('') : ''
13
+ currentSection = { type: 'section', title: sectionTitle, items: [] }
14
+ nav.push(currentSection)
15
+ } else if (block.type === 'bulletList' || block.type === 'orderedList') {
16
+ const listItems = []
11
17
  for (const itemNodes of block.items || []) {
12
18
  for (const node of itemNodes) {
13
19
  if (node.type === 'link') {
14
20
  const title = node.children ? node.children.map((c) => c.value || '').join('') : ''
15
- items.push({ title, href: node.url, children: [] })
21
+ listItems.push({ title, href: node.url, children: [] })
16
22
  }
17
23
  }
18
24
  }
25
+ if (currentSection) {
26
+ currentSection.items.push(...listItems)
27
+ } else {
28
+ nav.push(...listItems)
29
+ }
19
30
  }
20
31
  }
21
32
 
22
- return items
33
+ return nav
23
34
  }
24
35
 
25
36
  const scanDir = (dir, rootDir = dir) => {
@@ -34,10 +45,11 @@ const scanDir = (dir, rootDir = dir) => {
34
45
  if (entry.isDirectory()) {
35
46
  const children = scanDir(fullPath, rootDir)
36
47
  if (children.length > 0) {
37
- items.push({ title: entry.name, href: '', children })
48
+ const title = entry.name.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
49
+ items.push({ type: 'section', title, items: children })
38
50
  }
39
51
  } else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'SUMMARY.md') {
40
- const title = entry.name.replace(/\.md$/, '')
52
+ const title = entry.name.replace(/\.md$/, '').replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
41
53
  items.push({ title, href: relPath, children: [] })
42
54
  }
43
55
  }
@@ -48,8 +60,7 @@ const scanDir = (dir, rootDir = dir) => {
48
60
  const getNavigationTree = (inputDir) => {
49
61
  const summaryPath = path.join(inputDir, 'SUMMARY.md')
50
62
  if (fs.existsSync(summaryPath)) {
51
- const content = fs.readFileSync(summaryPath, 'utf8')
52
- return parseSummaryMd(content)
63
+ return parseSummaryMd(fs.readFileSync(summaryPath, 'utf8'))
53
64
  }
54
65
  return scanDir(inputDir)
55
66
  }