@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 +80 -33
- package/bin/cli.js +3 -2
- package/lib/config.js +51 -19
- package/lib/icons.js +10 -0
- package/lib/layout.js +87 -45
- package/lib/site.js +28 -32
- package/lib/summary.js +19 -8
- package/lib/theme.css +719 -0
- package/lib/theme.js +16 -70
- package/lib/toc.js +12 -5
- package/lib/yaml.js +82 -0
- package/package.json +1 -1
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
|
|
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:**
|
|
13
|
-
* **Rich Component Support:** Supports
|
|
14
|
-
* **Static Asset Copying:** Automatically copies non-
|
|
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
|
-
##
|
|
18
|
+
## CLI Usage
|
|
17
19
|
|
|
18
20
|
```bash
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
+
# Generate site using default docs.yaml configuration in ./docs
|
|
22
|
+
markdown-docs -i ./docs -o ./_site
|
|
21
23
|
|
|
22
|
-
#
|
|
23
|
-
markdown-docs -i ./docs -o ./_site -
|
|
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` |
|
|
31
|
-
| `-o` | `--output` |
|
|
32
|
-
| `-
|
|
33
|
-
| | `--
|
|
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
|
-
|
|
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
|
-
-
|
|
14
|
-
--
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
25
|
-
<title>${title}</title>
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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.
|
|
18
|
-
files.push(
|
|
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
|
|
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
|
-
|
|
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
|
|
7
|
+
const nav = []
|
|
8
|
+
let currentSection = null
|
|
8
9
|
|
|
9
10
|
for (const block of ast.blocks) {
|
|
10
|
-
if (block.type === '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
return parseSummaryMd(content)
|
|
63
|
+
return parseSummaryMd(fs.readFileSync(summaryPath, 'utf8'))
|
|
53
64
|
}
|
|
54
65
|
return scanDir(inputDir)
|
|
55
66
|
}
|