@mgks/docmd 0.3.8 → 0.3.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mgks/docmd",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
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.562.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,
@@ -886,11 +890,36 @@ details[open]>.collapsible-summary .collapsible-arrow svg {
886
890
  }
887
891
 
888
892
  .toc-sidebar {
889
- width: 180px;
893
+ position: -webkit-sticky;
890
894
  position: sticky;
891
895
  top: 2rem;
896
+ max-height: calc(100vh - 15rem);
892
897
  overflow-y: auto;
893
- align-self: flex-start
898
+ }
899
+
900
+ .toc-link.active {
901
+ font-weight: 700;
902
+ color: var(--link-color);
903
+ border-left: 2px solid var(--link-color);
904
+ padding-left: 8px;
905
+ margin-left: -10px;
906
+ }
907
+
908
+ @media (max-width: 1024px) {
909
+ .content-layout {
910
+ flex-direction: column-reverse;
911
+ }
912
+
913
+ .toc-sidebar {
914
+ position: static;
915
+ width: 100%;
916
+ max-height: none;
917
+ margin-bottom: 2rem;
918
+ border-left: none;
919
+ border-bottom: 1px solid var(--border-color);
920
+ padding-left: 0;
921
+ padding-bottom: 1rem;
922
+ }
894
923
  }
895
924
 
896
925
  .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,58 @@ let themeInitScript = '';
23
21
  } catch (e) { /* ignore */ }
24
22
  })();
25
23
 
26
- // Removes excessive whitespace and blank lines from the generated HTML
24
+ // Basic whitespace cleanup (keep this simple version)
27
25
  function cleanupHtml(html) {
28
26
  if (!html) return '';
29
- return html
30
- .replace(/^[ \t]+$/gm, '')
31
- .replace(/\n{3,}/g, '\n\n')
32
- .trim();
27
+ return html.replace(/^\s*[\r\n]/gm, '').trim();
33
28
  }
34
29
 
35
- // Rewrites links based on build mode (Offline/File protocol vs Web Server)
36
- function fixHtmlLinks(htmlContent, relativePathToRoot, isOfflineMode) {
30
+ function fixHtmlLinks(htmlContent, relativePathToRoot, isOfflineMode, configBase = '/') {
37
31
  if (!htmlContent) return '';
38
32
  const root = relativePathToRoot || './';
33
+ const baseUrl = configBase.endsWith('/') ? configBase : configBase + '/';
39
34
 
40
- return htmlContent.replace(/href="((?:\/|\.\/|\.\.\/)[^"]*)"/g, (match, href) => {
41
- let finalPath = href;
42
-
43
- // Convert absolute project paths to relative
44
- if (href.startsWith('/')) {
45
- finalPath = root + href.substring(1);
35
+ return htmlContent.replace(/(href|src)=["']([^"']+)["']/g, (match, attr, url) => {
36
+ if (url.startsWith('#') || url.startsWith('http') || url.startsWith('mailto:') || url === '') {
37
+ return match;
46
38
  }
47
-
48
- // Handle offline mode (force index.html for directories)
39
+
40
+ let finalPath = url;
41
+
42
+ // 1. Handle Base URL removal
43
+ if (baseUrl !== '/' && url.startsWith(baseUrl)) {
44
+ finalPath = '/' + url.substring(baseUrl.length);
45
+ }
46
+
47
+ // 2. Handle Absolute Paths
48
+ if (finalPath.startsWith('/')) {
49
+ // Simple logic: if root relative, prepend relative path
50
+ finalPath = root + finalPath.substring(1);
51
+ }
52
+
53
+ // 3. Offline Mode Logic
49
54
  if (isOfflineMode) {
50
- const cleanPath = finalPath.split('#')[0].split('?')[0];
51
- if (!path.extname(cleanPath)) {
52
- if (finalPath.includes('#')) {
53
- const parts = finalPath.split('#');
54
- const prefix = parts[0].endsWith('/') ? parts[0] : parts[0] + '/';
55
- finalPath = prefix + 'index.html#' + parts[1];
56
- } else {
57
- finalPath += (finalPath.endsWith('/') ? '' : '/') + 'index.html';
55
+ const [pathOnly] = finalPath.split(/[?#]/);
56
+ const ext = path.extname(pathOnly);
57
+ const isAsset = ['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico'].includes(ext.toLowerCase());
58
+
59
+ if (!isAsset && !ext) {
60
+ if (finalPath.endsWith('/')) {
61
+ finalPath += 'index.html';
62
+ } else if (!finalPath.includes('#')) {
63
+ finalPath += '/index.html';
58
64
  }
59
65
  }
60
66
  } else {
61
- // Web mode (strip index.html for clean URLs)
62
67
  if (finalPath.endsWith('/index.html')) {
63
68
  finalPath = finalPath.substring(0, finalPath.length - 10);
64
69
  }
65
70
  }
66
- return `href="${finalPath}"`;
71
+
72
+ return `${attr}="${finalPath}"`;
67
73
  });
68
74
  }
69
75
 
70
- // aggregates HTML snippets from various plugins (SEO, Analytics, etc.)
71
76
  async function processPluginHooks(config, pageData, relativePathToRoot) {
72
77
  let metaTagsHtml = '';
73
78
  let faviconLinkHtml = '';
@@ -77,17 +82,16 @@ async function processPluginHooks(config, pageData, relativePathToRoot) {
77
82
  let pluginBodyScriptsHtml = '';
78
83
 
79
84
  const safeRoot = relativePathToRoot || './';
80
- const indent = ' '; // 4 spaces for cleaner output
81
85
 
82
86
  if (config.favicon) {
83
87
  const cleanFaviconPath = config.favicon.startsWith('/') ? config.favicon.substring(1) : config.favicon;
84
88
  const finalFaviconHref = `${safeRoot}${cleanFaviconPath}`;
85
- faviconLinkHtml = `${indent}<link rel="icon" href="${finalFaviconHref}" type="image/x-icon" sizes="any">\n${indent}<link rel="shortcut icon" href="${finalFaviconHref}" type="image/x-icon">`;
89
+ faviconLinkHtml = `<link rel="icon" href="${finalFaviconHref}" type="image/x-icon" sizes="any">\n<link rel="shortcut icon" href="${finalFaviconHref}" type="image/x-icon">`;
86
90
  }
87
91
 
88
92
  if (config.theme && config.theme.name && config.theme.name !== 'default') {
89
93
  const themeCssPath = `assets/css/docmd-theme-${config.theme.name}.css`;
90
- themeCssLinkHtml = `${indent}<link rel="stylesheet" href="${safeRoot}${themeCssPath}">`;
94
+ themeCssLinkHtml = `<link rel="stylesheet" href="${safeRoot}${themeCssPath}">`;
91
95
  }
92
96
 
93
97
  if (config.plugins?.seo) {
@@ -103,7 +107,6 @@ async function processPluginHooks(config, pageData, relativePathToRoot) {
103
107
  return { metaTagsHtml, faviconLinkHtml, themeCssLinkHtml, pluginStylesHtml, pluginHeadScriptsHtml, pluginBodyScriptsHtml };
104
108
  }
105
109
 
106
- // Main function to assemble the page data and render the EJS template
107
110
  async function generateHtmlPage(templateData, isOfflineMode = false) {
108
111
  let { content, frontmatter, outputPath, headings, config } = templateData;
109
112
  const { currentPagePath, prevPage, nextPage, relativePathToRoot, navigationHtml, siteTitle } = templateData;
@@ -111,19 +114,16 @@ async function generateHtmlPage(templateData, isOfflineMode = false) {
111
114
 
112
115
  if (!relativePathToRoot) templateData.relativePathToRoot = './';
113
116
 
114
- // Process content links and generate plugin assets
115
- content = fixHtmlLinks(content, templateData.relativePathToRoot, isOfflineMode);
117
+ content = fixHtmlLinks(content, templateData.relativePathToRoot, isOfflineMode, config.base);
116
118
  const pluginOutputs = await processPluginHooks(config, { frontmatter, outputPath }, templateData.relativePathToRoot);
117
119
 
118
- // Process footer markdown if present
119
120
  let footerHtml = '';
120
121
  if (config.footer) {
121
122
  if (!mdInstance) mdInstance = createMarkdownItInstance(config);
122
123
  footerHtml = mdInstance.renderInline(config.footer);
123
- footerHtml = fixHtmlLinks(footerHtml, templateData.relativePathToRoot, isOfflineMode);
124
+ footerHtml = fixHtmlLinks(footerHtml, templateData.relativePathToRoot, isOfflineMode, config.base);
124
125
  }
125
126
 
126
- // Determine which template to use
127
127
  let templateName = frontmatter.noStyle === true ? 'no-style.ejs' : 'layout.ejs';
128
128
  const layoutTemplatePath = path.join(__dirname, '..', 'templates', templateName);
129
129
  if (!await fs.exists(layoutTemplatePath)) throw new Error(`Template not found: ${layoutTemplatePath}`);
@@ -131,7 +131,6 @@ async function generateHtmlPage(templateData, isOfflineMode = false) {
131
131
 
132
132
  const isActivePage = currentPagePath && content && content.trim().length > 0;
133
133
 
134
- // Build the "Edit this page" link
135
134
  let editUrl = null;
136
135
  let editLinkText = 'Edit this page';
137
136
  if (config.editLink && config.editLink.enabled && config.editLink.baseUrl) {
@@ -141,10 +140,9 @@ async function generateHtmlPage(templateData, isOfflineMode = false) {
141
140
  editLinkText = config.editLink.text || editLinkText;
142
141
  }
143
142
 
144
- // Prepare complete data object for EJS
145
143
  const ejsData = {
146
144
  ...templateData,
147
- description: frontmatter.description || '', // Fix for reference error
145
+ description: frontmatter.description || '',
148
146
  footerHtml, editUrl, editLinkText, isActivePage,
149
147
  defaultMode: config.theme?.defaultMode || 'light',
150
148
  logo: config.logo, sidebarConfig: config.sidebar || {}, theme: config.theme,
@@ -155,13 +153,12 @@ async function generateHtmlPage(templateData, isOfflineMode = false) {
155
153
  isOfflineMode
156
154
  };
157
155
 
158
- // Render and format
159
156
  const rawHtml = renderHtmlPage(layoutTemplate, ejsData, layoutTemplatePath);
160
157
  const pkgVersion = require('../../package.json').version;
161
158
  const brandingComment = `<!-- Generated by docmd (v${pkgVersion}) - https://docmd.io -->\n`;
162
159
 
163
- // Apply smart formatting to the final HTML string
164
- return brandingComment + formatHtml(rawHtml);
160
+ // REMOVED: formatHtml(rawHtml)
161
+ return brandingComment + cleanupHtml(rawHtml);
165
162
  }
166
163
 
167
164
  function renderHtmlPage(templateContent, ejsData, filename = 'template.ejs', options = {}) {
@@ -173,14 +170,12 @@ function renderHtmlPage(templateContent, ejsData, filename = 'template.ejs', opt
173
170
  }
174
171
  }
175
172
 
176
- // Generate the sidebar navigation HTML separately
177
173
  async function generateNavigationHtml(navItems, currentPagePath, relativePathToRoot, config, isOfflineMode = false) {
178
174
  const navTemplatePath = path.join(__dirname, '..', 'templates', 'navigation.ejs');
179
175
  if (!await fs.exists(navTemplatePath)) throw new Error(`Navigation template not found: ${navTemplatePath}`);
180
176
  const navTemplate = await fs.readFile(navTemplatePath, 'utf8');
181
177
  const safeRoot = relativePathToRoot || './';
182
178
 
183
- // We render raw here; the main page formatter will clean this up later
184
179
  return ejs.render(navTemplate, {
185
180
  navItems, currentPagePath, relativePathToRoot: safeRoot, config, isOfflineMode, renderIcon
186
181
  }, { 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'); // Required for inner rendering fallback logic
3
+ const MarkdownIt = require('markdown-it');
4
4
  const { containers } = require('./containers');
5
5
 
6
- // --- Helper: Create isolated parser for tabs (prevents recursion stack issues) ---
7
- // Note: We pass a callback to get the main config logic from setup.js later if needed,
8
- // but for now we reconstruct a basic one for tab/changelog internals to ensure plugins run.
9
- // Ideally, we prefer reusing state.md.render() where possible.
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 (Nesting Logic) ---
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
- if (nextContent.startsWith(':::')) {
44
- const containerMatch = nextContent.match(/^:::\s*(\w+)/);
45
- if (containerMatch && containerMatch[1] !== containerName) {
46
- const innerContainer = containers[containerMatch[1]];
47
- if (innerContainer && innerContainer.render && !innerContainer.selfClosing) {
48
- depth++;
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 (FIXED FOR NESTING) ---
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.startsWith(':::')) {
104
- const match = nextContent.match(/^:::\s*(\w+)/);
105
- if (match) {
106
- const containerName = match[1];
107
- // Don't count self-closing like buttons for depth
108
- const containerDef = containers[containerName];
109
- if (!containerDef || !containerDef.selfClosing) {
110
- depth++;
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 currentContent = [];
175
+ let currentContentLines = [];
134
176
 
135
177
  for (let i = 0; i < lines.length; i++) {
136
- const line = lines[i].trim();
137
- const markerMatch = line.match(/^==\s+(.+)$/);
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 = currentContent.join('\n');
184
+ currentEntry.content = smartDedent(currentContentLines.join('\n'));
142
185
  entries.push(currentEntry);
143
186
  }
144
187
  currentEntry = { meta: markerMatch[1], content: '' };
145
- currentContent = [];
188
+ currentContentLines = [];
146
189
  } else if (currentEntry) {
147
- currentContent.push(lines[i]);
190
+ currentContentLines.push(rawLine);
148
191
  }
149
192
  }
150
193
  if (currentEntry) {
151
- currentEntry.content = currentContent.join('\n');
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.startsWith('== tab')) { continue; }
194
-
195
- if (nextContent.startsWith(':::')) {
196
- const containerMatch = nextContent.match(/^:::\s*(\w+)/);
197
- if (containerMatch) {
198
- const containerName = containerMatch[1];
199
- const innerContainer = containers[containerName];
200
- if (innerContainer && !innerContainer.selfClosing) {
201
- depth++;
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.startsWith(':::')) {
249
- const containerMatch = nextContent.match(/^:::\s*(\w+)/);
250
- if (containerMatch && containerMatch[1] !== 'tabs') {
251
- if (containerMatch[1] === 'steps') continue;
252
- const innerContainer = containers[containerMatch[1]];
253
- if (innerContainer && !innerContainer.selfClosing) depth++;
254
- continue;
255
- }
256
- }
257
-
258
- if (nextContent === ':::') {
259
- depth--;
260
- if (depth === 0) { found = true; break; }
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 currentContent = [];
325
+ let currentContentLines = [];
276
326
 
277
327
  for (let i = 0; i < lines.length; i++) {
278
- const line = lines[i].trim();
279
- const tabMatch = line.match(/^==\s*tab\s+(?:"([^"]+)"|(\S+))$/);
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
- currentTab.content = currentContent.join('\n').trim();
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
- currentContent = [];
341
+ currentContentLines = [];
289
342
  } else if (currentTab) {
290
- if (lines[i].trim() && !lines[i].trim().startsWith('==')) {
291
- currentContent.push(lines[i]);
292
- }
343
+ currentContentLines.push(rawLine);
293
344
  }
294
345
  }
295
346
  if (currentTab) {
296
- currentTab.content = currentContent.join('\n').trim();
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.trim()) {
319
- // Use recursion here if possible, or create basic instance to prevent circular dep issues in this modular file
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, '-') // Replace spaces with -
39
- .replace(/[^\w\u4e00-\u9fa5-]+/g, '') // Remove all non-word chars (keeping hyphens). Added unicode support implies keeping more chars if needed.
40
- .replace(/--+/g, '-') // Replace multiple - with single -
41
- .replace(/^-+/, '') // Trim - from start of text
42
- .replace(/-+$/, ''); // Trim - from end of text
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
- return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`;
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
- * Flattens the navigation tree and finds the previous and next pages relative to the current page.
5
- * @param {Array} navItems - The navigation array from config.
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
- // Recursive function to flatten the navigation tree
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
- let normalizedItemPath = item.path;
26
-
27
- // Ensure it starts with a slash
28
- if (!normalizedItemPath.startsWith('/')) {
29
- normalizedItemPath = '/' + normalizedItemPath;
30
- }
31
-
32
- // Ensure it ends with a slash (unless it's the root path)
33
- if (normalizedItemPath.length > 1 && !normalizedItemPath.endsWith('/')) {
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
- flatNavigation.push({
38
- title: item.title,
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
- extractNavigationItems(navItems);
59
+ recurse(navItems);
49
60
 
50
- const currentPageIndex = flatNavigation.findIndex(item => item.path === currentPagePath);
61
+ // Find index using canonical paths
62
+ const index = flatNavigation.findIndex(item => item.canonical === currentCanonical);
51
63
 
52
- if (currentPageIndex === -1) {
64
+ if (index === -1) {
53
65
  return { prevPage: null, nextPage: null };
54
66
  }
55
67
 
56
- const prevPage = currentPageIndex > 0 ? flatNavigation[currentPageIndex - 1] : null;
57
- const nextPage = currentPageIndex < flatNavigation.length - 1 ? flatNavigation[currentPageIndex + 1] : null;
58
-
59
- return { prevPage, nextPage };
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,27 +1,27 @@
1
- // Source file from the docmd project — https://github.com/docmd-io/docmd
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
- // Set HTML Attribute (for main CSS variables)
9
+ // Set HTML Attribute
15
10
  document.documentElement.setAttribute('data-theme', theme);
16
11
 
17
- // Handle Highlight.js Theme (if present)
12
+ // Resolve 'system' to actual mode for Highlight.js
13
+ var effectiveTheme = theme;
14
+ if (theme === 'system') {
15
+ effectiveTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
16
+ }
17
+
18
+ // Handle Highlight.js Theme
18
19
  var highlightLink = document.getElementById('highlight-theme');
19
20
  if (highlightLink) {
20
21
  var baseHref = highlightLink.getAttribute('data-base-href');
21
- // Check if the current href matches the desired theme
22
- // If not, swap it immediately before the browser renders code blocks
23
- if (baseHref && !highlightLink.href.includes('docmd-highlight-' + theme)) {
24
- highlightLink.href = baseHref + 'docmd-highlight-' + theme + '.css';
22
+ if (baseHref) {
23
+ // Force load the resolved theme (light/dark)
24
+ highlightLink.href = baseHref + 'docmd-highlight-' + effectiveTheme + '.css';
25
25
  }
26
26
  }
27
27
  } catch (e) {
@@ -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 };