@mgks/docmd 0.2.8 → 0.3.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.
@@ -12,6 +12,9 @@ const { version } = require('../../package.json');
12
12
  const matter = require('gray-matter');
13
13
  const MarkdownIt = require('markdown-it');
14
14
  const hljs = require('highlight.js');
15
+ const CleanCSS = require('clean-css');
16
+ const esbuild = require('esbuild');
17
+ const MiniSearch = require('minisearch');
15
18
 
16
19
  // Debug function to log navigation information
17
20
  function logNavigationPaths(pagePath, navPath, normalizedPath) {
@@ -42,12 +45,12 @@ const ASSET_VERSIONS = {
42
45
  function formatPathForDisplay(absolutePath, cwd) {
43
46
  // Get the relative path from CWD
44
47
  const relativePath = path.relative(cwd, absolutePath);
45
-
48
+
46
49
  // If it's not a subdirectory, prefix with ./ for clarity
47
50
  if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
48
51
  return `./${relativePath}`;
49
52
  }
50
-
53
+
51
54
  // Return the relative path
52
55
  return relativePath;
53
56
  }
@@ -61,6 +64,8 @@ async function buildSite(configPath, options = { isDev: false, preserve: false,
61
64
  const OUTPUT_DIR = path.resolve(CWD, config.outputDir);
62
65
  const USER_ASSETS_DIR = path.resolve(CWD, 'assets');
63
66
  const md = createMarkdownItInstance(config);
67
+ const shouldMinify = !options.isDev && config.minify !== false;
68
+ const searchIndexData = [];
64
69
 
65
70
  if (!await fs.pathExists(SRC_DIR)) {
66
71
  throw new Error(`Source directory not found: ${formatPathForDisplay(SRC_DIR, CWD)}`);
@@ -85,27 +90,70 @@ async function buildSite(configPath, options = { isDev: false, preserve: false,
85
90
  const preservedFiles = [];
86
91
  const userAssetsCopied = [];
87
92
 
93
+ // Function to process and copy a single asset
94
+ const processAndCopyAsset = async (srcPath, destPath) => {
95
+ const ext = path.extname(srcPath).toLowerCase();
96
+
97
+ if (process.env.DOCMD_DEBUG) {
98
+ console.log(`[Asset Debug] Processing: ${path.basename(srcPath)} | Minify: ${shouldMinify} | Ext: ${ext}`);
99
+ }
100
+
101
+ if (shouldMinify && ext === '.css') {
102
+ try {
103
+ const content = await fs.readFile(srcPath, 'utf8');
104
+ const output = new CleanCSS({}).minify(content);
105
+ if (output.errors.length > 0) {
106
+ console.warn(`⚠️ CSS Minification error for ${path.basename(srcPath)}, using original.`, output.errors);
107
+ await fs.copyFile(srcPath, destPath);
108
+ } else {
109
+ await fs.writeFile(destPath, output.styles);
110
+ }
111
+ } catch (e) {
112
+ console.warn(`⚠️ CSS processing failed: ${e.message}`);
113
+ await fs.copyFile(srcPath, destPath);
114
+ }
115
+ }
116
+ else if (shouldMinify && ext === '.js') {
117
+ try {
118
+ const content = await fs.readFile(srcPath, 'utf8');
119
+ // Simple minification transform
120
+ const result = await esbuild.transform(content, {
121
+ minify: true,
122
+ loader: 'js',
123
+ target: 'es2015' // Ensure compatibility
124
+ });
125
+ await fs.writeFile(destPath, result.code);
126
+ } catch (e) {
127
+ console.warn(`⚠️ JS Minification failed: ${e.message}`);
128
+ await fs.copyFile(srcPath, destPath);
129
+ }
130
+ }
131
+ else {
132
+ // Images, fonts, or dev mode -> standard copy
133
+ await fs.copyFile(srcPath, destPath);
134
+ }
135
+ };
136
+
88
137
  // Copy user assets from root assets/ directory if it exists
89
138
  if (await fs.pathExists(USER_ASSETS_DIR)) {
90
139
  const assetsDestDir = path.join(OUTPUT_DIR, 'assets');
91
140
  await fs.ensureDir(assetsDestDir);
92
-
141
+
93
142
  if (!options.isDev) {
94
143
  console.log(`📂 Copying user assets from ${formatPathForDisplay(USER_ASSETS_DIR, CWD)} to ${formatPathForDisplay(assetsDestDir, CWD)}...`);
95
144
  }
96
-
145
+
97
146
  const userAssetFiles = await getAllFiles(USER_ASSETS_DIR);
98
-
99
147
  for (const srcFile of userAssetFiles) {
100
148
  const relativePath = path.relative(USER_ASSETS_DIR, srcFile);
101
149
  const destFile = path.join(assetsDestDir, relativePath);
102
-
103
- // Ensure directory exists
150
+
104
151
  await fs.ensureDir(path.dirname(destFile));
105
- await fs.copyFile(srcFile, destFile);
152
+ await processAndCopyAsset(srcFile, destFile);
153
+
106
154
  userAssetsCopied.push(relativePath);
107
155
  }
108
-
156
+
109
157
  if (!options.isDev && userAssetsCopied.length > 0) {
110
158
  console.log(`📦 Copied ${userAssetsCopied.length} user assets`);
111
159
  }
@@ -114,41 +162,34 @@ async function buildSite(configPath, options = { isDev: false, preserve: false,
114
162
  // Copy assets
115
163
  const assetsSrcDir = path.join(__dirname, '..', 'assets');
116
164
  const assetsDestDir = path.join(OUTPUT_DIR, 'assets');
117
-
165
+
118
166
  if (await fs.pathExists(assetsSrcDir)) {
119
167
  if (!options.isDev) {
120
168
  console.log(`📂 Copying docmd assets to ${formatPathForDisplay(assetsDestDir, CWD)}...`);
121
169
  }
122
-
170
+
123
171
  // Create destination directory if it doesn't exist
124
172
  await fs.ensureDir(assetsDestDir);
125
-
173
+
126
174
  // Get all files from source directory recursively
127
175
  const assetFiles = await getAllFiles(assetsSrcDir);
128
-
129
- // Copy each file individually, checking for existing files if preserve flag is set
130
176
  for (const srcFile of assetFiles) {
131
177
  const relativePath = path.relative(assetsSrcDir, srcFile);
132
178
  const destFile = path.join(assetsDestDir, relativePath);
133
-
134
- // Check if destination file already exists
135
179
  const fileExists = await fs.pathExists(destFile);
136
-
137
- // Skip if the file exists and either:
138
- // 1. The preserve flag is set, OR
139
- // 2. The file was copied from user assets (user assets take precedence)
180
+
140
181
  if (fileExists && (options.preserve || userAssetsCopied.includes(relativePath))) {
141
- // Skip file and add to preserved list
142
182
  preservedFiles.push(relativePath);
143
183
  if (!options.isDev && options.preserve) {
144
184
  console.log(` Preserving existing file: ${relativePath}`);
145
185
  }
186
+
146
187
  } else {
147
- // Copy file (either it doesn't exist or we're not preserving)
148
188
  await fs.ensureDir(path.dirname(destFile));
149
- await fs.copyFile(srcFile, destFile);
189
+ await processAndCopyAsset(srcFile, destFile);
150
190
  }
151
191
  }
192
+
152
193
  } else {
153
194
  console.warn(`⚠️ Assets source directory not found: ${formatPathForDisplay(assetsSrcDir, CWD)}`);
154
195
  }
@@ -189,7 +230,7 @@ async function buildSite(configPath, options = { isDev: false, preserve: false,
189
230
  for (const filePath of markdownFiles) {
190
231
  try {
191
232
  const relativePath = path.relative(SRC_DIR, filePath);
192
-
233
+
193
234
  // Skip file if already processed in this dev build cycle
194
235
  if (options.noDoubleProcessing && processedFiles.has(relativePath)) {
195
236
  continue;
@@ -203,12 +244,12 @@ async function buildSite(configPath, options = { isDev: false, preserve: false,
203
244
  if (!processedData) {
204
245
  continue;
205
246
  }
206
-
247
+
207
248
  // Destructure the valid data
208
- const { frontmatter: pageFrontmatter, htmlContent, headings } = processedData;
209
-
249
+ const { frontmatter: pageFrontmatter, htmlContent, headings, searchData } = processedData;
250
+
210
251
  const isIndexFile = path.basename(relativePath) === 'index.md';
211
-
252
+
212
253
  let outputHtmlPath;
213
254
  if (isIndexFile) {
214
255
  const dirPath = path.dirname(relativePath);
@@ -221,9 +262,9 @@ async function buildSite(configPath, options = { isDev: false, preserve: false,
221
262
 
222
263
  let relativePathToRoot = path.relative(path.dirname(finalOutputHtmlPath), OUTPUT_DIR);
223
264
  if (relativePathToRoot === '') {
224
- relativePathToRoot = './';
265
+ relativePathToRoot = './';
225
266
  } else {
226
- relativePathToRoot = relativePathToRoot.replace(/\\/g, '/') + '/';
267
+ relativePathToRoot = relativePathToRoot.replace(/\\/g, '/') + '/';
227
268
  }
228
269
 
229
270
  let normalizedPath = path.relative(SRC_DIR, filePath).replace(/\\/g, '/');
@@ -259,7 +300,7 @@ async function buildSite(configPath, options = { isDev: false, preserve: false,
259
300
  const cleanPath = prevPage.path.substring(1);
260
301
  prevPage.url = relativePathToRoot + cleanPath;
261
302
  }
262
-
303
+
263
304
  if (nextPage) {
264
305
  const cleanPath = nextPage.path.substring(1);
265
306
  nextPage.url = relativePathToRoot + cleanPath;
@@ -286,17 +327,35 @@ async function buildSite(configPath, options = { isDev: false, preserve: false,
286
327
  await fs.ensureDir(path.dirname(finalOutputHtmlPath));
287
328
  await fs.writeFile(finalOutputHtmlPath, pageHtml);
288
329
 
289
- const sitemapOutputPath = isIndexFile
290
- ? (path.dirname(relativePath) === '.' ? '' : path.dirname(relativePath) + '/')
291
- : relativePath.replace(/\.md$/, '/');
330
+ const sitemapOutputPath = isIndexFile
331
+ ? (path.dirname(relativePath) === '.' ? '' : path.dirname(relativePath) + '/')
332
+ : relativePath.replace(/\.md$/, '/');
292
333
 
293
334
  processedPages.push({
294
335
  outputPath: sitemapOutputPath.replace(/\\/g, '/'),
295
336
  frontmatter: pageFrontmatter
296
337
  });
338
+
339
+ // Collect Search Data
340
+ if (searchData) {
341
+ let pageUrl = outputHtmlPath.replace(/\\/g, '/');
342
+ if (pageUrl.endsWith('/index.html')) {
343
+ pageUrl = pageUrl.substring(0, pageUrl.length - 10); // remove index.html
344
+ }
345
+
346
+ // Add to index array
347
+ searchIndexData.push({
348
+ id: pageUrl, // URL is the ID
349
+ title: searchData.title,
350
+ text: searchData.content,
351
+ headings: searchData.headings.join(' ')
352
+ });
353
+ }
354
+
297
355
  } catch (error) {
298
356
  console.error(`❌ An unexpected error occurred while processing file ${path.relative(CWD, filePath)}:`, error);
299
357
  }
358
+
300
359
  }
301
360
 
302
361
  // Generate sitemap if enabled in config
@@ -308,13 +367,40 @@ async function buildSite(configPath, options = { isDev: false, preserve: false,
308
367
  }
309
368
  }
310
369
 
370
+ // Generate search index if enabled
371
+ if (config.search !== false) {
372
+ console.log('🔍 Generating search index...');
373
+
374
+ // Create MiniSearch instance
375
+ const miniSearch = new MiniSearch({
376
+ fields: ['title', 'headings', 'text'], // fields to index for full-text search
377
+ storeFields: ['title', 'id', 'text'], // fields to return with search results (don't store full text to keep JSON small)
378
+ searchOptions: {
379
+ boost: { title: 2, headings: 1.5 }, // title matches are more important
380
+ fuzzy: 0.2
381
+ }
382
+ });
383
+
384
+ // Add documents
385
+ miniSearch.addAll(searchIndexData);
386
+
387
+ // Serialize to JSON
388
+ const jsonIndex = JSON.stringify(miniSearch.toJSON());
389
+ const searchIndexPath = path.join(OUTPUT_DIR, 'search-index.json');
390
+ await fs.writeFile(searchIndexPath, jsonIndex);
391
+
392
+ if (!options.isDev) {
393
+ console.log(`✅ Search index generated (${(jsonIndex.length / 1024).toFixed(1)} KB)`);
394
+ }
395
+ }
396
+
311
397
  // Print summary of preserved files at the end of build
312
398
  if (preservedFiles.length > 0 && !options.isDev) {
313
399
  console.log(`\n📋 Build Summary: ${preservedFiles.length} existing files were preserved:`);
314
400
  preservedFiles.forEach(file => console.log(` - assets/${file}`));
315
401
  console.log(`\nTo update these files in future builds, run without the --preserve flag.`);
316
402
  }
317
-
403
+
318
404
  if (userAssetsCopied.length > 0 && !options.isDev) {
319
405
  console.log(`\n📋 User Assets: ${userAssetsCopied.length} files were copied from your assets/ directory:`);
320
406
  if (userAssetsCopied.length <= 10) {
@@ -325,6 +411,74 @@ async function buildSite(configPath, options = { isDev: false, preserve: false,
325
411
  }
326
412
  }
327
413
 
414
+ // Bundle third-party libraries into assets
415
+ const copyLibrary = async (packageName, fileToBundle, destFileName) => {
416
+ try {
417
+ let srcPath;
418
+
419
+ // 1. Resolve Source Path
420
+ try {
421
+ srcPath = require.resolve(`${packageName}/${fileToBundle}`);
422
+ } catch (e) {
423
+ const mainPath = require.resolve(packageName);
424
+ let currentDir = path.dirname(mainPath);
425
+ let packageRoot = null;
426
+
427
+ for (let i = 0; i < 5; i++) {
428
+ if (await fs.pathExists(path.join(currentDir, 'package.json'))) {
429
+ packageRoot = currentDir;
430
+ break;
431
+ }
432
+ currentDir = path.dirname(currentDir);
433
+ }
434
+
435
+ if (packageRoot) {
436
+ srcPath = path.join(packageRoot, fileToBundle);
437
+ }
438
+ }
439
+
440
+ // 2. Process and Write
441
+ if (srcPath && await fs.pathExists(srcPath)) {
442
+ const destPath = path.join(OUTPUT_DIR, 'assets/js', destFileName);
443
+
444
+ // Read content
445
+ let content = await fs.readFile(srcPath, 'utf8');
446
+
447
+ // This prevents the browser from looking for index.js.map or similar files we didn't copy
448
+ content = content.replace(/\/\/# sourceMappingURL=.*$/gm, '');
449
+
450
+ // Minify if production build
451
+ if (shouldMinify) {
452
+ try {
453
+ const result = await esbuild.transform(content, {
454
+ minify: true,
455
+ loader: 'js',
456
+ target: 'es2015'
457
+ });
458
+ await fs.writeFile(destPath, result.code);
459
+ } catch (minErr) {
460
+ console.warn(`⚠️ Minification failed for ${packageName}, using sanitized original.`, minErr.message);
461
+ await fs.writeFile(destPath, content);
462
+ }
463
+ } else {
464
+ // Write sanitized original in dev mode
465
+ await fs.writeFile(destPath, content);
466
+ }
467
+
468
+ } else {
469
+ console.warn(`⚠️ Could not locate ${fileToBundle} in ${packageName}`);
470
+ }
471
+ } catch (e) {
472
+ console.warn(`⚠️ Failed to bundle ${packageName}: ${e.message}`);
473
+ }
474
+ };
475
+
476
+ // Bundle MiniSearch
477
+ await copyLibrary('minisearch', 'dist/umd/index.js', 'minisearch.js');
478
+
479
+ // Bundle Mermaid
480
+ await copyLibrary('mermaid', 'dist/mermaid.min.js', 'mermaid.min.js');
481
+
328
482
  return {
329
483
  config,
330
484
  processedPages,
@@ -336,10 +490,10 @@ async function buildSite(configPath, options = { isDev: false, preserve: false,
336
490
  async function findFilesToCleanup(dir) {
337
491
  const filesToRemove = [];
338
492
  const items = await fs.readdir(dir, { withFileTypes: true });
339
-
493
+
340
494
  for (const item of items) {
341
495
  const fullPath = path.join(dir, item.name);
342
-
496
+
343
497
  if (item.isDirectory()) {
344
498
  // Don't delete the assets directory
345
499
  if (item.name !== 'assets') {
@@ -347,13 +501,13 @@ async function findFilesToCleanup(dir) {
347
501
  filesToRemove.push(...subDirFiles);
348
502
  }
349
503
  } else if (
350
- item.name.endsWith('.html') ||
504
+ item.name.endsWith('.html') ||
351
505
  item.name === 'sitemap.xml'
352
506
  ) {
353
507
  filesToRemove.push(fullPath);
354
508
  }
355
509
  }
356
-
510
+
357
511
  return filesToRemove;
358
512
  }
359
513
 
@@ -361,7 +515,7 @@ async function findFilesToCleanup(dir) {
361
515
  async function getAllFiles(dir) {
362
516
  const files = [];
363
517
  const items = await fs.readdir(dir, { withFileTypes: true });
364
-
518
+
365
519
  for (const item of items) {
366
520
  const fullPath = path.join(dir, item.name);
367
521
  if (item.isDirectory()) {
@@ -370,7 +524,7 @@ async function getAllFiles(dir) {
370
524
  files.push(fullPath);
371
525
  }
372
526
  }
373
-
527
+
374
528
  return files;
375
529
  }
376
530
 
@@ -24,6 +24,12 @@ module.exports = {
24
24
  srcDir: 'docs', // Source directory for Markdown files
25
25
  outputDir: 'site', // Directory for generated static site
26
26
 
27
+ // Search Configuration
28
+ search: true, // Enable/disable search functionality
29
+
30
+ // Build Options
31
+ minify: true, // Enable/disable HTML/CSS/JS minification
32
+
27
33
  // Sidebar Configuration
28
34
  sidebar: {
29
35
  collapsible: true, // or false to disable
@@ -88,6 +94,15 @@ module.exports = {
88
94
  // Add other future plugin configurations here by their key
89
95
  },
90
96
 
97
+ // "Edit this page" Link Configuration
98
+ editLink: {
99
+ enabled: false,
100
+ // The URL to the folder containing your docs in the git repo
101
+ // Note: It usually ends with /edit/main/docs or /blob/main/docs
102
+ baseUrl: 'https://github.com/mgks/docmd/edit/main/docs',
103
+ text: 'Edit this page on GitHub'
104
+ },
105
+
91
106
  // Navigation Structure (Sidebar)
92
107
  // Icons are kebab-case names from Lucide Icons (https://lucide.dev/)
93
108
  navigation: [
@@ -4,6 +4,7 @@ const fs = require('fs-extra');
4
4
  const path = require('path');
5
5
  const matter = require('gray-matter');
6
6
  const { createMarkdownItInstance } = require('./markdown/setup');
7
+ const striptags = require('striptags');
7
8
 
8
9
  function decodeHtmlEntities(html) {
9
10
  return html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ');
@@ -56,8 +57,18 @@ async function processMarkdownFile(filePath, md, config) {
56
57
  htmlContent = md.render(markdownContent);
57
58
  headings = extractHeadingsFromHtml(htmlContent);
58
59
  }
60
+
61
+ let searchData = null;
62
+ if (!frontmatter.noindex) {
63
+ const rawText = decodeHtmlEntities(striptags(htmlContent));
64
+ searchData = {
65
+ title: frontmatter.title || 'Untitled',
66
+ content: rawText.slice(0, 5000), // Safety cap to prevent massive JSON
67
+ headings: headings.map(h => h.text)
68
+ };
69
+ }
59
70
 
60
- return { frontmatter, htmlContent, headings };
71
+ return { frontmatter, htmlContent, headings, searchData };
61
72
  }
62
73
 
63
74
  async function findMarkdownFiles(dir) {
@@ -100,6 +100,27 @@ async function generateHtmlPage(templateData) {
100
100
 
101
101
  const isActivePage = currentPagePath && content && content.trim().length > 0;
102
102
 
103
+ // Calculate Edit Link
104
+ let editUrl = null;
105
+ let editLinkText = 'Edit this page';
106
+
107
+ if (config.editLink && config.editLink.enabled && config.editLink.baseUrl) {
108
+ // Normalize URL (remove trailing slash)
109
+ const baseUrl = config.editLink.baseUrl.replace(/\/$/, '');
110
+
111
+ // Get the source file path relative to srcDir
112
+ let relativeSourcePath = outputPath
113
+ .replace(/\/index\.html$/, '.md') // folder/index.html -> folder.md
114
+ .replace(/\\/g, '/'); // fix windows slashes
115
+
116
+ // Special case: The root index.html comes from index.md
117
+ if (relativeSourcePath === 'index.html') relativeSourcePath = 'index.md';
118
+
119
+ // Let's assume a standard 1:1 mapping for v0.2.x
120
+ editUrl = `${baseUrl}/${relativeSourcePath}`;
121
+ editLinkText = config.editLink.text || editLinkText;
122
+ }
123
+
103
124
  const ejsData = {
104
125
  content,
105
126
  pageTitle,
@@ -107,6 +128,8 @@ async function generateHtmlPage(templateData) {
107
128
  description: frontmatter.description,
108
129
  siteTitle,
109
130
  navigationHtml,
131
+ editUrl,
132
+ editLinkText,
110
133
  defaultMode: config.theme?.defaultMode || 'light',
111
134
  relativePathToRoot,
112
135
  logo: config.logo,
@@ -17,20 +17,36 @@ const headingIdPlugin = (md) => {
17
17
  const originalHeadingOpen = md.renderer.rules.heading_open || function(tokens, idx, options, env, self) {
18
18
  return self.renderToken(tokens, idx, options);
19
19
  };
20
+
20
21
  md.renderer.rules.heading_open = function(tokens, idx, options, env, self) {
21
22
  const token = tokens[idx];
22
- const contentToken = tokens[idx + 1];
23
- if (contentToken && contentToken.type === 'inline' && contentToken.content) {
24
- const headingText = contentToken.content;
25
- const id = headingText
26
- .toLowerCase()
27
- .replace(/\s+/g, '-')
28
- .replace(/[^\w-]+/g, '')
29
- .replace(/--+/g, '-')
30
- .replace(/^-+/, '')
31
- .replace(/-+$/, '');
32
- if (id) token.attrSet('id', id);
23
+
24
+ // Check if an ID was already set by markdown-it-attrs (e.g. {#my-id})
25
+ const existingId = token.attrGet('id');
26
+
27
+ // If NO ID exists, generate one automatically from the text content
28
+ if (!existingId) {
29
+ const contentToken = tokens[idx + 1];
30
+ if (contentToken && contentToken.type === 'inline' && contentToken.content) {
31
+ 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
+ const id = headingText
37
+ .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
43
+
44
+ if (id) {
45
+ token.attrSet('id', id);
46
+ }
47
+ }
33
48
  }
49
+
34
50
  return originalHeadingOpen(tokens, idx, options, env, self);
35
51
  };
36
52
  };
@@ -66,6 +66,15 @@
66
66
  </div>
67
67
  <% if (theme && theme.enableModeToggle && theme.positionMode === 'top') { %>
68
68
  <div class="header-right">
69
+ <% if (config.search !== false) { %>
70
+ <button class="docmd-search-trigger" aria-label="Search">
71
+ <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><path d="m21 21-4.34-4.34"></path><circle cx="11" cy="11" r="8"></circle></svg>
72
+ <span class="search-label">Search</span>
73
+ <span class="search-keys">
74
+ <kbd class="docmd-kbd">⌘</kbd><kbd class="docmd-kbd">k</kbd>
75
+ </span>
76
+ </button>
77
+ <% } %>
69
78
  <button id="theme-toggle-button" aria-label="Toggle theme" class="theme-toggle-button theme-toggle-header">
70
79
  <%# renderIcon is available in the global EJS scope from html-generator %>
71
80
  <%- renderIcon('sun', { class: 'icon-sun' }) %>
@@ -115,23 +124,66 @@
115
124
  <%- include('toc', { content, headings, navigationHtml, isActivePage }) %>
116
125
  </div>
117
126
  </div>
127
+
128
+ <!-- Page footer actions -->
129
+ <div class="page-footer-actions">
130
+ <% if (locals.editUrl) { %>
131
+ <a href="<%= editUrl %>" target="_blank" rel="noopener noreferrer" class="edit-link">
132
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pencil"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
133
+ <%= editLinkText %>
134
+ </a>
135
+ <% } %>
136
+
137
+ <% if (locals.lastUpdated) { %>
138
+ <!-- Placeholder for future Last Updated feature -->
139
+ <% } %>
140
+ </div>
118
141
  </main>
142
+
119
143
  <footer class="page-footer">
120
144
  <div class="footer-content">
121
145
  <div class="user-footer">
122
146
  <%- footerHtml || '' %>
123
147
  </div>
124
148
  <div class="branding-footer">
125
- Build with <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path><path d="M12 5 9.04 7.96a2.17 2.17 0 0 0 0 3.08c.82.82 2.13.85 3 .07l2.07-1.9a2.82 2.82 0 0 1 3.79 0l2.96 2.66"></path><path d="m18 15-2-2"></path><path d="m15 18-2-2"></path></svg> <a href="https://docmd.mgks.dev" target="_blank" rel="noopener">docmd.</a>
149
+ Build with <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path><path d="M12 5 9.04 7.96a2.17 2.17 0 0 0 0 3.08c.82.82 2.13.85 3 .07l2.07-1.9a2.82 2.82 0 0 1 3.79 0l2.96 2.66"></path><path d="m18 15-2-2"></path><path d="m15 18-2-2"></path></svg> <a href="https://github.com/mgks/docmd" target="_blank" rel="noopener">docmd.</a>
126
150
  </div>
127
151
  </div>
128
152
  </footer>
129
153
  </div>
130
154
 
155
+ <% if (config.search !== false) { %>
156
+ <!-- Search Modal -->
157
+ <div id="docmd-search-modal" class="docmd-search-modal" style="display: none;">
158
+ <div class="docmd-search-box">
159
+ <div class="docmd-search-header">
160
+ <svg xmlns="http://www.w3.org/2000/svg" width="1.25em" height="1.25em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><path d="m21 21-4.34-4.34"></path><circle cx="11" cy="11" r="8"></circle></svg>
161
+ <input type="text" id="docmd-search-input" placeholder="Search documentation..." autocomplete="off" spellcheck="false">
162
+ <button onclick="window.closeDocmdSearch()" class="docmd-search-close" aria-label="Close search">
163
+ <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
164
+ </button>
165
+ </div>
166
+ <div id="docmd-search-results" class="docmd-search-results">
167
+ <!-- Results injected here -->
168
+ </div>
169
+ <div class="docmd-search-footer">
170
+ <span><kbd class="docmd-kbd">↑</kbd> <kbd class="docmd-kbd">↓</kbd> to navigate</span>
171
+ <span><kbd class="docmd-kbd">ESC</kbd> to close</span>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ <% } %>
176
+
131
177
  <script src="<%= relativePathToRoot %>assets/js/docmd-main.js"></script>
132
-
178
+
179
+ <% if (config.search !== false) { %>
180
+ <!-- Search Scripts -->
181
+ <script src="<%= relativePathToRoot %>assets/js/minisearch.js"></script>
182
+ <script src="<%= relativePathToRoot %>assets/js/docmd-search.js"></script>
183
+ <% } %>
184
+
133
185
  <!-- Mermaid.js for diagram rendering -->
134
- <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
186
+ <script src="<%= relativePathToRoot %>assets/js/mermaid.min.js"></script>
135
187
  <script src="<%= relativePathToRoot %>assets/js/docmd-mermaid.js"></script>
136
188
 
137
189
  <% (customJsFiles || []).forEach(jsFile => { %>