mdcms/app/index.html
Claude df0f179004
Fix renderer XSS/routing bugs and restore CLI fetch-deps
Renderer (app/index.html):
- Guard the router so navigateTo and the hashchange/popstate handlers only
  load relative .md paths (isSafePagePath). Blocks fetching attacker-
  controlled external URLs injected via the location hash.
- Stop treating in-page heading anchors (#heading) as page files, which
  previously replaced the page with a 404.
- HTML-escape meta.title, link href/title attributes, not-found/offline
  messages, and the icon fallback img; block javascript:/data: hrefs via
  safeUrl.
- Hydrate mdcms tags nested inside tabs/accordions/callouts.
- Configure marked once instead of on every render.
- Validate stored theme value; fix text-align center; resolve per-category
  offline message after categories initialise.

CLI (mdcms.py):
- Restore CDN_DEPS, _WOFF2_URL_RE, _fetch_bunny_fonts, _patch_index_html so
  fetch-deps no longer raises NameError.
- Compare site markers against SITE_FORMAT_VERSION with zero-padded version
  comparison, removing the spurious "update available" warning on v0.6 sites.
- Bump CLI to 0.6.1.

https://claude.ai/code/session_018KXUwmSNMGF2UBywTChCcS
2026-06-12 07:07:15 +00:00

3373 lines
127 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- mdcms v0.6.0 | DO NOT REMOVE THIS COMMENT -->
<!--
MD-CMS v0.6.0 — Renderer
Copyright 2026 Kristian Benestad | kbenestad.codeberg.page
Licensed under the Apache License, Version 2.0 (the "Licence");
you may not use this file except in compliance with the Licence.
You may obtain a copy of the Licence at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the Licence is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the Licence for the specific language governing permissions and
limitations under the Licence.
-->
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
<meta name="description" content="">
<link rel="icon" href="assets/images/favicon.png">
<link rel="manifest" href="manifest.json">
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./service-worker.js').catch(() => {});
});
}
</script>
<!-- Libraries (CDN) -->
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0/dist/fuse.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" id="hljs-light">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css" id="hljs-dark" disabled>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
<style>
/* ═══════════════════════════════════════════
CSS RESET & BASE
═══════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ═══════════════════════════════════════════
THEME: CSS CUSTOM PROPERTIES
═══════════════════════════════════════════ */
:root[data-theme="light"] {
--accent: #2563EB;
--accent-rgb: 37, 99, 235;
--bg-main: #FFFFFF;
--bg-nav: #F8FAFC;
--nav-font-colour: var(--font-colour);
--nav-link-colour: var(--nav-font-colour);
--nav-link-active-colour: var(--accent);
--nav-section-heading-colour: var(--font-colour-muted);
--nav-sitename-colour: var(--nav-link-colour);
--nav-description-colour: var(--nav-section-heading-colour);
--nav-toggle-colour: var(--nav-section-heading-colour);
--nav-active-bg: rgba(var(--accent-rgb), 0.10);
--nav-hover-bg: rgba(var(--accent-rgb), 0.05);
--font-colour: #1E293B;
--font-colour-muted: #64748B;
--code-bg: #F1F5F9;
--code-font: #1E293B;
--divider: color-mix(in srgb, var(--bg-main) 85%, var(--font-colour));
--table-header-bg: rgba(var(--accent-rgb), 0.08);
--table-border: #E2E8F0;
--link-colour: #2563EB;
--link-hover-colour: #1D4ED8;
--link-visited-colour: #7C3AED;
--link-decoration: underline;
--link-hover-decoration: underline;
--link-hover-weight: normal;
--link-visited-decoration: underline;
--search-bg: #FFFFFF;
--search-border: #E2E8F0;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--scrollbar-thumb: #CBD5E1;
--scrollbar-track: transparent;
--overlay-bg: rgba(0,0,0,0.3);
}
:root[data-theme="dark"] {
--accent: #60A5FA;
--accent-rgb: 96, 165, 250;
--bg-main: #0F172A;
--bg-nav: #1E293B;
--nav-font-colour: #E2E8F0;
--nav-link-colour: var(--nav-font-colour);
--nav-sitename-colour: var(--nav-link-colour);
--nav-description-colour: var(--nav-section-heading-colour);
--nav-toggle-colour: var(--nav-section-heading-colour);
--nav-link-active-colour: var(--accent);
--nav-section-heading-colour: var(--font-colour-muted);
--nav-active-bg: rgba(96, 165, 250, 0.15);
--nav-hover-bg: rgba(96, 165, 250, 0.08);
--font-colour: #F1F5F9;
--font-colour-muted: #94A3B8;
--code-bg: #1E293B;
--code-font: #E2E8F0;
--divider: color-mix(in srgb, var(--bg-main) 85%, var(--font-colour));
--table-header-bg: rgba(96, 165, 250, 0.10);
--table-border: #334155;
--link-colour: #60A5FA;
--link-hover-colour: #93C5FD;
--link-visited-colour: #A78BFA;
--link-decoration: underline;
--link-hover-decoration: underline;
--link-hover-weight: normal;
--link-visited-decoration: underline;
--search-bg: #1E293B;
--search-border: #334155;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
--shadow-md: 0 4px 12px rgba(0,0,0,0.3);
--scrollbar-thumb: #475569;
--scrollbar-track: transparent;
--overlay-bg: rgba(0,0,0,0.6);
}
/* ═══════════════════════════════════════════
TYPOGRAPHY
═══════════════════════════════════════════ */
:root {
--font-title: system-ui, -apple-system, sans-serif;
--font-body: system-ui, -apple-system, sans-serif;
--font-code: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--font-title-weight: 700;
--font-body-weight: 400;
--main-width: 80em;
--nav-width: 20em;
--line-height-body: 1.7;
--colour-info: #2563EB;
--colour-warning: #D97706;
--colour-success: #16A34A;
--colour-error: #DC2626;
}
html { font-size: 16px; scroll-behavior: smooth; }
body {
font-family: var(--font-body);
font-weight: var(--font-body-weight);
color: var(--font-colour);
background: var(--bg-main);
line-height: var(--line-height-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.2s ease, color 0.2s ease;
}
/* ═══════════════════════════════════════════
LAYOUT: SIDEBAR MODE
═══════════════════════════════════════════ */
.layout-sidebar { display: flex; min-height: 100vh; }
.layout-sidebar .sidebar {
position: fixed;
top: 0;
width: var(--nav-width);
height: 100vh;
background: var(--bg-nav);
border-right: 1px solid var(--divider);
display: flex;
flex-direction: column;
overflow-y: auto;
z-index: 100;
transition: background-color 0.2s ease, transform 0.3s ease, visibility 0s ease 0.3s;
}
.layout-sidebar.nav-left .sidebar { left: 0; }
.layout-sidebar.nav-right .sidebar { right: 0; border-right: none; border-left: 1px solid var(--divider); }
.layout-sidebar .main-area { flex: 1; min-width: 0; }
.layout-sidebar.nav-left .main-area { margin-left: var(--nav-width); }
.layout-sidebar.nav-right .main-area { margin-right: var(--nav-width); }
.main-content { max-width: var(--main-width); margin: 0 auto; padding: 2rem 3rem 4rem; }
.sidebar::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 2px; }
.sidebar::-webkit-scrollbar-track { background: var(--scrollbar-track); }
.layout-sidebar .mobile-header { display: none; }
/* ═══════════════════════════════════════════
SIDEBAR CONTENT
═══════════════════════════════════════════ */
.sidebar-header {
padding: 1.5rem 1.25rem 1rem;
flex-shrink: 0;
}
.sidebar-logo { max-width: 48px; max-height: 48px; margin-bottom: 0.5rem; display: block; }
.sidebar-sitename {
font-family: var(--font-title);
font-weight: var(--font-title-weight);
font-size: 1.15rem;
color: var(--nav-sitename-colour);
line-height: 1.3;
text-decoration: none;
display: block;
}
.sidebar-sitename:hover { color: var(--nav-link-active-colour, var(--accent)); }
.sidebar-description { font-size: 0.8rem; color: var(--nav-description-colour); margin-top: 0.25rem; line-height: 1.4; }
/* Search */
.search-container { padding: 0.75rem 1.25rem; flex-shrink: 0; }
.search-box {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
font-size: 0.85rem;
font-family: var(--font-body);
border: 1px solid var(--search-border);
border-radius: 6px;
background: var(--search-bg);
color: var(--font-colour);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.search-box:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.15);
}
.search-box::placeholder { color: var(--font-colour-muted); }
.search-wrapper { position: relative; }
.search-icon {
position: absolute;
left: 0.7rem;
top: 50%;
transform: translateY(-50%);
width: 15px;
height: 15px;
color: var(--font-colour-muted);
pointer-events: none;
}
.search-results {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-nav);
border: 1px solid var(--search-border);
border-radius: 6px;
box-shadow: var(--shadow-md);
max-height: 320px;
overflow-y: auto;
z-index: 200;
display: none;
}
.search-results.active { display: block; }
.search-result-item {
padding: 0.6rem 0.85rem;
cursor: pointer;
border-bottom: 1px solid var(--divider);
transition: background 0.1s;
}
.search-result-item:last-child { border-bottom: none; }
.search-result-item:hover { background: var(--nav-hover-bg); }
.search-result-title { font-size: 0.85rem; font-weight: 600; color: var(--font-colour); }
.search-result-snippet {
font-size: 0.75rem;
color: var(--font-colour-muted);
margin-top: 0.15rem;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Navigation links */
.nav-links { flex: 1; padding: 0.5rem 0; overflow-y: auto; }
.nav-section-heading {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--nav-section-heading-colour, var(--font-colour-muted));
padding: 1rem 1.25rem 0.35rem;
user-select: none;
border-left: 3px solid transparent;
}
.nav-section-heading.toggleable {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.4rem;
}
.nav-section-heading.toggleable:hover { color: var(--font-colour); }
.nav-section-heading .toggle-icon {
display: inline-flex;
align-items: center;
width: 1em;
height: 1em;
flex-shrink: 0;
opacity: 0.6;
}
.mdcms-icon { display: inline-flex; align-items: center; line-height: 1; }
.mdcms-icon svg { width: 1em; height: 1em; fill: currentColor; display: block; }
.nav-item.depth-1 { padding-left: 2.5rem; }
.nav-item.depth-2 { padding-left: 3.5rem; }
.nav-item.depth-3 { padding-left: 4.5rem; }
.nav-section-heading.depth-1 { padding-left: 2rem; }
.nav-section-heading.depth-2 { padding-left: 3rem; }
.nav-section-heading.depth-3 { padding-left: 4rem; }
.nav-item {
display: block;
padding: 0.45rem 1.25rem;
font-size: 0.875rem;
color: var(--nav-link-colour, var(--nav-font-colour, var(--font-colour)));
text-decoration: none;
transition: background 0.1s, color 0.1s;
border-left: 3px solid transparent;
cursor: pointer;
}
.nav-item:hover { background: var(--nav-hover-bg); }
.nav-item.active {
background: var(--nav-active-bg);
border-left-color: var(--nav-link-active-colour, var(--accent));
color: var(--nav-link-active-colour, var(--accent));
font-weight: 600;
}
/* Sidebar footer */
.sidebar-footer {
padding: 0.75rem 1.25rem;
flex-shrink: 0;
}
.theme-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
font-size: 0.8rem;
color: var(--nav-toggle-colour);
background: none;
border: 1px solid color-mix(in srgb, var(--bg-nav) 70%, var(--nav-toggle-colour));
border-radius: 6px;
cursor: pointer;
font-family: var(--font-body);
transition: color 0.15s, border-color 0.15s;
width: 100%;
justify-content: center;
}
.theme-toggle:hover { color: var(--nav-link-colour, var(--font-colour)); border-color: var(--nav-toggle-colour); }
.theme-toggle svg { width: 16px; height: 16px; flex-shrink: 0; }
/* ═══════════════════════════════════════════
LAYOUT: TOPBAR MODE
═══════════════════════════════════════════ */
.layout-topbar .topbar {
position: fixed;
top: 0; left: 0; right: 0;
height: 56px;
background: var(--bg-nav);
border-bottom: 1px solid var(--divider);
display: flex;
align-items: center;
padding: 0 1.5rem;
z-index: 100;
transition: background-color 0.2s ease;
gap: 1rem;
}
.topbar-brand { display: flex; align-items: center; gap: 0.6rem; text-decoration: none; flex-shrink: 0; }
.topbar-logo { max-height: 28px; max-width: 28px; }
.topbar-sitename {
font-family: var(--font-title);
font-weight: var(--font-title-weight);
font-size: 1rem;
color: var(--font-colour);
}
.topbar-nav { display: flex; align-items: center; gap: 0.25rem; flex: 1; overflow-x: auto; }
.topbar-nav .nav-item {
padding: 0.35rem 0.75rem;
border-radius: 5px;
border-left: none;
white-space: nowrap;
font-size: 0.85rem;
}
.topbar-nav .nav-item.active { border-left: none; background: var(--nav-active-bg); }
/* ─── Topbar grouped navigation (dropdowns) ─── */
.topbar-nav .nav-group { position: relative; }
.topbar-nav .nav-trigger {
display: flex; align-items: center; gap: 0.25rem;
padding: 0.35rem 0.75rem; border-radius: 5px;
background: none; border: none; cursor: pointer;
color: var(--font-colour); font-size: 0.85rem;
font-family: inherit; white-space: nowrap; text-decoration: none; line-height: inherit;
}
.topbar-nav .nav-trigger:hover,
.topbar-nav .nav-group.open > .nav-trigger { background: var(--nav-hover-bg); }
.topbar-nav .nav-group.has-active > .nav-trigger { background: var(--nav-active-bg); }
.topbar-nav .nav-caret { font-size: 0.6rem; color: var(--font-colour-muted); opacity: 0.55; line-height: 1; }
.topbar-nav .nav-dropdown {
display: none; position: absolute; top: calc(100% + 4px); left: 0;
background: var(--bg-nav); border: 1px solid var(--divider);
border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.1);
min-width: 160px; z-index: 200; padding: 0.25rem 0;
}
.topbar-nav .nav-group.open > .nav-dropdown { display: block; }
.topbar-nav .nav-dropdown .nav-item {
display: block; padding: 0.45rem 1rem;
border-left: none; border-radius: 0; white-space: nowrap;
}
.topbar-nav .nav-dropdown .nav-item:hover { background: var(--nav-hover-bg); }
.topbar-nav .nav-dropdown .nav-item.active { background: var(--nav-active-bg); font-weight: 600; }
.topbar-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.topbar-search { position: relative; }
.topbar-search .search-box { width: 200px; padding: 0.4rem 0.6rem 0.4rem 2rem; font-size: 0.8rem; }
.topbar-search .search-icon { left: 0.55rem; width: 14px; height: 14px; }
.topbar-search .search-results { width: 320px; right: 0; left: auto; }
.topbar .theme-toggle { width: auto; padding: 0.35rem 0.5rem; font-size: 0; border: none; }
.topbar .theme-toggle svg { width: 18px; height: 18px; }
.layout-topbar .main-area { margin-top: 56px; }
.layout-topbar .mobile-nav-panel { display: none; }
.layout-topbar .hamburger { display: none; }
/* ═══════════════════════════════════════════
HAMBURGER MENU
═══════════════════════════════════════════ */
.hamburger {
display: none;
background: none;
border: none;
cursor: pointer;
padding: 0.4rem;
color: var(--nav-link-colour, var(--font-colour));
z-index: 150;
}
.hamburger svg { width: 22px; height: 22px; }
.mobile-overlay { display: none; position: fixed; inset: 0; background: var(--overlay-bg); z-index: 90; }
.mobile-overlay.active { display: block; }
/* ═══════════════════════════════════════════
MARKDOWN CONTENT
═══════════════════════════════════════════ */
.md-content h1, .md-content h2, .md-content h3,
.md-content h4, .md-content h5, .md-content h6 {
font-family: var(--font-title);
font-weight: var(--font-title-weight);
color: var(--accent);
line-height: 1.3;
margin-top: 1.8em;
margin-bottom: 0.6em;
position: relative;
}
.md-content h1 { font-size: 2rem; margin-top: 0; }
.md-content h2 { font-size: 1.5rem; }
.md-content h3 { font-size: 1.25rem; }
.md-content h4 { font-size: 1.1rem; }
.md-content h5 { font-size: 1rem; }
.md-content h6 { font-size: 0.9rem; }
.md-content h1:first-child, .md-content h2:first-child, .md-content h3:first-child { margin-top: 0; }
.md-content p { margin-bottom: 1.1em; }
.md-content a {
color: var(--link-colour);
text-decoration: var(--link-decoration);
transition: color 0.15s;
}
.md-content a:hover {
color: var(--link-hover-colour);
text-decoration: var(--link-hover-decoration);
font-weight: var(--link-hover-weight);
}
.md-content a:visited {
color: var(--link-visited-colour);
text-decoration: var(--link-visited-decoration);
}
.md-content strong { font-weight: 700; }
.md-content em { font-style: italic; }
.md-content ul, .md-content ol { margin-bottom: 1.1em; padding-left: 1.75em; }
.md-content li { margin-bottom: 0.3em; }
.md-content li > ul, .md-content li > ol { margin-bottom: 0.3em; margin-top: 0.3em; }
.md-content blockquote {
border-left: 4px solid var(--accent);
padding: 0.75em 1.25em;
margin: 0 0 1.1em 0;
color: var(--font-colour-muted);
background: rgba(var(--accent-rgb), 0.03);
border-radius: 0 6px 6px 0;
}
.md-content blockquote p:last-child { margin-bottom: 0; }
.md-content code {
font-family: var(--font-code);
font-size: 0.875em;
background: var(--code-bg);
color: var(--code-font);
padding: 0.15em 0.4em;
border-radius: 4px;
}
.md-content pre {
margin-bottom: 1.1em;
border-radius: 8px;
overflow-x: auto;
background: var(--code-bg);
border: 1px solid var(--divider);
}
.md-content pre code {
display: block;
padding: 1em 1.25em;
background: none;
border-radius: 0;
line-height: 1.5;
font-size: 0.85em;
}
.md-content hr { border: none; height: 1px; background: var(--divider); margin: 2em 0; }
.md-content img { max-width: 100%; height: auto; border-radius: 6px; margin: 0.5em 0; }
.md-content table { width: 100%; border-collapse: collapse; margin-bottom: 1.1em; font-size: 0.9em; }
.md-content th {
background: var(--table-header-bg);
font-weight: 700;
text-align: left;
border-bottom: 2px solid var(--accent);
}
.md-content th, .md-content td { padding: 0.6em 0.85em; border: 1px solid var(--table-border); }
.md-content tr:hover { background: rgba(var(--accent-rgb), 0.02); }
::selection { background: rgba(var(--accent-rgb), 0.2); }
/* ═══════════════════════════════════════════
CATEGORY BAR (phase 3)
═══════════════════════════════════════════ */
.category-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.25rem 0 1rem;
font-size: 0.9rem;
color: var(--font-colour-muted);
flex-wrap: wrap;
}
.category-bar[dir="rtl"] { justify-content: flex-start; }
.category-icon {
display: inline-flex;
align-items: center;
font-size: 1.1rem;
color: var(--font-colour-muted);
}
.category-dropdown { position: relative; }
.category-trigger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--search-bg);
border: 1px solid var(--search-border);
border-radius: 6px;
padding: 0.35rem 0.65rem;
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--font-colour);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.category-trigger:hover { border-color: var(--font-colour-muted); }
.category-trigger:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.15); }
.category-trigger .caret { font-size: 0.7rem; opacity: 0.7; }
.category-panel {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 220px;
background: var(--bg-nav);
border: 1px solid var(--search-border);
border-radius: 6px;
box-shadow: var(--shadow-md);
z-index: 150;
display: none;
overflow: hidden;
}
.category-bar[dir="rtl"] .category-panel { left: 0; right: auto; }
.category-dropdown.open .category-panel { display: block; }
.category-search {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
font-family: var(--font-body);
border: none;
border-bottom: 1px solid var(--divider);
background: var(--bg-main);
color: var(--font-colour);
outline: none;
box-sizing: border-box;
}
.category-options { max-height: 280px; overflow-y: auto; }
.category-option {
padding: 0.55rem 0.85rem;
cursor: pointer;
font-size: 0.88rem;
color: var(--nav-link-colour, var(--font-colour));
border-bottom: 1px solid var(--divider);
transition: background 0.1s;
}
.category-option:last-child { border-bottom: none; }
.category-option:hover { background: var(--nav-hover-bg); }
.category-option.active { background: var(--nav-active-bg); color: var(--nav-link-active-colour, var(--accent)); font-weight: 600; }
.category-option .secondary {
display: block;
font-size: 0.75rem;
color: var(--nav-section-heading-colour, var(--font-colour-muted));
margin-top: 0.15rem;
}
/* Font-loading banner */
.font-loading-banner {
background: rgba(var(--accent-rgb), 0.08);
color: var(--font-colour);
padding: 0.6rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
text-align: center;
/* Always show in default font, never the category's pending font */
font-family: system-ui, -apple-system, sans-serif;
}
/* RTL page content */
.md-content[dir="rtl"], .title-bar[dir="rtl"] { text-align: right; }
.sidebar[dir="rtl"] .nav-item,
.sidebar[dir="rtl"] .nav-section-heading { text-align: right; }
/* Page meta */
.page-meta {
font-size: 0.85rem;
color: var(--font-colour-muted);
margin-bottom: 1.75rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--divider);
line-height: 1.5;
}
/* Title bar */
.title-bar {
font-size: 0.8rem;
color: var(--font-colour-muted);
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
.title-bar-sep { opacity: 0.5; }
/* Scroll to top */
.scroll-top {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-md);
opacity: 0;
visibility: hidden;
transition: opacity 0.25s, visibility 0.25s, transform 0.15s;
z-index: 50;
}
.scroll-top.visible { opacity: 1; visibility: visible; }
.scroll-top:hover { transform: scale(1.1); }
.scroll-top svg { width: 18px; height: 18px; }
/* Footer */
.site-footer {
padding: 2rem 3rem;
text-align: center;
font-size: 0.8rem;
color: var(--font-colour-muted);
border-top: 1px solid var(--divider);
margin-top: 2rem;
}
.site-footer a { color: var(--link-colour); }
/* Loading */
.loading-spinner { display: flex; justify-content: center; padding: 4rem 0; }
.loading-spinner::after {
content: '';
width: 28px;
height: 28px;
border: 3px solid var(--divider);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error-message { text-align: center; padding: 3rem 1rem; color: var(--font-colour-muted); }
.error-message h2 { color: var(--accent); margin-bottom: 0.5rem; }
/* ═══════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════ */
@media (max-width: 768px) {
.hamburger { display: flex; }
.layout-sidebar .sidebar {
transform: translateX(-100%);
visibility: hidden;
width: 80vw;
max-width: 320px;
box-shadow: var(--shadow-md);
}
.layout-sidebar.nav-right .sidebar { transform: translateX(100%); }
.layout-sidebar .sidebar.open { transform: translateX(0); visibility: visible; transition: background-color 0.2s ease, transform 0.3s ease; }
.layout-sidebar .main-area { margin-left: 0 !important; margin-right: 0 !important; }
.main-content { padding: 1rem 1.25rem 3rem; }
.layout-sidebar .mobile-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg-nav);
border-bottom: 1px solid var(--divider);
position: sticky;
top: 0;
z-index: 50;
}
.mobile-header .sidebar-sitename { font-size: 1rem; }
.topbar-nav { display: none; }
.topbar-search { display: none; }
.layout-topbar .hamburger { display: inline-flex; }
.layout-topbar .mobile-nav-panel {
display: block;
position: fixed;
top: 56px; left: 0; right: 0; bottom: 0;
background: var(--bg-nav);
z-index: 90;
overflow-y: auto;
transform: translateY(-100%);
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
padding: 0.5rem 0;
}
.layout-topbar .mobile-nav-panel.open { transform: translateY(0); opacity: 1; }
.layout-topbar .mobile-nav-panel .search-container { padding: 0.75rem 1rem; }
.layout-topbar .mobile-nav-panel .nav-item {
padding: 0.6rem 1.25rem;
font-size: 1rem;
border-left: none;
}
.layout-topbar .mobile-nav-panel .nav-item.active { border-left: none; font-weight: 600; }
.layout-topbar .mobile-nav-panel .nav-group-row { display: flex; align-items: stretch; }
.layout-topbar .mobile-nav-panel .nav-group-row .nav-item { flex: 1; }
.layout-topbar .mobile-nav-panel .nav-section-label {
flex: 1; display: flex; align-items: center;
padding: 0.6rem 1.25rem; font-size: 1rem; color: var(--font-colour); font-weight: 500;
}
.layout-topbar .mobile-nav-panel .nav-expand-btn {
background: none; border: none; cursor: pointer;
color: var(--font-colour-muted); padding: 0 1.25rem; font-size: 1.2rem; line-height: 1; flex-shrink: 0;
}
.layout-topbar .mobile-nav-panel .nav-group-children { display: none; }
.layout-topbar .mobile-nav-panel .nav-group-children.open { display: block; }
.layout-topbar .mobile-nav-panel .nav-group-children .nav-item { padding-left: 2.5rem; }
}
@media (max-width: 480px) {
.layout-sidebar .sidebar { width: 100vw; max-width: none; }
.layout-topbar .mobile-nav-panel { bottom: 0; }
.main-content { padding: 1rem 1rem 3rem; }
}
/* ═══════════════════════════════════════════
TAG SYSTEM: CALLOUTS
═══════════════════════════════════════════ */
.mdcms-callout {
border-left: 4px solid var(--callout-primary, var(--accent));
background: var(--callout-bg, transparent);
border-radius: 0 6px 6px 0;
padding: 0.85rem 1rem 0.85rem 1rem;
margin: 1.25rem 0;
}
.mdcms-callout-title {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 700;
font-size: 0.95rem;
margin-bottom: 0.45rem;
}
.mdcms-callout-title .mdcms-icon { font-size: 1.1em; }
.mdcms-callout-body { font-size: 0.95rem; }
.mdcms-callout-body > *:first-child { margin-top: 0; }
.mdcms-callout-body > *:last-child { margin-bottom: 0; }
/* ═══════════════════════════════════════════
TAG SYSTEM: TABLE OF CONTENTS
═══════════════════════════════════════════ */
.mdcms-toc { margin: 1rem 0; }
.mdcms-toc-section { font-size: 1rem; font-weight: 600; margin: 1.5rem 0 0.4rem; color: var(--font-colour-muted); border-bottom: 1px solid var(--divider); padding-bottom: 0.25rem; }
.mdcms-toc-list { list-style: none; padding: 0; margin: 0 0 0.5rem; }
.mdcms-toc-list li { padding: 0.2rem 0; border-bottom: 1px solid var(--divider); }
.mdcms-toc-list li:last-child { border-bottom: none; }
.mdcms-toc-list a { color: var(--accent); text-decoration: none; font-size: 0.95rem; }
.mdcms-toc-list a:hover { text-decoration: underline; }
/* ═══════════════════════════════════════════
TAG SYSTEM: POST LISTINGS
═══════════════════════════════════════════ */
.mdcms-posts { margin: 1rem 0; }
.post-list { list-style: none; padding: 0; margin: 0 0 0.75rem; }
.post-list li { padding: 0.35rem 0; border-bottom: 1px solid var(--divider); display: flex; gap: 0.5rem; }
.post-list li:last-child { border-bottom: none; }
.post-list a { color: var(--accent); text-decoration: none; font-size: 0.92rem; display: flex; gap: 0.5rem; width: 100%; }
.post-list a:hover { color: var(--accent-hover, var(--accent)); text-decoration: underline; }
.post-date { color: var(--font-colour-muted); white-space: nowrap; flex-shrink: 0;
display: inline-block; min-width: var(--post-date-width, 10rem); }
.post-sep { color: var(--font-colour-muted); flex-shrink: 0; }
.post-title { flex: 1; }
.posts-empty { color: var(--font-colour-muted); font-style: italic; }
.post-year-select {
padding: 0.3rem 0.6rem;
border: 1px solid var(--divider);
border-radius: 4px;
background: var(--bg);
color: var(--font-colour);
font-size: 0.9rem;
margin-bottom: 1rem;
cursor: pointer;
}
.post-month-heading {
font-size: 1rem;
font-weight: 600;
margin: 1rem 0 0.35rem;
color: var(--font-colour);
}
.post-pagination {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.75rem;
font-size: 0.85rem;
}
.page-info { color: var(--font-colour-muted); }
.page-btn {
padding: 0.3rem 0.7rem;
border: 1px solid var(--divider);
border-radius: 4px;
background: var(--bg);
color: var(--accent);
cursor: pointer;
font-size: 0.85rem;
}
.page-btn:disabled { opacity: 0.4; cursor: default; }
.page-btn:hover:not(:disabled) { background: var(--nav-hover-bg); }
.page-jump-label { color: var(--font-colour-muted); margin-left: 0.5rem; }
.page-jump {
width: 3.5rem;
padding: 0.25rem 0.4rem;
border: 1px solid var(--divider);
border-radius: 4px;
background: var(--bg);
color: var(--font-colour);
font-size: 0.85rem;
text-align: center;
}
.post-load-more {
display: block;
margin: 0.75rem auto 0;
padding: 0.4rem 1.5rem;
border: 1px solid var(--divider);
border-radius: 4px;
background: var(--bg);
color: var(--accent);
cursor: pointer;
font-size: 0.9rem;
}
.post-load-more:hover { background: var(--nav-hover-bg); }
/* ═══════════════════════════════════════════
TAG SYSTEM: TABS
═══════════════════════════════════════════ */
.mdcms-tabs { margin: 1.25rem 0; }
/* Underline variant */
.mdcms-tabs-underline .mdcms-tabs-strip {
display: flex;
flex-wrap: wrap;
gap: 0 18px;
border-bottom: 1px solid var(--mdcms-strip-border, color-mix(in srgb, var(--font-colour) 12%, transparent));
}
.mdcms-tabs-underline .mdcms-tab-btn {
padding: 8px 2px;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
cursor: pointer;
font-size: inherit;
font-family: inherit;
font-weight: 500;
color: var(--font-colour-muted);
line-height: inherit;
transition: color 0.15s;
}
.mdcms-tabs-underline .mdcms-tab-btn:hover { color: var(--font-colour); }
.mdcms-tabs-underline .mdcms-tab-btn[aria-selected="true"] {
font-weight: 600;
color: var(--font-colour);
border-bottom-color: var(--accent);
}
/* Filled variant */
.mdcms-tabs-filled .mdcms-tabs-strip { display: flex; flex-wrap: wrap; gap: 4px; }
.mdcms-tabs-filled .mdcms-tab-btn {
padding: 6px 11px;
border-radius: 4px;
border: 1px solid var(--mdcms-filled-border, rgba(var(--accent-rgb), 0.30));
background: var(--mdcms-filled-bg, rgba(var(--accent-rgb), 0.10));
color: var(--mdcms-filled-fg-muted, var(--font-colour-muted));
cursor: pointer;
font-size: inherit;
font-family: inherit;
font-weight: 400;
line-height: inherit;
transition: color 0.15s;
}
.mdcms-tabs-filled .mdcms-tab-btn:hover { color: var(--mdcms-filled-fg, var(--font-colour)); }
.mdcms-tabs-filled .mdcms-tab-btn[aria-selected="true"] {
background: var(--mdcms-bg, var(--bg-main));
border-color: rgba(var(--accent-rgb), 0.55);
color: var(--accent);
font-weight: 600;
}
/* Shared panel */
.mdcms-tabs-panel { padding-top: 1rem; }
.mdcms-tabs-panel[hidden] { display: none; }
.mdcms-tabs-panel > *:first-child { margin-top: 0; }
.mdcms-tabs-panel > *:last-child { margin-bottom: 0; }
/* ═══════════════════════════════════════════
TAG SYSTEM: ACCORDIONS
═══════════════════════════════════════════ */
.mdcms-accordion { margin: 1.25rem 0; }
.mdcms-accordion-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
font-size: inherit;
font-family: inherit;
text-align: left;
line-height: inherit;
}
.mdcms-accordion-chevron {
display: inline-flex;
flex-shrink: 0;
width: 1.1em;
height: 1.1em;
transition: transform 0.2s ease;
transform: rotate(0deg);
}
.mdcms-accordion-item[data-open="false"] .mdcms-accordion-chevron { transform: rotate(-90deg); }
.mdcms-accordion-body[hidden] { display: none; }
.mdcms-accordion-body > *:first-child { margin-top: 0; }
.mdcms-accordion-body > *:last-child { margin-bottom: 0; }
/* Underline variant */
.mdcms-accordion-underline .mdcms-accordion-item { margin-bottom: 6px; }
.mdcms-accordion-underline .mdcms-accordion-btn {
padding: 8px 2px 9px;
border-bottom: 2px solid var(--mdcms-bar, var(--accent));
font-weight: 600;
font-size: 0.75rem;
color: var(--font-colour);
}
.mdcms-accordion-underline .mdcms-accordion-chevron { color: var(--mdcms-bar, var(--accent)); }
.mdcms-accordion-underline .mdcms-accordion-body {
border-left: 1px solid var(--mdcms-bar, var(--accent));
border-right: 1px solid var(--mdcms-bar, var(--accent));
border-bottom: 1px solid var(--mdcms-bar, var(--accent));
border-radius: 0 0 3px 3px;
padding: 8px 10px 9px;
color: var(--font-colour-muted);
}
/* Filled variant — closed */
.mdcms-accordion-filled .mdcms-accordion-item { margin-bottom: 6px; }
.mdcms-accordion-filled .mdcms-accordion-item[data-open="true"] { margin-bottom: 8px; }
.mdcms-accordion-filled .mdcms-accordion-btn {
padding: 8px 11px;
border-radius: 4px;
border: 1px solid var(--mdcms-filled-border, rgba(var(--accent-rgb), 0.30));
background: var(--mdcms-filled-bg, rgba(var(--accent-rgb), 0.10));
color: var(--mdcms-filled-fg, var(--font-colour));
}
.mdcms-accordion-filled .mdcms-accordion-chevron { color: var(--mdcms-filled-fg, var(--font-colour)); }
/* Filled variant — open: item becomes the outer frame */
.mdcms-accordion-filled .mdcms-accordion-item[data-open="true"] {
border: 1px solid var(--mdcms-bar, var(--accent));
border-radius: 4px;
overflow: hidden;
}
.mdcms-accordion-filled .mdcms-accordion-item[data-open="true"] > .mdcms-accordion-btn {
border: none;
border-radius: 0;
}
.mdcms-accordion-filled .mdcms-accordion-body {
background: var(--mdcms-bg, var(--bg-main));
padding: 8px 11px 9px;
color: var(--font-colour-muted);
}
@media print {
.sidebar, .topbar, .scroll-top, .hamburger,
.mobile-header, .theme-toggle, .search-container { display: none !important; }
.main-area { margin: 0 !important; }
.main-content { max-width: 100%; padding: 0; }
}
</style>
</head>
<body>
<div id="app"></div>
<button class="scroll-top" id="scrollTop" aria-label="Scroll to top">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="18 15 12 9 6 15"/>
</svg>
</button>
<script>
/* ═══════════════════════════════════════════════════════════════
MD-CMS v0.2 — Core Application (phase 1: no sections/categories/tags)
═══════════════════════════════════════════════════════════════ */
(function() {
'use strict';
// ─── State ────────────────────────────────────────────────
// Capture the intended pathname before anything mutates the URL.
// 404.html (GitHub Pages SPA routing) encodes the original path as ?_route=.
const _initialPathname = (() => {
const route = new URLSearchParams(window.location.search).get('_route');
if (route) {
const u = new URL(window.location);
u.searchParams.delete('_route');
window.history.replaceState(null, '', u);
return route;
}
return window.location.pathname;
})();
let basePath = '/'; // set by initBasePath() once nav data is loaded
let config = {};
let navData = [];
let navSections = [];
let searchIndex = [];
let fuseInstance = null;
let currentPage = null;
let themeConfig = {};
// Category state (phase 3)
let categoriesUse = false;
let categoriesList = []; // [{code, name, direction, message, notfoundmessage, pagenotfoundmessage, visibilityifnocontent, font, ...}]
let categoriesByCode = {}; // code → category object
let defaultCategoryCode = null;
let activeCategory = null; // current code
let sectionnamesMode = 'same'; // 'same' | 'per-category'
let loadedFonts = new Set(); // track which font files have been loaded
let defaultLineHeight = null; // theme/CSS default, captured after theme is applied
// ─── Icons ────────────────────────────────────────────────
const STANDARD_ICONS = ['dark_mode','light_mode','menu','search','arrow_right','arrow_drop_down','mobile_arrow_down','language','info','warning','error','success','exclamation','dangerous','report','history','text_compare','keyboard_arrow_right','keyboard_arrow_down','keyboard_double_arrow_right','keyboard_double_arrow_down','expand_content','collapse_content','add','minimize'];
const iconCache = {};
function normaliseIconName(name) {
return String(name).trim().replace(/\.svg$/i, '').toLowerCase().replace(/[\s-]+/g, '_') + '.svg';
}
async function loadIcon(name) {
const filename = normaliseIconName(name);
if (filename in iconCache) return iconCache[filename];
try {
const resp = await fetch('assets/icons/' + filename);
iconCache[filename] = resp.ok ? await resp.text() : null;
} catch (e) { iconCache[filename] = null; }
return iconCache[filename];
}
function getIcon(name) {
return iconCache[normaliseIconName(name)] || null;
}
function iconEl(name, className) {
const svg = getIcon(name);
const span = document.createElement('span');
span.className = 'mdcms-icon' + (className ? ' ' + className : '');
const filename = normaliseIconName(name);
if (svg) {
span.innerHTML = svg;
} else {
const img = document.createElement('img');
img.src = 'assets/icons/' + encodeURIComponent(filename);
img.alt = '[missing: ' + filename + ']';
img.style.cssText = 'width:1em;height:1em;display:inline-block;';
span.appendChild(img);
}
return span;
}
// ─── Helpers ──────────────────────────────────────────────
function el(tag, attrs, children) {
const e = document.createElement(tag);
if (attrs) Object.entries(attrs).forEach(([k, v]) => {
if (k === 'className') e.className = v;
else if (k === 'innerHTML') e.innerHTML = v;
else if (k === 'textContent') e.textContent = v;
else if (k.startsWith('on')) e.addEventListener(k.slice(2).toLowerCase(), v);
else e.setAttribute(k, v);
});
if (children) {
if (typeof children === 'string') e.innerHTML = children;
else if (Array.isArray(children)) children.forEach(c => { if (c) e.appendChild(c); });
else e.appendChild(children);
}
return e;
}
function slugify(text) {
return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').trim();
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
// Reject hrefs with dangerous schemes (javascript:, data:, vbscript:).
function safeUrl(url) {
var u = String(url == null ? '' : url).trim();
if (/^[a-z][a-z0-9+.\-]*:/i.test(u)) {
if (/^(https?|mailto|tel|ftp):/i.test(u)) return u;
return '#';
}
return u; // relative URL or fragment
}
// A routable page file is a relative .md path with no scheme or traversal.
// Heading-anchor hashes (no .md) and external URLs both fail this check.
function isSafePagePath(file) {
return typeof file === 'string'
&& /^[\w./-]+\.md$/.test(file)
&& !file.includes('..')
&& file[0] !== '/';
}
function parseFrontmatter(md) {
const match = md.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
if (!match) return { meta: {}, body: md };
try {
const meta = jsyaml.load(match[1]) || {};
return { meta, body: md.slice(match[0].length) };
} catch (e) {
return { meta: {}, body: md };
}
}
function formatDate(dateStr) {
// Uses config-driven formatting for page meta display.
if (!dateStr) return '';
var hasTime = String(dateStr).includes(':');
return hasTime ? fmtDatetime(dateStr) : fmtDate(dateStr);
}
function hexToRgb(hex) {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
const n = parseInt(hex, 16);
return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`;
}
function parseLinkStyle(val) {
if (!val) return {};
const parts = val.split(':');
const colour = parts[0];
const styles = (parts[1] || '').split(',').map(s => s.trim());
return {
colour,
underline: styles.includes('underline'),
noUnderline: styles.includes('no-underline'),
bold: styles.includes('bold'),
italics: styles.includes('italics')
};
}
// ─── Category helpers (phase 3) ───────────────────────────
function initCategories() {
categoriesUse = config['categories-use'] === true || config['categories-use'] === 'yes';
sectionnamesMode = config['categories-sectionnames'] || 'same';
if (!categoriesUse) return;
const dflt = config['default-category'];
if (dflt && dflt.code) {
defaultCategoryCode = dflt.code;
categoriesList.push(Object.assign({}, dflt, { _isDefault: true }));
categoriesByCode[dflt.code] = categoriesList[categoriesList.length - 1];
}
(config.categories || []).forEach(c => {
if (!c || !c.code) return;
const entry = Object.assign({}, c);
categoriesList.push(entry);
categoriesByCode[c.code] = entry;
});
// Resolve active category from URL
const params = new URLSearchParams(window.location.search);
const fromUrl = params.get('cat');
activeCategory = (fromUrl && categoriesByCode[fromUrl]) ? fromUrl : defaultCategoryCode;
}
function setActiveCategory(code) {
if (!categoriesByCode[code]) return;
activeCategory = code;
const url = new URL(window.location);
if (code === defaultCategoryCode) url.searchParams.delete('cat');
else url.searchParams.set('cat', code);
window.history.replaceState(null, '', url);
maybeLoadCategoryFont(code).then(() => {
renderNav();
if (currentPage) navigateTo(currentPage);
});
}
async function maybeLoadCategoryFont(code) {
const cat = categoriesByCode[code];
if (!cat || !cat.font) {
document.body.style.fontFamily = '';
return;
}
const family = 'mdcms-cat-' + code;
if (loadedFonts.has(cat.font)) {
document.body.style.fontFamily = `"${family}", ${getComputedStyle(document.documentElement).getPropertyValue('--font-body').trim()}`;
return;
}
showFontLoadingBanner();
const css = `@font-face { font-family: "${family}"; src: url("assets/fonts/${cat.font}"); }`;
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
try {
const face = new FontFace(family, `url(assets/fonts/${cat.font})`);
await face.load();
document.fonts.add(face);
loadedFonts.add(cat.font);
document.body.style.fontFamily = `"${family}", ${getComputedStyle(document.documentElement).getPropertyValue('--font-body').trim()}`;
} catch (e) {
console.warn('Font load failed:', e);
}
hideFontLoadingBanner();
}
function showFontLoadingBanner() {
let banner = document.getElementById('fontLoadingBanner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'fontLoadingBanner';
banner.className = 'font-loading-banner';
banner.textContent = 'Updating font, please wait…';
const content = document.getElementById('pageContent');
if (content && content.parentNode) content.parentNode.insertBefore(banner, content);
}
}
function hideFontLoadingBanner() {
const b = document.getElementById('fontLoadingBanner');
if (b) b.remove();
}
function _isMdResponse(r) {
// Reject HTML responses — servers with SPA routing (e.g. Cloudflare Pages with
// "/* /index.html 200") return index.html with 200 for missing files, which would
// be mistaken for a found markdown file.
const ct = r.headers.get('content-type') || '';
return !ct.startsWith('text/html');
}
async function fetchPageFile(conceptualFile) {
// conceptualFile like "pages/foo.md". Returns { ok, text, resolvedFile } or { ok: false }.
if (!categoriesUse) {
const r = await fetch(conceptualFile);
if (r.ok && _isMdResponse(r)) return { ok: true, text: await r.text(), resolvedFile: conceptualFile };
return { ok: false };
}
const base = conceptualFile.replace(/\.md$/, '');
const isHome = conceptualFile === defaultPage();
const cat = categoriesByCode[activeCategory];
const tries = [];
if (!activeCategory || activeCategory === defaultCategoryCode) {
// On the default category: serve base; also try an explicitly-coded default variant.
tries.push(conceptualFile);
if (defaultCategoryCode) tries.push(`${base}.${defaultCategoryCode}.md`);
} else {
// Non-default category: always try its variant first.
tries.push(`${base}.${activeCategory}.md`);
// Fall back to default language ONLY when allowed — i.e. the active
// category has `notfoundmessage` set, or this is the home page
// (home is always served even when a category variant is missing).
const allowFallback = isHome || !!(cat && cat.notfoundmessage);
if (allowFallback) {
tries.push(conceptualFile);
if (defaultCategoryCode) tries.push(`${base}.${defaultCategoryCode}.md`);
}
}
const seen = new Set();
for (const url of tries) {
if (seen.has(url)) continue;
seen.add(url);
const r = await fetch(url);
if (r.ok && _isMdResponse(r)) return { ok: true, text: await r.text(), resolvedFile: url };
}
return { ok: false };
}
function pageNotFoundMessage() {
const cat = activeCategory && categoriesByCode[activeCategory];
if (cat && cat.pagenotfoundmessage) return cat.pagenotfoundmessage;
if (config.pagenotfoundmessage) return config.pagenotfoundmessage;
return 'Please select a page above to continue.';
}
function sectionDisplayName(section) {
if (!categoriesUse || sectionnamesMode !== 'per-category' || !activeCategory) {
return section.defaultname;
}
const cn = section.categorynames || {};
return cn[activeCategory] || section.defaultname;
}
function pageDisplayTitle(page) {
if (categoriesUse && page.titles && activeCategory && page.titles[activeCategory]) {
return page.titles[activeCategory];
}
return page.title;
}
function pageShouldDisplay(page) {
// Nav filter. Returns true if the page should appear in nav for the active category.
// - Non-category sites: always show
// - Home page: always show (per config.homepage or default 'pages/home.md')
// - Variant exists for active category: show
// - Active category has notfoundmessage: show (renderer falls back to default language)
// - Active category has visibilityifnocontent: visible: show (renderer shows pagenotfoundmessage)
// - Otherwise: hide
if (!categoriesUse) return true;
if (page.file === defaultPage()) return true;
if (page.uncategorized) return true;
const variants = page.variants || [];
if (variants.includes(activeCategory)) return true;
const cat = categoriesByCode[activeCategory];
return !!(cat && (cat.notfoundmessage || cat.visibilityifnocontent === 'visible'));
}
// ─── Theme ────────────────────────────────────────────────
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('md-cms-theme', theme);
const lightSheet = document.getElementById('hljs-light');
const darkSheet = document.getElementById('hljs-dark');
if (lightSheet) lightSheet.disabled = (theme === 'dark');
if (darkSheet) darkSheet.disabled = (theme === 'light');
const btn = document.querySelector('.theme-toggle');
if (btn) {
const isDark = theme === 'dark';
btn.innerHTML = '';
btn.appendChild(iconEl(isDark ? 'light_mode' : 'dark_mode'));
btn.appendChild(el('span', { textContent: isDark ? 'Light mode' : 'Dark mode' }));
}
computeDerivedTokens();
}
function getInitialTheme() {
const saved = localStorage.getItem('md-cms-theme');
if (saved === 'light' || saved === 'dark') return saved;
const def = config['default-theme'] || 'system';
if (def === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return def;
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
}
function applyThemeYml(tc) {
if (!tc) return;
const root = document.documentElement;
const getOrCreateStyle = id => {
let s = document.getElementById(id);
if (!s) { s = document.createElement('style'); s.id = id; document.head.appendChild(s); }
return s;
};
let modeCss = '';
['light', 'dark'].forEach(mode => {
const m = tc[mode];
if (!m) return;
const vars = [];
if (m.accent) {
const rgb = hexToRgb(m.accent);
vars.push(`--accent: ${m.accent}`);
vars.push(`--accent-rgb: ${rgb}`);
vars.push(`--nav-active-bg: rgba(${rgb}, 0.10)`);
vars.push(`--nav-hover-bg: rgba(${rgb}, 0.05)`);
vars.push(`--table-header-bg: rgba(${rgb}, 0.08)`);
vars.push(`--link-colour: ${m.accent}`);
}
if (m.background) { vars.push(`--bg-main: ${m.background}`); vars.push(`--search-bg: ${m.background}`); }
if (m['nav-background']) vars.push(`--bg-nav: ${m['nav-background']}`);
if (m.text) { vars.push(`--font-colour: ${m.text}`); vars.push(`--code-font: ${m.text}`); }
if (m['text-muted']) vars.push(`--font-colour-muted: ${m['text-muted']}`);
if (m['nav-link']) vars.push(`--nav-link-colour: ${m['nav-link']}`);
if (m['nav-link-active']) vars.push(`--nav-link-active-colour: ${m['nav-link-active']}`);
if (m['nav-section-heading']) vars.push(`--nav-section-heading-colour: ${m['nav-section-heading']}`);
if (m['nav-sitename']) vars.push(`--nav-sitename-colour: ${m['nav-sitename']}`);
if (m['nav-description']) vars.push(`--nav-description-colour: ${m['nav-description']}`);
if (m['nav-toggle']) vars.push(`--nav-toggle-colour: ${m['nav-toggle']}`);
if (m['divider']) vars.push(`--divider: ${m['divider']}`);
if (vars.length) modeCss += `:root[data-theme="${mode}"] { ${vars.join('; ')}; }\n`;
});
if (modeCss) getOrCreateStyle('theme-overrides').textContent = modeCss;
if (tc['colours-semantic'] || tc['colours-semantic-dark']) {
let semCss = '';
if (tc['colours-semantic']) {
const sem = tc['colours-semantic'];
const semVars = [];
if (sem.info) semVars.push(`--colour-info: ${sem.info}`);
if (sem.warning) semVars.push(`--colour-warning: ${sem.warning}`);
if (sem.success) semVars.push(`--colour-success: ${sem.success}`);
if (sem.error) semVars.push(`--colour-error: ${sem.error}`);
if (semVars.length) semCss += `:root { ${semVars.join('; ')}; }\n`;
}
if (tc['colours-semantic-dark']) {
const semD = tc['colours-semantic-dark'];
const semDVars = [];
if (semD.info) semDVars.push(`--colour-info: ${semD.info}`);
if (semD.warning) semDVars.push(`--colour-warning: ${semD.warning}`);
if (semD.success) semDVars.push(`--colour-success: ${semD.success}`);
if (semD.error) semDVars.push(`--colour-error: ${semD.error}`);
if (semDVars.length) semCss += `:root[data-theme="dark"] { ${semDVars.join('; ')}; }\n`;
}
if (semCss) getOrCreateStyle('theme-semantic').textContent = semCss;
}
if (tc['main-width']) root.style.setProperty('--main-width', tc['main-width']);
if (tc['nav-width']) root.style.setProperty('--nav-width', tc['nav-width']);
if (tc['line-height']) root.style.setProperty('--line-height-body', String(tc['line-height']));
if (tc['font-size']) document.documentElement.style.fontSize = `${tc['font-size'] * 16}px`;
}
function applyConfigTheme() {
const root = document.documentElement;
['light', 'dark'].forEach(mode => {
const themeConf = config[mode];
if (!themeConf) return;
const prefix = `[data-theme="${mode}"]`;
const style = document.getElementById('theme-overrides') || (() => {
const s = document.createElement('style');
s.id = 'theme-overrides';
document.head.appendChild(s);
return s;
})();
let css = '';
const modeCSS = [];
if (themeConf['main-accentcolour']) {
const rgb = hexToRgb(themeConf['main-accentcolour']);
modeCSS.push(`--accent: ${themeConf['main-accentcolour']}`);
modeCSS.push(`--accent-rgb: ${rgb}`);
}
const map = {
'main-bgcolour': '--bg-main',
'navigation-bgcolour': '--bg-nav',
'navigation-fontcolour': '--nav-font-colour',
'font-colour': '--font-colour',
'font-colour-muted': '--font-colour-muted',
'code-bgcolour': '--code-bg',
'code-fontcolour': '--code-font',
'divider-colour': '--divider',
'table-header-bgcolour': '--table-header-bg',
'table-border-colour': '--table-border'
};
Object.entries(map).forEach(([key, prop]) => {
if (themeConf[key]) modeCSS.push(`${prop}: ${themeConf[key]}`);
});
if (themeConf['navigation-active-bgcolour'])
modeCSS.push(`--nav-active-bg: ${themeConf['navigation-active-bgcolour']}`);
if (themeConf['navigation-hover-bgcolour'])
modeCSS.push(`--nav-hover-bg: ${themeConf['navigation-hover-bgcolour']}`);
const linkConf = parseLinkStyle(themeConf['link-style']);
if (linkConf.colour) modeCSS.push(`--link-colour: ${linkConf.colour}`);
if (linkConf.underline) modeCSS.push('--link-decoration: underline');
else if (linkConf.noUnderline) modeCSS.push('--link-decoration: none');
const linkHover = parseLinkStyle(themeConf['link-style-hover']);
if (linkHover.colour) modeCSS.push(`--link-hover-colour: ${linkHover.colour}`);
if (linkHover.bold) modeCSS.push('--link-hover-weight: 700');
if (linkHover.underline) modeCSS.push('--link-hover-decoration: underline');
else if (linkHover.noUnderline) modeCSS.push('--link-hover-decoration: none');
const linkVisited = parseLinkStyle(themeConf['link-style-visited']);
if (linkVisited.colour) modeCSS.push(`--link-visited-colour: ${linkVisited.colour}`);
if (linkVisited.underline) modeCSS.push('--link-visited-decoration: underline');
else if (linkVisited.noUnderline) modeCSS.push('--link-visited-decoration: none');
if (modeCSS.length) css += `:root${prefix} { ${modeCSS.join('; ')}; }\n`;
style.textContent += css;
});
if (config['main-width']) root.style.setProperty('--main-width', config['main-width']);
if (config['nav-width']) root.style.setProperty('--nav-width', config['nav-width']);
}
// ─── Fonts ────────────────────────────────────────────────
function loadFonts(tc) {
if (document.querySelector('link[data-mdcms-fonts]')) return;
function parseFont(spec) {
if (!spec) return null;
const parts = spec.split(':');
if (parts.length >= 3) return { provider: parts[0].trim(), name: parts.slice(1, -1).join(':').trim(), weight: parts[parts.length - 1].trim() };
if (parts.length === 2) return { provider: 'bunny', name: parts[0].trim(), weight: parts[1].trim() };
return { provider: 'bunny', name: parts[0].trim(), weight: '400' };
}
const src = tc || {};
const bodyFont = parseFont(src['font-body'] || config['font-body']);
const headingFont = parseFont(src['font-heading'] || src['font-title'] || config['font-title']);
const codeFont = parseFont(src['font-code'] || config['font-code']);
const allFonts = [bodyFont, headingFont, codeFont].filter(Boolean);
const bunnyFonts = allFonts.filter(f => f.provider === 'bunny');
const googleFonts = allFonts.filter(f => f.provider === 'google');
if (bunnyFonts.length) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.bunny.net/css?family=${bunnyFonts.map(f => `${f.name.replace(/ /g, '+')}:${f.weight}`).join('&family=')}`;
document.head.appendChild(link);
}
if (googleFonts.length) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?${googleFonts.map(f => `family=${f.name.replace(/ /g, '+')}:wght@${f.weight}`).join('&')}&display=swap`;
document.head.appendChild(link);
}
const root = document.documentElement;
if (headingFont) {
root.style.setProperty('--font-title', `"${headingFont.name}", system-ui, sans-serif`);
root.style.setProperty('--font-title-weight', headingFont.weight);
}
if (bodyFont) {
root.style.setProperty('--font-body', `"${bodyFont.name}", system-ui, sans-serif`);
root.style.setProperty('--font-body-weight', bodyFont.weight);
}
if (codeFont) {
root.style.setProperty('--font-code', `"${codeFont.name}", monospace`);
}
}
// ─── Markdown ─────────────────────────────────────────────
let _markedConfigured = false;
function configureMarked() {
if (_markedConfigured) return;
marked.setOptions({ gfm: true, breaks: false, headerIds: true, mangle: false });
const renderer = new marked.Renderer();
renderer.heading = function(text, level) {
let headingText = typeof text === 'object' ? text.text : text;
let rawLevel = typeof text === 'object' ? text.depth : level;
const id = slugify(headingText.replace(/<[^>]*>/g, ''));
return `<h${rawLevel} id="${id}">${headingText}</h${rawLevel}>`;
};
renderer.link = function(href, title, text) {
let linkHref, linkTitle, linkText;
if (typeof href === 'object') {
linkHref = href.href; linkTitle = href.title; linkText = href.text;
} else {
linkHref = href; linkTitle = title; linkText = text;
}
const isExternal = linkHref && (linkHref.startsWith('http://') || linkHref.startsWith('https://'));
const isMd = linkHref && linkHref.endsWith('.md');
const titleAttr = linkTitle ? ` title="${escapeHtml(linkTitle)}"` : '';
if (isExternal) {
return `<a href="${escapeHtml(safeUrl(linkHref))}" target="_blank" rel="noopener noreferrer"${titleAttr}>${linkText}</a>`;
}
if (isMd) {
return `<a href="#${escapeHtml(linkHref)}" data-internal="true"${titleAttr}>${linkText}</a>`;
}
return `<a href="${escapeHtml(safeUrl(linkHref))}"${titleAttr}>${linkText}</a>`;
};
renderer.code = function(code, lang, escaped) {
let codeText, codeLang;
if (typeof code === 'object') {
codeText = code.text; codeLang = code.lang;
} else {
codeText = code; codeLang = lang;
}
// Match both ```mdcms (type in content) and ```mdcms callout-info (type in fence)
if (codeLang && (codeLang === 'mdcms' || codeLang.startsWith('mdcms '))) {
const fenceType = codeLang === 'mdcms' ? '' : codeLang.slice('mdcms '.length).trim();
const fullText = fenceType ? (fenceType + '\n' + (codeText || '')) : (codeText || '');
const tag = parseMdcmsTag(fullText);
// For tab/accordion blocks, preserve the raw fence body to avoid trim() breaking YAML indentation.
if (/^tab(-underline|-filled)?$|^accordion(-underline|-filled)?$/.test(tag.tagName)) {
tag.rawBody = codeText || '';
}
const encoded = JSON.stringify(tag).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
return '<div class="mdcms-tag" data-config="' + encoded + '"></div>';
}
const esc = (codeText || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const cls = codeLang ? ' class="language-' + codeLang + '"' : '';
return '<pre><code' + cls + '>' + esc + '</code></pre>';
};
marked.use({ renderer });
_markedConfigured = true;
}
function renderMarkdown(mdBody) {
configureMarked();
return marked.parse(mdBody);
}
// ─── Tag system ─────────────────────────────────────────
// Date/time formatting engine. Reads config keys:
// date: system | pattern (default: "D Mmmm YYYY")
// time: system | 12hrs | 24hrs (default: "24hrs")
// monthnames: "January, February, ..."
// monthnamesabbreviated: "Jan, Feb, ..."
const MONTHS_EN = ['January','February','March','April','May','June',
'July','August','September','October','November','December'];
const MONTHS_EN_ABBR = ['Jan','Feb','Mar','Apr','May','Jun',
'Jul','Aug','Sep','Oct','Nov','Dec'];
function getMonthNames() {
if (config.monthnames) {
return config.monthnames.split(',').map(function(s) { return s.trim(); });
}
// Try Intl if a language hint is available
try {
var lang = config.language || document.documentElement.lang || 'en';
if (lang !== 'en') {
var names = [];
for (var i = 0; i < 12; i++) {
names.push(new Intl.DateTimeFormat(lang, { month: 'long' }).format(new Date(2024, i, 1)));
}
return names;
}
} catch (e) { /* fall through */ }
return MONTHS_EN;
}
function getMonthNamesAbbr() {
if (config.monthnamesabbreviated) {
return config.monthnamesabbreviated.split(',').map(function(s) { return s.trim(); });
}
try {
var lang = config.language || document.documentElement.lang || 'en';
if (lang !== 'en') {
var names = [];
for (var i = 0; i < 12; i++) {
names.push(new Intl.DateTimeFormat(lang, { month: 'short' }).format(new Date(2024, i, 1)));
}
return names;
}
} catch (e) { /* fall through */ }
return MONTHS_EN_ABBR;
}
function getDatePattern() {
var p = config.date || 'system';
if (p === 'system') return 'D Mmmm YYYY';
return p;
}
function getTimeMode() {
var t = config.time || 'system';
if (t === 'system') return '24hrs';
return t; // '12hrs' | '24hrs'
}
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
function applyDatePattern(pattern, year, month, day) {
// month is 1-based
var full = getMonthNames();
var abbr = getMonthNamesAbbr();
return pattern
.replace('YYYY', '' + year)
.replace('YY', ('' + year).slice(-2))
.replace('Mmmm', full[month - 1] || '')
.replace('Mmm', abbr[month - 1] || '')
.replace('MM', pad2(month))
.replace('DD', pad2(day))
.replace(/\bD\b/, '' + day);
}
function formatTime(timePart) {
// timePart like "14:30"
var mode = getTimeMode();
if (mode === '24hrs') return timePart;
var bits = timePart.split(':');
var h = parseInt(bits[0], 10);
var m = bits[1] || '00';
var suffix = h >= 12 ? ' PM' : ' AM';
if (h === 0) h = 12;
else if (h > 12) h -= 12;
return h + ':' + m + suffix;
}
function fmtDate(dateStr) {
var s = dateStr instanceof Date ? dateStr.toISOString().slice(0, 10) : String(dateStr);
var parts = s.split('-');
var y = parseInt(parts[0], 10), m = parseInt(parts[1], 10), d = parseInt(parts[2], 10);
return applyDatePattern(getDatePattern(), y, m, d);
}
function fmtDatetime(dtStr) {
var s = dtStr instanceof Date ? dtStr.toISOString().slice(0, 16).replace('T', ' ') : String(dtStr);
var sp = s.split(' ');
var datePart = sp[0], timePart = sp[1] || '00:00';
return fmtDate(datePart) + ' at ' + formatTime(timePart);
}
function parseMdcmsTag(text) {
var lines = text.trim().split('\n');
var tagName = lines[0].trim();
var options = {};
var bodyStart = lines.length;
for (var i = 1; i < lines.length; i++) {
var m = lines[i].match(/^\s*([a-z\-]+)\s*:\s*(.*)$/i);
if (m) {
options[m[1].toLowerCase()] = m[2].trim();
} else {
bodyStart = i;
break;
}
}
var body = lines.slice(bodyStart).join('\n').trim();
return { tagName: tagName, options: options, body: body };
}
function parsePostTagName(name) {
var m = name.match(
/^posts-created-(chronological|reversechronological)(?:-(byyear|byyearmonth|lastyear|lastmonth))?$/
);
if (!m) return null;
return { order: m[1], modifier: m[2] || null };
}
function getPostEntries(parsed, options) {
const { order, modifier } = parsed;
// Start with posts from search index
let posts = (searchIndex || []).filter(function(e) {
return e.file && e.file.startsWith('posts/');
});
// Category filter
if (categoriesUse && activeCategory) {
posts = posts.filter(function(e) { return !e.category || e.category === activeCategory; });
}
// Field filter
posts = posts.filter(function(e) { return !!e.created; });
// Time-window filter
if (modifier === 'lastyear' || modifier === 'lastmonth') {
var now = new Date();
var cutoff = new Date(now);
if (modifier === 'lastyear') cutoff.setDate(cutoff.getDate() - 365);
else cutoff.setDate(cutoff.getDate() - 30);
posts = posts.filter(function(e) {
return new Date(e.created.replace(' ', 'T')) >= cutoff;
});
}
// Sort
posts.sort(function(a, b) {
var da = a.created || '', db = b.created || '';
return order === 'chronological' ? da.localeCompare(db) : db.localeCompare(da);
});
return posts;
}
function makePostList(entries) {
var ul = document.createElement('ul');
ul.className = 'post-list';
entries.forEach(function(e) {
var li = document.createElement('li');
var a = document.createElement('a');
a.href = '#' + e.file;
var dateSpan = document.createElement('span');
dateSpan.className = 'post-date';
dateSpan.textContent = e.display;
var sepSpan = document.createElement('span');
sepSpan.className = 'post-sep';
sepSpan.textContent = '—';
var titleSpan = document.createElement('span');
titleSpan.className = 'post-title';
titleSpan.textContent = e.title;
a.appendChild(dateSpan);
a.appendChild(sepSpan);
a.appendChild(titleSpan);
a.addEventListener('click', function(ev) {
ev.preventDefault();
navigateTo(e.file);
});
li.appendChild(a);
ul.appendChild(li);
});
return ul;
}
function renderPaginatedList(container, entries, mode, batchSize) {
if (mode === 'none' || entries.length <= batchSize) {
container.appendChild(makePostList(entries));
return;
}
if (mode === 'yes') {
// Full pagination
var currentPg = 1;
var totalPages = Math.ceil(entries.length / batchSize);
var listWrap = document.createElement('div');
var pagBar = document.createElement('div');
pagBar.className = 'post-pagination';
container.appendChild(listWrap);
container.appendChild(pagBar);
function showPage() {
listWrap.innerHTML = '';
var start = (currentPg - 1) * batchSize;
listWrap.appendChild(makePostList(entries.slice(start, start + batchSize)));
pagBar.innerHTML = '';
var info = el('span', { className: 'page-info', textContent: 'Page ' + currentPg + '/' + totalPages });
pagBar.appendChild(info);
var prev = el('button', { className: 'page-btn', textContent: '\u2039 Previous' });
prev.disabled = currentPg <= 1;
prev.addEventListener('click', function() { currentPg--; showPage(); });
pagBar.appendChild(prev);
var next = el('button', { className: 'page-btn', textContent: 'Next \u203A' });
next.disabled = currentPg >= totalPages;
next.addEventListener('click', function() { currentPg++; showPage(); });
pagBar.appendChild(next);
var jumpLabel = el('span', { className: 'page-jump-label', textContent: 'Jump to page' });
pagBar.appendChild(jumpLabel);
var jumpInput = document.createElement('input');
jumpInput.type = 'number'; jumpInput.className = 'page-jump';
jumpInput.min = 1; jumpInput.max = totalPages; jumpInput.value = currentPg;
jumpInput.addEventListener('change', function() {
var v = parseInt(jumpInput.value, 10);
if (v >= 1 && v <= totalPages) { currentPg = v; showPage(); }
});
pagBar.appendChild(jumpInput);
}
showPage();
} else {
// Load more (mode === 'no' or default)
var shown = batchSize;
var listWrap2 = document.createElement('div');
container.appendChild(listWrap2);
function showBatch() {
listWrap2.innerHTML = '';
listWrap2.appendChild(makePostList(entries.slice(0, shown)));
var oldBtn = container.querySelector('.post-load-more');
if (oldBtn) oldBtn.remove();
if (shown < entries.length) {
var btn = el('button', { className: 'post-load-more', textContent: 'Load more' });
btn.addEventListener('click', function() { shown += batchSize; showBatch(); });
container.appendChild(btn);
}
}
showBatch();
}
}
function renderPostTag(container, tag) {
var parsed = parsePostTagName(tag.tagName);
if (!parsed) {
container.textContent = 'Unknown tag: ' + tag.tagName;
return;
}
var opts = tag.options;
var posts = getPostEntries(parsed, opts);
var modifier = parsed.modifier;
var paginate = opts.paginate || 'no';
var limitVal = opts.limit || 'all';
var batchSize = (limitVal === 'all') ? 20 : parseInt(limitVal, 10) || 20;
// Format each entry
var formatted = posts.map(function(p) {
return {
display: fmtDatetime(p.created),
title: p.title,
file: p.file
};
});
if (formatted.length === 0) {
container.innerHTML = '<p class="posts-empty">No posts found.</p>';
return;
}
container.className = 'mdcms-posts';
container.innerHTML = '';
// Set date column width to the longest formatted date string
var maxDateLen = 0;
formatted.forEach(function(e) { if (e.display.length > maxDateLen) maxDateLen = e.display.length; });
container.style.setProperty('--post-date-width', (maxDateLen + 0.5) + 'ch');
var groupBy = (modifier === 'byyear' || modifier === 'byyearmonth') ? modifier : null;
if (!groupBy) {
// Flat list — optionally cap total if paginate:none
var list = formatted;
if (paginate === 'none' && limitVal !== 'all') {
list = formatted.slice(0, batchSize);
}
renderPaginatedList(container, list, paginate, batchSize);
return;
}
// Grouped by year (or year+month)
function getYear(p) {
return p.created ? p.created.substring(0, 4) : 'Unknown';
}
function getYearMonth(p) {
return p.created ? p.created.substring(0, 7) : 'Unknown';
}
function monthLabel(ym) {
var m = parseInt(ym.substring(5, 7), 10);
return getMonthNames()[m - 1] || ym;
}
// Build year groups from unformatted posts (need raw date access)
var yearMap = new Map();
posts.forEach(function(p, i) {
var y = getYear(p);
if (!yearMap.has(y)) yearMap.set(y, []);
yearMap.get(y).push(formatted[i]);
});
var years = Array.from(yearMap.keys());
// Determine active year
var selectYr = (opts.selectyear || 'yes') === 'yes';
var defYear = opts.defaultyear || 'current';
var curYear;
if (defYear === 'current') {
curYear = String(new Date().getFullYear());
if (!years.includes(curYear)) curYear = years[0];
} else {
curYear = years.includes(defYear) ? defYear : years[0];
}
// Year selector
if (selectYr && years.length > 1) {
var sel = document.createElement('select');
sel.className = 'post-year-select';
years.forEach(function(y) {
var opt = document.createElement('option');
opt.value = y; opt.textContent = y;
if (y === curYear) opt.selected = true;
sel.appendChild(opt);
});
container.appendChild(sel);
sel.addEventListener('change', function() { curYear = sel.value; renderYear(); });
}
var contentArea = document.createElement('div');
contentArea.className = 'post-content-area';
container.appendChild(contentArea);
// Build month sub-groups if needed
var yearMonthMap = null;
if (groupBy === 'byyearmonth') {
yearMonthMap = new Map();
posts.forEach(function(p, i) {
var y = getYear(p);
if (!yearMonthMap.has(y)) yearMonthMap.set(y, new Map());
var ym = getYearMonth(p);
var mMap = yearMonthMap.get(y);
if (!mMap.has(ym)) mMap.set(ym, []);
mMap.get(ym).push(formatted[i]);
});
}
function renderYear() {
contentArea.innerHTML = '';
var items = yearMap.get(curYear) || [];
if (groupBy === 'byyearmonth' && yearMonthMap) {
var mMap = yearMonthMap.get(curYear) || new Map();
mMap.forEach(function(monthItems, ym) {
var heading = document.createElement('h4');
heading.className = 'post-month-heading';
heading.textContent = monthLabel(ym);
contentArea.appendChild(heading);
// Within each month, apply pagination individually would be too complex;
// show all month items, pagination applies to full year below if needed.
contentArea.appendChild(makePostList(monthItems));
});
} else {
renderPaginatedList(contentArea, items, paginate, batchSize);
}
}
renderYear();
}
// Callout type defaults (fallback when theme.yml has no callouts block)
const CALLOUT_DEFAULTS = {
info: { icon: 'info', colour: '#2563EB' },
warning: { icon: 'warning', colour: '#D97706' },
success: { icon: 'success', colour: '#16A34A' },
error: { icon: 'error', colour: '#DC2626' },
};
function renderCalloutTag(container, tag) {
var typeMatch = tag.tagName.match(/^callout-(info|warning|success|error)$/);
var calloutType = typeMatch ? typeMatch[1] : 'info';
var opts = tag.options;
var msgKey = opts.message || null;
var title = opts.title || null;
var iconName = opts.icon || null;
var bodyMd = tag.body || '';
// Resolve message: key — config.yml callouts block
if (msgKey) {
var msgDefs = config.callouts || {};
var msgDef = msgDefs[msgKey];
if (msgDef) {
// Override callout type from message definition
if (msgDef.type) calloutType = msgDef.type;
// Language resolution: activeCategory → defaultCategoryCode → first key
var lang = activeCategory || defaultCategoryCode;
var langEntry = (lang && msgDef[lang]) || msgDef[defaultCategoryCode];
if (!langEntry) {
var keys = Object.keys(msgDef).filter(function(k) { return k !== 'type'; });
langEntry = msgDef[keys[0]];
}
if (langEntry) {
title = langEntry.title || null;
bodyMd = langEntry.text || '';
}
if (opts.title || tag.body) {
console.warn('[mdcms] callout: message: key takes precedence; inline title/body ignored.');
}
}
}
// Get callout colours/icon from theme.yml callouts block, then fallback
var themeCallouts = (themeConfig.callouts || {})[calloutType] || {};
var fallback = CALLOUT_DEFAULTS[calloutType] || CALLOUT_DEFAULTS.info;
var primaryColour = themeCallouts['primary-colour'] || fallback.colour;
var bgColour = themeCallouts['background-colour'] || fallback.colour;
if (!iconName) iconName = themeCallouts.icon || fallback.icon;
// Build element
container.className = 'mdcms-callout mdcms-callout-' + calloutType;
container.style.setProperty('--callout-primary', primaryColour);
container.style.setProperty('--callout-bg', hexToRgba(bgColour, 0.08));
if (title) {
var titleRow = document.createElement('div');
titleRow.className = 'mdcms-callout-title';
titleRow.style.color = primaryColour;
titleRow.appendChild(iconEl(iconName));
var titleText = document.createElement('span');
titleText.textContent = title;
titleRow.appendChild(titleText);
container.appendChild(titleRow);
}
if (bodyMd) {
var bodyEl = document.createElement('div');
bodyEl.className = 'mdcms-callout-body';
bodyEl.innerHTML = marked.parse(bodyMd);
container.appendChild(bodyEl);
}
}
function hexToRgba(hex, alpha) {
var h = hex.replace('#', '');
if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
var r = parseInt(h.substring(0,2), 16);
var g = parseInt(h.substring(2,4), 16);
var b = parseInt(h.substring(4,6), 16);
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
}
function parseColorToHex(val) {
if (!val) return null;
val = val.trim();
if (val.startsWith('#')) {
if (val.length === 4) return '#' + val[1]+val[1]+val[2]+val[2]+val[3]+val[3];
return val.toLowerCase();
}
var m = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (m) return '#' + [m[1],m[2],m[3]].map(function(n) { return parseInt(n).toString(16).padStart(2,'0'); }).join('');
return null;
}
function relativeLuminance(hex) {
hex = hex.replace('#','');
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
var r = parseInt(hex.substr(0,2),16)/255;
var g = parseInt(hex.substr(2,2),16)/255;
var b = parseInt(hex.substr(4,2),16)/255;
function lin(c) { return c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); }
return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b);
}
function hexToHsl(hex) {
hex = hex.replace('#','');
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
var r = parseInt(hex.substr(0,2),16)/255;
var g = parseInt(hex.substr(2,2),16)/255;
var b = parseInt(hex.substr(4,2),16)/255;
var max = Math.max(r,g,b), min = Math.min(r,g,b);
var h = 0, s = 0, l = (max+min)/2;
if (max !== min) {
var d = max - min;
s = l > 0.5 ? d/(2-max-min) : d/(max+min);
switch(max) {
case r: h = ((g-b)/d + (g<b?6:0))/6; break;
case g: h = ((b-r)/d + 2)/6; break;
case b: h = ((r-g)/d + 4)/6; break;
}
}
return [h, s, l];
}
// HSL chroma: S × (1-|2L-1|) — gives perceptually meaningful colorfulness
// unlike raw HSL S which is artificially high near white/black.
function hslChroma(hex) {
var hsl = hexToHsl(hex);
return hsl[1] * (1 - Math.abs(2 * hsl[2] - 1));
}
function computeDerivedTokens() {
var cs = getComputedStyle(document.documentElement);
var bgHex = parseColorToHex(cs.getPropertyValue('--bg-main').trim());
var navHex = parseColorToHex(cs.getPropertyValue('--bg-nav').trim());
var textHex = parseColorToHex(cs.getPropertyValue('--font-colour').trim());
var mutedHex = parseColorToHex(cs.getPropertyValue('--font-colour-muted').trim());
var accentHex = parseColorToHex(cs.getPropertyValue('--accent').trim());
if (!bgHex || !navHex || !textHex || !mutedHex || !accentHex) return;
var bgL = relativeLuminance(bgHex);
var navL = relativeLuminance(navHex);
var navC = hslChroma(navHex);
var bgC = hslChroma(bgHex);
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
var navIsAccent = Math.abs(bgL - navL) > 0.22 || (navC > 0.35 && Math.abs(navC - bgC) > 0.25);
var navIsInverted = Math.abs(bgL - navL) > 0.35;
var navText = navIsInverted ? (navL < 0.5 ? '#F2EFE8' : '#161412') : textHex;
var navTextMuted = navIsInverted ? hexToRgba(navText, 0.6) : mutedHex;
var filledBg = navIsAccent ? navHex : hexToRgba(accentHex, 0.10);
var filledBorder = navIsAccent ? hexToRgba(navText, 0.18) : hexToRgba(accentHex, 0.30);
var filledFg = navIsAccent ? navText : textHex;
var filledFgMuted = navIsAccent ? navTextMuted : mutedHex;
var barColor = navIsAccent ? navHex : accentHex;
var stripAlpha = isDark ? 0.14 : 0.10;
var root = document.documentElement;
root.style.setProperty('--mdcms-bg', bgHex);
root.style.setProperty('--mdcms-accent', accentHex);
root.style.setProperty('--mdcms-filled-bg', filledBg);
root.style.setProperty('--mdcms-filled-border', filledBorder);
root.style.setProperty('--mdcms-filled-fg', filledFg);
root.style.setProperty('--mdcms-filled-fg-muted',filledFgMuted);
root.style.setProperty('--mdcms-bar', barColor);
root.style.setProperty('--mdcms-strip-border', hexToRgba(textHex, stripAlpha));
}
function renderTocTag(container) {
const byCode = {};
navSections.forEach(s => { byCode[s.code] = s; });
const sortedSections = navSections
.filter(s => !isDraftSection(s.code, byCode))
.sort((a, b) => ((a.sort ?? 999) - (b.sort ?? 999)) || (a.code || '').localeCompare(b.code || ''));
const visiblePages = navData.filter(p => {
if (p.file === currentPage) return false;
if (!pageShouldDisplay(p)) return false;
const sid = p['section-id'];
if (sid && isDraftSection(sid, byCode)) return false;
return true;
});
const bySection = {};
const unsectioned = [];
visiblePages.forEach(p => {
const sid = p['section-id'] || null;
if (sid) { (bySection[sid] = bySection[sid] || []).push(p); }
else unsectioned.push(p);
});
function sortPages(pages) {
return [...pages].sort((a, b) =>
((a.sort ?? 999) - (b.sort ?? 999)) || a.file.localeCompare(b.file));
}
function makeList(pages) {
const ul = document.createElement('ul');
ul.className = 'mdcms-toc-list';
pages.forEach(p => {
const a = el('a', { href: '#' + p.file, textContent: pageDisplayTitle(p) });
a.addEventListener('click', e => { e.preventDefault(); navigateTo(p.file); });
ul.appendChild(el('li', {}, a));
});
return ul;
}
const div = el('div', { className: 'mdcms-toc' });
if (unsectioned.length) div.appendChild(makeList(sortPages(unsectioned)));
sortedSections.forEach(section => {
const pages = bySection[section.code];
if (!pages || !pages.length) return;
div.appendChild(el('h3', { className: 'mdcms-toc-section', textContent: sectionDisplayName(section) }));
div.appendChild(makeList(sortPages(pages)));
});
if (!div.children.length) div.textContent = 'No pages found.';
container.replaceWith(div);
}
function renderTabsTag(container, cfg) {
var variant = cfg.tagName === 'tab' ? 'tab-underline' : cfg.tagName;
var isFilled = variant === 'tab-filled';
var varClass = isFilled ? 'filled' : 'underline';
var items = [];
try {
// Use rawBody (pre-trim YAML) when available; fall back to reconstructed form.
var rawYaml = cfg.rawBody !== undefined ? cfg.rawBody : ('items:\n' + (cfg.body || ''));
var parsed = jsyaml.load(rawYaml);
items = (parsed && parsed.items) || [];
} catch (e) {
container.textContent = 'Error parsing tab items.';
return;
}
if (!items.length) { container.textContent = 'No tab items.'; return; }
var selectedIdx = items.findIndex(function(it) { return it && it.default === 'selected'; });
if (selectedIdx < 0) selectedIdx = 0;
var wrapper = el('div', { className: 'mdcms-tabs mdcms-tabs-' + varClass });
var strip = el('div', { className: 'mdcms-tabs-strip', role: 'tablist' });
var panels = [];
items.forEach(function(item, i) {
if (!item) return;
var isSelected = i === selectedIdx;
var btn = el('button', {
className: 'mdcms-tab-btn',
role: 'tab',
type: 'button',
'aria-selected': String(isSelected)
});
var titleStyle = item['title-style'] || '';
var lvlMatch = titleStyle.match(/^(#{1,6})$/);
var titleSpan;
if (lvlMatch) {
titleSpan = el('span', { role: 'heading', 'aria-level': String(lvlMatch[1].length) });
titleSpan.textContent = item.title || '';
} else {
titleSpan = el('span', { textContent: item.title || '' });
}
btn.appendChild(titleSpan);
strip.appendChild(btn);
var panel = el('div', { className: 'mdcms-tabs-panel', role: 'tabpanel' });
panel.innerHTML = renderMarkdown(String(item.content || ''));
if (!isSelected) panel.setAttribute('hidden', '');
panels.push(panel);
btn.addEventListener('click', (function(idx) {
return function() {
strip.querySelectorAll('.mdcms-tab-btn').forEach(function(b, j) {
b.setAttribute('aria-selected', String(j === idx));
if (j === idx) panels[j].removeAttribute('hidden');
else panels[j].setAttribute('hidden', '');
});
};
})(i));
});
wrapper.appendChild(strip);
panels.forEach(function(p) { wrapper.appendChild(p); });
container.replaceWith(wrapper);
}
function renderAccordionTag(container, cfg) {
var variant = cfg.tagName === 'accordion' ? 'accordion-underline' : cfg.tagName;
var isFilled = variant === 'accordion-filled';
var varClass = isFilled ? 'filled' : 'underline';
var items = [];
try {
var rawYaml = cfg.rawBody !== undefined ? cfg.rawBody : ('items:\n' + (cfg.body || ''));
var parsed = jsyaml.load(rawYaml);
items = (parsed && parsed.items) || [];
} catch (e) {
container.textContent = 'Error parsing accordion items.';
return;
}
if (!items.length) { container.textContent = 'No accordion items.'; return; }
var CHEVRON_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>';
var wrapper = el('div', { className: 'mdcms-accordion mdcms-accordion-' + varClass });
items.forEach(function(item) {
if (!item) return;
var isOpen = item.default === 'open';
var itemEl = el('div', { className: 'mdcms-accordion-item' });
itemEl.setAttribute('data-open', String(isOpen));
var btn = el('button', {
className: 'mdcms-accordion-btn',
type: 'button',
'aria-expanded': String(isOpen)
});
var titleStyle = item['title-style'] || '';
var lvlMatch = titleStyle.match(/^(#{1,6})$/);
var titleEl = el('span', { className: 'mdcms-accordion-title' });
if (lvlMatch) {
var heading = el('span', { role: 'heading', 'aria-level': String(lvlMatch[1].length) });
heading.textContent = item.title || '';
titleEl.appendChild(heading);
} else {
titleEl.textContent = item.title || '';
}
btn.appendChild(titleEl);
var chevron = el('span', { className: 'mdcms-accordion-chevron' });
chevron.innerHTML = CHEVRON_SVG;
btn.appendChild(chevron);
var body = el('div', { className: 'mdcms-accordion-body' });
body.innerHTML = renderMarkdown(String(item.content || ''));
if (!isOpen) body.setAttribute('hidden', '');
btn.addEventListener('click', function() {
var open = itemEl.getAttribute('data-open') === 'true';
var next = !open;
itemEl.setAttribute('data-open', String(next));
btn.setAttribute('aria-expanded', String(next));
if (next) body.removeAttribute('hidden');
else body.setAttribute('hidden', '');
});
itemEl.appendChild(btn);
itemEl.appendChild(body);
wrapper.appendChild(itemEl);
});
container.replaceWith(wrapper);
}
function hydrateMdcmsTags() {
// Rendering a tag (tab/accordion/callout) can emit further .mdcms-tag
// elements in its body, so keep sweeping until none are left. A processed
// marker and an iteration cap guard against runaway loops.
var MAX_PASSES = 10;
for (var pass = 0; pass < MAX_PASSES; pass++) {
var pending = Array.prototype.filter.call(
document.querySelectorAll('.mdcms-tag'),
function(t) { return !t.hasAttribute('data-mdcms-hydrated'); }
);
if (!pending.length) break;
pending.forEach(function(tagEl) {
tagEl.setAttribute('data-mdcms-hydrated', '');
try {
var cfg = JSON.parse(tagEl.getAttribute('data-config'));
if (/^callout-(info|warning|success|error)$/.test(cfg.tagName)) {
renderCalloutTag(tagEl, cfg);
} else if (cfg.tagName === 'toc') {
renderTocTag(tagEl);
} else if (/^tab(-underline|-filled)?$/.test(cfg.tagName)) {
renderTabsTag(tagEl, cfg);
} else if (/^accordion(-underline|-filled)?$/.test(cfg.tagName)) {
renderAccordionTag(tagEl, cfg);
} else {
renderPostTag(tagEl, cfg);
}
} catch (e) {
tagEl.textContent = 'Error rendering tag.';
}
});
}
}
// ─── Shell ────────────────────────────────────────────────
function buildSidebar() {
const app = document.getElementById('app');
const navPos = config['nav-position'] || 'left';
const layout = el('div', { className: `layout-sidebar nav-${navPos}` });
const mobileHeader = el('div', { className: 'mobile-header' });
const hamburger = el('button', { className: 'hamburger', 'aria-label': 'Open menu' });
hamburger.appendChild(iconEl('menu'));
const mobileName = el('span', { className: 'sidebar-sitename', textContent: config.sitename || 'MD-CMS' });
mobileHeader.appendChild(hamburger);
if (config.logo) {
mobileHeader.appendChild(el('img', { className: 'topbar-logo', src: `assets/images/${config.logo}`, alt: '' }));
}
mobileHeader.appendChild(mobileName);
const overlay = el('div', { className: 'mobile-overlay' });
overlay.addEventListener('click', () => closeMobileMenu());
const sidebar = el('div', { className: 'sidebar' });
const header = el('div', { className: 'sidebar-header' });
if (config.logo) {
header.appendChild(el('img', { className: 'sidebar-logo', src: `assets/images/${config.logo}`, alt: '' }));
}
const nameLink = el('a', { className: 'sidebar-sitename', href: '#', textContent: config.sitename || 'MD-CMS' });
nameLink.addEventListener('click', (e) => {
e.preventDefault();
navigateTo(defaultPage());
});
header.appendChild(nameLink);
if (config.sitedescription) {
header.appendChild(el('div', { className: 'sidebar-description', textContent: config.sitedescription }));
}
sidebar.appendChild(header);
if (config.search !== false) sidebar.appendChild(buildSearchWidget());
const navLinksEl = el('div', { className: 'nav-links', id: 'navLinks' });
sidebar.appendChild(navLinksEl);
const sidebarFooter = el('div', { className: 'sidebar-footer' });
const toggleBtn = el('button', { className: 'theme-toggle', 'aria-label': 'Toggle theme' });
toggleBtn.addEventListener('click', toggleTheme);
sidebarFooter.appendChild(toggleBtn);
sidebar.appendChild(sidebarFooter);
const mainArea = el('div', { className: 'main-area' });
mainArea.appendChild(mobileHeader);
const mainContent = el('div', { className: 'main-content' });
const catBar = buildCategoryBar();
if (catBar) mainContent.appendChild(catBar);
mainContent.appendChild(el('div', { id: 'pageContent' }));
mainArea.appendChild(mainContent);
if (config.footer) {
const footer = el('div', { className: 'site-footer' });
footer.innerHTML = marked.parseInline(config.footer);
mainArea.appendChild(footer);
}
layout.appendChild(sidebar);
layout.appendChild(overlay);
layout.appendChild(mainArea);
app.appendChild(layout);
hamburger.addEventListener('click', () => {
sidebar.classList.toggle('open');
overlay.classList.toggle('active');
hamburger.innerHTML = ''; hamburger.appendChild(iconEl('menu'));
});
function closeMobileMenu() {
sidebar.classList.remove('open');
overlay.classList.remove('active');
hamburger.innerHTML = ''; hamburger.appendChild(iconEl('menu'));
}
window._closeMobileMenu = closeMobileMenu;
}
function buildTopbar() {
const app = document.getElementById('app');
const layout = el('div', { className: 'layout-topbar' });
const topbar = el('div', { className: 'topbar' });
const brand = el('a', { className: 'topbar-brand', href: '#' });
brand.addEventListener('click', (e) => {
e.preventDefault();
navigateTo(defaultPage());
});
if (config.logo) {
brand.appendChild(el('img', { className: 'topbar-logo', src: `assets/images/${config.logo}`, alt: '' }));
}
brand.appendChild(el('span', { className: 'topbar-sitename', textContent: config.sitename || 'MD-CMS' }));
topbar.appendChild(brand);
const hamburger = el('button', { className: 'hamburger', 'aria-label': 'Open menu' });
hamburger.appendChild(iconEl('menu'));
const navLinksEl = el('div', { className: 'topbar-nav', id: 'navLinks' });
topbar.appendChild(navLinksEl);
const actions = el('div', { className: 'topbar-actions' });
if (config.search !== false) {
const searchW = buildSearchWidget();
searchW.className = 'topbar-search';
actions.appendChild(searchW);
}
const toggleBtn = el('button', { className: 'theme-toggle', 'aria-label': 'Toggle theme' });
toggleBtn.addEventListener('click', toggleTheme);
actions.appendChild(toggleBtn);
actions.appendChild(hamburger);
topbar.appendChild(actions);
const mobilePanel = el('div', { className: 'mobile-nav-panel', id: 'mobileNavPanel' });
if (config.search !== false) mobilePanel.appendChild(buildSearchWidget());
const mobileNavLinks = el('div', { className: 'nav-links', id: 'mobileNavLinks' });
mobilePanel.appendChild(mobileNavLinks);
layout.appendChild(topbar);
layout.appendChild(mobilePanel);
const mainArea = el('div', { className: 'main-area' });
const mainContent = el('div', { className: 'main-content' });
const catBar = buildCategoryBar();
if (catBar) mainContent.appendChild(catBar);
mainContent.appendChild(el('div', { id: 'pageContent' }));
mainArea.appendChild(mainContent);
if (config.footer) {
const footer = el('div', { className: 'site-footer' });
footer.innerHTML = marked.parseInline(config.footer);
mainArea.appendChild(footer);
}
layout.appendChild(mainArea);
app.appendChild(layout);
hamburger.addEventListener('click', () => {
const panel = document.getElementById('mobileNavPanel');
panel.classList.toggle('open');
hamburger.innerHTML = ''; hamburger.appendChild(iconEl('menu'));
});
window._closeMobileMenu = function() {
const panel = document.getElementById('mobileNavPanel');
if (panel) panel.classList.remove('open');
hamburger.innerHTML = ''; hamburger.appendChild(iconEl('menu'));
};
document.addEventListener('click', () => {
document.querySelectorAll('.topbar-nav .nav-group.open').forEach(g => g.classList.remove('open'));
});
}
function buildSearchWidget() {
const container = el('div', { className: 'search-container' });
const wrapper = el('div', { className: 'search-wrapper' });
const icon = iconEl('search', 'search-icon');
const input = el('input', { className: 'search-box', type: 'text', placeholder: 'Search...' });
const results = el('div', { className: 'search-results' });
wrapper.appendChild(icon);
wrapper.appendChild(input);
wrapper.appendChild(results);
container.appendChild(wrapper);
let debounceTimer;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(input.value, results), 200);
});
input.addEventListener('focus', () => {
if (input.value.trim() && results.children.length) results.classList.add('active');
});
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) results.classList.remove('active');
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { results.classList.remove('active'); input.blur(); }
});
return container;
}
function buildCategoryBar() {
if (!categoriesUse || !categoriesList.length) return null;
const bar = el('div', { className: 'category-bar' });
if (config['categories-selecticon']) {
bar.appendChild(iconEl(config['categories-selecticon'], 'category-icon'));
}
if (config['categories-selecttext']) {
bar.appendChild(el('span', { className: 'category-label', textContent: config['categories-selecttext'] }));
}
const dropdown = el('div', { className: 'category-dropdown', id: 'categoryDropdown' });
const trigger = el('button', { className: 'category-trigger', type: 'button' });
const triggerLabel = el('span', { id: 'categoryTriggerLabel' });
trigger.appendChild(triggerLabel);
trigger.appendChild(iconEl('arrow_drop_down', 'caret'));
dropdown.appendChild(trigger);
const panel = el('div', { className: 'category-panel' });
const searchInput = el('input', { className: 'category-search', type: 'text', placeholder: 'Search...' });
const options = el('div', { className: 'category-options', id: 'categoryOptions' });
panel.appendChild(searchInput);
panel.appendChild(options);
dropdown.appendChild(panel);
bar.appendChild(dropdown);
trigger.addEventListener('click', () => {
dropdown.classList.toggle('open');
if (dropdown.classList.contains('open')) {
searchInput.value = '';
populateCategoryOptions('');
searchInput.focus();
}
});
searchInput.addEventListener('input', () => populateCategoryOptions(searchInput.value));
document.addEventListener('click', (e) => {
if (!dropdown.contains(e.target)) dropdown.classList.remove('open');
});
return bar;
}
function visibleCategoryCodesForCurrentPage() {
// Which categories should appear in the dropdown:
// - the variant exists for this page, OR
// - the category has a notfoundmessage (fallback to default content), OR
// - the category has visibilityifnocontent: visible (shows pagenotfoundmessage instead)
// - always include the active category so user can see what they're on
const out = new Set();
const page = currentPage
? navData.find(p => p.file === currentPage)
: null;
categoriesList.forEach(cat => {
const hasVariant = !page || page.uncategorized || !(page.variants && page.variants.length) || page.variants.includes(cat.code);
const alwaysVisible = !!cat.notfoundmessage || cat.visibilityifnocontent === 'visible';
if (hasVariant || alwaysVisible || cat.code === activeCategory) out.add(cat.code);
});
return out;
}
function populateCategoryOptions(filter) {
const container = document.getElementById('categoryOptions');
if (!container) return;
container.innerHTML = '';
const visible = visibleCategoryCodesForCurrentPage();
const q = filter.trim().toLowerCase();
const page = currentPage ? navData.find(p => p.file === currentPage) : null;
categoriesList.forEach(cat => {
if (!visible.has(cat.code)) return;
const primary = cat.message || cat.name;
const secondary = cat['name-latin'] && cat['name-latin'] !== cat.name ? cat['name-latin'] : '';
const hay = (primary + ' ' + secondary + ' ' + cat.code).toLowerCase();
if (q && !hay.includes(q)) return;
const option = el('div', {
className: 'category-option' + (cat.code === activeCategory ? ' active' : ''),
'data-code': cat.code
});
option.appendChild(document.createTextNode(primary));
const hasVariant = !page || page.uncategorized || !(page.variants && page.variants.length) || page.variants.includes(cat.code);
if (!hasVariant && cat.notfoundmessage) {
option.appendChild(el('span', { className: 'secondary', textContent: cat.notfoundmessage }));
} else if (secondary) {
option.appendChild(el('span', { className: 'secondary', textContent: secondary }));
}
option.addEventListener('click', () => {
document.getElementById('categoryDropdown').classList.remove('open');
setActiveCategory(cat.code);
});
container.appendChild(option);
});
}
function refreshCategoryBar() {
if (!categoriesUse) return;
const label = document.getElementById('categoryTriggerLabel');
const cat = categoriesByCode[activeCategory];
if (label && cat) label.textContent = cat.message || cat.name || activeCategory;
// Apply RTL to page content + category bar based on active direction
const direction = (cat && cat.direction === 'rtl') ? 'rtl' : 'ltr';
document.querySelectorAll('.category-bar').forEach(b => b.setAttribute('dir', direction));
document.querySelectorAll('.md-content, .title-bar').forEach(el => el.setAttribute('dir', direction));
document.querySelectorAll('.sidebar').forEach(el => el.setAttribute('dir', direction));
// Apply per-category line-height override, or restore theme default
const lh = cat && cat['line-height'];
document.documentElement.style.setProperty('--line-height-body', lh ? String(lh) : (defaultLineHeight || '1.7'));
}
function doSearch(query, resultsEl) {
resultsEl.innerHTML = '';
if (!query.trim() || !fuseInstance) {
resultsEl.classList.remove('active');
return;
}
let results = fuseInstance.search(query, { limit: 16 });
if (categoriesUse && activeCategory) {
results = results.filter(r => !r.item.category || r.item.category === activeCategory);
}
results = results.slice(0, 8);
if (!results.length) {
resultsEl.innerHTML = '<div class="search-result-item"><span class="search-result-title">No results found</span></div>';
resultsEl.classList.add('active');
return;
}
results.forEach(r => {
const item = el('div', { className: 'search-result-item' });
item.appendChild(el('div', null, el('span', { className: 'search-result-title', textContent: r.item.title })));
if (r.item.description) {
item.appendChild(el('div', { className: 'search-result-snippet', textContent: r.item.description }));
}
item.addEventListener('click', () => {
navigateTo(r.item.file);
resultsEl.classList.remove('active');
document.querySelectorAll('.search-box').forEach(i => i.value = '');
if (window._closeMobileMenu) window._closeMobileMenu();
});
resultsEl.appendChild(item);
});
resultsEl.classList.add('active');
}
// ─── Nav rendering ────────────────────────────────────────
//
// Layout:
// - Sidebar mode: tree with section headings + nesting
// - Topbar mode (inline): flat list of pages only
// - Topbar mode (mobile panel): tree (same as sidebar)
//
// Section visibility:
// - `visible`: always shown
// - `hidden`: heading with +/- toggle; state persisted in localStorage
// - `draft`: section and its pages/descendants fully hidden
function isDraftSection(code, byCode) {
let s = byCode[code];
while (s) {
if (s.pagesvisibility === 'draft') return true;
if (!s.parent) return false;
s = byCode[s.parent];
}
return false;
}
function buildSectionTree(liveSections) {
const byParent = {};
liveSections.forEach(s => {
const key = s.parent || '';
(byParent[key] = byParent[key] || []).push(s);
});
Object.values(byParent).forEach(arr => arr.sort((a, b) => {
const ka = a.parent ? (a['parent-sort'] ?? 999) : (a.sort ?? 999);
const kb = b.parent ? (b['parent-sort'] ?? 999) : (b.sort ?? 999);
return ka - kb || a.code.localeCompare(b.code);
}));
function attach(node) {
node.children = byParent[node.code] || [];
node.children.forEach(attach);
}
const top = byParent[''] || [];
top.forEach(attach);
return top;
}
function groupPagesBySection(liveCodes) {
const groups = { '': [] };
liveCodes.forEach(c => { groups[c] = []; });
navData.forEach(p => {
const sid = p['section-id'] || '';
if (sid === '' || liveCodes.has(sid)) {
(groups[sid] = groups[sid] || []).push(p);
}
});
Object.values(groups).forEach(arr => arr.sort((a, b) => {
return ((a.sort ?? 999) - (b.sort ?? 999)) || a.file.localeCompare(b.file);
}));
return groups;
}
function sectionExpanded(code) {
return localStorage.getItem('md-cms-section:' + code) === 'expanded';
}
function toggleSection(code) {
const key = 'md-cms-section:' + code;
localStorage.setItem(key, localStorage.getItem(key) === 'expanded' ? 'collapsed' : 'expanded');
}
function makeNavItem(page, depth) {
if (!pageShouldDisplay(page)) return null;
const title = pageDisplayTitle(page);
const cls = 'nav-item' + (depth > 0 ? ' depth-' + depth : '');
const link = el('a', {
className: cls,
textContent: title,
href: '#' + page.file,
'data-file': page.file
});
link.addEventListener('click', (e) => {
e.preventDefault();
navigateTo(page.file);
if (window._closeMobileMenu) window._closeMobileMenu();
});
return link;
}
function renderTreeSection(container, section, depth, groups) {
const isHidden = section.pagesvisibility === 'hidden';
const depthClass = depth > 0 ? ' depth-' + depth : '';
const heading = el('div', {
className: 'nav-section-heading' + (isHidden ? ' toggleable' : '') + depthClass
});
const name = sectionDisplayName(section);
if (isHidden) {
const expanded = sectionExpanded(section.code);
const expandIcon = themeConfig['nav-section-expand-icon'] || 'arrow_right';
const collapseIcon = themeConfig['nav-section-collapse-icon'] || 'arrow_drop_down';
heading.innerHTML = '';
heading.appendChild(iconEl(expanded ? collapseIcon : expandIcon, 'toggle-icon'));
heading.appendChild(el('span', { textContent: name }));
heading.addEventListener('click', () => {
toggleSection(section.code);
renderNav();
highlightNav(currentPage);
});
container.appendChild(heading);
if (!expanded) return;
} else {
heading.textContent = name;
container.appendChild(heading);
}
(groups[section.code] || []).forEach(p => {
const item = makeNavItem(p, depth + 1);
if (item) container.appendChild(item);
});
section.children.forEach(c => renderTreeSection(container, c, depth + 1, groups));
}
function renderTree(container) {
const byCode = {};
navSections.forEach(s => { if (s.code) byCode[s.code] = s; });
const liveSections = navSections.filter(s => s.code && !isDraftSection(s.code, byCode));
const liveCodes = new Set(liveSections.map(s => s.code));
const groups = groupPagesBySection(liveCodes);
(groups[''] || []).forEach(p => {
const item = makeNavItem(p, 0);
if (item) container.appendChild(item);
});
const tree = buildSectionTree(liveSections);
tree.forEach(root => renderTreeSection(container, root, 0, groups));
}
function buildTopbarNavItems() {
const byCode = {};
navSections.forEach(s => { if (s.code) byCode[s.code] = s; });
const items = [];
// Sections (non-draft), each becomes a dropdown trigger
navSections.forEach(s => {
if (!s.code || isDraftSection(s.code, byCode)) return;
const pages = navData.filter(p => p['section-id'] === s.code && pageShouldDisplay(p));
pages.sort((a, b) => ((a.sort ?? 999) - (b.sort ?? 999)) || a.file.localeCompare(b.file));
if (!pages.length) return;
items.push({ type: 'section', sort: s.sort ?? 999, section: s, pages });
});
// Unsectioned pages (or pages whose section isn't in nav), grouped by sort century
const unsectioned = navData.filter(p => {
if (!pageShouldDisplay(p)) return false;
const sid = p['section-id'];
return !sid || !byCode[sid];
});
unsectioned.sort((a, b) => ((a.sort ?? 999) - (b.sort ?? 999)) || a.file.localeCompare(b.file));
const centuryMap = new Map();
unsectioned.forEach(p => {
const c = Math.floor((p.sort ?? 999) / 100);
if (!centuryMap.has(c)) centuryMap.set(c, []);
centuryMap.get(c).push(p);
});
for (const [, pgs] of centuryMap) {
items.push({ type: 'group', sort: pgs[0].sort ?? 999, primary: pgs[0], children: pgs.slice(1) });
}
items.sort((a, b) => a.sort - b.sort);
return items;
}
function makeTopbarPageGroup({ primary, children }, isMobile) {
const group = el('div', { className: 'nav-group' });
const hasChildren = children.length > 0;
if (isMobile) {
const row = el('div', { className: 'nav-group-row' });
const link = makeNavItem(primary, 0);
if (link) row.appendChild(link);
if (hasChildren) {
const childrenEl = el('div', { className: 'nav-group-children' });
children.forEach(p => { const it = makeNavItem(p, 1); if (it) childrenEl.appendChild(it); });
const btn = el('button', { className: 'nav-expand-btn', 'aria-label': 'Expand', textContent: '+' });
btn.addEventListener('click', () => {
const open = childrenEl.classList.toggle('open');
btn.textContent = open ? '' : '+';
});
row.appendChild(btn);
group.appendChild(row);
group.appendChild(childrenEl);
} else {
group.appendChild(row);
}
} else {
const title = pageDisplayTitle(primary);
const trigger = el('a', { className: 'nav-trigger', href: '#' + primary.file, 'data-file': primary.file });
trigger.appendChild(el('span', { textContent: title }));
if (hasChildren) trigger.appendChild(iconEl('arrow_drop_down', 'nav-caret'));
group.appendChild(trigger);
if (hasChildren) {
const dropdown = el('div', { className: 'nav-dropdown' });
children.forEach(p => { const it = makeNavItem(p, 0); if (it) dropdown.appendChild(it); });
group.appendChild(dropdown);
group.addEventListener('mouseenter', () => group.classList.add('open'));
group.addEventListener('mouseleave', () => group.classList.remove('open'));
trigger.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
group.classList.toggle('open');
navigateTo(primary.file);
if (window._closeMobileMenu) window._closeMobileMenu();
});
} else {
trigger.addEventListener('click', e => {
e.preventDefault();
navigateTo(primary.file);
if (window._closeMobileMenu) window._closeMobileMenu();
});
}
}
return group;
}
function makeTopbarSection({ section, pages }, isMobile) {
const group = el('div', { className: 'nav-group' });
const isHidden = section.pagesvisibility === 'hidden';
const name = sectionDisplayName(section);
if (isMobile) {
const row = el('div', { className: 'nav-group-row' });
row.appendChild(el('span', { className: 'nav-section-label', textContent: name }));
const childrenEl = el('div', { className: 'nav-group-children' + (isHidden ? '' : ' open') });
pages.forEach(p => { const it = makeNavItem(p, 1); if (it) childrenEl.appendChild(it); });
const btn = el('button', { className: 'nav-expand-btn', 'aria-label': 'Expand', textContent: isHidden ? '+' : '' });
btn.addEventListener('click', () => {
const open = childrenEl.classList.toggle('open');
btn.textContent = open ? '' : '+';
});
row.appendChild(btn);
group.appendChild(row);
group.appendChild(childrenEl);
} else {
const trigger = el('button', { className: 'nav-trigger', type: 'button' });
trigger.appendChild(el('span', { textContent: name }));
trigger.appendChild(iconEl('arrow_drop_down', 'nav-caret'));
trigger.addEventListener('click', e => { e.stopPropagation(); group.classList.toggle('open'); });
group.appendChild(trigger);
const dropdown = el('div', { className: 'nav-dropdown' });
pages.forEach(p => { const it = makeNavItem(p, 0); if (it) dropdown.appendChild(it); });
group.appendChild(dropdown);
if (!isHidden) {
group.addEventListener('mouseenter', () => group.classList.add('open'));
group.addEventListener('mouseleave', () => group.classList.remove('open'));
}
}
return group;
}
function renderTopbarGrouped(container, isMobile) {
const items = buildTopbarNavItems();
items.forEach(item => {
container.appendChild(
item.type === 'group'
? makeTopbarPageGroup(item, isMobile)
: makeTopbarSection(item, isMobile)
);
});
}
function renderNav() {
const topbar = config.navigation === 'topbar';
const main = document.getElementById('navLinks');
const mobile = document.getElementById('mobileNavLinks');
if (main) {
main.innerHTML = '';
if (topbar) renderTopbarGrouped(main, false);
else renderTree(main);
}
if (mobile) {
mobile.innerHTML = '';
if (topbar) renderTopbarGrouped(mobile, true);
else renderTree(mobile);
}
}
function highlightNav(file) {
document.querySelectorAll('.nav-item, .nav-trigger[data-file]').forEach(item => {
item.classList.toggle('active', item.getAttribute('data-file') === file);
});
document.querySelectorAll('.topbar-nav .nav-group').forEach(group => {
group.classList.toggle('has-active', !!group.querySelector('[data-file].active'));
});
}
// ─── Clean URL routing ────────────────────────────────────
// Returns the file (e.g. "pages/timesheet.md") if the given slug is a section-id
// that has a matching pages/{slug}.md entry in navData.
function resolveSlugToFile(slug) {
if (!slug || !navSections.some(s => s.code === slug)) return null;
const file = `pages/${slug}.md`;
return navData.some(p => p.file === file) ? file : null;
}
// Called once after navData/navSections are populated.
// Sets basePath (the app root) and returns the initial page file when the URL
// already contains a section-id slug (direct access via clean URL or 404 redirect).
function initBasePath() {
const segments = _initialPathname.split('/').filter(Boolean);
if (segments.length > 0) {
const lastSeg = segments[segments.length - 1];
const file = resolveSlugToFile(lastSeg);
if (file) {
const slugIdx = _initialPathname.lastIndexOf('/' + lastSeg);
basePath = _initialPathname.slice(0, slugIdx + 1) || '/';
return file;
}
}
basePath = _initialPathname.endsWith('/') ? _initialPathname : _initialPathname + '/';
return null;
}
// ─── Page loading ─────────────────────────────────────────
async function navigateTo(file) {
const contentEl = document.getElementById('pageContent');
// Guard the router: only fetch relative .md paths. This blocks loading
// attacker-controlled external URLs (e.g. #https://evil/x.md) or traversal
// paths injected via the location hash.
if (!isSafePagePath(file)) {
contentEl.innerHTML = `<div class="error-message"><h2>Page not available</h2><p>${escapeHtml(pageNotFoundMessage())}</p></div>`;
return;
}
currentPage = file;
highlightNav(file);
// If the active category is "hidden" (no notfoundmessage, not visibilityifnocontent:visible)
// and this page has no variant for it, silently switch to the default category instead of
// showing an error.
if (categoriesUse && activeCategory !== defaultCategoryCode && file !== defaultPage()) {
const cat = categoriesByCode[activeCategory];
const isHidden = cat && !cat.notfoundmessage && cat.visibilityifnocontent !== 'visible';
if (isHidden) {
const pageEntry = navData.find(p => p.file === file);
const hasVariant = !pageEntry || pageEntry.uncategorized
|| !(pageEntry.variants && pageEntry.variants.length)
|| pageEntry.variants.includes(activeCategory);
if (!hasVariant) {
setActiveCategory(defaultCategoryCode);
return;
}
}
}
// Build a clean URL. Pages whose filename matches a nav section-id get a clean
// pathname (e.g. /timesheet); all other pages keep the hash-based URL.
const u = new URL(window.location);
if (categoriesUse && activeCategory && activeCategory !== defaultCategoryCode) {
u.searchParams.set('cat', activeCategory);
} else {
u.searchParams.delete('cat');
}
const slugMatch = file.match(/^pages\/([^/]+)\.md$/);
const slug = slugMatch ? slugMatch[1] : null;
if (slug && navSections.some(s => s.code === slug)) {
u.pathname = basePath + slug;
u.hash = '';
} else {
u.pathname = basePath;
u.hash = '#' + file;
}
window.history.replaceState(null, '', u);
contentEl.innerHTML = '<div class="loading-spinner"></div>';
const result = await fetchPageFile(file);
if (!result.ok) {
const offlineMsg = !navigator.onLine && localStorage.getItem('mdcms-offline');
const bodyMsg = offlineMsg
? `<p>${escapeHtml(offlineMsg)}</p>`
: `<p>${escapeHtml(pageNotFoundMessage())}</p>`;
contentEl.innerHTML = `<div class="error-message"><h2>Page not available</h2>${bodyMsg}</div>`;
document.title = (config.sitename || 'MD-CMS');
refreshCategoryBar();
if (categoriesUse) populateCategoryOptions('');
return;
}
const { meta, body } = parseFrontmatter(result.text);
let html = `<div class="title-bar">
<span>${escapeHtml(config.sitename || 'MD-CMS')}</span>
<span class="title-bar-sep"></span>
<span>${escapeHtml(meta.title || file)}</span>
</div>`;
html += '<div class="md-content">' + renderMarkdown(body) + '</div>';
contentEl.innerHTML = html;
// Hydrate any mdcms tag blocks (post listings)
hydrateMdcmsTags();
const firstH = contentEl.querySelector('.md-content h1, .md-content h2');
if (firstH && (meta.author || meta.created)) {
const metaEl = document.createElement('div');
metaEl.className = 'page-meta';
let metaText = '';
if (meta.author) metaText += meta.author;
const displayDate = meta.created;
if (displayDate) {
if (metaText) metaText += ' | ';
metaText += 'Published ' + formatDate(displayDate);
if (meta.modified) metaText += ' (last updated ' + formatDate(meta.modified) + ')';
}
metaEl.textContent = metaText;
firstH.after(metaEl);
}
document.title = (meta.title ? meta.title + ' — ' : '') + (config.sitename || 'MD-CMS');
const descMeta = document.querySelector('meta[name="description"]');
if (descMeta) descMeta.setAttribute('content', meta.description || config.sitedescription || '');
if (meta.language) document.documentElement.setAttribute('lang', meta.language);
if (typeof hljs !== 'undefined') {
contentEl.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
}
window.scrollTo(0, 0);
refreshCategoryBar();
if (categoriesUse) populateCategoryOptions('');
}
// ─── Defaults & routing ──────────────────────────────────
function defaultPage() {
return config.homepage || 'pages/home.md';
}
function getPageFromHash() {
const hash = window.location.hash.slice(1);
return hash || null;
}
window.addEventListener('hashchange', () => {
const page = getPageFromHash();
// Ignore in-page heading anchors (e.g. #installation) — only route real .md
// pages. Without this, clicking a heading link wipes the page with a 404.
if (page && page !== currentPage && isSafePagePath(page)) navigateTo(page);
});
window.addEventListener('popstate', () => {
const slug = window.location.pathname.replace(basePath, '').replace(/^\//, '').replace(/\/$/, '');
const pathPage = slug ? resolveSlugToFile(slug) : null;
const page = pathPage || getPageFromHash();
if (page && page !== currentPage && isSafePagePath(page)) navigateTo(page);
});
// ─── Scroll to top ───────────────────────────────────────
const scrollBtn = document.getElementById('scrollTop');
window.addEventListener('scroll', () => {
scrollBtn.classList.toggle('visible', window.scrollY > 300);
});
scrollBtn.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
// ─── Boot ────────────────────────────────────────────────
async function boot() {
try {
const configResp = await fetch('config.yml');
if (!configResp.ok) throw new Error('config.yml not found');
config = jsyaml.load(await configResp.text()) || {};
document.title = config.sitename || 'MD-CMS';
if (config.favicon) {
const link = document.querySelector('link[rel="icon"]');
if (link) link.href = `assets/images/${config.favicon}`;
} else if (config.logo) {
const link = document.querySelector('link[rel="icon"]');
if (link) link.href = `assets/images/${config.logo}`;
}
if (config.theme) {
try {
const themeResp = await fetch(config.theme);
if (themeResp.ok) themeConfig = jsyaml.load(await themeResp.text()) || {};
} catch (e) { /* fall back to hardcoded CSS defaults */ }
}
loadFonts(themeConfig);
initCategories();
// Resolve the offline message after initCategories(), which sets
// defaultCategoryCode — otherwise a per-category default is missed.
const offlineMsgCfg = config['offline-message'];
if (offlineMsgCfg) {
const offlineText = typeof offlineMsgCfg === 'string'
? offlineMsgCfg
: (offlineMsgCfg[defaultCategoryCode] || offlineMsgCfg['en'] || Object.values(offlineMsgCfg)[0] || '');
if (offlineText) localStorage.setItem('mdcms-offline', offlineText);
}
const iconsToPreload = [...STANDARD_ICONS];
if (config['categories-selecticon']) iconsToPreload.push(config['categories-selecticon']);
await Promise.all(iconsToPreload.map(name => loadIcon(name)));
const navMode = config.navigation || 'sidebar';
if (navMode === 'topbar') buildTopbar();
else buildSidebar();
if (config.theme) applyThemeYml(themeConfig);
else applyConfigTheme();
defaultLineHeight = getComputedStyle(document.documentElement).getPropertyValue('--line-height-body').trim() || '1.7';
applyTheme(getInitialTheme());
// nav.yml — phase 2 expects `sections:` + `pages:` blocks; phase 1 flat
// list is accepted for backwards compatibility.
const navResp = await fetch('nav.yml');
if (navResp.ok) {
const parsed = jsyaml.load(await navResp.text()) || {};
if (Array.isArray(parsed)) {
navData = parsed;
navSections = [];
} else {
navData = parsed.pages || [];
navSections = parsed.sections || [];
}
renderNav();
}
const routeFromPath = initBasePath();
if (config.search !== false) {
try {
const searchResp = await fetch('search.json');
if (searchResp.ok) {
searchIndex = await searchResp.json();
fuseInstance = new Fuse(searchIndex, {
keys: [
{ name: 'title', weight: 3 },
{ name: 'keywords', weight: 2 },
{ name: 'description', weight: 1.5 },
{ name: 'body', weight: 1 }
],
threshold: 0.35,
ignoreLocation: true,
includeMatches: true
});
}
} catch (e) { /* search index optional */ }
}
// Initial category application: load font if needed, render dropdown
if (categoriesUse) {
refreshCategoryBar();
await maybeLoadCategoryFont(activeCategory);
}
const hashPage = getPageFromHash();
await navigateTo(routeFromPath || hashPage || defaultPage());
} catch (err) {
document.getElementById('app').innerHTML = `<div style="max-width:600px;margin:4rem auto;padding:2rem;text-align:center;font-family:system-ui;">
<h1 style="color:#EF4444;font-size:1.5rem;">MD-CMS Error</h1>
<p style="margin-top:1rem;color:#64748B;">${err.message}</p>
<p style="margin-top:0.5rem;color:#94A3B8;font-size:0.9rem;">Make sure config.yml exists. If running locally, use a local HTTP server (option 8 in mdcms.py).</p>
</div>`;
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();
</script>
</body>
</html>