@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.
- package/.gitattributes +2 -0
- package/.github/FUNDING.yml +15 -0
- package/.github/workflows/deploy-docmd.yml +45 -0
- package/.github/workflows/publish.yml +84 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/bin/docmd.js +63 -0
- package/config.js +137 -0
- package/docs/cli-commands.md +87 -0
- package/docs/configuration.md +166 -0
- package/docs/contributing.md +86 -0
- package/docs/deployment.md +129 -0
- package/docs/getting-started/basic-usage.md +88 -0
- package/docs/getting-started/index.md +21 -0
- package/docs/getting-started/installation.md +75 -0
- package/docs/index.md +56 -0
- package/docs/plugins/analytics.md +76 -0
- package/docs/plugins/index.md +71 -0
- package/docs/plugins/seo.md +79 -0
- package/docs/plugins/sitemap.md +88 -0
- package/docs/theming/available-themes.md +85 -0
- package/docs/theming/custom-css-js.md +84 -0
- package/docs/theming/icons.md +93 -0
- package/docs/theming/index.md +19 -0
- package/docs/theming/light-dark-mode.md +107 -0
- package/docs/writing-content/custom-containers.md +129 -0
- package/docs/writing-content/frontmatter.md +76 -0
- package/docs/writing-content/index.md +17 -0
- package/docs/writing-content/markdown-syntax.md +277 -0
- package/package.json +56 -0
- package/src/assets/css/highlight-dark.css +1 -0
- package/src/assets/css/highlight-light.css +1 -0
- package/src/assets/css/main.css +562 -0
- package/src/assets/css/theme-sky.css +499 -0
- package/src/assets/css/toc.css +76 -0
- package/src/assets/favicon.ico +0 -0
- package/src/assets/images/docmd-logo.png +0 -0
- package/src/assets/images/docmd-preview.png +0 -0
- package/src/assets/images/logo-dark.png +0 -0
- package/src/assets/images/logo-light.png +0 -0
- package/src/assets/js/theme-toggle.js +59 -0
- package/src/commands/build.js +300 -0
- package/src/commands/dev.js +182 -0
- package/src/commands/init.js +51 -0
- package/src/core/config-loader.js +28 -0
- package/src/core/file-processor.js +376 -0
- package/src/core/html-generator.js +139 -0
- package/src/core/icon-renderer.js +105 -0
- package/src/plugins/analytics.js +44 -0
- package/src/plugins/seo.js +65 -0
- package/src/plugins/sitemap.js +100 -0
- package/src/templates/layout.ejs +174 -0
- package/src/templates/navigation.ejs +107 -0
- 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(/&/g, '&')
|
|
265
|
+
.replace(/</g, '<')
|
|
266
|
+
.replace(/>/g, '>')
|
|
267
|
+
.replace(/"/g, '"')
|
|
268
|
+
.replace(/'/g, "'")
|
|
269
|
+
.replace(/ /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 };
|