@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 +21 -0
- package/README.md +10 -0
- package/package.json +20 -0
- package/src/features/basics.js +38 -0
- package/src/features/changelog.js +115 -0
- package/src/features/common-containers.js +97 -0
- package/src/features/index.js +15 -0
- package/src/features/steps.js +79 -0
- package/src/features/tabs.js +144 -0
- package/src/html-renderer.js +51 -0
- package/src/index.js +15 -0
- package/src/markdown-processor.js +149 -0
- package/src/utils/icon-renderer.js +37 -0
- package/src/utils/navigation-helper.js +56 -0
- package/src/utils/validator.js +81 -0
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 };
|