@docmd/parser 0.4.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 docmd (docmd.io)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # @docmd/parser
2
+
3
+ The pure logic engine for **docmd**.
4
+
5
+ This package handles:
6
+ - Markdown parsing (via `markdown-it`)
7
+ - Custom features (Callouts, Tabs, Steps, Changelogs)
8
+ - HTML template rendering (EJS)
9
+
10
+ It is environment-agnostic (Node.js & Browser).
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@docmd/parser",
3
+ "version": "0.4.0",
4
+ "description": "Pure Markdown parsing engine for docmd",
5
+ "main": "src/index.js",
6
+ "dependencies": {
7
+ "ejs": "^3.1.10",
8
+ "gray-matter": "^4.0.3",
9
+ "highlight.js": "^11.9.0",
10
+ "lucide-static": "^0.370.0",
11
+ "markdown-it": "^14.1.0",
12
+ "markdown-it-attrs": "^4.1.6",
13
+ "markdown-it-footnote": "^4.0.0",
14
+ "markdown-it-task-lists": "^2.1.1",
15
+ "markdown-it-abbr": "^2.0.0",
16
+ "markdown-it-deflist": "^3.0.0",
17
+ "markdown-it-container": "^4.0.0"
18
+ },
19
+ "license": "MIT"
20
+ }
@@ -0,0 +1,38 @@
1
+ module.exports = {
2
+ name: 'basics',
3
+ setup(md) {
4
+ // 1. Custom Image Renderer
5
+ const defaultImageRenderer = md.renderer.rules.image || function(tokens, idx, options, env, self) {
6
+ return self.renderToken(tokens, idx, options);
7
+ };
8
+
9
+ md.renderer.rules.image = function(tokens, idx, options, env, self) {
10
+ const renderedImage = defaultImageRenderer(tokens, idx, options, env, self);
11
+ const nextToken = tokens[idx + 1];
12
+
13
+ // Look ahead for attributes syntax { .class } immediately after image
14
+ if (nextToken && nextToken.type === 'attrs_block') {
15
+ // markdown-it-attrs usually handles this, but if we need specific logic for
16
+ // aligning images that don't use standard attributes, we do it here.
17
+ // For standard docmd usage, markdown-it-attrs handles {.align-center}, etc.
18
+ // But we explicitly support 'attributes merging' if needed.
19
+
20
+ // Actually, for docmd v0.3 compatibility, we rely on `markdown-it-attrs`
21
+ // to handle classes like .align-right, .size-small.
22
+ // So we might strictly NOT need a custom renderer unless we are doing
23
+ // something proprietary.
24
+
25
+ // However, we DO need the TABLE wrapper.
26
+ }
27
+ return renderedImage;
28
+ };
29
+
30
+ // 2. Table Wrapper (Horizontal Scroll)
31
+ md.renderer.rules.table_open = (tokens, idx, options, env, self) => {
32
+ return '<div class="table-wrapper">' + self.renderToken(tokens, idx, options);
33
+ };
34
+ md.renderer.rules.table_close = (tokens, idx, options, env, self) => {
35
+ return self.renderToken(tokens, idx, options) + '</div>';
36
+ };
37
+ }
38
+ };
@@ -0,0 +1,115 @@
1
+ // Helper: Smart Dedent (reused, ideally move to utils, but inline is fine for isolation)
2
+ function smartDedent(str) {
3
+ const lines = str.split('\n');
4
+ let minIndent = Infinity;
5
+ lines.forEach(line => {
6
+ if (line.trim().length === 0) return;
7
+ const match = line.match(/^ */);
8
+ if (match[0].length < minIndent) minIndent = match[0].length;
9
+ });
10
+ if (minIndent === Infinity) return str;
11
+ return lines.map(line => line.trim().length ? line.substring(minIndent) : '').join('\n');
12
+ }
13
+
14
+ function changelogRule(state, startLine, endLine, silent) {
15
+ const start = state.bMarks[startLine] + state.tShift[startLine];
16
+ const max = state.eMarks[startLine];
17
+ const lineContent = state.src.slice(start, max).trim();
18
+
19
+ if (lineContent !== '::: changelog') return false;
20
+ if (silent) return true;
21
+
22
+ let nextLine = startLine;
23
+ let found = false;
24
+ let depth = 1;
25
+ let inFence = false;
26
+
27
+ while (nextLine < endLine) {
28
+ nextLine++;
29
+ const nextStart = state.bMarks[nextLine] + state.tShift[nextLine];
30
+ const nextMax = state.eMarks[nextLine];
31
+ const nextContent = state.src.slice(nextStart, nextMax).trim();
32
+
33
+ // Simple fence check
34
+ if (/^(\s{0,3})(~{3,}|`{3,})/.test(nextContent)) inFence = !inFence;
35
+
36
+ if (!inFence) {
37
+ if (nextContent.startsWith(':::')) {
38
+ if (nextContent.match(/^:::\s*changelog/)) depth++;
39
+ else if (nextContent === ':::') {
40
+ depth--;
41
+ if (depth === 0) { found = true; break; }
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ if (!found) return false;
48
+
49
+ // Extract content block
50
+ let content = '';
51
+ for (let i = startLine + 1; i < nextLine; i++) {
52
+ const lineStart = state.bMarks[i] + state.tShift[i];
53
+ const lineEnd = state.eMarks[i];
54
+ content += state.src.slice(lineStart, lineEnd) + '\n';
55
+ }
56
+
57
+ // Parse "== Date" entries
58
+ const lines = content.split('\n');
59
+ const entries = [];
60
+ let currentEntry = null;
61
+ let currentContentLines = [];
62
+
63
+ for (let i = 0; i < lines.length; i++) {
64
+ const rawLine = lines[i];
65
+ const trimmedLine = rawLine.trim();
66
+ const markerMatch = trimmedLine.match(/^==\s+(.+)$/);
67
+
68
+ if (markerMatch) {
69
+ if (currentEntry) {
70
+ currentEntry.content = smartDedent(currentContentLines.join('\n'));
71
+ entries.push(currentEntry);
72
+ }
73
+ currentEntry = { meta: markerMatch[1], content: '' };
74
+ currentContentLines = [];
75
+ } else if (currentEntry) {
76
+ currentContentLines.push(rawLine);
77
+ }
78
+ }
79
+ if (currentEntry) {
80
+ currentEntry.content = smartDedent(currentContentLines.join('\n'));
81
+ entries.push(currentEntry);
82
+ }
83
+
84
+ state.push('changelog_open', 'div', 1);
85
+
86
+ entries.forEach(entry => {
87
+ // We render HTML blocks directly for the timeline structure
88
+ const entryOpen = state.push('html_block', '', 0);
89
+ entryOpen.content = `<div class="changelog-entry">
90
+ <div class="changelog-meta"><span class="changelog-date">${entry.meta}</span></div>
91
+ <div class="changelog-body">`;
92
+
93
+ // Recurse render the markdown inside the entry
94
+ entryOpen.content += state.md.render(entry.content, state.env);
95
+
96
+ const entryClose = state.push('html_block', '', 0);
97
+ entryClose.content = `</div></div>`;
98
+ });
99
+
100
+ state.push('changelog_close', 'div', -1);
101
+ state.line = nextLine + 1;
102
+ return true;
103
+ }
104
+
105
+ module.exports = {
106
+ name: 'changelog',
107
+ setup(md) {
108
+ // Register Rule
109
+ md.block.ruler.before('fence', 'changelog_timeline', changelogRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
110
+
111
+ // Register Container Renderer
112
+ md.renderer.rules.changelog_open = () => '<div class="docmd-container changelog-timeline">';
113
+ md.renderer.rules.changelog_close = () => '</div>';
114
+ }
115
+ };
@@ -0,0 +1,97 @@
1
+ const container = require('markdown-it-container');
2
+
3
+ module.exports = {
4
+ name: 'common-containers',
5
+ setup(md) {
6
+ // 1. Callout
7
+ md.use(container, 'callout', {
8
+ render: (tokens, idx) => {
9
+ if (tokens[idx].nesting === 1) {
10
+ const info = tokens[idx].info.trim();
11
+ const parts = info.split(' ');
12
+ const type = parts[1] || 'info';
13
+ const title = parts.slice(2).join(' ');
14
+ return `<div class="docmd-container callout callout-${type}">${title ? `<div class="callout-title">${title}</div>` : ''}<div class="callout-content">\n`;
15
+ }
16
+ return '</div></div>\n';
17
+ }
18
+ });
19
+
20
+ // 2. Card
21
+ md.use(container, 'card', {
22
+ render: (tokens, idx) => {
23
+ if (tokens[idx].nesting === 1) {
24
+ const title = tokens[idx].info.replace('card', '').trim();
25
+ return `<div class="docmd-container card">${title ? `<div class="card-title">${title}</div>` : ''}<div class="card-content">\n`;
26
+ }
27
+ return '</div></div>\n';
28
+ }
29
+ });
30
+
31
+ // 3. Collapsible
32
+ md.use(container, 'collapsible', {
33
+ render: (tokens, idx) => {
34
+ if (tokens[idx].nesting === 1) {
35
+ const info = tokens[idx].info.replace('collapsible', '').trim();
36
+ // Check for "open" keyword
37
+ const isOpen = info.startsWith('open ') || info === 'open';
38
+ const title = isOpen ? info.replace('open', '').trim() : info;
39
+ const displayTitle = title || 'Click to expand';
40
+
41
+ return `<details class="docmd-container collapsible" ${isOpen ? 'open' : ''}>
42
+ <summary class="collapsible-summary">
43
+ <span class="collapsible-title">${displayTitle}</span>
44
+ <span class="collapsible-arrow"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg></span>
45
+ </summary>
46
+ <div class="collapsible-content">\n`;
47
+ }
48
+ return '</div></details>\n';
49
+ }
50
+ });
51
+
52
+ // 4. Button (Note: Buttons are often block-level if using :::, or inline if different syntax)
53
+ md.use(container, 'button', {
54
+ render: (tokens, idx) => {
55
+ if (tokens[idx].nesting === 1) {
56
+ const info = tokens[idx].info.trim().substring(6).trim(); // Remove "button"
57
+
58
+ // Default vars
59
+ let text = 'Button';
60
+ let url = '#';
61
+ let style = '';
62
+
63
+ // Helper to check if string looks like a URL
64
+ const isUrl = (s) => s.startsWith('http') || s.startsWith('/') || s.startsWith('./') || s.startsWith('#');
65
+
66
+ const parts = info.split(/\s+/);
67
+
68
+ // Logic: Scan parts to find the URL. Everything before is Text. Everything after is Color/Style.
69
+ const urlIndex = parts.findIndex(p => isUrl(p));
70
+
71
+ if (urlIndex > -1) {
72
+ url = parts[urlIndex];
73
+ text = parts.slice(0, urlIndex).join(' ').replace(/_/g, ' '); // Allow underscores for spaces if needed
74
+
75
+ // Check for color after URL
76
+ if (parts[urlIndex + 1] && parts[urlIndex + 1].startsWith('color:')) {
77
+ const color = parts[urlIndex + 1].split(':')[1];
78
+ style = ` style="background-color: ${color}; border-color: ${color}; color: #fff;"`;
79
+ }
80
+ } else {
81
+ // No URL found, assume first part is text
82
+ if (parts.length > 0) text = parts.join(' ');
83
+ }
84
+
85
+ // Handle External Link
86
+ let targetAttr = '';
87
+ if (url.startsWith('http')) {
88
+ targetAttr = ' target="_blank" rel="noopener noreferrer"';
89
+ }
90
+
91
+ return `<a href="${url}" class="docmd-button"${style}${targetAttr}>${text}`;
92
+ }
93
+ return '</a>\n';
94
+ }
95
+ });
96
+ }
97
+ };
@@ -0,0 +1,15 @@
1
+ const common = require('./common-containers');
2
+ const tabs = require('./tabs');
3
+ const steps = require('./steps');
4
+ const changelog = require('./changelog');
5
+ const basics = require('./basics');
6
+
7
+ const FEATURES = [basics, common, tabs, steps, changelog];
8
+
9
+ function registerFeatures(md) {
10
+ FEATURES.forEach(feature => {
11
+ if (feature.setup) feature.setup(md);
12
+ });
13
+ }
14
+
15
+ module.exports = { registerFeatures };
@@ -0,0 +1,79 @@
1
+ function stepsRule(state, startLine, endLine, silent) {
2
+ const start = state.bMarks[startLine] + state.tShift[startLine];
3
+ const max = state.eMarks[startLine];
4
+ const lineContent = state.src.slice(start, max).trim();
5
+ if (lineContent !== '::: steps') return false;
6
+ if (silent) return true;
7
+
8
+ // ... (Same seeking logic as tabs, abbreviated for brevity) ...
9
+ let nextLine = startLine;
10
+ let found = false;
11
+ let depth = 1;
12
+ let inFence = false;
13
+ while (nextLine < endLine) {
14
+ nextLine++;
15
+ const nextStart = state.bMarks[nextLine] + state.tShift[nextLine];
16
+ const nextMax = state.eMarks[nextLine];
17
+ const nextContent = state.src.slice(nextStart, nextMax).trim();
18
+ if (/^(\s{0,3})(~{3,}|`{3,})/.test(nextContent)) inFence = !inFence;
19
+ if (!inFence && nextContent === ':::') {
20
+ depth--;
21
+ if (depth === 0) { found = true; break; }
22
+ }
23
+ }
24
+
25
+ if (!found) return false;
26
+
27
+ const openToken = state.push('steps_open', 'div', 1);
28
+ openToken.info = '';
29
+
30
+ const oldParentType = state.parentType;
31
+ const oldLineMax = state.lineMax;
32
+ state.parentType = 'container';
33
+ state.lineMax = nextLine;
34
+
35
+ state.md.block.tokenize(state, startLine + 1, nextLine);
36
+
37
+ const closeToken = state.push('steps_close', 'div', -1);
38
+
39
+ state.parentType = oldParentType;
40
+ state.lineMax = oldLineMax;
41
+ state.line = nextLine + 1;
42
+ return true;
43
+ }
44
+
45
+ module.exports = {
46
+ name: 'steps',
47
+ setup(md) {
48
+ md.block.ruler.before('fence', 'steps_container', stepsRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
49
+
50
+ // Custom List Renderer for Steps
51
+ md.renderer.rules.steps_open = () => '<div class="docmd-container steps steps-reset">';
52
+ md.renderer.rules.steps_close = () => '</div>';
53
+
54
+ // Hook into list rendering to add classes when inside steps
55
+ md.renderer.rules.ordered_list_open = function(tokens, idx, options, env, self) {
56
+ let isInSteps = false;
57
+ // Check tokens backward to see if we are inside a steps container
58
+ for (let i = idx - 1; i >= 0; i--) {
59
+ if (tokens[i].type === 'steps_open') { isInSteps = true; break; }
60
+ if (tokens[i].type === 'steps_close') break;
61
+ }
62
+ if (isInSteps) {
63
+ const start = tokens[idx].attrGet('start');
64
+ return start ? `<ol class="steps-list" start="${start}">` : '<ol class="steps-list">';
65
+ }
66
+ return self.renderToken(tokens, idx, options);
67
+ };
68
+
69
+ md.renderer.rules.list_item_open = function(tokens, idx, options, env, self) {
70
+ let isInSteps = false;
71
+ for (let i = idx - 1; i >= 0; i--) {
72
+ if (tokens[i].type === 'steps_open') { isInSteps = true; break; }
73
+ if (tokens[i].type === 'steps_close') break;
74
+ }
75
+ if (isInSteps) return '<li class="step-item">';
76
+ return '<li>';
77
+ };
78
+ }
79
+ };
@@ -0,0 +1,144 @@
1
+ // Helper for "Smart Dedent"
2
+ function smartDedent(str) {
3
+ const lines = str.split('\n');
4
+ let minIndent = Infinity;
5
+ lines.forEach(line => {
6
+ if (line.trim().length === 0) return;
7
+ const match = line.match(/^ */);
8
+ if (match[0].length < minIndent) minIndent = match[0].length;
9
+ });
10
+ if (minIndent === Infinity) return str;
11
+ return lines.map(line => {
12
+ if (line.trim().length === 0) return '';
13
+ const dedented = line.substring(minIndent);
14
+ // Fix Code Fences that were indented
15
+ if (/^\s{4,}(`{3,}|~{3,})/.test(dedented)) return dedented.trimStart();
16
+ return dedented;
17
+ }).join('\n');
18
+ }
19
+
20
+ // Helper to identify fences
21
+ function isFenceLine(line) {
22
+ return /^(\s{0,3})(~{3,}|`{3,})/.test(line);
23
+ }
24
+
25
+ // The Parsing Rule
26
+ function tabsRule(state, startLine, endLine, silent) {
27
+ const start = state.bMarks[startLine] + state.tShift[startLine];
28
+ const max = state.eMarks[startLine];
29
+ const lineContent = state.src.slice(start, max).trim();
30
+
31
+ if (lineContent !== '::: tabs') return false;
32
+ if (silent) return true;
33
+
34
+ let nextLine = startLine;
35
+ let found = false;
36
+ let depth = 1;
37
+ let inFence = false;
38
+
39
+ // Find closing :::
40
+ while (nextLine < endLine) {
41
+ nextLine++;
42
+ const nextStart = state.bMarks[nextLine] + state.tShift[nextLine];
43
+ const nextMax = state.eMarks[nextLine];
44
+ const nextContent = state.src.slice(nextStart, nextMax).trim();
45
+
46
+ if (isFenceLine(nextContent)) inFence = !inFence;
47
+
48
+ if (!inFence) {
49
+ if (nextContent.startsWith(':::')) {
50
+ if (nextContent.match(/^:::\s*tabs/)) depth++;
51
+ else if (nextContent === ':::') {
52
+ depth--;
53
+ if (depth === 0) { found = true; break; }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ if (!found) return false;
59
+
60
+ // Extract content
61
+ let content = '';
62
+ for (let i = startLine + 1; i < nextLine; i++) {
63
+ const lineStart = state.bMarks[i] + state.tShift[i];
64
+ const lineEnd = state.eMarks[i];
65
+ content += state.src.slice(lineStart, lineEnd) + '\n';
66
+ }
67
+
68
+ // Parse "== tab" lines
69
+ const lines = content.split('\n');
70
+ const tabs = [];
71
+ let currentTab = null;
72
+ let currentContentLines = [];
73
+
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const rawLine = lines[i];
76
+ const trimmedLine = rawLine.trim();
77
+ const tabMatch = trimmedLine.match(/^==\s*tab\s+(?:"([^"]+)"|(\S+))$/);
78
+
79
+ if (tabMatch) {
80
+ if (currentTab) {
81
+ currentTab.content = smartDedent(currentContentLines.join('\n'));
82
+ tabs.push(currentTab);
83
+ }
84
+ const title = tabMatch[1] || tabMatch[2];
85
+ currentTab = { title: title, content: '' };
86
+ currentContentLines = [];
87
+ } else if (currentTab) {
88
+ currentContentLines.push(rawLine);
89
+ }
90
+ }
91
+ if (currentTab) {
92
+ currentTab.content = smartDedent(currentContentLines.join('\n'));
93
+ tabs.push(currentTab);
94
+ }
95
+
96
+ // Generate Tokens
97
+ const openToken = state.push('tabs_open', 'div', 1);
98
+ openToken.attrs = [['class', 'docmd-tabs']];
99
+
100
+ const navToken = state.push('tabs_nav_open', 'div', 1);
101
+ tabs.forEach((tab, index) => {
102
+ const navItemToken = state.push('tabs_nav_item', 'div', 0);
103
+ navItemToken.attrs = [['class', `docmd-tabs-nav-item ${index === 0 ? 'active' : ''}`]];
104
+ navItemToken.content = tab.title;
105
+ });
106
+ state.push('tabs_nav_close', 'div', -1);
107
+
108
+ const contentToken = state.push('tabs_content_open', 'div', 1);
109
+ tabs.forEach((tab, index) => {
110
+ const paneToken = state.push('tab_pane_open', 'div', 1);
111
+ paneToken.attrs = [['class', `docmd-tab-pane ${index === 0 ? 'active' : ''}`]];
112
+
113
+ if (tab.content) {
114
+ // Recurse parsing inside tabs
115
+ const renderedContent = state.md.render(tab.content, state.env);
116
+ const htmlToken = state.push('html_block', '', 0);
117
+ htmlToken.content = renderedContent;
118
+ }
119
+ state.push('tab_pane_close', 'div', -1);
120
+ });
121
+ state.push('tabs_content_close', 'div', -1);
122
+ state.push('tabs_close', 'div', -1);
123
+
124
+ state.line = nextLine + 1;
125
+ return true;
126
+ }
127
+
128
+ module.exports = {
129
+ name: 'tabs',
130
+ setup(md) {
131
+ // Register Rule
132
+ md.block.ruler.before('fence', 'enhanced_tabs', tabsRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
133
+
134
+ // Register Renderers
135
+ md.renderer.rules.tabs_nav_open = () => '<div class="docmd-tabs-nav">';
136
+ md.renderer.rules.tabs_nav_close = () => '</div>';
137
+ md.renderer.rules.tabs_nav_item = (tokens, idx) => `<div class="${tokens[idx].attrs[0][1]}">${tokens[idx].content}</div>`;
138
+ md.renderer.rules.tabs_content_open = () => '<div class="docmd-tabs-content">';
139
+ md.renderer.rules.tabs_content_close = () => '</div>';
140
+ md.renderer.rules.tab_pane_open = (tokens, idx) => `<div class="${tokens[idx].attrs[0][1]}">`;
141
+ md.renderer.rules.tab_pane_close = () => '</div>';
142
+ md.renderer.rules.tabs_close = () => '</div>';
143
+ }
144
+ };
@@ -0,0 +1,51 @@
1
+ const ejs = require('ejs');
2
+ const { renderIcon } = require('./utils/icon-renderer');
3
+
4
+ /**
5
+ * Renders an EJS template string with provided data.
6
+ * NOTE: The 'templateString' must be read by the CLI and passed here.
7
+ */
8
+ function renderTemplate(templateString, data, options = {}) {
9
+ // Inject core helpers into every template
10
+ const fullData = {
11
+ ...data,
12
+ renderIcon,
13
+ // Helper to fix links relative to root
14
+ fixLink: (url) => fixHtmlLinks(url, data.relativePathToRoot, data.isOfflineMode, data.config?.base)
15
+ };
16
+
17
+ try {
18
+ return ejs.render(templateString, fullData, options);
19
+ } catch (e) {
20
+ throw new Error(`EJS Render Error: ${e.message}`);
21
+ }
22
+ }
23
+
24
+ function fixHtmlLinks(url, root = './', isOffline = false, base = '/') {
25
+ if (!url || url.startsWith('http') || url.startsWith('#') || url.startsWith('mailto:')) return url;
26
+
27
+ let final = url;
28
+
29
+ // Strip base if present
30
+ if (base !== '/' && url.startsWith(base)) {
31
+ final = '/' + url.substring(base.length);
32
+ }
33
+
34
+ // Make relative
35
+ if (final.startsWith('/')) {
36
+ final = root + final.substring(1);
37
+ }
38
+
39
+ // Offline adjustments
40
+ if (isOffline) {
41
+ if (!final.includes('.') && !final.endsWith('/')) final += '/index.html';
42
+ else if (final.endsWith('/')) final += 'index.html';
43
+ } else {
44
+ // Clean URLs
45
+ if (final.endsWith('/index.html')) final = final.substring(0, final.length - 10);
46
+ }
47
+
48
+ return final;
49
+ }
50
+
51
+ module.exports = { renderTemplate };
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ const { createMarkdownProcessor, processContent } = require('./markdown-processor');
2
+ const { renderTemplate } = require('./html-renderer');
3
+ const { renderIcon } = require('./utils/icon-renderer');
4
+ const { validateConfig } = require('./utils/validator');
5
+
6
+ module.exports = {
7
+ // Logic
8
+ createMarkdownProcessor,
9
+ processContent,
10
+ renderTemplate,
11
+ validateConfig,
12
+
13
+ // Utils
14
+ renderIcon
15
+ };
@@ -0,0 +1,149 @@
1
+ const MarkdownIt = require('markdown-it');
2
+ const matter = require('gray-matter');
3
+ const hljs = require('highlight.js');
4
+
5
+ // Standard Plugins
6
+ const attrs = require('markdown-it-attrs');
7
+ const footnote = require('markdown-it-footnote');
8
+ const taskLists = require('markdown-it-task-lists');
9
+ const abbr = require('markdown-it-abbr');
10
+ const deflist = require('markdown-it-deflist');
11
+
12
+ // The Feature Registry
13
+ const { registerFeatures } = require('./features');
14
+
15
+ // Custom Heading ID Logic (Internal helper)
16
+ const headingIdPlugin = (md) => {
17
+ const originalHeadingOpen = md.renderer.rules.heading_open || function(tokens, idx, options, env, self) {
18
+ return self.renderToken(tokens, idx, options);
19
+ };
20
+
21
+ md.renderer.rules.heading_open = function(tokens, idx, options, env, self) {
22
+ const token = tokens[idx];
23
+ const existingId = token.attrGet('id');
24
+
25
+ if (!existingId) {
26
+ const contentToken = tokens[idx + 1];
27
+ if (contentToken && contentToken.type === 'inline' && contentToken.content) {
28
+ const headingText = contentToken.content;
29
+ const id = headingText
30
+ .toLowerCase()
31
+ .replace(/\s+/g, '-')
32
+ .replace(/[^\w\u4e00-\u9fa5-]+/g, '')
33
+ .replace(/--+/g, '-')
34
+ .replace(/^-+/, '')
35
+ .replace(/-+$/, '');
36
+
37
+ if (id) token.attrSet('id', id);
38
+ }
39
+ }
40
+ return originalHeadingOpen(tokens, idx, options, env, self);
41
+ };
42
+ };
43
+
44
+ function createMarkdownProcessor(config = {}, pluginsCallback) {
45
+ const mdOptions = {
46
+ html: true,
47
+ linkify: true,
48
+ typographer: true,
49
+ breaks: true,
50
+ };
51
+
52
+ // Syntax Highlighting
53
+ const highlightFn = (str, lang) => {
54
+ if (lang === 'mermaid') {
55
+ return `<pre class="mermaid">${new MarkdownIt().utils.escapeHtml(str)}</pre>`;
56
+ }
57
+ if (lang && hljs.getLanguage(lang)) {
58
+ try {
59
+ const highlighted = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
60
+ return `<pre class="hljs"><code>${highlighted}</code></pre>`;
61
+ } catch (e) { /* ignore */ }
62
+ }
63
+ return `<pre class="hljs"><code>${new MarkdownIt().utils.escapeHtml(str)}</code></pre>`;
64
+ };
65
+
66
+ mdOptions.highlight = config.theme?.codeHighlight !== false ? highlightFn : (str, lang) => {
67
+ if (lang === 'mermaid') return `<pre class="mermaid">${new MarkdownIt().utils.escapeHtml(str)}</pre>`;
68
+ return `<pre><code>${new MarkdownIt().utils.escapeHtml(str)}</code></pre>`;
69
+ };
70
+
71
+ const md = new MarkdownIt(mdOptions);
72
+
73
+ // 1. Core Plugins
74
+ md.use(attrs, { leftDelimiter: '{', rightDelimiter: '}' });
75
+ md.use(footnote);
76
+ md.use(taskLists);
77
+ md.use(abbr);
78
+ md.use(deflist);
79
+ md.use(headingIdPlugin);
80
+
81
+ // 2. Register Built-in Features (Callouts, Tabs, Steps, etc.)
82
+ registerFeatures(md);
83
+
84
+ // 3. External Plugins Hook
85
+ if (typeof pluginsCallback === 'function') {
86
+ pluginsCallback(md);
87
+ }
88
+
89
+ return md;
90
+ }
91
+
92
+ function stripHtml(html) {
93
+ if (!html) return '';
94
+ return html.replace(/<[^>]*>?/gm, '');
95
+ }
96
+
97
+ function extractHeadings(html) {
98
+ const headings = [];
99
+ const regex = /<h([1-6])[^>]*?id="([^"]*)"[^>]*?>([\s\S]*?)<\/h\1>/g;
100
+ let match;
101
+ while ((match = regex.exec(html)) !== null) {
102
+ headings.push({
103
+ level: parseInt(match[1], 10),
104
+ id: match[2],
105
+ text: match[3].replace(/<\/?[^>]+(>|$)/g, '').trim()
106
+ });
107
+ }
108
+ return headings;
109
+ }
110
+
111
+ function processContent(rawString, mdInstance, config) {
112
+ let frontmatter, markdownContent;
113
+
114
+ try {
115
+ const parsed = matter(rawString);
116
+ frontmatter = parsed.data;
117
+ markdownContent = parsed.content;
118
+ } catch (e) {
119
+ console.error('Error parsing frontmatter:', e.message);
120
+ return null;
121
+ }
122
+
123
+ if (!frontmatter.title && config.autoTitleFromH1 !== false) {
124
+ const h1Match = markdownContent.match(/^#\s+(.*)/m);
125
+ if (h1Match) frontmatter.title = h1Match[1].trim();
126
+ }
127
+
128
+ let htmlContent, headings;
129
+ if (frontmatter.noStyle === true) {
130
+ htmlContent = markdownContent;
131
+ headings = [];
132
+ } else {
133
+ htmlContent = mdInstance.render(markdownContent);
134
+ headings = extractHeadings(htmlContent);
135
+ }
136
+
137
+ let searchData = null;
138
+ if (!frontmatter.noindex) {
139
+ searchData = {
140
+ title: frontmatter.title || 'Untitled',
141
+ content: stripHtml(htmlContent).slice(0, 5000),
142
+ headings: headings.map(h => h.text)
143
+ };
144
+ }
145
+
146
+ return { frontmatter, htmlContent, headings, searchData };
147
+ }
148
+
149
+ module.exports = { createMarkdownProcessor, processContent };
@@ -0,0 +1,37 @@
1
+ const lucideStatic = require('lucide-static');
2
+
3
+ // Convert kebab-case to PascalCase (e.g., arrow-right -> ArrowRight)
4
+ function kebabToPascal(str) {
5
+ return str.split('-').map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
6
+ }
7
+
8
+ const exceptions = {
9
+ 'arrow-up-right-square': 'ExternalLink',
10
+ 'file-cog': 'Settings',
11
+ 'cloud-upload': 'UploadCloud'
12
+ };
13
+
14
+ function renderIcon(name, options = {}) {
15
+ if (!name) return '';
16
+
17
+ const key = exceptions[name] || kebabToPascal(name);
18
+ const svgData = lucideStatic[key];
19
+
20
+ if (!svgData) return ''; // Fail silently or warn via callback
21
+
22
+ // Inject attributes into the raw SVG string
23
+ const attrs = [
24
+ `class="lucide-icon icon-${name} ${options.class || ''}"`,
25
+ `width="${options.width || '1em'}"`,
26
+ `height="${options.height || '1em'}"`,
27
+ `stroke="${options.stroke || 'currentColor'}"`,
28
+ `stroke-width="${options.strokeWidth || 2}"`,
29
+ 'fill="none"',
30
+ 'stroke-linecap="round"',
31
+ 'stroke-linejoin="round"'
32
+ ].join(' ');
33
+
34
+ return svgData.replace('<svg', `<svg ${attrs}`);
35
+ }
36
+
37
+ module.exports = { renderIcon };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Normalizes paths to a "canonical" form for comparison.
3
+ */
4
+ function getCanonicalPath(p) {
5
+ if (!p) return '';
6
+ if (p.startsWith('http')) return p;
7
+
8
+ // 1. Remove ./ and leading /
9
+ let path = p.replace(/^\.?\//, '');
10
+
11
+ // 2. Remove file extension
12
+ path = path.replace(/(\.html|\.md)$/, '');
13
+
14
+ // 3. Remove index suffix
15
+ if (path.endsWith('index')) {
16
+ path = path.slice(0, -5);
17
+ }
18
+
19
+ // 4. Remove trailing slash
20
+ if (path.endsWith('/')) {
21
+ path = path.slice(0, -1);
22
+ }
23
+
24
+ // 5. Ensure root is empty string or consistent slash
25
+ return path === '' ? '/' : '/' + path;
26
+ }
27
+
28
+ function findPageNeighbors(navItems, currentPagePath) {
29
+ const flatNavigation = [];
30
+ const currentCanonical = getCanonicalPath(currentPagePath);
31
+
32
+ function recurse(items) {
33
+ if (!items) return;
34
+ for (const item of items) {
35
+ if (item.path && !item.external) {
36
+ flatNavigation.push({
37
+ title: item.title,
38
+ path: item.path,
39
+ url: item.path, // We will fix this URL in build.js context if needed
40
+ canonical: getCanonicalPath(item.path)
41
+ });
42
+ }
43
+ if (item.children) recurse(item.children);
44
+ }
45
+ }
46
+ recurse(navItems);
47
+
48
+ const index = flatNavigation.findIndex(item => item.canonical === currentCanonical);
49
+
50
+ return {
51
+ prevPage: index > 0 ? flatNavigation[index - 1] : null,
52
+ nextPage: index < flatNavigation.length - 1 ? flatNavigation[index + 1] : null
53
+ };
54
+ }
55
+
56
+ module.exports = { findPageNeighbors };
@@ -0,0 +1,81 @@
1
+ const chalk = require('chalk');
2
+
3
+ // Known configuration keys for typo detection
4
+ const KNOWN_KEYS = [
5
+ 'siteTitle', 'siteUrl', 'srcDir', 'outputDir', 'logo',
6
+ 'sidebar', 'theme', 'customJs', 'autoTitleFromH1',
7
+ 'copyCode', 'plugins', 'navigation', 'footer', 'sponsor', 'favicon',
8
+ 'search', 'minify', 'editLink', 'pageNavigation'
9
+ ];
10
+
11
+ // Common typos mapping
12
+ const TYPO_MAPPING = {
13
+ 'site_title': 'siteTitle',
14
+ 'sitetitle': 'siteTitle',
15
+ 'baseUrl': 'siteUrl',
16
+ 'source': 'srcDir',
17
+ 'out': 'outputDir',
18
+ 'customCSS': 'theme.customCss',
19
+ 'customcss': 'theme.customCss',
20
+ 'customJS': 'customJs',
21
+ 'customjs': 'customJs',
22
+ 'nav': 'navigation',
23
+ 'menu': 'navigation'
24
+ };
25
+
26
+ function validateConfig(config) {
27
+ const errors = [];
28
+ const warnings = [];
29
+
30
+ // 1. Required Fields
31
+ if (!config.siteTitle) {
32
+ errors.push('Missing required property: "siteTitle"');
33
+ }
34
+
35
+ // 2. Type Checking
36
+ if (config.navigation && !Array.isArray(config.navigation)) {
37
+ errors.push('"navigation" must be an Array');
38
+ }
39
+
40
+ if (config.customJs && !Array.isArray(config.customJs)) {
41
+ errors.push('"customJs" must be an Array of strings');
42
+ }
43
+
44
+ if (config.theme) {
45
+ if (config.theme.customCss && !Array.isArray(config.theme.customCss)) {
46
+ errors.push('"theme.customCss" must be an Array of strings');
47
+ }
48
+ }
49
+
50
+ // 3. Typos and Unknown Keys (Top Level)
51
+ Object.keys(config).forEach(key => {
52
+ if (TYPO_MAPPING[key]) {
53
+ warnings.push(`Found unknown property "${key}". Did you mean "${TYPO_MAPPING[key]}"?`);
54
+ }
55
+ });
56
+
57
+ // 4. Theme specific typos
58
+ if (config.theme) {
59
+ if (config.theme.customCSS) {
60
+ warnings.push('Found "theme.customCSS". Did you mean "theme.customCss"?');
61
+ }
62
+ }
63
+
64
+ // Output results
65
+ if (warnings.length > 0) {
66
+ console.log(chalk.yellow('\n⚠️ Configuration Warnings:'));
67
+ warnings.forEach(w => console.log(chalk.yellow(` - ${w}`)));
68
+ }
69
+
70
+ if (errors.length > 0) {
71
+ console.log(chalk.red('\n❌ Configuration Errors:'));
72
+ errors.forEach(e => console.log(chalk.red(` - ${e}`)));
73
+ console.log('');
74
+ // We throw to stop the build process in the CLI
75
+ throw new Error('Invalid configuration file.');
76
+ }
77
+
78
+ return true;
79
+ }
80
+
81
+ module.exports = { validateConfig };