@mgks/docmd 0.3.8 → 0.3.11
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/package.json +2 -2
- package/src/assets/css/docmd-main.css +73 -23
- package/src/assets/js/docmd-main.js +38 -0
- package/src/core/config-loader.js +1 -0
- package/src/core/html-generator.js +41 -45
- package/src/core/markdown/rules.js +137 -89
- package/src/core/markdown/setup.js +8 -19
- package/src/core/navigation-helper.js +51 -39
- package/src/templates/navigation.ejs +12 -2
- package/src/templates/partials/theme-init.js +17 -11
- package/src/core/html-formatter.js +0 -97
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mgks/docmd",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"description": "Generate beautiful, lightweight static documentation sites directly from your Markdown files. Zero clutter, just content.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"esbuild": "^0.27.2",
|
|
38
38
|
"gray-matter": "^4.0.3",
|
|
39
39
|
"highlight.js": "^11.11.1",
|
|
40
|
-
"lucide-static": "^0.
|
|
40
|
+
"lucide-static": "^0.563.0",
|
|
41
41
|
"markdown-it": "^14.1.0",
|
|
42
42
|
"markdown-it-abbr": "^2.0.0",
|
|
43
43
|
"markdown-it-attrs": "^4.3.1",
|
|
@@ -51,6 +51,11 @@
|
|
|
51
51
|
--lightbox-text: #fff
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
html {
|
|
55
|
+
scroll-behavior: smooth;
|
|
56
|
+
scroll-padding-top: 80px;
|
|
57
|
+
}
|
|
58
|
+
|
|
54
59
|
body {
|
|
55
60
|
font-family: var(--font-family-sans);
|
|
56
61
|
background-color: var(--bg-color);
|
|
@@ -212,7 +217,6 @@ body.sidebar-collapsed .main-content-wrapper {
|
|
|
212
217
|
flex-grow: 1;
|
|
213
218
|
display: flex;
|
|
214
219
|
flex-direction: column;
|
|
215
|
-
overflow: hidden
|
|
216
220
|
}
|
|
217
221
|
|
|
218
222
|
.header-left,
|
|
@@ -848,13 +852,6 @@ details[open]>.collapsible-summary .collapsible-arrow svg {
|
|
|
848
852
|
margin-bottom: .5rem
|
|
849
853
|
}
|
|
850
854
|
|
|
851
|
-
.toc-title {
|
|
852
|
-
margin-top: 0;
|
|
853
|
-
font-size: 1rem;
|
|
854
|
-
font-weight: 700;
|
|
855
|
-
color: var(--text-muted)
|
|
856
|
-
}
|
|
857
|
-
|
|
858
855
|
.toc-list {
|
|
859
856
|
list-style: none;
|
|
860
857
|
padding-left: 0;
|
|
@@ -866,31 +863,84 @@ details[open]>.collapsible-summary .collapsible-arrow svg {
|
|
|
866
863
|
line-height: 1.4
|
|
867
864
|
}
|
|
868
865
|
|
|
866
|
+
/* Base Link State */
|
|
869
867
|
.toc-link {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
868
|
+
display: block;
|
|
869
|
+
padding: 0.3rem 1rem;
|
|
870
|
+
font-size: 0.85rem;
|
|
871
|
+
color: var(--text-secondary);
|
|
872
|
+
/* Pre-allocate border space to prevent jumping */
|
|
873
|
+
border-left: 2px solid transparent;
|
|
874
|
+
margin-left: -1px; /* Overlap the sidebar's border-left */
|
|
875
|
+
transition: all 0.2s ease;
|
|
876
|
+
white-space: nowrap;
|
|
877
|
+
overflow: hidden;
|
|
878
|
+
text-overflow: ellipsis;
|
|
876
879
|
}
|
|
877
880
|
|
|
878
|
-
.toc-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
+
.toc-link:hover {
|
|
882
|
+
color: var(--text-primary);
|
|
883
|
+
background-color: var(--bg-surface);
|
|
881
884
|
}
|
|
882
885
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
+
/* Active State - Only changes color, no layout shift */
|
|
887
|
+
.toc-link.active {
|
|
888
|
+
color: var(--primary);
|
|
889
|
+
border-left-color: var(--primary);
|
|
890
|
+
background-color: var(--primary-light);
|
|
886
891
|
}
|
|
887
892
|
|
|
893
|
+
/* Nesting Levels */
|
|
894
|
+
.toc-level-3 { padding-left: 2rem; }
|
|
895
|
+
.toc-level-4 { padding-left: 3rem; }
|
|
896
|
+
|
|
897
|
+
/* Handle Dark Mode specifics for the trail */
|
|
898
|
+
:root[data-theme="dark"] .toc-link.active {
|
|
899
|
+
background-color: rgba(59, 130, 246, 0.1);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/* TOC Sidebar Container */
|
|
888
903
|
.toc-sidebar {
|
|
889
|
-
|
|
904
|
+
position: -webkit-sticky;
|
|
890
905
|
position: sticky;
|
|
891
|
-
top:
|
|
906
|
+
top: 5rem;
|
|
907
|
+
max-height: calc(100vh - 15rem);
|
|
892
908
|
overflow-y: auto;
|
|
893
|
-
align-self: flex-start
|
|
909
|
+
align-self: flex-start;
|
|
910
|
+
/* Create the vertical guide rail */
|
|
911
|
+
border-left: 1px solid var(--border-color);
|
|
912
|
+
padding-left: 0;
|
|
913
|
+
margin-left: 1rem;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.toc-container {
|
|
917
|
+
padding: 0;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.toc-title {
|
|
921
|
+
padding-left: 1rem;
|
|
922
|
+
margin-bottom: 1rem;
|
|
923
|
+
font-size: 0.75rem;
|
|
924
|
+
color: var(--text-tertiary);
|
|
925
|
+
text-transform: uppercase;
|
|
926
|
+
letter-spacing: 0.05em;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
@media (max-width: 1024px) {
|
|
930
|
+
.content-layout {
|
|
931
|
+
flex-direction: column-reverse;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
.toc-sidebar {
|
|
935
|
+
position: static;
|
|
936
|
+
width: 100%;
|
|
937
|
+
max-height: none;
|
|
938
|
+
margin-bottom: 2rem;
|
|
939
|
+
border-left: none;
|
|
940
|
+
border-bottom: 1px solid var(--border-color);
|
|
941
|
+
padding-left: 0;
|
|
942
|
+
padding-bottom: 1rem;
|
|
943
|
+
}
|
|
894
944
|
}
|
|
895
945
|
|
|
896
946
|
.docmd-tabs,
|
|
@@ -209,6 +209,43 @@ function syncBodyTheme() {
|
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
// --- Scroll Spy Logic ---
|
|
213
|
+
function initializeScrollSpy() {
|
|
214
|
+
const tocLinks = document.querySelectorAll('.toc-link');
|
|
215
|
+
const headings = document.querySelectorAll('.main-content h2, .main-content h3');
|
|
216
|
+
|
|
217
|
+
if (tocLinks.length === 0 || headings.length === 0) return;
|
|
218
|
+
|
|
219
|
+
const observerOptions = {
|
|
220
|
+
root: null,
|
|
221
|
+
// Trigger when heading crosses the top 10% of screen
|
|
222
|
+
rootMargin: '-10% 0px -80% 0px',
|
|
223
|
+
threshold: 0
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const observer = new IntersectionObserver((entries) => {
|
|
227
|
+
entries.forEach(entry => {
|
|
228
|
+
if (entry.isIntersecting) {
|
|
229
|
+
// 1. Clear current active state
|
|
230
|
+
tocLinks.forEach(link => link.classList.remove('active'));
|
|
231
|
+
|
|
232
|
+
// 2. Find link corresponding to this heading
|
|
233
|
+
const id = entry.target.getAttribute('id');
|
|
234
|
+
const activeLink = document.querySelector(`.toc-link[href="#${id}"]`);
|
|
235
|
+
|
|
236
|
+
if (activeLink) {
|
|
237
|
+
activeLink.classList.add('active');
|
|
238
|
+
|
|
239
|
+
// Optional: Auto-scroll the TOC sidebar itself if needed
|
|
240
|
+
// activeLink.scrollIntoView({ block: 'nearest' });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}, observerOptions);
|
|
245
|
+
|
|
246
|
+
headings.forEach(heading => observer.observe(heading));
|
|
247
|
+
}
|
|
248
|
+
|
|
212
249
|
// --- Main Execution ---
|
|
213
250
|
document.addEventListener('DOMContentLoaded', () => {
|
|
214
251
|
syncBodyTheme();
|
|
@@ -219,4 +256,5 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
219
256
|
initializeCollapsibleNav();
|
|
220
257
|
initializeMobileMenus();
|
|
221
258
|
initializeSidebarScroll();
|
|
259
|
+
initializeScrollSpy();
|
|
222
260
|
});
|
|
@@ -38,6 +38,7 @@ async function loadConfig(configPath) {
|
|
|
38
38
|
validateConfig(config);
|
|
39
39
|
|
|
40
40
|
// Basic validation and defaults
|
|
41
|
+
config.base = config.base || '/';
|
|
41
42
|
config.srcDir = config.srcDir || 'docs';
|
|
42
43
|
config.outputDir = config.outputDir || 'site';
|
|
43
44
|
config.theme = config.theme || {};
|
|
@@ -7,12 +7,10 @@ const { createMarkdownItInstance } = require('./file-processor');
|
|
|
7
7
|
const { generateSeoMetaTags } = require('../plugins/seo');
|
|
8
8
|
const { generateAnalyticsScripts } = require('../plugins/analytics');
|
|
9
9
|
const { renderIcon } = require('./icon-renderer');
|
|
10
|
-
const { formatHtml } = require('./html-formatter');
|
|
11
10
|
|
|
12
11
|
let mdInstance = null;
|
|
13
12
|
let themeInitScript = '';
|
|
14
13
|
|
|
15
|
-
// Load the theme initialization script into memory once to avoid repeated disk I/O
|
|
16
14
|
(async () => {
|
|
17
15
|
try {
|
|
18
16
|
const themeInitPath = path.join(__dirname, '..', 'templates', 'partials', 'theme-init.js');
|
|
@@ -23,51 +21,59 @@ let themeInitScript = '';
|
|
|
23
21
|
} catch (e) { /* ignore */ }
|
|
24
22
|
})();
|
|
25
23
|
|
|
26
|
-
//
|
|
24
|
+
// Basic whitespace cleanup (keep this simple version)
|
|
27
25
|
function cleanupHtml(html) {
|
|
28
26
|
if (!html) return '';
|
|
29
|
-
return html
|
|
30
|
-
|
|
31
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
32
|
-
.trim();
|
|
27
|
+
// return html.replace(/^\s*[\r\n]/gm, '').trim(); // Remove leading/trailing blank lines from each line
|
|
28
|
+
return html.trim(); // Only trim the start/end of the whole document
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
|
|
36
|
-
function fixHtmlLinks(htmlContent, relativePathToRoot, isOfflineMode) {
|
|
31
|
+
function fixHtmlLinks(htmlContent, relativePathToRoot, isOfflineMode, configBase = '/') {
|
|
37
32
|
if (!htmlContent) return '';
|
|
38
33
|
const root = relativePathToRoot || './';
|
|
34
|
+
const baseUrl = configBase.endsWith('/') ? configBase : configBase + '/';
|
|
39
35
|
|
|
40
|
-
return htmlContent.replace(/href="(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// Convert absolute project paths to relative
|
|
44
|
-
if (href.startsWith('/')) {
|
|
45
|
-
finalPath = root + href.substring(1);
|
|
36
|
+
return htmlContent.replace(/(href|src)=["']([^"']+)["']/g, (match, attr, url) => {
|
|
37
|
+
if (url.startsWith('#') || url.startsWith('http') || url.startsWith('mailto:') || url === '') {
|
|
38
|
+
return match;
|
|
46
39
|
}
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
|
|
41
|
+
let finalPath = url;
|
|
42
|
+
|
|
43
|
+
// 1. Handle Base URL removal
|
|
44
|
+
if (baseUrl !== '/' && url.startsWith(baseUrl)) {
|
|
45
|
+
finalPath = '/' + url.substring(baseUrl.length);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. Handle Absolute Paths
|
|
49
|
+
if (finalPath.startsWith('/')) {
|
|
50
|
+
// Simple logic: if root relative, prepend relative path
|
|
51
|
+
finalPath = root + finalPath.substring(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 3. Offline Mode Logic
|
|
49
55
|
if (isOfflineMode) {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
const [pathOnly] = finalPath.split(/[?#]/);
|
|
57
|
+
const ext = path.extname(pathOnly);
|
|
58
|
+
const isAsset = ['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico'].includes(ext.toLowerCase());
|
|
59
|
+
|
|
60
|
+
if (!isAsset && !ext) {
|
|
61
|
+
if (finalPath.endsWith('/')) {
|
|
62
|
+
finalPath += 'index.html';
|
|
63
|
+
} else if (!finalPath.includes('#')) {
|
|
64
|
+
finalPath += '/index.html';
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
67
|
} else {
|
|
61
|
-
// Web mode (strip index.html for clean URLs)
|
|
62
68
|
if (finalPath.endsWith('/index.html')) {
|
|
63
69
|
finalPath = finalPath.substring(0, finalPath.length - 10);
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
|
-
|
|
72
|
+
|
|
73
|
+
return `${attr}="${finalPath}"`;
|
|
67
74
|
});
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
// aggregates HTML snippets from various plugins (SEO, Analytics, etc.)
|
|
71
77
|
async function processPluginHooks(config, pageData, relativePathToRoot) {
|
|
72
78
|
let metaTagsHtml = '';
|
|
73
79
|
let faviconLinkHtml = '';
|
|
@@ -77,17 +83,16 @@ async function processPluginHooks(config, pageData, relativePathToRoot) {
|
|
|
77
83
|
let pluginBodyScriptsHtml = '';
|
|
78
84
|
|
|
79
85
|
const safeRoot = relativePathToRoot || './';
|
|
80
|
-
const indent = ' '; // 4 spaces for cleaner output
|
|
81
86
|
|
|
82
87
|
if (config.favicon) {
|
|
83
88
|
const cleanFaviconPath = config.favicon.startsWith('/') ? config.favicon.substring(1) : config.favicon;
|
|
84
89
|
const finalFaviconHref = `${safeRoot}${cleanFaviconPath}`;
|
|
85
|
-
faviconLinkHtml =
|
|
90
|
+
faviconLinkHtml = `<link rel="icon" href="${finalFaviconHref}" type="image/x-icon" sizes="any">\n<link rel="shortcut icon" href="${finalFaviconHref}" type="image/x-icon">`;
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
if (config.theme && config.theme.name && config.theme.name !== 'default') {
|
|
89
94
|
const themeCssPath = `assets/css/docmd-theme-${config.theme.name}.css`;
|
|
90
|
-
themeCssLinkHtml =
|
|
95
|
+
themeCssLinkHtml = `<link rel="stylesheet" href="${safeRoot}${themeCssPath}">`;
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
if (config.plugins?.seo) {
|
|
@@ -103,7 +108,6 @@ async function processPluginHooks(config, pageData, relativePathToRoot) {
|
|
|
103
108
|
return { metaTagsHtml, faviconLinkHtml, themeCssLinkHtml, pluginStylesHtml, pluginHeadScriptsHtml, pluginBodyScriptsHtml };
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
// Main function to assemble the page data and render the EJS template
|
|
107
111
|
async function generateHtmlPage(templateData, isOfflineMode = false) {
|
|
108
112
|
let { content, frontmatter, outputPath, headings, config } = templateData;
|
|
109
113
|
const { currentPagePath, prevPage, nextPage, relativePathToRoot, navigationHtml, siteTitle } = templateData;
|
|
@@ -111,19 +115,16 @@ async function generateHtmlPage(templateData, isOfflineMode = false) {
|
|
|
111
115
|
|
|
112
116
|
if (!relativePathToRoot) templateData.relativePathToRoot = './';
|
|
113
117
|
|
|
114
|
-
|
|
115
|
-
content = fixHtmlLinks(content, templateData.relativePathToRoot, isOfflineMode);
|
|
118
|
+
content = fixHtmlLinks(content, templateData.relativePathToRoot, isOfflineMode, config.base);
|
|
116
119
|
const pluginOutputs = await processPluginHooks(config, { frontmatter, outputPath }, templateData.relativePathToRoot);
|
|
117
120
|
|
|
118
|
-
// Process footer markdown if present
|
|
119
121
|
let footerHtml = '';
|
|
120
122
|
if (config.footer) {
|
|
121
123
|
if (!mdInstance) mdInstance = createMarkdownItInstance(config);
|
|
122
124
|
footerHtml = mdInstance.renderInline(config.footer);
|
|
123
|
-
footerHtml = fixHtmlLinks(footerHtml, templateData.relativePathToRoot, isOfflineMode);
|
|
125
|
+
footerHtml = fixHtmlLinks(footerHtml, templateData.relativePathToRoot, isOfflineMode, config.base);
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
// Determine which template to use
|
|
127
128
|
let templateName = frontmatter.noStyle === true ? 'no-style.ejs' : 'layout.ejs';
|
|
128
129
|
const layoutTemplatePath = path.join(__dirname, '..', 'templates', templateName);
|
|
129
130
|
if (!await fs.exists(layoutTemplatePath)) throw new Error(`Template not found: ${layoutTemplatePath}`);
|
|
@@ -131,7 +132,6 @@ async function generateHtmlPage(templateData, isOfflineMode = false) {
|
|
|
131
132
|
|
|
132
133
|
const isActivePage = currentPagePath && content && content.trim().length > 0;
|
|
133
134
|
|
|
134
|
-
// Build the "Edit this page" link
|
|
135
135
|
let editUrl = null;
|
|
136
136
|
let editLinkText = 'Edit this page';
|
|
137
137
|
if (config.editLink && config.editLink.enabled && config.editLink.baseUrl) {
|
|
@@ -141,10 +141,9 @@ async function generateHtmlPage(templateData, isOfflineMode = false) {
|
|
|
141
141
|
editLinkText = config.editLink.text || editLinkText;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// Prepare complete data object for EJS
|
|
145
144
|
const ejsData = {
|
|
146
145
|
...templateData,
|
|
147
|
-
description: frontmatter.description || '',
|
|
146
|
+
description: frontmatter.description || '',
|
|
148
147
|
footerHtml, editUrl, editLinkText, isActivePage,
|
|
149
148
|
defaultMode: config.theme?.defaultMode || 'light',
|
|
150
149
|
logo: config.logo, sidebarConfig: config.sidebar || {}, theme: config.theme,
|
|
@@ -155,13 +154,12 @@ async function generateHtmlPage(templateData, isOfflineMode = false) {
|
|
|
155
154
|
isOfflineMode
|
|
156
155
|
};
|
|
157
156
|
|
|
158
|
-
// Render and format
|
|
159
157
|
const rawHtml = renderHtmlPage(layoutTemplate, ejsData, layoutTemplatePath);
|
|
160
158
|
const pkgVersion = require('../../package.json').version;
|
|
161
159
|
const brandingComment = `<!-- Generated by docmd (v${pkgVersion}) - https://docmd.io -->\n`;
|
|
162
160
|
|
|
163
|
-
//
|
|
164
|
-
return brandingComment +
|
|
161
|
+
// REMOVED: formatHtml(rawHtml)
|
|
162
|
+
return brandingComment + cleanupHtml(rawHtml);
|
|
165
163
|
}
|
|
166
164
|
|
|
167
165
|
function renderHtmlPage(templateContent, ejsData, filename = 'template.ejs', options = {}) {
|
|
@@ -173,14 +171,12 @@ function renderHtmlPage(templateContent, ejsData, filename = 'template.ejs', opt
|
|
|
173
171
|
}
|
|
174
172
|
}
|
|
175
173
|
|
|
176
|
-
// Generate the sidebar navigation HTML separately
|
|
177
174
|
async function generateNavigationHtml(navItems, currentPagePath, relativePathToRoot, config, isOfflineMode = false) {
|
|
178
175
|
const navTemplatePath = path.join(__dirname, '..', 'templates', 'navigation.ejs');
|
|
179
176
|
if (!await fs.exists(navTemplatePath)) throw new Error(`Navigation template not found: ${navTemplatePath}`);
|
|
180
177
|
const navTemplate = await fs.readFile(navTemplatePath, 'utf8');
|
|
181
178
|
const safeRoot = relativePathToRoot || './';
|
|
182
179
|
|
|
183
|
-
// We render raw here; the main page formatter will clean this up later
|
|
184
180
|
return ejs.render(navTemplate, {
|
|
185
181
|
navItems, currentPagePath, relativePathToRoot: safeRoot, config, isOfflineMode, renderIcon
|
|
186
182
|
}, { filename: navTemplatePath });
|
|
@@ -1,14 +1,49 @@
|
|
|
1
1
|
// Source file from the docmd project — https://github.com/docmd-io/docmd
|
|
2
2
|
|
|
3
|
-
const MarkdownIt = require('markdown-it');
|
|
3
|
+
const MarkdownIt = require('markdown-it');
|
|
4
4
|
const { containers } = require('./containers');
|
|
5
5
|
|
|
6
|
-
// --- Helper:
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
// --- Helper: Smart Dedent ---
|
|
7
|
+
// This handles the "Tab Indentation" and "Code Block" nesting issues
|
|
8
|
+
function smartDedent(str) {
|
|
9
|
+
const lines = str.split('\n');
|
|
10
|
+
|
|
11
|
+
// 1. Calculate global minimum indent (ignoring empty lines)
|
|
12
|
+
let minIndent = Infinity;
|
|
13
|
+
lines.forEach(line => {
|
|
14
|
+
if (line.trim().length === 0) return;
|
|
15
|
+
const match = line.match(/^ */);
|
|
16
|
+
const indent = match ? match[0].length : 0;
|
|
17
|
+
if (indent < minIndent) minIndent = indent;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (minIndent === Infinity) return str;
|
|
21
|
+
|
|
22
|
+
// 2. Strip the common indent
|
|
23
|
+
const dedented = lines.map(line => {
|
|
24
|
+
if (line.trim().length === 0) return '';
|
|
25
|
+
return line.substring(minIndent);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// 3. Fix Code Fences
|
|
29
|
+
// If a line looks like a code fence (```) but is still indented 4+ spaces,
|
|
30
|
+
// it will be parsed as an "Indented Code Block" containing text, not a Fence.
|
|
31
|
+
// We force-pull these specific lines to the left to ensure they render as Fences.
|
|
32
|
+
return dedented.map(line => {
|
|
33
|
+
// Regex: 4 or more spaces, followed by 3 or more backticks/tildes
|
|
34
|
+
if (/^\s{4,}(`{3,}|~{3,})/.test(line)) {
|
|
35
|
+
return line.trimStart();
|
|
36
|
+
}
|
|
37
|
+
return line;
|
|
38
|
+
}).join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Helper to check if a line starts a fence
|
|
42
|
+
function isFenceLine(line) {
|
|
43
|
+
return /^(\s{0,3})(~{3,}|`{3,})/.test(line);
|
|
44
|
+
}
|
|
10
45
|
|
|
11
|
-
// --- 1. Advanced Container Rule
|
|
46
|
+
// --- 1. Advanced Container Rule ---
|
|
12
47
|
function advancedContainerRule(state, startLine, endLine, silent) {
|
|
13
48
|
const start = state.bMarks[startLine] + state.tShift[startLine];
|
|
14
49
|
const max = state.eMarks[startLine];
|
|
@@ -33,6 +68,7 @@ function advancedContainerRule(state, startLine, endLine, silent) {
|
|
|
33
68
|
let nextLine = startLine;
|
|
34
69
|
let found = false;
|
|
35
70
|
let depth = 1;
|
|
71
|
+
let inFence = false;
|
|
36
72
|
|
|
37
73
|
while (nextLine < endLine) {
|
|
38
74
|
nextLine++;
|
|
@@ -40,23 +76,28 @@ function advancedContainerRule(state, startLine, endLine, silent) {
|
|
|
40
76
|
const nextMax = state.eMarks[nextLine];
|
|
41
77
|
const nextContent = state.src.slice(nextStart, nextMax).trim();
|
|
42
78
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
79
|
+
// Check for fences to prevent parsing ::: inside code blocks (#42)
|
|
80
|
+
if (isFenceLine(nextContent)) inFence = !inFence;
|
|
81
|
+
|
|
82
|
+
if (!inFence) {
|
|
83
|
+
if (nextContent.startsWith(':::')) {
|
|
84
|
+
const containerMatch = nextContent.match(/^:::\s*(\w+)/);
|
|
85
|
+
if (containerMatch && containerMatch[1] !== containerName) {
|
|
86
|
+
const innerContainer = containers[containerMatch[1]];
|
|
87
|
+
if (innerContainer && innerContainer.render && !innerContainer.selfClosing) {
|
|
88
|
+
depth++;
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (nextContent === ':::') {
|
|
95
|
+
depth--;
|
|
96
|
+
if (depth === 0) {
|
|
97
|
+
found = true;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
49
100
|
}
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (nextContent === ':::') {
|
|
55
|
-
depth--;
|
|
56
|
-
if (depth === 0) {
|
|
57
|
-
found = true;
|
|
58
|
-
break;
|
|
59
|
-
}
|
|
60
101
|
}
|
|
61
102
|
}
|
|
62
103
|
|
|
@@ -81,7 +122,7 @@ function advancedContainerRule(state, startLine, endLine, silent) {
|
|
|
81
122
|
return true;
|
|
82
123
|
}
|
|
83
124
|
|
|
84
|
-
// --- 2. Changelog Timeline Rule
|
|
125
|
+
// --- 2. Changelog Timeline Rule ---
|
|
85
126
|
function changelogTimelineRule(state, startLine, endLine, silent) {
|
|
86
127
|
const start = state.bMarks[startLine] + state.tShift[startLine];
|
|
87
128
|
const max = state.eMarks[startLine];
|
|
@@ -93,6 +134,7 @@ function changelogTimelineRule(state, startLine, endLine, silent) {
|
|
|
93
134
|
let nextLine = startLine;
|
|
94
135
|
let found = false;
|
|
95
136
|
let depth = 1;
|
|
137
|
+
let inFence = false;
|
|
96
138
|
|
|
97
139
|
while (nextLine < endLine) {
|
|
98
140
|
nextLine++;
|
|
@@ -100,21 +142,21 @@ function changelogTimelineRule(state, startLine, endLine, silent) {
|
|
|
100
142
|
const nextMax = state.eMarks[nextLine];
|
|
101
143
|
const nextContent = state.src.slice(nextStart, nextMax).trim();
|
|
102
144
|
|
|
103
|
-
if (nextContent
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
145
|
+
if (isFenceLine(nextContent)) inFence = !inFence;
|
|
146
|
+
|
|
147
|
+
if (!inFence) {
|
|
148
|
+
if (nextContent.startsWith(':::')) {
|
|
149
|
+
const match = nextContent.match(/^:::\s*(\w+)/);
|
|
150
|
+
if (match) {
|
|
151
|
+
const containerName = match[1];
|
|
152
|
+
const containerDef = containers[containerName];
|
|
153
|
+
if (!containerDef || !containerDef.selfClosing) depth++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (nextContent === ':::') {
|
|
157
|
+
depth--;
|
|
158
|
+
if (depth === 0) { found = true; break; }
|
|
111
159
|
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (nextContent === ':::') {
|
|
116
|
-
depth--;
|
|
117
|
-
if (depth === 0) { found = true; break; }
|
|
118
160
|
}
|
|
119
161
|
}
|
|
120
162
|
|
|
@@ -130,25 +172,26 @@ function changelogTimelineRule(state, startLine, endLine, silent) {
|
|
|
130
172
|
const lines = content.split('\n');
|
|
131
173
|
const entries = [];
|
|
132
174
|
let currentEntry = null;
|
|
133
|
-
let
|
|
175
|
+
let currentContentLines = [];
|
|
134
176
|
|
|
135
177
|
for (let i = 0; i < lines.length; i++) {
|
|
136
|
-
const
|
|
137
|
-
const
|
|
178
|
+
const rawLine = lines[i];
|
|
179
|
+
const trimmedLine = rawLine.trim();
|
|
180
|
+
const markerMatch = trimmedLine.match(/^==\s+(.+)$/);
|
|
138
181
|
|
|
139
182
|
if (markerMatch) {
|
|
140
183
|
if (currentEntry) {
|
|
141
|
-
currentEntry.content =
|
|
184
|
+
currentEntry.content = smartDedent(currentContentLines.join('\n'));
|
|
142
185
|
entries.push(currentEntry);
|
|
143
186
|
}
|
|
144
187
|
currentEntry = { meta: markerMatch[1], content: '' };
|
|
145
|
-
|
|
188
|
+
currentContentLines = [];
|
|
146
189
|
} else if (currentEntry) {
|
|
147
|
-
|
|
190
|
+
currentContentLines.push(rawLine);
|
|
148
191
|
}
|
|
149
192
|
}
|
|
150
193
|
if (currentEntry) {
|
|
151
|
-
currentEntry.content =
|
|
194
|
+
currentEntry.content = smartDedent(currentContentLines.join('\n'));
|
|
152
195
|
entries.push(currentEntry);
|
|
153
196
|
}
|
|
154
197
|
|
|
@@ -160,7 +203,6 @@ function changelogTimelineRule(state, startLine, endLine, silent) {
|
|
|
160
203
|
<div class="changelog-meta"><span class="changelog-date">${entry.meta}</span></div>
|
|
161
204
|
<div class="changelog-body">`;
|
|
162
205
|
|
|
163
|
-
// This ensures callouts/cards inside changelogs are parsed
|
|
164
206
|
entryOpen.content += state.md.render(entry.content, state.env);
|
|
165
207
|
|
|
166
208
|
const entryClose = state.push('html_block', '', 0);
|
|
@@ -183,6 +225,7 @@ function stepsContainerRule(state, startLine, endLine, silent) {
|
|
|
183
225
|
let nextLine = startLine;
|
|
184
226
|
let found = false;
|
|
185
227
|
let depth = 1;
|
|
228
|
+
let inFence = false;
|
|
186
229
|
|
|
187
230
|
while (nextLine < endLine) {
|
|
188
231
|
nextLine++;
|
|
@@ -190,23 +233,23 @@ function stepsContainerRule(state, startLine, endLine, silent) {
|
|
|
190
233
|
const nextMax = state.eMarks[nextLine];
|
|
191
234
|
const nextContent = state.src.slice(nextStart, nextMax).trim();
|
|
192
235
|
|
|
193
|
-
if (nextContent
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
236
|
+
if (isFenceLine(nextContent)) inFence = !inFence;
|
|
237
|
+
|
|
238
|
+
if (!inFence) {
|
|
239
|
+
if (nextContent.startsWith('== tab')) continue;
|
|
240
|
+
if (nextContent.startsWith(':::')) {
|
|
241
|
+
const containerMatch = nextContent.match(/^:::\s*(\w+)/);
|
|
242
|
+
if (containerMatch) {
|
|
243
|
+
const containerName = containerMatch[1];
|
|
244
|
+
const innerContainer = containers[containerName];
|
|
245
|
+
if (innerContainer && !innerContainer.selfClosing) depth++;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (nextContent === ':::') {
|
|
250
|
+
depth--;
|
|
251
|
+
if (depth === 0) { found = true; break; }
|
|
202
252
|
}
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (nextContent === ':::') {
|
|
208
|
-
depth--;
|
|
209
|
-
if (depth === 0) { found = true; break; }
|
|
210
253
|
}
|
|
211
254
|
}
|
|
212
255
|
|
|
@@ -227,7 +270,7 @@ function stepsContainerRule(state, startLine, endLine, silent) {
|
|
|
227
270
|
return true;
|
|
228
271
|
}
|
|
229
272
|
|
|
230
|
-
// --- 4. Enhanced Tabs Rule ---
|
|
273
|
+
// --- 4. Enhanced Tabs Rule (Fixed) ---
|
|
231
274
|
function enhancedTabsRule(state, startLine, endLine, silent) {
|
|
232
275
|
const start = state.bMarks[startLine] + state.tShift[startLine];
|
|
233
276
|
const max = state.eMarks[startLine];
|
|
@@ -239,61 +282,69 @@ function enhancedTabsRule(state, startLine, endLine, silent) {
|
|
|
239
282
|
let nextLine = startLine;
|
|
240
283
|
let found = false;
|
|
241
284
|
let depth = 1;
|
|
285
|
+
let inFence = false;
|
|
286
|
+
|
|
242
287
|
while (nextLine < endLine) {
|
|
243
288
|
nextLine++;
|
|
244
289
|
const nextStart = state.bMarks[nextLine] + state.tShift[nextLine];
|
|
245
290
|
const nextMax = state.eMarks[nextLine];
|
|
246
291
|
const nextContent = state.src.slice(nextStart, nextMax).trim();
|
|
247
292
|
|
|
248
|
-
if (nextContent
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
293
|
+
if (isFenceLine(nextContent)) inFence = !inFence;
|
|
294
|
+
|
|
295
|
+
if (!inFence) {
|
|
296
|
+
if (nextContent.startsWith(':::')) {
|
|
297
|
+
const containerMatch = nextContent.match(/^:::\s*(\w+)/);
|
|
298
|
+
if (containerMatch && containerMatch[1] !== 'tabs') {
|
|
299
|
+
if (containerMatch[1] === 'steps') continue;
|
|
300
|
+
const innerContainer = containers[containerMatch[1]];
|
|
301
|
+
if (innerContainer && !innerContainer.selfClosing) depth++;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (nextContent === ':::') {
|
|
306
|
+
depth--;
|
|
307
|
+
if (depth === 0) { found = true; break; }
|
|
308
|
+
}
|
|
261
309
|
}
|
|
262
310
|
}
|
|
263
311
|
if (!found) return false;
|
|
264
312
|
|
|
265
313
|
let content = '';
|
|
314
|
+
// Capture content preserving newlines
|
|
266
315
|
for (let i = startLine + 1; i < nextLine; i++) {
|
|
267
316
|
const lineStart = state.bMarks[i] + state.tShift[i];
|
|
268
317
|
const lineEnd = state.eMarks[i];
|
|
318
|
+
// .slice() keeps the indentation of the line as it is in source
|
|
269
319
|
content += state.src.slice(lineStart, lineEnd) + '\n';
|
|
270
320
|
}
|
|
271
321
|
|
|
272
322
|
const lines = content.split('\n');
|
|
273
323
|
const tabs = [];
|
|
274
324
|
let currentTab = null;
|
|
275
|
-
let
|
|
325
|
+
let currentContentLines = [];
|
|
276
326
|
|
|
277
327
|
for (let i = 0; i < lines.length; i++) {
|
|
278
|
-
const
|
|
279
|
-
const
|
|
328
|
+
const rawLine = lines[i];
|
|
329
|
+
const trimmedLine = rawLine.trim();
|
|
330
|
+
|
|
331
|
+
const tabMatch = trimmedLine.match(/^==\s*tab\s+(?:"([^"]+)"|(\S+))$/);
|
|
280
332
|
|
|
281
333
|
if (tabMatch) {
|
|
282
334
|
if (currentTab) {
|
|
283
|
-
|
|
335
|
+
// Apply Smart Dedent before saving
|
|
336
|
+
currentTab.content = smartDedent(currentContentLines.join('\n'));
|
|
284
337
|
tabs.push(currentTab);
|
|
285
338
|
}
|
|
286
339
|
const title = tabMatch[1] || tabMatch[2];
|
|
287
340
|
currentTab = { title: title, content: '' };
|
|
288
|
-
|
|
341
|
+
currentContentLines = [];
|
|
289
342
|
} else if (currentTab) {
|
|
290
|
-
|
|
291
|
-
currentContent.push(lines[i]);
|
|
292
|
-
}
|
|
343
|
+
currentContentLines.push(rawLine);
|
|
293
344
|
}
|
|
294
345
|
}
|
|
295
346
|
if (currentTab) {
|
|
296
|
-
currentTab.content =
|
|
347
|
+
currentTab.content = smartDedent(currentContentLines.join('\n'));
|
|
297
348
|
tabs.push(currentTab);
|
|
298
349
|
}
|
|
299
350
|
|
|
@@ -315,10 +366,8 @@ function enhancedTabsRule(state, startLine, endLine, silent) {
|
|
|
315
366
|
const paneToken = state.push('tab_pane_open', 'div', 1);
|
|
316
367
|
paneToken.attrs = [['class', `docmd-tab-pane ${index === 0 ? 'active' : ''}`]];
|
|
317
368
|
|
|
318
|
-
if (tab.content
|
|
319
|
-
|
|
320
|
-
// Since we are inside rules.js, we rely on state.md being available or fallback to simple render
|
|
321
|
-
const renderedContent = state.md.render(tab.content.trim(), state.env);
|
|
369
|
+
if (tab.content) {
|
|
370
|
+
const renderedContent = state.md.render(tab.content, state.env);
|
|
322
371
|
const htmlToken = state.push('html_block', '', 0);
|
|
323
372
|
htmlToken.content = renderedContent;
|
|
324
373
|
}
|
|
@@ -331,7 +380,6 @@ function enhancedTabsRule(state, startLine, endLine, silent) {
|
|
|
331
380
|
return true;
|
|
332
381
|
}
|
|
333
382
|
|
|
334
|
-
// --- 5. Standalone Closing Rule ---
|
|
335
383
|
const standaloneClosingRule = (state, startLine, endLine, silent) => {
|
|
336
384
|
const start = state.bMarks[startLine] + state.tShift[startLine];
|
|
337
385
|
const max = state.eMarks[startLine];
|
|
@@ -20,33 +20,25 @@ const headingIdPlugin = (md) => {
|
|
|
20
20
|
|
|
21
21
|
md.renderer.rules.heading_open = function(tokens, idx, options, env, self) {
|
|
22
22
|
const token = tokens[idx];
|
|
23
|
-
|
|
24
|
-
// Check if an ID was already set by markdown-it-attrs (e.g. {#my-id})
|
|
25
23
|
const existingId = token.attrGet('id');
|
|
26
24
|
|
|
27
|
-
// If NO ID exists, generate one automatically from the text content
|
|
28
25
|
if (!existingId) {
|
|
29
26
|
const contentToken = tokens[idx + 1];
|
|
30
27
|
if (contentToken && contentToken.type === 'inline' && contentToken.content) {
|
|
31
28
|
const headingText = contentToken.content;
|
|
32
|
-
|
|
33
|
-
// Note: markdown-it-attrs strips the curly braces content from .content
|
|
34
|
-
// BEFORE this rule runs, so headingText should be clean.
|
|
35
|
-
|
|
36
29
|
const id = headingText
|
|
37
30
|
.toLowerCase()
|
|
38
|
-
.replace(/\s+/g, '-')
|
|
39
|
-
.replace(/[^\w\u4e00-\u9fa5-]+/g, '')
|
|
40
|
-
.replace(/--+/g, '-')
|
|
41
|
-
.replace(/^-+/, '')
|
|
42
|
-
.replace(/-+$/, '');
|
|
31
|
+
.replace(/\s+/g, '-')
|
|
32
|
+
.replace(/[^\w\u4e00-\u9fa5-]+/g, '')
|
|
33
|
+
.replace(/--+/g, '-')
|
|
34
|
+
.replace(/^-+/, '')
|
|
35
|
+
.replace(/-+$/, '');
|
|
43
36
|
|
|
44
37
|
if (id) {
|
|
45
38
|
token.attrSet('id', id);
|
|
46
39
|
}
|
|
47
40
|
}
|
|
48
41
|
}
|
|
49
|
-
|
|
50
42
|
return originalHeadingOpen(tokens, idx, options, env, self);
|
|
51
43
|
};
|
|
52
44
|
};
|
|
@@ -59,13 +51,15 @@ function createMarkdownItInstance(config) {
|
|
|
59
51
|
breaks: true,
|
|
60
52
|
};
|
|
61
53
|
|
|
54
|
+
// Removed newlines from template literals to prevent extra padding in <pre> blocks
|
|
62
55
|
const highlightFn = (str, lang) => {
|
|
63
56
|
if (lang === 'mermaid') {
|
|
64
57
|
return `<pre class="mermaid">${new MarkdownIt().utils.escapeHtml(str)}</pre>`;
|
|
65
58
|
}
|
|
66
59
|
if (lang && hljs.getLanguage(lang)) {
|
|
67
60
|
try {
|
|
68
|
-
|
|
61
|
+
const highlighted = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
|
|
62
|
+
return `<pre class="hljs"><code>${highlighted}</code></pre>`;
|
|
69
63
|
} catch (e) { console.error(e); }
|
|
70
64
|
}
|
|
71
65
|
return `<pre class="hljs"><code>${new MarkdownIt().utils.escapeHtml(str)}</code></pre>`;
|
|
@@ -78,7 +72,6 @@ function createMarkdownItInstance(config) {
|
|
|
78
72
|
|
|
79
73
|
const md = new MarkdownIt(mdOptions);
|
|
80
74
|
|
|
81
|
-
// Plugins
|
|
82
75
|
md.use(attrs, { leftDelimiter: '{', rightDelimiter: '}' });
|
|
83
76
|
md.use(markdown_it_footnote);
|
|
84
77
|
md.use(markdown_it_task_lists);
|
|
@@ -86,28 +79,24 @@ function createMarkdownItInstance(config) {
|
|
|
86
79
|
md.use(markdown_it_deflist);
|
|
87
80
|
md.use(headingIdPlugin);
|
|
88
81
|
|
|
89
|
-
// Register Containers
|
|
90
82
|
Object.keys(containers).forEach(containerName => {
|
|
91
83
|
const container = containers[containerName];
|
|
92
84
|
md.renderer.rules[`container_${containerName}_open`] = container.render;
|
|
93
85
|
md.renderer.rules[`container_${containerName}_close`] = container.render;
|
|
94
86
|
});
|
|
95
87
|
|
|
96
|
-
// Register Rules (Order Matters)
|
|
97
88
|
md.block.ruler.before('fence', 'steps_container', rules.stepsContainerRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
|
|
98
89
|
md.block.ruler.before('fence', 'enhanced_tabs', rules.enhancedTabsRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
|
|
99
90
|
md.block.ruler.before('fence', 'changelog_timeline', rules.changelogTimelineRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
|
|
100
91
|
md.block.ruler.before('paragraph', 'advanced_container', rules.advancedContainerRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
|
|
101
92
|
md.block.ruler.before('paragraph', 'standalone_closing', rules.standaloneClosingRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
|
|
102
93
|
|
|
103
|
-
// Register Renderers
|
|
104
94
|
md.renderer.rules.ordered_list_open = renderers.customOrderedListOpenRenderer;
|
|
105
95
|
md.renderer.rules.list_item_open = renderers.customListItemOpenRenderer;
|
|
106
96
|
md.renderer.rules.image = renderers.customImageRenderer;
|
|
107
97
|
md.renderer.rules.table_open = renderers.tableOpenRenderer;
|
|
108
98
|
md.renderer.rules.table_close = renderers.tableCloseRenderer;
|
|
109
99
|
|
|
110
|
-
// Tabs Renderers
|
|
111
100
|
md.renderer.rules.tabs_open = renderers.tabsOpenRenderer;
|
|
112
101
|
md.renderer.rules.tabs_nav_open = renderers.tabsNavOpenRenderer;
|
|
113
102
|
md.renderer.rules.tabs_nav_close = renderers.tabsNavCloseRenderer;
|
|
@@ -1,62 +1,74 @@
|
|
|
1
1
|
// Source file from the docmd project — https://github.com/docmd-io/docmd
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* @param {string} currentPagePath - The normalized path of the current page.
|
|
7
|
-
* @returns {{prevPage: object|null, nextPage: object|null}}
|
|
4
|
+
* Normalizes paths to a "canonical" form for comparison.
|
|
5
|
+
* Handles: "./path", "/path", "path/index.html", "path.md"
|
|
8
6
|
*/
|
|
7
|
+
function getCanonicalPath(p) {
|
|
8
|
+
if (!p) return '';
|
|
9
|
+
if (p.startsWith('http')) return p; // Don't touch external URLs
|
|
10
|
+
|
|
11
|
+
// 1. Remove leading dot-slash or slash
|
|
12
|
+
let path = p.replace(/^(\.\/|\/)+/, '');
|
|
13
|
+
|
|
14
|
+
// 2. Remove query strings or hashes
|
|
15
|
+
path = path.split('?')[0].split('#')[0];
|
|
16
|
+
|
|
17
|
+
// 3. Remove file extensions (.html, .md)
|
|
18
|
+
path = path.replace(/(\.html|\.md)$/, '');
|
|
19
|
+
|
|
20
|
+
// 4. Handle index files (folder/index -> folder)
|
|
21
|
+
if (path.endsWith('/index')) {
|
|
22
|
+
path = path.slice(0, -6);
|
|
23
|
+
} else if (path === 'index') {
|
|
24
|
+
path = '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 5. Remove trailing slash
|
|
28
|
+
if (path.endsWith('/')) {
|
|
29
|
+
path = path.slice(0, -1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return path;
|
|
33
|
+
}
|
|
34
|
+
|
|
9
35
|
function findPageNeighbors(navItems, currentPagePath) {
|
|
10
36
|
const flatNavigation = [];
|
|
37
|
+
const currentCanonical = getCanonicalPath(currentPagePath);
|
|
11
38
|
|
|
12
|
-
|
|
13
|
-
function extractNavigationItems(items) {
|
|
39
|
+
function recurse(items) {
|
|
14
40
|
if (!items || !Array.isArray(items)) return;
|
|
15
|
-
|
|
16
|
-
for (const item of items) {
|
|
17
|
-
if (item.external || !item.path || item.path === '#') {
|
|
18
|
-
// If it's a category with no path but has children, recurse into them
|
|
19
|
-
if (item.children && Array.isArray(item.children)) {
|
|
20
|
-
extractNavigationItems(item.children);
|
|
21
|
-
}
|
|
22
|
-
continue; // Skip external links and parent items without a direct path
|
|
23
|
-
}
|
|
24
41
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
if (!
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
normalizedItemPath += '/';
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
// Logic: Only consider items that have a path and are NOT external
|
|
44
|
+
// Also ignore items that are just '#' (placeholder parents)
|
|
45
|
+
if (item.path && !item.external && !item.path.startsWith('http') && item.path !== '#') {
|
|
46
|
+
flatNavigation.push({
|
|
47
|
+
title: item.title,
|
|
48
|
+
path: item.path, // Keep original path for the HREF
|
|
49
|
+
canonical: getCanonicalPath(item.path) // Use canonical for comparison
|
|
50
|
+
});
|
|
35
51
|
}
|
|
36
52
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
path: normalizedItemPath,
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
if (item.children && Array.isArray(item.children)) {
|
|
43
|
-
extractNavigationItems(item.children);
|
|
53
|
+
if (item.children) {
|
|
54
|
+
recurse(item.children);
|
|
44
55
|
}
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
58
|
|
|
48
|
-
|
|
59
|
+
recurse(navItems);
|
|
49
60
|
|
|
50
|
-
|
|
61
|
+
// Find index using canonical paths
|
|
62
|
+
const index = flatNavigation.findIndex(item => item.canonical === currentCanonical);
|
|
51
63
|
|
|
52
|
-
if (
|
|
64
|
+
if (index === -1) {
|
|
53
65
|
return { prevPage: null, nextPage: null };
|
|
54
66
|
}
|
|
55
67
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
68
|
+
return {
|
|
69
|
+
prevPage: index > 0 ? flatNavigation[index - 1] : null,
|
|
70
|
+
nextPage: index < flatNavigation.length - 1 ? flatNavigation[index + 1] : null
|
|
71
|
+
};
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
module.exports = { findPageNeighbors };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<%# Source file from the docmd project — https://github.com/docmd-io/docmd %>
|
|
2
2
|
|
|
3
|
+
<%# src/templates/navigation.ejs %>
|
|
3
4
|
<nav class="sidebar-nav" aria-label="Main navigation">
|
|
4
5
|
<ul>
|
|
5
6
|
<%
|
|
@@ -33,19 +34,28 @@
|
|
|
33
34
|
let itemPath = item.path || '#';
|
|
34
35
|
const isAbsoluteUrl = itemPath.startsWith('http://') || itemPath.startsWith('https://');
|
|
35
36
|
const normalizedItemPath = (isExplicitExternal || isAbsoluteUrl) ? itemPath : normalizePath(itemPath);
|
|
37
|
+
|
|
36
38
|
const isActive = !isExplicitExternal && !isAbsoluteUrl && currentPagePath === normalizedItemPath;
|
|
37
39
|
const isParentOfActive = !isActive && hasActiveChild(item, currentPagePath);
|
|
38
|
-
|
|
40
|
+
|
|
41
|
+
// Logic: An item is only "collapsible" if children exist AND collapsible flag is true
|
|
42
|
+
const isCollapsible = item.children && item.collapsible === true;
|
|
43
|
+
|
|
44
|
+
// Logic: It should be open if it's the active parent or it's simply not a collapsible menu
|
|
39
45
|
const shouldBeOpen = isParentOfActive || (isActive && item.children && item.children.length > 0);
|
|
46
|
+
|
|
40
47
|
const liClasses = [];
|
|
41
48
|
if (isActive) liClasses.push('active');
|
|
42
49
|
if (isParentOfActive) liClasses.push('active-parent');
|
|
43
50
|
if (isCollapsible) liClasses.push('collapsible');
|
|
51
|
+
|
|
44
52
|
let liAttributes = `class="${liClasses.join(' ')}"`;
|
|
45
53
|
if (isCollapsible) {
|
|
46
54
|
liAttributes += ` data-nav-id="${item.path}"`;
|
|
47
55
|
if (shouldBeOpen) liAttributes += ` aria-expanded="true"`;
|
|
56
|
+
else liAttributes += ` aria-expanded="false"`;
|
|
48
57
|
}
|
|
58
|
+
|
|
49
59
|
let finalHref = item.path;
|
|
50
60
|
if (!isExplicitExternal && !isAbsoluteUrl) {
|
|
51
61
|
if (!itemPath || itemPath === '#') {
|
|
@@ -77,7 +87,7 @@
|
|
|
77
87
|
<% if (isExplicitExternal) { %> <%- renderIcon('external-link', { class: 'nav-external-icon' }) %> <% } %>
|
|
78
88
|
</a>
|
|
79
89
|
<% if (item.children) { %>
|
|
80
|
-
<ul class="submenu" style="<%= (isCollapsible
|
|
90
|
+
<ul class="submenu" style="display: <%= (!isCollapsible || shouldBeOpen) ? 'block' : 'none' %>;">
|
|
81
91
|
<% renderNav(item.children); %>
|
|
82
92
|
</ul>
|
|
83
93
|
<% } %>
|
|
@@ -1,26 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/*
|
|
4
|
-
* Initialize the theme from localStorage
|
|
5
|
-
*/
|
|
1
|
+
/* Source file from the docmd project — https://github.com/docmd-io/docmd */
|
|
6
2
|
|
|
7
3
|
(function() {
|
|
8
4
|
try {
|
|
9
|
-
// Determine Theme
|
|
10
5
|
var localValue = localStorage.getItem('docmd-theme');
|
|
11
6
|
var configValue = window.DOCMD_DEFAULT_MODE || 'light';
|
|
12
7
|
var theme = localValue ? localValue : configValue;
|
|
13
8
|
|
|
14
|
-
//
|
|
9
|
+
// Resolve system preference immediately
|
|
10
|
+
if (theme === 'system') {
|
|
11
|
+
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Apply to HTML and Body to ensure all CSS selectors catch it
|
|
15
15
|
document.documentElement.setAttribute('data-theme', theme);
|
|
16
|
+
|
|
17
|
+
// We use a small interval to ensure body exists (since this script is in head)
|
|
18
|
+
var checkBody = setInterval(function() {
|
|
19
|
+
if (document.body) {
|
|
20
|
+
document.body.setAttribute('data-theme', theme);
|
|
21
|
+
clearInterval(checkBody);
|
|
22
|
+
}
|
|
23
|
+
}, 10);
|
|
16
24
|
|
|
17
|
-
// Handle Highlight.js Theme
|
|
25
|
+
// Handle Highlight.js Theme
|
|
18
26
|
var highlightLink = document.getElementById('highlight-theme');
|
|
19
27
|
if (highlightLink) {
|
|
20
28
|
var baseHref = highlightLink.getAttribute('data-base-href');
|
|
21
|
-
|
|
22
|
-
// If not, swap it immediately before the browser renders code blocks
|
|
23
|
-
if (baseHref && !highlightLink.href.includes('docmd-highlight-' + theme)) {
|
|
29
|
+
if (baseHref) {
|
|
24
30
|
highlightLink.href = baseHref + 'docmd-highlight-' + theme + '.css';
|
|
25
31
|
}
|
|
26
32
|
}
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
// Source file from the docmd project — https://github.com/docmd-io/docmd
|
|
2
|
-
|
|
3
|
-
// Tags that don't have a closing tag (void elements)
|
|
4
|
-
const VOID_TAGS = new Set([
|
|
5
|
-
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
6
|
-
'link', 'meta', 'param', 'source', 'track', 'wbr', '!doctype'
|
|
7
|
-
]);
|
|
8
|
-
|
|
9
|
-
// Tags where whitespace should be preserved exactly
|
|
10
|
-
const PRESERVE_TAGS = new Set(['pre', 'script', 'style', 'textarea']);
|
|
11
|
-
|
|
12
|
-
function formatHtml(html) {
|
|
13
|
-
if (!html) return '';
|
|
14
|
-
|
|
15
|
-
const lines = html.split(/\r?\n/);
|
|
16
|
-
let formatted = [];
|
|
17
|
-
let indentLevel = 0;
|
|
18
|
-
const indentSize = 4; // 4 spaces per level
|
|
19
|
-
|
|
20
|
-
// State tracking
|
|
21
|
-
let inPreserveBlock = false;
|
|
22
|
-
let preserveTagName = '';
|
|
23
|
-
|
|
24
|
-
for (let i = 0; i < lines.length; i++) {
|
|
25
|
-
let line = lines[i].trim(); // Start with a clean slate for this line
|
|
26
|
-
|
|
27
|
-
if (!line) continue; // Skip empty lines
|
|
28
|
-
|
|
29
|
-
// --- 1. Handle Preserve Blocks (pre/script/style) ---
|
|
30
|
-
if (inPreserveBlock) {
|
|
31
|
-
// Check if this line ends the block
|
|
32
|
-
if (line.toLowerCase().includes(`</${preserveTagName}>`)) {
|
|
33
|
-
inPreserveBlock = false;
|
|
34
|
-
} else {
|
|
35
|
-
// If inside preserve block, add raw line (no trim) from original
|
|
36
|
-
formatted.push(lines[i]);
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Check if this line STARTS a preserve block
|
|
42
|
-
for (const tag of PRESERVE_TAGS) {
|
|
43
|
-
if (line.toLowerCase().startsWith(`<${tag}`)) {
|
|
44
|
-
inPreserveBlock = true;
|
|
45
|
-
preserveTagName = tag;
|
|
46
|
-
// If it's a one-liner (e.g. <script>...</script>), handle it immediately
|
|
47
|
-
if (line.toLowerCase().includes(`</${tag}>`)) {
|
|
48
|
-
inPreserveBlock = false;
|
|
49
|
-
}
|
|
50
|
-
break;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// --- 2. Calculate Indentation ---
|
|
55
|
-
|
|
56
|
-
// Detect closing tags at the start of the line (e.g. </div> or -->)
|
|
57
|
-
const isClosing = /^<\//.test(line) || /^-->/.test(line);
|
|
58
|
-
|
|
59
|
-
if (isClosing && indentLevel > 0) {
|
|
60
|
-
indentLevel--;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Apply indentation
|
|
64
|
-
const spaces = ' '.repeat(indentLevel * indentSize);
|
|
65
|
-
formatted.push(spaces + line);
|
|
66
|
-
|
|
67
|
-
// --- 3. Adjust Indent for Next Line ---
|
|
68
|
-
|
|
69
|
-
// Detect opening tags
|
|
70
|
-
// Logic: Starts with <, not </, not <!, not void tag, and not self-closing />
|
|
71
|
-
const isOpening = /^<[\w]+/.test(line)
|
|
72
|
-
&& !/^<\//.test(line)
|
|
73
|
-
&& !/\/>$/.test(line); // Ends with />
|
|
74
|
-
|
|
75
|
-
if (isOpening) {
|
|
76
|
-
// Extract tag name to check if it's void
|
|
77
|
-
const tagNameMatch = line.match(/^<([\w-]+)/);
|
|
78
|
-
if (tagNameMatch) {
|
|
79
|
-
const tagName = tagNameMatch[1].toLowerCase();
|
|
80
|
-
if (!VOID_TAGS.has(tagName)) {
|
|
81
|
-
// Check if it's a one-liner: <div>Content</div>
|
|
82
|
-
// Logic: count opening vs closing tags in this line
|
|
83
|
-
// Simple heuristic: if line contains </tagName>, it's closed
|
|
84
|
-
const hasClosingPair = new RegExp(`</${tagName}>`, 'i').test(line);
|
|
85
|
-
|
|
86
|
-
if (!hasClosingPair) {
|
|
87
|
-
indentLevel++;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return formatted.join('\n');
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
module.exports = { formatHtml };
|