@mgks/docmd 0.1.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.
Files changed (54) hide show
  1. package/.gitattributes +2 -0
  2. package/.github/FUNDING.yml +15 -0
  3. package/.github/workflows/deploy-docmd.yml +45 -0
  4. package/.github/workflows/publish.yml +84 -0
  5. package/LICENSE +21 -0
  6. package/README.md +83 -0
  7. package/bin/docmd.js +63 -0
  8. package/config.js +137 -0
  9. package/docs/cli-commands.md +87 -0
  10. package/docs/configuration.md +166 -0
  11. package/docs/contributing.md +86 -0
  12. package/docs/deployment.md +129 -0
  13. package/docs/getting-started/basic-usage.md +88 -0
  14. package/docs/getting-started/index.md +21 -0
  15. package/docs/getting-started/installation.md +75 -0
  16. package/docs/index.md +56 -0
  17. package/docs/plugins/analytics.md +76 -0
  18. package/docs/plugins/index.md +71 -0
  19. package/docs/plugins/seo.md +79 -0
  20. package/docs/plugins/sitemap.md +88 -0
  21. package/docs/theming/available-themes.md +85 -0
  22. package/docs/theming/custom-css-js.md +84 -0
  23. package/docs/theming/icons.md +93 -0
  24. package/docs/theming/index.md +19 -0
  25. package/docs/theming/light-dark-mode.md +107 -0
  26. package/docs/writing-content/custom-containers.md +129 -0
  27. package/docs/writing-content/frontmatter.md +76 -0
  28. package/docs/writing-content/index.md +17 -0
  29. package/docs/writing-content/markdown-syntax.md +277 -0
  30. package/package.json +56 -0
  31. package/src/assets/css/highlight-dark.css +1 -0
  32. package/src/assets/css/highlight-light.css +1 -0
  33. package/src/assets/css/main.css +562 -0
  34. package/src/assets/css/theme-sky.css +499 -0
  35. package/src/assets/css/toc.css +76 -0
  36. package/src/assets/favicon.ico +0 -0
  37. package/src/assets/images/docmd-logo.png +0 -0
  38. package/src/assets/images/docmd-preview.png +0 -0
  39. package/src/assets/images/logo-dark.png +0 -0
  40. package/src/assets/images/logo-light.png +0 -0
  41. package/src/assets/js/theme-toggle.js +59 -0
  42. package/src/commands/build.js +300 -0
  43. package/src/commands/dev.js +182 -0
  44. package/src/commands/init.js +51 -0
  45. package/src/core/config-loader.js +28 -0
  46. package/src/core/file-processor.js +376 -0
  47. package/src/core/html-generator.js +139 -0
  48. package/src/core/icon-renderer.js +105 -0
  49. package/src/plugins/analytics.js +44 -0
  50. package/src/plugins/seo.js +65 -0
  51. package/src/plugins/sitemap.js +100 -0
  52. package/src/templates/layout.ejs +174 -0
  53. package/src/templates/navigation.ejs +107 -0
  54. package/src/templates/toc.ejs +34 -0
@@ -0,0 +1,376 @@
1
+ // src/core/file-processor.js
2
+ const fs = require('fs-extra');
3
+ const MarkdownIt = require('markdown-it');
4
+ const matter = require('gray-matter');
5
+ const hljs = require('highlight.js');
6
+ const container = require('markdown-it-container');
7
+
8
+ const md = new MarkdownIt({
9
+ html: true,
10
+ linkify: true,
11
+ typographer: true,
12
+ highlight: function (str, lang) {
13
+ if (lang && hljs.getLanguage(lang)) {
14
+ try {
15
+ return '<pre class="hljs"><code>' +
16
+ hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
17
+ '</code></pre>';
18
+ } catch (e) {
19
+ console.error(`Error highlighting language ${lang}:`, e);
20
+ }
21
+ }
22
+ return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
23
+ }
24
+ });
25
+
26
+ // Add anchors to headings for TOC linking
27
+ md.use((md) => {
28
+ // Original renderer
29
+ const defaultRender = md.renderer.rules.heading_open || function(tokens, idx, options, env, self) {
30
+ return self.renderToken(tokens, idx, options);
31
+ };
32
+
33
+ md.renderer.rules.heading_open = function(tokens, idx, options, env, self) {
34
+ const token = tokens[idx];
35
+ // Get the heading level (h1, h2, etc.)
36
+ const level = token.tag.substring(1);
37
+
38
+ // Find the heading text from the next inline token
39
+ const contentToken = tokens[idx + 1];
40
+ if (contentToken && contentToken.type === 'inline') {
41
+ const headingText = contentToken.content;
42
+
43
+ // Generate an ID from the heading text
44
+ // Simple slugify: lowercase, replace spaces and special chars with dashes
45
+ const id = headingText
46
+ .toLowerCase()
47
+ .replace(/\s+/g, '-')
48
+ .replace(/[^\w-]/g, '')
49
+ .replace(/--+/g, '-')
50
+ .replace(/^-+|-+$/g, '');
51
+
52
+ // Add the id attribute
53
+ if (id) {
54
+ token.attrSet('id', id);
55
+ }
56
+ }
57
+
58
+ // Call the original renderer
59
+ return defaultRender(tokens, idx, options, env, self);
60
+ };
61
+ });
62
+
63
+ // Custom Containers
64
+ md.use(container, 'callout', {
65
+ validate: function(params) {
66
+ // Allows optional title for callout: ::: callout type [Optional Title Text]
67
+ return params.trim().match(/^callout\s+(info|warning|tip|danger|success)(\s+.*)?$/);
68
+ },
69
+ render: function (tokens, idx) {
70
+ const token = tokens[idx];
71
+ const match = token.info.trim().match(/^callout\s+(info|warning|tip|danger|success)(\s+(.*))?$/);
72
+
73
+ if (token.nesting === 1) {
74
+ const type = match[1];
75
+ const title = match[3] ? md.renderInline(match[3]) : ''; // Render title as markdown
76
+ let titleHtml = '';
77
+ if (title) {
78
+ titleHtml = `<div class="callout-title">${title}</div>`;
79
+ }
80
+ return `<div class="docmd-container callout callout-${type}">\n${titleHtml}<div class="callout-content">\n`;
81
+ } else {
82
+ return '</div></div>\n';
83
+ }
84
+ }
85
+ });
86
+
87
+ md.use(container, 'card', {
88
+ validate: function(params) {
89
+ // Allows optional title for card: ::: card [Optional Title Text]
90
+ return params.trim().match(/^card(\s+.*)?$/);
91
+ },
92
+ render: function (tokens, idx) {
93
+ const token = tokens[idx];
94
+ const titleText = token.info.trim().substring('card'.length).trim();
95
+
96
+ if (token.nesting === 1) {
97
+ let titleHtml = '';
98
+ if (titleText) {
99
+ titleHtml = `<div class="card-title">${md.renderInline(titleText)}</div>\n`;
100
+ }
101
+ return `<div class="docmd-container card">\n${titleHtml}<div class="card-content">\n`;
102
+ } else {
103
+ return '</div></div>\n';
104
+ }
105
+ }
106
+ });
107
+
108
+ // Steps container: Uses CSS counters for H4 or P > STRONG elements within it.
109
+ // Markdown syntax:
110
+ // ::: steps
111
+ // > 1. **First Step Title:**
112
+ // > Content for step 1
113
+ //
114
+ // > 2. **Second Step Title:**
115
+ // > More content
116
+ // :::
117
+ md.use(container, 'steps', {
118
+ render: function (tokens, idx) {
119
+ if (tokens[idx].nesting === 1) {
120
+ // style="counter-reset: step-counter;" is added for CSS counters
121
+ return '<div class="docmd-container steps" style="counter-reset: step-counter;">\n';
122
+ } else {
123
+ return '</div>\n';
124
+ }
125
+ }
126
+ });
127
+
128
+ // Post-process step markers for blockquote-based steps
129
+ function processStepsContent(html) {
130
+ // Clean up any malformed containers
131
+ html = html.replace(/<blockquote>\s*<p>::: /g, '<p>');
132
+
133
+ // Find all steps containers and process their blockquotes as steps
134
+ return html.replace(
135
+ /<div class="docmd-container steps"[^>]*>([\s\S]*?)<\/div>/g,
136
+ function(match, stepsContent) {
137
+ // Process blockquotes within steps container
138
+ const processedContent = stepsContent
139
+ // Handle numbered steps - improved pattern to better capture the number and title
140
+ .replace(
141
+ /<blockquote>\s*<[^>]*>\s*(\d+|[*])(?:\.)?(?:\s*)(?:<strong>)?([^<]*)(?:<\/strong>)?(?::)?(?:\s*)([\s\S]*?)(?=<\/blockquote>)/g,
142
+ function(blockquote, stepNumber, stepTitle, stepContent) {
143
+ // Ensure there's always content in the step title
144
+ const title = stepTitle.trim() || `Step ${stepNumber}`;
145
+
146
+ // Preserve paragraph breaks in the content
147
+ const formattedContent = stepContent
148
+ .replace(/<p>([\s\S]*?)<\/p>/g, '</div><p>$1</p><div class="step-content">') // Convert paragraphs
149
+ .replace(/<pre([\s\S]*?)<\/pre>/g, '</div><pre$1</pre><div class="step-content">'); // Preserve code blocks
150
+
151
+ return `<div class="step"><h4>${stepNumber}. <strong>${title}</strong></h4><div class="step-content">${formattedContent}</div></div>`;
152
+ }
153
+ )
154
+ // Handle unnumbered steps - like "**Title:**"
155
+ .replace(
156
+ /<blockquote>\s*<p><strong>([^<:]*?):<\/strong>([\s\S]*?)(?=<\/blockquote>)/g,
157
+ function(blockquote, stepTitle, stepContent) {
158
+ // Preserve paragraph breaks in the content
159
+ const formattedContent = stepContent
160
+ .replace(/<p>([\s\S]*?)<\/p>/g, '</div><p>$1</p><div class="step-content">') // Convert paragraphs
161
+ .replace(/<pre([\s\S]*?)<\/pre>/g, '</div><pre$1</pre><div class="step-content">'); // Preserve code blocks
162
+
163
+ return `<div class="step"><h4><strong>${stepTitle}</strong></h4><div class="step-content">${formattedContent}</div></div>`;
164
+ }
165
+ )
166
+ // Handle any remaining blockquotes as generic steps
167
+ .replace(
168
+ /<blockquote>([\s\S]*?)<\/blockquote>/g,
169
+ function(blockquote, content) {
170
+ return `<div class="step">${content}</div>`;
171
+ }
172
+ );
173
+
174
+ // Fix any empty step-content divs or doubled divs
175
+ let fixedContent = processedContent
176
+ .replace(/<div class="step-content"><\/div><div class="step-content">/g, '<div class="step-content">')
177
+ .replace(/<div class="step-content"><\/div>/g, '');
178
+
179
+ return `<div class="docmd-container steps" style="counter-reset: step-counter;">${fixedContent}</div>`;
180
+ }
181
+ );
182
+ }
183
+
184
+ // Pre-process step markers in Markdown content
185
+ // to ensure they'll be processed correctly by the markdown renderer
186
+ function preprocessStepMarkers(content) {
187
+ // Find content between ::: steps and ::: markers
188
+ return content.replace(
189
+ /:::\s*steps\s*\n([\s\S]*?):::/g,
190
+ function(match, stepsContent) {
191
+ // Replace the step markers with a format that will survive markdown parsing
192
+ const processedSteps = stepsContent.replace(
193
+ /^::\s*((?:\d+|\*)?\.?\s*)(.*)$/gm,
194
+ function(stepMatch, stepNumber, stepContent) {
195
+ // Format it as a heading that we can target later
196
+ return `### STEP_MARKER ${stepNumber}${stepContent}`;
197
+ }
198
+ );
199
+
200
+ return `::: steps\n${processedSteps}:::`;
201
+ }
202
+ );
203
+ }
204
+
205
+ // Post-process step markers back to the expected format
206
+ function postprocessStepMarkers(html) {
207
+ return html.replace(
208
+ /<h3>STEP_MARKER\s*((?:\d+|\*)?\.?\s*)(.*?)<\/h3>/g,
209
+ function(match, stepNumber, stepContent) {
210
+ return `<h4>${stepNumber}${stepContent}</h4>`;
211
+ }
212
+ );
213
+ }
214
+
215
+ // Escape container syntax in code blocks
216
+ function escapeContainerSyntax(content) {
217
+ // Find all fenced code blocks and escape container markers within them
218
+ return content.replace(
219
+ /```(.*?)\n([\s\S]*?)```/g,
220
+ function(match, language, codeContent) {
221
+ // Don't modify code blocks that already contain escaped markers
222
+ if (codeContent.includes("\\:::") || codeContent.includes("\\::")) {
223
+ return match;
224
+ }
225
+
226
+ // Escape ::: and :: markers within code blocks, but use a special marker
227
+ // that won't render a backslash in the final output
228
+ const escapedContent = codeContent
229
+ .replace(/:::/g, "___DOCMD_CONTAINER_ESCAPED___:::")
230
+ .replace(/^::/gm, "___DOCMD_CONTAINER_ESCAPED___::");
231
+
232
+ return "```" + language + "\n" + escapedContent + "```";
233
+ }
234
+ );
235
+ }
236
+
237
+ // Fix container syntax issues in Markdown content
238
+ function normalizeContainerSyntax(content) {
239
+ // 1. Ensure container opening markers are at the beginning of lines, not inline
240
+ let fixed = content.replace(/([^\n])(:::)/g, '$1\n$2');
241
+
242
+ // 2. Ensure container closing markers are at the beginning of lines and have proper newlines
243
+ fixed = fixed.replace(/(:::)([^\n])/g, '$1\n$2');
244
+
245
+ // 3. Fix extra spaces after container marker
246
+ fixed = fixed.replace(/:::\s+(\w+)/g, '::: $1');
247
+
248
+ // 4. Fix container markers that have newlines within them
249
+ fixed = fixed.replace(/:::\n(\w+)/g, '::: $1');
250
+
251
+ // 5. Fix missing spaces between ::: and container type
252
+ fixed = fixed.replace(/:::(\w+)/g, '::: $1');
253
+
254
+ return fixed;
255
+ }
256
+
257
+ /**
258
+ * Decodes HTML entities in a string
259
+ * @param {string} html - The HTML string to decode
260
+ * @returns {string} - Decoded string
261
+ */
262
+ function decodeHtmlEntities(html) {
263
+ return html
264
+ .replace(/&amp;/g, '&')
265
+ .replace(/&lt;/g, '<')
266
+ .replace(/&gt;/g, '>')
267
+ .replace(/&quot;/g, '"')
268
+ .replace(/&#39;/g, "'")
269
+ .replace(/&nbsp;/g, ' ');
270
+ }
271
+
272
+ /**
273
+ * Extracts headings from HTML content for table of contents generation
274
+ * @param {string} htmlContent - The rendered HTML content
275
+ * @returns {Array} - Array of heading objects with id, level, and text
276
+ */
277
+ function extractHeadingsFromHtml(htmlContent) {
278
+ const headings = [];
279
+
280
+ // Regular expression to find heading tags (h1-h6) with their content and id attributes
281
+ const headingRegex = /<h([1-6])[^>]*?id="([^"]*)"[^>]*?>([\s\S]*?)<\/h\1>/g;
282
+
283
+ let match;
284
+ while ((match = headingRegex.exec(htmlContent)) !== null) {
285
+ const level = parseInt(match[1], 10);
286
+ const id = match[2];
287
+ // Remove any HTML tags inside the heading text
288
+ const textWithTags = match[3].replace(/<\/?[^>]+(>|$)/g, '');
289
+ // Decode any HTML entities in the text
290
+ const text = decodeHtmlEntities(textWithTags);
291
+
292
+ headings.push({ id, level, text });
293
+ }
294
+
295
+ return headings;
296
+ }
297
+
298
+ async function processMarkdownFile(filePath) {
299
+ const rawContent = await fs.readFile(filePath, 'utf8');
300
+ let frontmatter, markdownContent;
301
+
302
+ try {
303
+ const parsed = matter(rawContent);
304
+ frontmatter = parsed.data;
305
+ markdownContent = parsed.content;
306
+ } catch (e) {
307
+ if (e.name === 'YAMLException') {
308
+ // Provide more specific error for YAML parsing issues
309
+ const errorMessage = `Error parsing YAML frontmatter in ${filePath}: ${e.reason || e.message}${e.mark ? ` at line ${e.mark.line + 1}, column ${e.mark.column + 1}` : ''}. Please check the syntax.`;
310
+ console.error(`❌ ${errorMessage}`);
311
+ throw new Error(errorMessage); // Propagate error to stop build/dev
312
+ }
313
+ // For other errors from gray-matter or unknown errors
314
+ console.error(`❌ Error processing frontmatter in ${filePath}: ${e.message}`);
315
+ throw e;
316
+ }
317
+
318
+ if (!frontmatter.title) {
319
+ console.warn(`⚠️ Warning: Markdown file ${filePath} is missing a 'title' in its frontmatter. Using filename as fallback.`);
320
+ // Fallback title, or you could make it an error
321
+ // frontmatter.title = path.basename(filePath, path.extname(filePath));
322
+ }
323
+
324
+ // Check if this is a documentation example showing how to use containers
325
+ const isContainerDocumentation = markdownContent.includes('containerName [optionalTitleOrType]') ||
326
+ markdownContent.includes('## Callouts') ||
327
+ markdownContent.includes('## Cards') ||
328
+ markdownContent.includes('## Steps');
329
+
330
+ // Special handling for container documentation - escape container syntax in code blocks
331
+ if (isContainerDocumentation) {
332
+ markdownContent = escapeContainerSyntax(markdownContent);
333
+ }
334
+
335
+ // Normalize container syntax
336
+ const normalizedContent = normalizeContainerSyntax(markdownContent);
337
+
338
+ // Render to HTML
339
+ let htmlContent = md.render(normalizedContent);
340
+
341
+ // Apply steps formatting
342
+ htmlContent = processStepsContent(htmlContent);
343
+
344
+ // Fix any specific issues
345
+ // 1. Fix the issue with "These custom containers" paragraph in custom-containers.md
346
+ htmlContent = htmlContent.replace(
347
+ /<p>You should see "Application started successfully!" in your console.\s*<\/p>\s*<p>::: These custom containers/,
348
+ '<p>You should see "Application started successfully!" in your console.</p></div><p>These custom containers'
349
+ );
350
+
351
+ // 2. Fix any remaining ::: markers at the start of paragraphs
352
+ htmlContent = htmlContent.replace(/<p>:::\s+(.*?)<\/p>/g, '<p>$1</p>');
353
+
354
+ // 3. Fix any broken Asterisk steps
355
+ htmlContent = htmlContent.replace(
356
+ /<div class="step"><h4>\*\. <strong><\/strong><\/h4>(.+?)<\/strong>/,
357
+ '<div class="step"><h4>*. <strong>$1</strong>'
358
+ );
359
+
360
+ // 4. Replace our special escape marker with nothing (to fix backslash issue in rendered HTML)
361
+ htmlContent = htmlContent.replace(/___DOCMD_CONTAINER_ESCAPED___/g, '');
362
+
363
+ // Extract headings for table of contents
364
+ const headings = extractHeadingsFromHtml(htmlContent);
365
+
366
+ return {
367
+ frontmatter: {
368
+ title: "Untitled Page", // Default if not provided and no fallback
369
+ ...frontmatter
370
+ },
371
+ htmlContent,
372
+ headings, // Add headings to the returned object
373
+ };
374
+ }
375
+
376
+ module.exports = { processMarkdownFile, mdInstance: md, extractHeadingsFromHtml }; // Export mdInstance if needed by plugins for consistency
@@ -0,0 +1,139 @@
1
+ // src/core/html-generator.js
2
+ const ejs = require('ejs');
3
+ const path = require('path');
4
+ const fs = require('fs-extra');
5
+ const { mdInstance } = require('./file-processor'); // Import mdInstance for footer
6
+ const { generateSeoMetaTags } = require('../plugins/seo');
7
+ const { generateAnalyticsScripts } = require('../plugins/analytics');
8
+ const { renderIcon } = require('./icon-renderer'); // Import icon renderer
9
+
10
+ async function processPluginHooks(config, pageData, relativePathToRoot) {
11
+ let metaTagsHtml = '';
12
+ let faviconLinkHtml = '';
13
+ let themeCssLinkHtml = ''; // For theme.name CSS file
14
+ let pluginStylesHtml = ''; // For plugin-specific CSS
15
+ let pluginHeadScriptsHtml = '';
16
+ let pluginBodyScriptsHtml = '';
17
+
18
+ // 1. Favicon (built-in handling)
19
+ if (config.favicon) {
20
+ const faviconPath = config.favicon.startsWith('/') ? config.favicon.substring(1) : config.favicon;
21
+ faviconLinkHtml = ` <link rel="icon" href="${relativePathToRoot}${faviconPath}">\n`;
22
+ }
23
+
24
+ // 2. Theme CSS (built-in handling for theme.name)
25
+ if (config.theme && config.theme.name && config.theme.name !== 'default') {
26
+ // Assumes theme CSS files are like 'theme-yourthemename.css' in assets/css
27
+ const themeCssPath = `assets/css/theme-${config.theme.name}.css`;
28
+ // Check if theme file exists before linking (optional, good practice)
29
+ // For now, assume it will exist if specified.
30
+ themeCssLinkHtml = ` <link rel="stylesheet" href="${relativePathToRoot}${themeCssPath}">\n`;
31
+ }
32
+
33
+
34
+ // 3. SEO Plugin (if configured)
35
+ if (config.plugins?.seo) {
36
+ metaTagsHtml += generateSeoMetaTags(config, pageData, relativePathToRoot);
37
+ }
38
+
39
+ // 4. Analytics Plugin (if configured)
40
+ if (config.plugins?.analytics) {
41
+ const analyticsScripts = generateAnalyticsScripts(config, pageData);
42
+ pluginHeadScriptsHtml += analyticsScripts.headScriptsHtml;
43
+ pluginBodyScriptsHtml += analyticsScripts.bodyScriptsHtml;
44
+ }
45
+
46
+ // Future: Loop through a more generic plugin array if you evolve the system
47
+ // for (const plugin of config.activePlugins) { /* plugin.runHook('meta', ...) */ }
48
+
49
+ return {
50
+ metaTagsHtml,
51
+ faviconLinkHtml,
52
+ themeCssLinkHtml,
53
+ pluginStylesHtml,
54
+ pluginHeadScriptsHtml,
55
+ pluginBodyScriptsHtml,
56
+ };
57
+ }
58
+
59
+ async function generateHtmlPage(templateData) {
60
+ const {
61
+ content, pageTitle, siteTitle, navigationHtml,
62
+ relativePathToRoot, config, frontmatter, outputPath,
63
+ prevPage, nextPage, currentPagePath, headings
64
+ } = templateData;
65
+
66
+ // Process plugins to get their HTML contributions
67
+ const pluginOutputs = await processPluginHooks(
68
+ config,
69
+ { frontmatter, outputPath }, // pageData object
70
+ relativePathToRoot
71
+ );
72
+
73
+ let footerHtml = '';
74
+ if (config.footer) {
75
+ footerHtml = mdInstance.renderInline(config.footer);
76
+ }
77
+
78
+ const layoutTemplatePath = path.join(__dirname, '..', 'templates', 'layout.ejs');
79
+ if (!await fs.pathExists(layoutTemplatePath)) {
80
+ throw new Error(`Layout template not found: ${layoutTemplatePath}`);
81
+ }
82
+ const layoutTemplate = await fs.readFile(layoutTemplatePath, 'utf8');
83
+
84
+ // Determine if this is an active page for TOC display
85
+ // The currentPagePath exists and has content
86
+ const isActivePage = currentPagePath && content && content.trim().length > 0;
87
+
88
+ const ejsData = {
89
+ content,
90
+ pageTitle: frontmatter.title || pageTitle || 'Untitled', // Ensure pageTitle is robust
91
+ description: frontmatter.description, // Used by layout if no SEO plugin overrides
92
+ siteTitle,
93
+ navigationHtml,
94
+ defaultMode: config.theme?.defaultMode || 'light',
95
+ relativePathToRoot,
96
+ logo: config.logo,
97
+ theme: config.theme,
98
+ customCssFiles: config.theme?.customCss || [],
99
+ customJsFiles: config.customJs || [],
100
+ footer: config.footer,
101
+ footerHtml,
102
+ renderIcon,
103
+ prevPage,
104
+ nextPage,
105
+ currentPagePath, // Pass the current page path for active state detection
106
+ headings: headings || [], // Pass headings for TOC, default to empty array if not provided
107
+ isActivePage, // Flag to determine if TOC should be shown
108
+ ...pluginOutputs, // Spread all plugin generated HTML strings
109
+ };
110
+
111
+ try {
112
+ return ejs.render(layoutTemplate, ejsData);
113
+ } catch (e) {
114
+ console.error(`❌ Error rendering EJS template for ${outputPath}: ${e.message}`);
115
+ console.error("EJS Data:", JSON.stringify(ejsData, null, 2).substring(0, 1000) + "..."); // Log partial data
116
+ throw e; // Re-throw to stop build
117
+ }
118
+ }
119
+
120
+ async function generateNavigationHtml(navItems, currentPagePath, relativePathToRoot, config) {
121
+ const navTemplatePath = path.join(__dirname, '..', 'templates', 'navigation.ejs');
122
+ if (!await fs.pathExists(navTemplatePath)) {
123
+ throw new Error(`Navigation template not found: ${navTemplatePath}`);
124
+ }
125
+ const navTemplate = await fs.readFile(navTemplatePath, 'utf8');
126
+
127
+ // Make renderIcon available to the EJS template
128
+ const ejsHelpers = { renderIcon };
129
+
130
+ return ejs.render(navTemplate, {
131
+ navItems,
132
+ currentPagePath,
133
+ relativePathToRoot,
134
+ config, // Pass full config if needed by nav (e.g. for base path)
135
+ ...ejsHelpers
136
+ });
137
+ }
138
+
139
+ module.exports = { generateHtmlPage, generateNavigationHtml };
@@ -0,0 +1,105 @@
1
+ // src/core/icon-renderer.js
2
+ const lucideStatic = require('lucide-static'); // Access the raw icon data
3
+
4
+ // On first load, log debug information about a specific icon to understand its structure
5
+ let debugRun = false;
6
+ if (debugRun) {
7
+ console.log(`[docmd] Lucide static icons loaded - type: ${typeof lucideStatic}`);
8
+ if (typeof lucideStatic === 'object') {
9
+ console.log(`[docmd] Available icon keys (first 10): ${Object.keys(lucideStatic).slice(0, 10).join(', ')}...`);
10
+ console.log(`[docmd] Total icons available: ${Object.keys(lucideStatic).length}`);
11
+
12
+ // Inspect a sample icon to understand its structure
13
+ const sampleIcon = lucideStatic['Home'];
14
+ if (sampleIcon) {
15
+ console.log(`[docmd] Sample icon (Home) structure:`,
16
+ JSON.stringify(sampleIcon).substring(0, 150) + '...');
17
+ }
18
+ }
19
+ debugRun = false;
20
+ }
21
+
22
+ // Convert kebab-case to PascalCase for icon names (lucide-static uses PascalCase)
23
+ function kebabToPascal(str) {
24
+ return str
25
+ .split('-')
26
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
27
+ .join('');
28
+ }
29
+
30
+ // Map of special case icon mappings that can't be handled by the kebabToPascal conversion
31
+ // Only keep truly necessary mappings that can't be derived from kebab-case
32
+ const iconSpecialCases = {
33
+ 'arrow-up-right-square': 'ExternalLink', // Different name entirely
34
+ 'cloud-upload': 'UploadCloud', // Different word order
35
+ 'file-cog': 'Settings', // Completely different icon
36
+ };
37
+
38
+ const warnedMissingIcons = new Set();
39
+
40
+ function renderIcon(iconName, options = {}) {
41
+ // Try different ways to get the icon data
42
+ let iconData;
43
+
44
+ // 1. Check special cases mapping for exceptions
45
+ if (iconSpecialCases[iconName]) {
46
+ iconData = lucideStatic[iconSpecialCases[iconName]];
47
+ }
48
+
49
+ // 2. If not found, try standard PascalCase conversion
50
+ if (!iconData) {
51
+ const pascalCaseName = kebabToPascal(iconName);
52
+ iconData = lucideStatic[pascalCaseName];
53
+ }
54
+
55
+ if (!iconData) {
56
+ if (!warnedMissingIcons.has(iconName)) { // Check if already warned
57
+ console.warn(`[docmd] Lucide icon not found: ${iconName}. Falling back to empty string.`);
58
+ warnedMissingIcons.add(iconName); // Add to set so it doesn't warn again
59
+ }
60
+ return '';
61
+ }
62
+
63
+ try {
64
+ // The iconData is a string containing a complete SVG
65
+ // We need to extract the contents and apply our own attributes
66
+ const svgString = iconData.trim();
67
+
68
+ // Extract the SVG content between the opening and closing tags
69
+ const contentMatch = svgString.match(/<svg[^>]*>([\s\S]*)<\/svg>/);
70
+ if (!contentMatch) {
71
+ return ''; // Not a valid SVG
72
+ }
73
+
74
+ const svgContent = contentMatch[1];
75
+
76
+ // Create our custom attributes for the SVG
77
+ const attributes = {
78
+ class: `lucide-icon icon-${iconName} ${options.class || ''}`.trim(),
79
+ width: options.width || '1em',
80
+ height: options.height || '1em',
81
+ viewBox: '0 0 24 24',
82
+ fill: 'none',
83
+ stroke: options.stroke || 'currentColor',
84
+ 'stroke-width': options.strokeWidth || '2',
85
+ 'stroke-linecap': 'round',
86
+ 'stroke-linejoin': 'round',
87
+ };
88
+
89
+ const attributesString = Object.entries(attributes)
90
+ .map(([key, value]) => `${key}="${value}"`)
91
+ .join(' ');
92
+
93
+ // Return the new SVG with our attributes and the original content
94
+ return `<svg ${attributesString}>${svgContent}</svg>`;
95
+ } catch (err) {
96
+ console.error(`[docmd] Error rendering icon ${iconName}:`, err);
97
+ return '';
98
+ }
99
+ }
100
+
101
+ function clearWarnedIcons() {
102
+ warnedMissingIcons.clear();
103
+ }
104
+
105
+ module.exports = { renderIcon, clearWarnedIcons };
@@ -0,0 +1,44 @@
1
+ // src/plugins/analytics.js
2
+
3
+ function generateAnalyticsScripts(config, pageData) {
4
+ let headScriptsHtml = '';
5
+ let bodyScriptsHtml = ''; // For scripts that need to be at the end of body
6
+
7
+ const analyticsConfig = config.plugins?.analytics || {}; // Assuming analytics is under plugins.analytics
8
+
9
+ // Google Analytics 4 (GA4)
10
+ if (analyticsConfig.googleV4?.measurementId) {
11
+ const id = analyticsConfig.googleV4.measurementId;
12
+ headScriptsHtml += `
13
+ <!-- Google Analytics GA4 -->
14
+ <script async src="https://www.googletagmanager.com/gtag/js?id=${id}"></script>
15
+ <script>
16
+ window.dataLayer = window.dataLayer || [];
17
+ function gtag(){dataLayer.push(arguments);}
18
+ gtag('js', new Date());
19
+ gtag('config', '${id}');
20
+ </script>\n`;
21
+ }
22
+
23
+ // Google Analytics Universal Analytics (UA) - Legacy
24
+ if (analyticsConfig.googleUA?.trackingId) {
25
+ const id = analyticsConfig.googleUA.trackingId;
26
+ headScriptsHtml += `
27
+ <!-- Google Universal Analytics (Legacy) -->
28
+ <script async src="https://www.google-analytics.com/analytics.js"></script>
29
+ <script>
30
+ window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
31
+ ga('create', '${id}', 'auto');
32
+ ga('send', 'pageview');
33
+ </script>\n`;
34
+ }
35
+
36
+ // Example for a hypothetical future plugin requiring body script
37
+ // if (config.plugins?.someOtherAnalytics?.apiKey) {
38
+ // bodyScriptsHtml += `<script src="..."></script>\n`;
39
+ // }
40
+
41
+ return { headScriptsHtml, bodyScriptsHtml };
42
+ }
43
+
44
+ module.exports = { generateAnalyticsScripts };