@docmd/ui 0.4.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.
Binary file
@@ -0,0 +1,74 @@
1
+ // Source file from the docmd project — https://github.com/docmd-io/docmd
2
+
3
+ /*
4
+ * A simple lightbox implementation for gallery images
5
+ */
6
+
7
+ document.addEventListener('DOMContentLoaded', function () {
8
+ // Create lightbox elements
9
+ const lightbox = document.createElement('div');
10
+ lightbox.className = 'docmd-lightbox';
11
+ lightbox.innerHTML = `
12
+ <div class="docmd-lightbox-content">
13
+ <img src="" alt="">
14
+ <div class="docmd-lightbox-caption"></div>
15
+ </div>
16
+ <div class="docmd-lightbox-close">&times;</div>
17
+ `;
18
+ document.body.appendChild(lightbox);
19
+
20
+ const lightboxImg = lightbox.querySelector('img');
21
+ const lightboxCaption = lightbox.querySelector('.docmd-lightbox-caption');
22
+ const lightboxClose = lightbox.querySelector('.docmd-lightbox-close');
23
+
24
+ // Find all images with lightbox class or in image galleries
25
+ const lightboxImages = document.querySelectorAll('img.lightbox, .image-gallery img');
26
+
27
+ // Add click event to each image
28
+ lightboxImages.forEach(function (img) {
29
+ img.style.cursor = 'zoom-in';
30
+
31
+ img.addEventListener('click', function () {
32
+ // Get the image source and caption
33
+ const src = this.getAttribute('src');
34
+ let caption = this.getAttribute('alt') || '';
35
+
36
+ // If image is inside a figure with figcaption, use that caption
37
+ const figure = this.closest('figure');
38
+ if (figure) {
39
+ const figcaption = figure.querySelector('figcaption');
40
+ if (figcaption) {
41
+ caption = figcaption.textContent;
42
+ }
43
+ }
44
+
45
+ // Set the lightbox content
46
+ lightboxImg.setAttribute('src', src);
47
+ lightboxCaption.textContent = caption;
48
+
49
+ // Show the lightbox
50
+ lightbox.style.display = 'flex';
51
+ document.body.style.overflow = 'hidden'; // Prevent scrolling
52
+ });
53
+ });
54
+
55
+ // Close lightbox when clicking the close button or outside the image
56
+ lightboxClose.addEventListener('click', closeLightbox);
57
+ lightbox.addEventListener('click', function (e) {
58
+ if (e.target === lightbox) {
59
+ closeLightbox();
60
+ }
61
+ });
62
+
63
+ // Close lightbox when pressing Escape key
64
+ document.addEventListener('keydown', function (e) {
65
+ if (e.key === 'Escape' && lightbox.style.display === 'flex') {
66
+ closeLightbox();
67
+ }
68
+ });
69
+
70
+ function closeLightbox() {
71
+ lightbox.style.display = 'none';
72
+ document.body.style.overflow = ''; // Restore scrolling
73
+ }
74
+ });
@@ -0,0 +1,276 @@
1
+ // Source file from the docmd project — https://github.com/docmd-io/docmd
2
+
3
+ /*
4
+ * Main client-side script for docmd UI interactions
5
+ */
6
+
7
+ // --- Collapsible Navigation Logic ---
8
+ function initializeCollapsibleNav() {
9
+ const nav = document.querySelector('.sidebar-nav');
10
+ if (!nav) return;
11
+
12
+ // 1. Initial Cleanup (ensure classes match aria states)
13
+ nav.querySelectorAll('li.collapsible').forEach(item => {
14
+ // If server rendered it as expanded, ensure aria matches
15
+ if (item.classList.contains('expanded')) {
16
+ item.setAttribute('aria-expanded', 'true');
17
+ } else {
18
+ item.setAttribute('aria-expanded', 'false');
19
+ }
20
+ });
21
+
22
+ // 2. Event Delegation
23
+ nav.addEventListener('click', (e) => {
24
+ // Check if the click target is the arrow or inside the arrow wrapper
25
+ const arrow = e.target.closest('.collapse-icon-wrapper');
26
+
27
+ if (arrow) {
28
+ // STOP everything. Do not follow the link.
29
+ e.preventDefault();
30
+ e.stopPropagation();
31
+
32
+ const item = arrow.closest('li.collapsible');
33
+ if (item) {
34
+ // Toggle State
35
+ const isExpanded = item.classList.contains('expanded');
36
+
37
+ if (isExpanded) {
38
+ item.classList.remove('expanded');
39
+ item.setAttribute('aria-expanded', 'false');
40
+ } else {
41
+ item.classList.add('expanded');
42
+ item.setAttribute('aria-expanded', 'true');
43
+ }
44
+ }
45
+ return false;
46
+ }
47
+ });
48
+ }
49
+
50
+ // --- Mobile Menu Logic ---
51
+ function initializeMobileMenus() {
52
+ const sidebarBtn = document.querySelector('.sidebar-menu-button');
53
+ const sidebar = document.querySelector('.sidebar');
54
+
55
+ if (sidebarBtn && sidebar) {
56
+ sidebarBtn.addEventListener('click', (e) => {
57
+ e.stopPropagation();
58
+ sidebar.classList.toggle('mobile-expanded');
59
+ });
60
+ }
61
+
62
+ const tocBtn = document.querySelector('.toc-menu-button');
63
+ const tocContainer = document.querySelector('.toc-container');
64
+ const tocTitle = document.querySelector('.toc-title');
65
+
66
+ const toggleToc = (e) => {
67
+ if (window.getComputedStyle(tocBtn).display === 'none') return;
68
+ e.stopPropagation();
69
+ tocContainer.classList.toggle('mobile-expanded');
70
+ };
71
+
72
+ if (tocBtn && tocContainer) {
73
+ tocBtn.addEventListener('click', toggleToc);
74
+ if (tocTitle) tocTitle.addEventListener('click', toggleToc);
75
+ }
76
+ }
77
+
78
+ // --- Sidebar Scroll Preservation (Instant Center) ---
79
+ function initializeSidebarScroll() {
80
+ const sidebar = document.querySelector('.sidebar');
81
+ if (!sidebar) return;
82
+
83
+ // Wait for the layout to be stable
84
+ requestAnimationFrame(() => {
85
+ // Find the active link
86
+ const activeElement = sidebar.querySelector('a.active');
87
+
88
+ if (activeElement) {
89
+ activeElement.scrollIntoView({
90
+ behavior: 'auto', // INSTANT jump (prevents scrolling animation jitter)
91
+ block: 'center', // Center it vertically in the sidebar
92
+ inline: 'nearest'
93
+ });
94
+ }
95
+ });
96
+ }
97
+
98
+ // --- Theme Toggle Logic ---
99
+ function setupThemeToggleListener() {
100
+ const toggleButtons = document.querySelectorAll('.theme-toggle-button');
101
+
102
+ function applyTheme(theme) {
103
+ const validThemes = ['light', 'dark'];
104
+ const selectedTheme = validThemes.includes(theme) ? theme : 'light';
105
+
106
+ // 1. Update DOM & Storage
107
+ document.documentElement.setAttribute('data-theme', selectedTheme);
108
+ document.body.setAttribute('data-theme', selectedTheme);
109
+ localStorage.setItem('docmd-theme', selectedTheme);
110
+
111
+ // 2. Toggle Highlight.js Stylesheets
112
+ const lightLink = document.getElementById('hljs-light');
113
+ const darkLink = document.getElementById('hljs-dark');
114
+
115
+ if (lightLink && darkLink) {
116
+ if (selectedTheme === 'dark') {
117
+ lightLink.disabled = true;
118
+ darkLink.disabled = false;
119
+ } else {
120
+ lightLink.disabled = false;
121
+ darkLink.disabled = true;
122
+ }
123
+ }
124
+ }
125
+
126
+ toggleButtons.forEach(btn => {
127
+ // Clone button to remove old listeners if any (safety measure)
128
+ const newBtn = btn.cloneNode(true);
129
+ btn.parentNode.replaceChild(newBtn, btn);
130
+
131
+ newBtn.addEventListener('click', () => {
132
+ const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
133
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light';
134
+ applyTheme(newTheme);
135
+ });
136
+ });
137
+ }
138
+
139
+ // --- Sidebar Collapse Logic ---
140
+ function initializeSidebarToggle() {
141
+ const toggleButton = document.getElementById('sidebar-toggle-button');
142
+ const body = document.body;
143
+
144
+ if (!body.classList.contains('sidebar-collapsible') || !toggleButton) return;
145
+
146
+ const defaultConfigCollapsed = body.dataset.defaultCollapsed === 'true';
147
+ let isCollapsed = localStorage.getItem('docmd-sidebar-collapsed');
148
+
149
+ if (isCollapsed === null) isCollapsed = defaultConfigCollapsed;
150
+ else isCollapsed = isCollapsed === 'true';
151
+
152
+ if (isCollapsed) body.classList.add('sidebar-collapsed');
153
+
154
+ toggleButton.addEventListener('click', () => {
155
+ body.classList.toggle('sidebar-collapsed');
156
+ const currentlyCollapsed = body.classList.contains('sidebar-collapsed');
157
+ localStorage.setItem('docmd-sidebar-collapsed', currentlyCollapsed);
158
+ });
159
+ }
160
+
161
+ // --- Tabs Container Logic ---
162
+ function initializeTabs() {
163
+ document.querySelectorAll('.docmd-tabs').forEach(tabsContainer => {
164
+ const navItems = tabsContainer.querySelectorAll('.docmd-tabs-nav-item');
165
+ const tabPanes = tabsContainer.querySelectorAll('.docmd-tab-pane');
166
+
167
+ navItems.forEach((navItem, index) => {
168
+ navItem.addEventListener('click', () => {
169
+ navItems.forEach(item => item.classList.remove('active'));
170
+ tabPanes.forEach(pane => pane.classList.remove('active'));
171
+
172
+ navItem.classList.add('active');
173
+ if (tabPanes[index]) tabPanes[index].classList.add('active');
174
+ });
175
+ });
176
+ });
177
+ }
178
+
179
+ // --- Copy Code Button Logic ---
180
+ function initializeCopyCodeButtons() {
181
+ if (document.body.dataset.copyCodeEnabled !== 'true') return;
182
+
183
+ const copyIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>`;
184
+ const checkIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
185
+
186
+ document.querySelectorAll('pre').forEach(preElement => {
187
+ const codeElement = preElement.querySelector('code');
188
+ if (!codeElement) return;
189
+
190
+ const wrapper = document.createElement('div');
191
+ wrapper.style.position = 'relative';
192
+ wrapper.style.display = 'block';
193
+
194
+ preElement.parentNode.insertBefore(wrapper, preElement);
195
+ wrapper.appendChild(preElement);
196
+ preElement.style.position = 'static';
197
+
198
+ const copyButton = document.createElement('button');
199
+ copyButton.className = 'copy-code-button';
200
+ copyButton.innerHTML = copyIconSvg;
201
+ copyButton.setAttribute('aria-label', 'Copy code to clipboard');
202
+ wrapper.appendChild(copyButton);
203
+
204
+ copyButton.addEventListener('click', () => {
205
+ navigator.clipboard.writeText(codeElement.innerText).then(() => {
206
+ copyButton.innerHTML = checkIconSvg;
207
+ copyButton.classList.add('copied');
208
+ setTimeout(() => {
209
+ copyButton.innerHTML = copyIconSvg;
210
+ copyButton.classList.remove('copied');
211
+ }, 2000);
212
+ }).catch(err => {
213
+ console.error('Failed to copy text: ', err);
214
+ copyButton.innerText = 'Error';
215
+ });
216
+ });
217
+ });
218
+ }
219
+
220
+ // --- Theme Sync Function ---
221
+ function syncBodyTheme() {
222
+ const currentTheme = document.documentElement.getAttribute('data-theme');
223
+ if (currentTheme && document.body) {
224
+ document.body.setAttribute('data-theme', currentTheme);
225
+ }
226
+ }
227
+
228
+ // --- Scroll Spy Logic ---
229
+ function initializeScrollSpy() {
230
+ const tocLinks = document.querySelectorAll('.toc-link');
231
+ const headings = document.querySelectorAll('.main-content h2, .main-content h3');
232
+
233
+ if (tocLinks.length === 0 || headings.length === 0) return;
234
+
235
+ const observerOptions = {
236
+ root: null,
237
+ // Trigger when heading crosses the top 10% of screen
238
+ rootMargin: '-10% 0px -80% 0px',
239
+ threshold: 0
240
+ };
241
+
242
+ const observer = new IntersectionObserver((entries) => {
243
+ entries.forEach(entry => {
244
+ if (entry.isIntersecting) {
245
+ // 1. Clear current active state
246
+ tocLinks.forEach(link => link.classList.remove('active'));
247
+
248
+ // 2. Find link corresponding to this heading
249
+ const id = entry.target.getAttribute('id');
250
+ const activeLink = document.querySelector(`.toc-link[href="#${id}"]`);
251
+
252
+ if (activeLink) {
253
+ activeLink.classList.add('active');
254
+
255
+ // Optional: Auto-scroll the TOC sidebar itself if needed
256
+ // activeLink.scrollIntoView({ block: 'nearest' });
257
+ }
258
+ }
259
+ });
260
+ }, observerOptions);
261
+
262
+ headings.forEach(heading => observer.observe(heading));
263
+ }
264
+
265
+ // --- Main Execution ---
266
+ document.addEventListener('DOMContentLoaded', () => {
267
+ syncBodyTheme();
268
+ setupThemeToggleListener();
269
+ initializeSidebarToggle();
270
+ initializeTabs();
271
+ initializeCopyCodeButtons();
272
+ initializeCollapsibleNav();
273
+ initializeMobileMenus();
274
+ initializeSidebarScroll();
275
+ initializeScrollSpy();
276
+ });
package/index.js ADDED
@@ -0,0 +1,11 @@
1
+ const path = require('path');
2
+
3
+ module.exports = {
4
+ getTemplatesDir: () => path.join(__dirname, 'templates'),
5
+ getAssetsDir: () => path.join(__dirname, 'assets'),
6
+ // Helper to resolve template paths
7
+ getTemplatePath: (name) => {
8
+ const fileName = name.endsWith('.ejs') ? name : `${name}.ejs`;
9
+ return path.join(__dirname, 'templates', fileName);
10
+ }
11
+ };
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@docmd/ui",
3
+ "version": "0.4.0",
4
+ "description": "Base UI templates and assets for docmd",
5
+ "main": "index.js",
6
+ "files": [
7
+ "templates",
8
+ "assets",
9
+ "index.js"
10
+ ],
11
+ "license": "MIT"
12
+ }
@@ -0,0 +1,195 @@
1
+ <%# packages/ui/templates/layout.ejs %>
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+
8
+ <!-- Define Globals -->
9
+ <script>
10
+ var root = "<%= relativePathToRoot %>";
11
+ if (root && !root.endsWith('/')) root += '/';
12
+ if (root === '') root = './';
13
+ window.DOCMD_ROOT = root;
14
+ window.DOCMD_DEFAULT_MODE = "<%= defaultMode %>";
15
+ </script>
16
+
17
+ <title><%= pageTitle %></title>
18
+
19
+ <!-- Favicon -->
20
+ <%- faviconLinkHtml || '' %>
21
+
22
+ <!-- 1. CORE STYLES (Foundation) -->
23
+ <link rel="stylesheet" href="<%= relativePathToRoot %>assets/css/docmd-main.css?v=<%= buildHash %>">
24
+
25
+ <!-- 2. HIGHLIGHT.JS (Toggle Strategy) -->
26
+ <% if (config.theme?.codeHighlight !== false) {
27
+ const isDarkDefault = defaultMode === 'dark';
28
+ %>
29
+ <link rel="stylesheet"
30
+ href="<%= relativePathToRoot %>assets/css/docmd-highlight-light.css?v=<%= buildHash %>"
31
+ id="hljs-light"
32
+ <%= isDarkDefault ? 'disabled' : '' %>>
33
+
34
+ <link rel="stylesheet"
35
+ href="<%= relativePathToRoot %>assets/css/docmd-highlight-dark.css?v=<%= buildHash %>"
36
+ id="hljs-dark"
37
+ <%= isDarkDefault ? '' : 'disabled' %>>
38
+ <% } %>
39
+
40
+ <!-- 3. PLUGINS & THEMES (Overrides Core) -->
41
+ <!-- This includes 'docmd-theme-sky.css' injected by build.js -->
42
+ <%- pluginHeadScriptsHtml || '' %>
43
+
44
+ <!-- 4. CUSTOM CSS (Overrides Everything) -->
45
+ <% (customCssFiles || []).forEach(cssFile => { %>
46
+ <link rel="stylesheet" href="<%= relativePathToRoot %><%- cssFile.startsWith('/') ? cssFile.substring(1) : cssFile %>?v=<%= buildHash %>">
47
+ <% }); %>
48
+
49
+ <!-- Theme Init Script (Must be after styles to prevent FOUC) -->
50
+ <%- themeInitScript %>
51
+ </head>
52
+ <body class="<%= sidebarConfig.collapsible ? 'sidebar-collapsible' : 'sidebar-not-collapsible' %>"
53
+ data-default-collapsed="<%= sidebarConfig.defaultCollapsed %>"
54
+ data-copy-code-enabled="<%= config.copyCode === true %>">
55
+
56
+ <aside class="sidebar">
57
+ <div class="sidebar-header">
58
+ <% if (logo && logo.light && logo.dark) { %>
59
+ <a href="<%= logo.href || relativePathToRoot %>" class="logo-link">
60
+ <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 %>;"<% } %>>
61
+ <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 %>;"<% } %>>
62
+ </a>
63
+ <% } else { %>
64
+ <h1><a href="<%= relativePathToRoot %>index.html"><%= siteTitle %></a></h1>
65
+ <% } %>
66
+ <span class="mobile-view sidebar-menu-button float-right">
67
+ <%- renderIcon("ellipsis-vertical") %>
68
+ </span>
69
+ </div>
70
+
71
+ <%- navigationHtml %>
72
+
73
+ <% if (theme && theme.enableModeToggle && theme.positionMode !== 'top') { %>
74
+ <button id="theme-toggle-button" aria-label="Toggle theme" class="theme-toggle-button">
75
+ <%- renderIcon('sun', { class: 'icon-sun' }) %> <%- renderIcon('moon', { class: 'icon-moon' }) %>
76
+ </button>
77
+ <% } %>
78
+ </aside>
79
+
80
+ <div class="main-content-wrapper">
81
+ <div class="page-header">
82
+ <div class="header-left">
83
+ <% if (sidebarConfig.collapsible) { %>
84
+ <button id="sidebar-toggle-button" class="sidebar-toggle-button" aria-label="Toggle Sidebar">
85
+ <%- renderIcon('panel-left-close') %>
86
+ </button>
87
+ <% } %>
88
+ <h1><%= pageTitle %></h1>
89
+ </div>
90
+ <% if (theme && theme.enableModeToggle && theme.positionMode === 'top') { %>
91
+ <div class="header-right">
92
+
93
+ <!-- SEARCH BUTTON -->
94
+ <% if (config.search !== false) { %>
95
+ <button class="docmd-search-trigger" aria-label="Search">
96
+ <%- renderIcon('search') %>
97
+ <span class="search-label">Search</span>
98
+ <span class="search-keys"><kbd class="docmd-kbd">⌘</kbd><kbd class="docmd-kbd">k</kbd></span>
99
+ </button>
100
+ <% } %>
101
+
102
+ <!-- THEME TOGGLE -->
103
+ <button id="theme-toggle-button" aria-label="Toggle theme" class="theme-toggle-button theme-toggle-header">
104
+ <%- renderIcon('sun', { class: 'icon-sun' }) %> <%- renderIcon('moon', { class: 'icon-moon' }) %>
105
+ </button>
106
+
107
+ <!-- SPONSOR BUTTON -->
108
+ <% if (sponsor && sponsor.enabled) { %>
109
+ <a href="<%= sponsor.link %>" target="_blank" rel="noopener noreferrer" aria-label="Sponsor Project">
110
+ <button class="sponsor-link-button" aria-label="Sponsor">
111
+ <%- renderIcon('heart', { class: 'sponsor-icon' }) %>
112
+ </button>
113
+ </a>
114
+ <% } %>
115
+ </div>
116
+ <% } %>
117
+ </div>
118
+
119
+ <main class="content-area">
120
+ <div class="content-layout">
121
+ <div class="main-content">
122
+ <%- content %>
123
+
124
+ <% if (config.pageNavigation && (prevPage || nextPage)) { %>
125
+ <div class="page-navigation">
126
+ <% if (prevPage) { %>
127
+ <a href="<%= prevPage.url %>" class="prev-page">
128
+ <%- renderIcon('arrow-left', { class: 'page-nav-icon' }) %>
129
+ <span><small>Previous</small><strong><%= prevPage.title %></strong></span>
130
+ </a>
131
+ <% } else { %><div class="prev-page-placeholder"></div><% } %>
132
+ <% if (nextPage) { %>
133
+ <a href="<%= nextPage.url %>" class="next-page">
134
+ <span><small>Next</small><strong><%= nextPage.title %></strong></span>
135
+ <%- renderIcon('arrow-right', { class: 'page-nav-icon' }) %>
136
+ </a>
137
+ <% } else { %><div class="next-page-placeholder"></div><% } %>
138
+ </div>
139
+ <% } %>
140
+ </div>
141
+
142
+ <div class="toc-sidebar">
143
+ <%- include('toc', { content, headings, navigationHtml, isActivePage }) %>
144
+ </div>
145
+ </div>
146
+
147
+ <div class="page-footer-actions">
148
+ <% if (locals.editUrl) { %>
149
+ <a href="<%= editUrl %>" target="_blank" rel="noopener noreferrer" class="edit-link">
150
+ <%- renderIcon('pencil') %> <%= editLinkText %>
151
+ </a>
152
+ <% } %>
153
+ </div>
154
+ </main>
155
+
156
+ <footer class="page-footer">
157
+ <div class="footer-content">
158
+ <div class="user-footer"><%- footerHtml || '' %></div>
159
+ <div class="branding-footer">
160
+ Build with <a href="https://docmd.io" target="_blank" rel="noopener">docmd.</a>
161
+ </div>
162
+ </div>
163
+ </footer>
164
+ </div>
165
+
166
+ <% if (config.search !== false) { %>
167
+ <!-- Search Modal -->
168
+ <div id="docmd-search-modal" class="docmd-search-modal" style="display: none;">
169
+ <div class="docmd-search-box">
170
+ <div class="docmd-search-header">
171
+ <%- renderIcon('search') %>
172
+ <input type="text" id="docmd-search-input" placeholder="Search documentation..." autocomplete="off" spellcheck="false">
173
+ <button onclick="window.closeDocmdSearch()" class="docmd-search-close" aria-label="Close search">
174
+ <%- renderIcon('x') %>
175
+ </button>
176
+ </div>
177
+ <div id="docmd-search-results" class="docmd-search-results"></div>
178
+ <div class="docmd-search-footer">
179
+ <span><kbd class="docmd-kbd">↑</kbd> <kbd class="docmd-kbd">↓</kbd> to navigate</span>
180
+ <span><kbd class="docmd-kbd">ESC</kbd> to close</span>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ <% } %>
185
+
186
+ <!-- Plugin Scripts -->
187
+ <%- pluginBodyScriptsHtml || '' %>
188
+
189
+ <!-- User Custom JS (Sanitized) -->
190
+ <% (customJsFiles || []).forEach(jsFile => {
191
+ if (jsFile && jsFile.trim() !== '') { %>
192
+ <script src="<%= relativePathToRoot %><%- jsFile.startsWith('/') ? jsFile.substring(1) : jsFile %>"></script>
193
+ <% } }); %>
194
+ </body>
195
+ </html>
@@ -0,0 +1,92 @@
1
+ <%# packages/ui/templates/navigation.ejs %>
2
+
3
+ <nav class="sidebar-nav" aria-label="Main navigation">
4
+ <ul>
5
+ <%
6
+ function normalizePath(p) {
7
+ if (!p || p === '#') return '#';
8
+ if (p.startsWith('http')) return p;
9
+
10
+ // 1. Strip dot-slash and leading slash
11
+ let path = p.replace(/^(\.\/|\/)+/, '');
12
+
13
+ // 2. Remove extensions and index
14
+ path = path.replace(/(\/index\.html|index\.html|\.html|\.md)$/, '');
15
+
16
+ // 3. CRITICAL: Remove trailing slash for consistency
17
+ path = path.replace(/\/$/, '');
18
+
19
+ // 4. Return with leading slash for comparison
20
+ return '/' + path;
21
+ }
22
+
23
+ function isChildActive(item, currentPath) {
24
+ if (!item.children) return false;
25
+ const currentNorm = normalizePath(currentPath);
26
+ return item.children.some(child => {
27
+ const childNorm = normalizePath(child.path);
28
+ // Exact match or if the child itself has active children (recursion)
29
+ if (childNorm === currentNorm) return true;
30
+ return isChildActive(child, currentPath);
31
+ });
32
+ }
33
+
34
+ function renderNav(items) {
35
+ if (!items || !Array.isArray(items)) return;
36
+
37
+ items.forEach(item => {
38
+ const isExternal = item.external || false;
39
+ // An item is collapsible if it has children, unless explicitly set to false
40
+ const isCollapsible = item.children && item.children.length > 0 && item.collapsible !== false;
41
+
42
+ const itemNorm = normalizePath(item.path);
43
+ const currentNorm = normalizePath(currentPagePath);
44
+
45
+ const isActive = itemNorm === currentNorm && itemNorm !== '#';
46
+ const isParentOfActive = !isActive && isChildActive(item, currentPagePath);
47
+
48
+ // Auto-expand if it's a parent of the active page
49
+ const isOpen = isParentOfActive || (isActive && isCollapsible);
50
+
51
+ const liClasses = [];
52
+ if (isActive) liClasses.push('active');
53
+ if (isParentOfActive) liClasses.push('active-parent');
54
+ if (isCollapsible) liClasses.push('collapsible');
55
+ if (isOpen) liClasses.push('expanded');
56
+
57
+ // Construct HREF (Same as before)
58
+ let href = item.path || '#';
59
+ if (!isExternal && href !== '#' && !href.startsWith('http')) {
60
+ let cleanPath = href.replace(/^(\.\/|\/)+/, '');
61
+ href = relativePathToRoot + cleanPath;
62
+ if (isOfflineMode) {
63
+ if (href.endsWith('/')) href += 'index.html';
64
+ else if (!href.endsWith('.html')) href += '/index.html';
65
+ } else {
66
+ if (href.endsWith('/index.html')) href = href.slice(0, -10);
67
+ }
68
+ }
69
+ %>
70
+ <li class="<%= liClasses.join(' ') %>" <% if(isCollapsible) { %> aria-expanded="<%= isOpen %>" <% } %>>
71
+ <a href="<%= href %>" class="<%= isActive ? 'active' : '' %>" <%= isExternal ? 'target="_blank" rel="noopener"' : '' %>>
72
+ <% if (item.icon) { %> <%- renderIcon(item.icon) %> <% } %>
73
+ <span class="nav-item-title"><%= item.title %></span>
74
+
75
+ <% if (isCollapsible) { %>
76
+ <span class="collapse-icon-wrapper">
77
+ <%- renderIcon('chevron-right', { class: 'collapse-icon' }) %>
78
+ </span>
79
+ <% } %>
80
+
81
+ <% if (isExternal) { %> <%- renderIcon('external-link', { class: 'nav-external-icon' }) %> <% } %>
82
+ </a>
83
+
84
+ <% if (item.children) { %>
85
+ <ul class="submenu">
86
+ <% renderNav(item.children); %>
87
+ </ul>
88
+ <% } %>
89
+ </li>
90
+ <% }); } renderNav(navItems); %>
91
+ </ul>
92
+ </nav>