@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,65 @@
1
+ // src/plugins/seo.js
2
+ function generateSeoMetaTags(config, pageData, relativePathToRoot) {
3
+ let metaTagsHtml = '';
4
+ const { frontmatter, outputPath } = pageData;
5
+
6
+ if (frontmatter.noindex) {
7
+ metaTagsHtml += ' <meta name="robots" content="noindex">\n';
8
+ return metaTagsHtml; // No other SEO tags if noindex
9
+ }
10
+
11
+ const siteTitle = config.siteTitle;
12
+ const pageTitle = frontmatter.title || 'Untitled'; // Ensure pageTitle is always defined
13
+ const description = frontmatter.description || config.plugins?.seo?.defaultDescription || '';
14
+
15
+ // Construct pageUrl - ensure siteUrl in config has no trailing slash
16
+ const siteUrl = config.siteUrl ? config.siteUrl.replace(/\/$/, '') : '';
17
+ const pageSegment = outputPath.replace(/index\.html$/, '').replace(/\.html$/, '');
18
+ const pageUrl = `${siteUrl}${pageSegment.startsWith('/') ? pageSegment : '/' + pageSegment}`;
19
+
20
+ metaTagsHtml += ` <meta name="description" content="${description}">\n`;
21
+
22
+ // Open Graph
23
+ metaTagsHtml += ` <meta property="og:title" content="${pageTitle} | ${siteTitle}">\n`;
24
+ metaTagsHtml += ` <meta property="og:description" content="${description}">\n`;
25
+ metaTagsHtml += ` <meta property="og:url" content="${pageUrl}">\n`;
26
+ metaTagsHtml += ` <meta property="og:site_name" content="${config.plugins?.seo?.openGraph?.siteName || siteTitle}">\n`;
27
+
28
+ const ogImage = frontmatter.image || frontmatter.ogImage || config.plugins?.seo?.openGraph?.defaultImage;
29
+ if (ogImage) {
30
+ const ogImageUrl = ogImage.startsWith('http') ? ogImage : `${siteUrl}${ogImage.startsWith('/') ? ogImage : '/' + ogImage}`;
31
+ metaTagsHtml += ` <meta property="og:image" content="${ogImageUrl}">\n`;
32
+ }
33
+ metaTagsHtml += ` <meta property="og:type" content="${frontmatter.ogType || 'website'}">\n`;
34
+
35
+ // Twitter Card
36
+ const twitterCardType = frontmatter.twitterCard || config.plugins?.seo?.twitter?.cardType || 'summary';
37
+ metaTagsHtml += ` <meta name="twitter:card" content="${twitterCardType}">\n`;
38
+ if (config.plugins?.seo?.twitter?.siteUsername) {
39
+ metaTagsHtml += ` <meta name="twitter:site" content="${config.plugins.seo.twitter.siteUsername}">\n`;
40
+ }
41
+ const twitterCreator = frontmatter.twitterCreator || config.plugins?.seo?.twitter?.creatorUsername;
42
+ if (twitterCreator) {
43
+ metaTagsHtml += ` <meta name="twitter:creator" content="${twitterCreator}">\n`;
44
+ }
45
+ // Twitter title, description, image often fallback to OG tags if not explicitly set by Twitter.
46
+ // For explicitness:
47
+ metaTagsHtml += ` <meta name="twitter:title" content="${pageTitle} | ${siteTitle}">\n`;
48
+ metaTagsHtml += ` <meta name="twitter:description" content="${description}">\n`;
49
+ if (ogImage) { // Re-use ogImage for twitter:image if not specified differently
50
+ const twitterImageUrl = ogImage.startsWith('http') ? ogImage : `${siteUrl}${ogImage.startsWith('/') ? ogImage : '/' + ogImage}`;
51
+ metaTagsHtml += ` <meta name="twitter:image" content="${twitterImageUrl}">\n`;
52
+ }
53
+
54
+
55
+ // Keywords (optional, less impact nowadays)
56
+ if (frontmatter.keywords) {
57
+ const keywordsString = Array.isArray(frontmatter.keywords) ? frontmatter.keywords.join(', ') : frontmatter.keywords;
58
+ metaTagsHtml += ` <meta name="keywords" content="${keywordsString}">\n`;
59
+ }
60
+
61
+
62
+ return metaTagsHtml;
63
+ }
64
+
65
+ module.exports = { generateSeoMetaTags };
@@ -0,0 +1,100 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Generate sitemap.xml in the output directory root
6
+ * @param {Object} config - The full configuration object
7
+ * @param {Array} pages - Array of page objects with data about each processed page
8
+ * @param {string} outputDir - Path to the output directory
9
+ */
10
+ async function generateSitemap(config, pages, outputDir) {
11
+ // Skip if no siteUrl is defined (sitemap needs absolute URLs)
12
+ if (!config.siteUrl) {
13
+ console.warn('⚠️ No siteUrl defined in config. Skipping sitemap generation.');
14
+ return;
15
+ }
16
+
17
+ // Normalize siteUrl to ensure it has no trailing slash
18
+ const siteUrl = config.siteUrl.replace(/\/$/, '');
19
+
20
+ // Sitemap XML header
21
+ let sitemapXml = '<?xml version="1.0" encoding="UTF-8"?>\n';
22
+ sitemapXml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
23
+
24
+ // Get default settings from config
25
+ const defaultChangefreq = config.plugins?.sitemap?.defaultChangefreq || 'weekly';
26
+ const defaultPriority = config.plugins?.sitemap?.defaultPriority || 0.8;
27
+
28
+ const rootPriority = config.plugins?.sitemap?.rootPriority || 1.0;
29
+
30
+ // Helper function to convert paths to URLs
31
+ function pathToUrl(pagePath) {
32
+ // Handle index.html
33
+ if (pagePath === 'index.html') {
34
+ return siteUrl + '/';
35
+ }
36
+
37
+ // For paths already using pretty URLs (ending with slash)
38
+ if (pagePath.endsWith('/')) {
39
+ return siteUrl + '/' + pagePath;
40
+ }
41
+
42
+ // For paths still using .html extension
43
+ // Convert to no-extension format
44
+ if (pagePath.endsWith('.html')) {
45
+ const pathWithoutExt = pagePath.substring(0, pagePath.length - 5);
46
+ if (pathWithoutExt === '') {
47
+ return siteUrl + '/';
48
+ } else {
49
+ return siteUrl + '/' + pathWithoutExt + '/';
50
+ }
51
+ }
52
+
53
+ // Default case
54
+ return siteUrl + '/' + pagePath;
55
+ }
56
+
57
+ for (const page of pages) {
58
+ // Parse frontmatter for sitemap-specific overrides and metadata
59
+ const frontmatter = page.frontmatter || {};
60
+ const pagePath = page.outputPath || '';
61
+
62
+ // Skip if page is explicitly excluded from sitemap
63
+ if (frontmatter.sitemap === false) {
64
+ continue;
65
+ }
66
+
67
+ // Determine URL for this page
68
+ let url = pathToUrl(pagePath);
69
+
70
+ // Set priority
71
+ let priority = frontmatter.priority ||
72
+ (pagePath === 'index.html' ? rootPriority : defaultPriority);
73
+
74
+ // Set change frequency
75
+ const changefreq = frontmatter.changefreq || defaultChangefreq;
76
+
77
+ // Add page to sitemap
78
+ sitemapXml += ' <url>\n';
79
+ sitemapXml += ` <loc>${url}</loc>\n`;
80
+
81
+ // Add lastmod if available in frontmatter
82
+ if (frontmatter.lastmod) {
83
+ sitemapXml += ` <lastmod>${frontmatter.lastmod}</lastmod>\n`;
84
+ }
85
+
86
+ sitemapXml += ` <changefreq>${changefreq}</changefreq>\n`;
87
+ sitemapXml += ` <priority>${priority}</priority>\n`;
88
+ sitemapXml += ' </url>\n';
89
+ }
90
+
91
+ sitemapXml += '</urlset>';
92
+
93
+ // Write sitemap file
94
+ const sitemapPath = path.join(outputDir, 'sitemap.xml');
95
+ await fs.writeFile(sitemapPath, sitemapXml);
96
+
97
+ console.log(`✅ Generated sitemap at ${sitemapPath}`);
98
+ }
99
+
100
+ module.exports = { generateSitemap };
@@ -0,0 +1,174 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+
7
+ <%- metaTagsHtml || '' %> <%# SEO Plugin Meta Tags %>
8
+
9
+ <title><%= pageTitle %> | <%= siteTitle %></title>
10
+ <% if (description && !(metaTagsHtml && metaTagsHtml.includes('name="description"'))) { %>
11
+ <meta name="description" content="<%= description %>">
12
+ <% } %>
13
+
14
+ <%- faviconLinkHtml || '' %> <%# Favicon %>
15
+
16
+ <link rel="stylesheet" href="<%= relativePathToRoot %>assets/css/main.css">
17
+
18
+ <link rel="stylesheet" href="<%= relativePathToRoot %>assets/css/highlight-<%= defaultMode === 'dark' ? 'dark' : 'light' %>.css" id="highlight-theme">
19
+
20
+ <%- themeCssLinkHtml || '' %> <%# For theme.name specific CSS %>
21
+
22
+ <% (customCssFiles || []).forEach(cssFile => { %>
23
+ <link rel="stylesheet" href="<%= relativePathToRoot %><%- cssFile.startsWith('/') ? cssFile.substring(1) : cssFile %>">
24
+ <% }); %>
25
+
26
+ <%- pluginStylesHtml || '' %> <%# Plugin specific CSS %>
27
+
28
+ <%- pluginHeadScriptsHtml || '' %> <%# Plugin specific head scripts (e.g., Analytics) %>
29
+ </head>
30
+ <body data-theme="<%= defaultMode %>">
31
+ <aside class="sidebar">
32
+ <div class="sidebar-header">
33
+ <% if (logo && logo.light && logo.dark) { %>
34
+ <a href="<%= logo.href || (relativePathToRoot + 'index.html') %>" class="logo-link">
35
+ <img src="<%= relativePathToRoot %><%- logo.light.startsWith('/') ? logo.light.substring(1) : logo.light %>" alt="<%= logo.alt || siteTitle %>" class="logo-light" <% if (logo.height) { %>style="height: <%= logo.height %>;"<% } %>>
36
+ <img src="<%= relativePathToRoot %><%- logo.dark.startsWith('/') ? logo.dark.substring(1) : logo.dark %>" alt="<%= logo.alt || siteTitle %>" class="logo-dark" <% if (logo.height) { %>style="height: <%= logo.height %>;"<% } %>>
37
+ </a>
38
+ <% } else { %>
39
+ <h1><a href="<%= relativePathToRoot %>index.html"><%= siteTitle %></a></h1>
40
+ <% } %>
41
+ </div>
42
+ <%- navigationHtml %>
43
+ <% if (theme && theme.enableModeToggle) { %>
44
+ <button id="theme-toggle-button" aria-label="Toggle theme" class="theme-toggle-button">
45
+ <%# renderIcon is available in the global EJS scope from html-generator %>
46
+ <%- renderIcon('sun', { class: 'icon-sun' }) %>
47
+ <%- renderIcon('moon', { class: 'icon-moon' }) %>
48
+ </button>
49
+ <% } %>
50
+ </aside>
51
+ <div class="main-content-wrapper">
52
+ <header class="page-header">
53
+ <h1><%= pageTitle %></h1>
54
+ </header>
55
+ <main class="content-area">
56
+ <div class="content-layout">
57
+ <div class="main-content">
58
+ <%- content %>
59
+
60
+ <% if (prevPage || nextPage) { %>
61
+ <div class="page-navigation">
62
+ <% if (prevPage) { %>
63
+ <a href="<%= prevPage.url %>" class="prev-page">
64
+ <%- renderIcon('arrow-left', { class: 'page-nav-icon' }) %>
65
+ <span>
66
+ <small>Previous</small>
67
+ <strong><%= prevPage.title %></strong>
68
+ </span>
69
+ </a>
70
+ <% } else { %>
71
+ <div class="prev-page-placeholder"></div>
72
+ <% } %>
73
+
74
+ <% if (nextPage) { %>
75
+ <a href="<%= nextPage.url %>" class="next-page">
76
+ <span>
77
+ <small>Next</small>
78
+ <strong><%= nextPage.title %></strong>
79
+ </span>
80
+ <%- renderIcon('arrow-right', { class: 'page-nav-icon' }) %>
81
+ </a>
82
+ <% } else { %>
83
+ <div class="next-page-placeholder"></div>
84
+ <% } %>
85
+ </div>
86
+ <% } %>
87
+ </div>
88
+
89
+ <!-- DEBUG: <%- JSON.stringify({headingsLength: headings ? headings.length : 0}) %> -->
90
+
91
+ <!-- TOC sidebar -->
92
+ <div class="toc-sidebar">
93
+ <%
94
+ // Helper function to decode HTML entities
95
+ function decodeHtmlEntities(html) {
96
+ return html
97
+ .replace(/&amp;/g, '&')
98
+ .replace(/&lt;/g, '<')
99
+ .replace(/&gt;/g, '>')
100
+ .replace(/&quot;/g, '"')
101
+ .replace(/&#39;/g, "'")
102
+ .replace(/&nbsp;/g, ' ');
103
+ }
104
+
105
+ // Only show TOC on active pages
106
+ const isActive = navigationHtml && navigationHtml.includes('class="active"');
107
+
108
+ if (isActive) {
109
+ // Extract headings directly from content - match with or without id attribute
110
+ const headingRegex = /<h([2-4])[^>]*?(?:id="([^"]*)")?[^>]*?>([\s\S]*?)<\/h\1>/g;
111
+ const tocHeadings = [];
112
+ let match;
113
+ let contentStr = content.toString();
114
+
115
+ while ((match = headingRegex.exec(contentStr)) !== null) {
116
+ const level = parseInt(match[1], 10);
117
+ // Use ID if available, or generate one from the text
118
+ let id = match[2];
119
+ // Remove any HTML tags inside the heading text and decode HTML entities
120
+ const textWithTags = match[3].replace(/<\/?[^>]+(>|$)/g, '');
121
+ // Decode HTML entities
122
+ const text = decodeHtmlEntities(textWithTags);
123
+
124
+ if (!id) {
125
+ // Generate an ID from the heading text if none exists
126
+ id = text
127
+ .toLowerCase()
128
+ .replace(/\s+/g, '-')
129
+ .replace(/[^\w-]/g, '')
130
+ .replace(/--+/g, '-')
131
+ .replace(/^-+|-+$/g, '');
132
+ }
133
+
134
+ tocHeadings.push({ id, level, text });
135
+ }
136
+
137
+ // Only show TOC if there are enough headings
138
+ if (tocHeadings.length > 1) {
139
+ %>
140
+ <div class="toc-container">
141
+ <h2 class="toc-title">On This Page</h2>
142
+ <ul class="toc-list">
143
+ <% tocHeadings.forEach(heading => { %>
144
+ <li class="toc-item toc-level-<%= heading.level %>">
145
+ <a href="#<%= heading.id %>" class="toc-link"><%- heading.text %></a>
146
+ </li>
147
+ <% }); %>
148
+ </ul>
149
+ </div>
150
+ <% }
151
+ } %>
152
+ </div>
153
+ </div>
154
+ </main>
155
+ <footer class="page-footer">
156
+ <div class="footer-content">
157
+ <div class="user-footer">
158
+ <%- footerHtml || '' %>
159
+ </div>
160
+ <div class="branding-footer">
161
+ Build with 💜 <a href="https://docmd.mgks.dev" target="_blank" rel="noopener">docmd.</a>
162
+ </div>
163
+ </div>
164
+ </footer>
165
+ </div>
166
+
167
+ <script src="<%= relativePathToRoot %>assets/js/theme-toggle.js"></script>
168
+ <% (customJsFiles || []).forEach(jsFile => { %>
169
+ <script src="<%= relativePathToRoot %><%- jsFile.startsWith('/') ? jsFile.substring(1) : jsFile %>"></script>
170
+ <% }); %>
171
+
172
+ <%- pluginBodyScriptsHtml || '' %> <%# Plugin specific body scripts %>
173
+ </body>
174
+ </html>
@@ -0,0 +1,107 @@
1
+ <%# src/templates/navigation.ejs %>
2
+
3
+ <%# renderIcon is passed from html-generator.js %>
4
+
5
+ <%
6
+ // Debug function - uncomment to troubleshoot paths
7
+ function debugNavPaths(item, itemPath, currentPath, isActive, isParentActive) {
8
+ console.log('\nDEBUG NAV ITEM:');
9
+ console.log(`Title: ${item.title}`);
10
+ console.log(`Path: ${item.path}`);
11
+ console.log(`Computed item path: ${itemPath}`);
12
+ console.log(`Current page path: ${currentPath}`);
13
+ console.log(`Is directly active: ${isActive}`);
14
+ console.log(`Is parent active: ${isParentActive}`);
15
+ }
16
+
17
+ function renderNavItems(items, currentLevelPagePath, rootPath) { %>
18
+ <ul>
19
+ <% items.forEach(item => { %>
20
+ <%
21
+ let itemHref = '#'; // Default for non-linking parents or error
22
+ let isCurrentPageActive = false;
23
+ let isActivePage = false; // For the direct page itself
24
+ const isExternal = item.external === true;
25
+ const targetBlank = isExternal ? 'target="_blank" rel="noopener noreferrer"' : '';
26
+ let iconHtml = '';
27
+ let externalLinkIconHtml = '';
28
+
29
+ if (item.icon) {
30
+ // Use custom class for nav icons for specific styling
31
+ iconHtml = renderIcon(item.icon, { class: 'nav-item-icon' });
32
+ }
33
+
34
+ if (isExternal) {
35
+ itemHref = item.path; // Full URL for external links
36
+ // Use a Lucide icon like 'arrow-up-right' or 'external-link'
37
+ externalLinkIconHtml = renderIcon('arrow-up-right', { class: 'nav-external-icon', width: '0.8em', height: '0.8em' });
38
+ } else {
39
+ // Path normalization for internal links
40
+ let cleanPath = item.path.startsWith('/') ? item.path : '/' + item.path; // Ensure leading slash
41
+
42
+ // Handle paths with pretty URLs
43
+ if (cleanPath === '/') {
44
+ // Root path goes to index.html
45
+ itemHref = rootPath + 'index.html';
46
+ } else {
47
+ // Remove any trailing slash for consistency
48
+ const pathWithoutTrailingSlash = cleanPath.endsWith('/') ? cleanPath.slice(0, -1) : cleanPath;
49
+
50
+ // For all other paths, link to the directory (pretty URL)
51
+ // Remove leading slash and ensure clean path
52
+ const cleanedPath = pathWithoutTrailingSlash.substring(1);
53
+ itemHref = rootPath + cleanedPath + '/';
54
+ }
55
+
56
+ // SIMPLIFIED ACTIVE STATE LOGIC - START
57
+ // 1. Prepare the paths for comparison
58
+ // The item path in the format used in the navigation config
59
+ const normalizedItemPath = cleanPath.substring(1) || '';
60
+
61
+ // Special handling for root path
62
+ if (cleanPath === '/') {
63
+ isActivePage = currentLevelPagePath === 'index.html';
64
+ isCurrentPageActive = isActivePage;
65
+ }
66
+ // Parent folder with index.md
67
+ else if (cleanPath.endsWith('/')) {
68
+ // Direct match for folder/index.html pages
69
+ const folderPath = cleanPath.substring(1, cleanPath.length - 1); // Remove leading / and trailing /
70
+ isActivePage = currentLevelPagePath === folderPath + '/';
71
+ isCurrentPageActive = isActivePage;
72
+
73
+ // Check if any children are active
74
+ // If current path starts with this item's path, it's a parent of the active page
75
+ if (!isActivePage && currentLevelPagePath.startsWith(normalizedItemPath)) {
76
+ isCurrentPageActive = true;
77
+ }
78
+ }
79
+ // Regular page
80
+ else {
81
+ const pagePath = cleanPath.substring(1) + '/';
82
+ isActivePage = currentLevelPagePath === pagePath;
83
+ isCurrentPageActive = isActivePage;
84
+ }
85
+ // SIMPLIFIED ACTIVE STATE LOGIC - END
86
+ }
87
+ %>
88
+ <li class="<%= isCurrentPageActive ? 'active-parent' : '' %>">
89
+ <a href="<%= itemHref %>"
90
+ class="<%= isActivePage ? 'active' : '' %>"
91
+ <%- targetBlank %>>
92
+ <% if (iconHtml) { %><%- iconHtml %><% } %>
93
+ <span><%- item.title %></span> <%# Wrap title in span for styling if needed %>
94
+ <% if (externalLinkIconHtml) { %><%- externalLinkIconHtml %><% } %>
95
+ </a>
96
+
97
+ <% if (item.children && item.children.length > 0) { %>
98
+ <%- renderNavItems(item.children, currentLevelPagePath, rootPath) %>
99
+ <% } %>
100
+ </li>
101
+ <% }); %>
102
+ </ul>
103
+ <% } %>
104
+
105
+ <nav class="sidebar-nav">
106
+ <%- renderNavItems(navItems, currentPagePath, relativePathToRoot) %>
107
+ </nav>
@@ -0,0 +1,34 @@
1
+ <%# src/templates/toc.ejs %>
2
+ <%
3
+ // If direct headings aren't available, we'll try to extract them from the content
4
+ let tocHeadings = [];
5
+ if (headings && headings.length > 0) {
6
+ // Use provided headings if available
7
+ tocHeadings = headings.filter(h => h.level >= 2 && h.level <= 4);
8
+ } else if (content) {
9
+ // Basic regex to extract headings from HTML content
10
+ const headingRegex = /<h([2-4])[^>]*?id="([^"]*)"[^>]*?>([\s\S]*?)<\/h\1>/g;
11
+ let match;
12
+ while ((match = headingRegex.exec(content)) !== null) {
13
+ const level = parseInt(match[1], 10);
14
+ const id = match[2];
15
+ // Remove any HTML tags inside the heading text
16
+ const text = match[3].replace(/<\/?[^>]+(>|$)/g, '');
17
+ tocHeadings.push({ id, level, text });
18
+ }
19
+ }
20
+
21
+ // Only show TOC if there are enough headings
22
+ if (tocHeadings.length > 1) {
23
+ %>
24
+ <div class="toc-container">
25
+ <h2 class="toc-title">On This Page</h2>
26
+ <ul class="toc-list">
27
+ <% tocHeadings.forEach(heading => { %>
28
+ <li class="toc-item toc-level-<%= heading.level %>">
29
+ <a href="#<%= heading.id %>" class="toc-link"><%= heading.text %></a>
30
+ </li>
31
+ <% }); %>
32
+ </ul>
33
+ </div>
34
+ <% } %>