mirror of
https://github.com/kbenestad/mdcms.git
synced 2026-06-18 15:24:32 +00:00
Merge ab2ef3b4c9 into a25829984d
This commit is contained in:
commit
9d7ac79741
7 changed files with 610 additions and 56 deletions
|
|
@ -73,6 +73,8 @@ During development, run directly: `python3 mdcms.py <command>`
|
||||||
| `mdcms build` | Build using current working directory. Simplest form for GitHub Actions. |
|
| `mdcms build` | Build using current working directory. Simplest form for GitHub Actions. |
|
||||||
| `mdcms fetch-deps [name]` | Download all external JS/CSS deps to `assets/required/vendors/` and Bunny Fonts to `assets/fonts/`. Patches `index.html` to use local paths — no CDN requests after this. |
|
| `mdcms fetch-deps [name]` | Download all external JS/CSS deps to `assets/required/vendors/` and Bunny Fonts to `assets/fonts/`. Patches `index.html` to use local paths — no CDN requests after this. |
|
||||||
| `mdcms fetch-deps --path <path>` | Same, using an explicit path. |
|
| `mdcms fetch-deps --path <path>` | Same, using an explicit path. |
|
||||||
|
| `mdcms serve [name]` | Local preview server with SPA clean-URL fallback (default port 8800). Unknown extension-less paths are served `index.html`; paths with an extension still 404 (the renderer's category fallback depends on this). |
|
||||||
|
| `mdcms serve --path <path> --port <port>` | Same, with explicit path and port. |
|
||||||
|
|
||||||
## PWA config keys
|
## PWA config keys
|
||||||
|
|
||||||
|
|
@ -88,7 +90,7 @@ offline-message:
|
||||||
nb: "Du er frakoblet og noe innhold er utilgjengelig."
|
nb: "Du er frakoblet og noe innhold er utilgjengelig."
|
||||||
```
|
```
|
||||||
|
|
||||||
**Local preview:** Run `python3 -m http.server 8800` in the site directory and open `http://localhost:8800`. Do not open `index.html` directly — browsers block local file access due to CORS.
|
**Local preview:** Run `mdcms serve` in the site directory and open `http://localhost:8800`. Unlike `python3 -m http.server`, this handles clean-URL reloads (`/section-id`). Do not open `index.html` directly — browsers block local file access due to CORS. See `docs/hosting.md` for clean-URL setup on production hosts.
|
||||||
|
|
||||||
## Architecture of `mdcms.py`
|
## Architecture of `mdcms.py`
|
||||||
|
|
||||||
|
|
|
||||||
117
app/index.html
117
app/index.html
|
|
@ -901,7 +901,7 @@ body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--font-colour);
|
color: var(--font-colour);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
text-align: centre;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-load-more {
|
.post-load-more {
|
||||||
|
|
@ -1143,7 +1143,15 @@ body {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'mdcms-icon' + (className ? ' ' + className : '');
|
span.className = 'mdcms-icon' + (className ? ' ' + className : '');
|
||||||
const filename = normaliseIconName(name);
|
const filename = normaliseIconName(name);
|
||||||
span.innerHTML = svg || '<img src="assets/icons/' + filename + '" alt="[missing: ' + filename + ']" style="width:1em;height:1em;display:inline-block;">';
|
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;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1169,6 +1177,31 @@ body {
|
||||||
return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').trim();
|
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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) {
|
function parseFrontmatter(md) {
|
||||||
const match = md.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
const match = md.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
||||||
if (!match) return { meta: {}, body: md };
|
if (!match) return { meta: {}, body: md };
|
||||||
|
|
@ -1243,7 +1276,7 @@ body {
|
||||||
window.history.replaceState(null, '', url);
|
window.history.replaceState(null, '', url);
|
||||||
maybeLoadCategoryFont(code).then(() => {
|
maybeLoadCategoryFont(code).then(() => {
|
||||||
renderNav();
|
renderNav();
|
||||||
if (currentPage) navigateTo(currentPage);
|
if (currentPage) navigateTo(currentPage, { replace: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1397,7 +1430,7 @@ body {
|
||||||
|
|
||||||
function getInitialTheme() {
|
function getInitialTheme() {
|
||||||
const saved = localStorage.getItem('md-cms-theme');
|
const saved = localStorage.getItem('md-cms-theme');
|
||||||
if (saved) return saved;
|
if (saved === 'light' || saved === 'dark') return saved;
|
||||||
const def = config['default-theme'] || 'system';
|
const def = config['default-theme'] || 'system';
|
||||||
if (def === 'system') {
|
if (def === 'system') {
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
|
@ -1583,7 +1616,9 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Markdown ─────────────────────────────────────────────
|
// ─── Markdown ─────────────────────────────────────────────
|
||||||
function renderMarkdown(mdBody) {
|
let _markedConfigured = false;
|
||||||
|
function configureMarked() {
|
||||||
|
if (_markedConfigured) return;
|
||||||
marked.setOptions({ gfm: true, breaks: false, headerIds: true, mangle: false });
|
marked.setOptions({ gfm: true, breaks: false, headerIds: true, mangle: false });
|
||||||
const renderer = new marked.Renderer();
|
const renderer = new marked.Renderer();
|
||||||
|
|
||||||
|
|
@ -1603,13 +1638,14 @@ body {
|
||||||
}
|
}
|
||||||
const isExternal = linkHref && (linkHref.startsWith('http://') || linkHref.startsWith('https://'));
|
const isExternal = linkHref && (linkHref.startsWith('http://') || linkHref.startsWith('https://'));
|
||||||
const isMd = linkHref && linkHref.endsWith('.md');
|
const isMd = linkHref && linkHref.endsWith('.md');
|
||||||
|
const titleAttr = linkTitle ? ` title="${escapeHtml(linkTitle)}"` : '';
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
return `<a href="${linkHref}" target="_blank" rel="noopener noreferrer"${linkTitle ? ` title="${linkTitle}"` : ''}>${linkText}</a>`;
|
return `<a href="${escapeHtml(safeUrl(linkHref))}" target="_blank" rel="noopener noreferrer"${titleAttr}>${linkText}</a>`;
|
||||||
}
|
}
|
||||||
if (isMd) {
|
if (isMd) {
|
||||||
return `<a href="#${linkHref}" data-internal="true"${linkTitle ? ` title="${linkTitle}"` : ''}>${linkText}</a>`;
|
return `<a href="#${escapeHtml(linkHref)}" data-internal="true"${titleAttr}>${linkText}</a>`;
|
||||||
}
|
}
|
||||||
return `<a href="${linkHref}"${linkTitle ? ` title="${linkTitle}"` : ''}>${linkText}</a>`;
|
return `<a href="${escapeHtml(safeUrl(linkHref))}"${titleAttr}>${linkText}</a>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
renderer.code = function(code, lang, escaped) {
|
renderer.code = function(code, lang, escaped) {
|
||||||
|
|
@ -1637,6 +1673,11 @@ body {
|
||||||
};
|
};
|
||||||
|
|
||||||
marked.use({ renderer });
|
marked.use({ renderer });
|
||||||
|
_markedConfigured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(mdBody) {
|
||||||
|
configureMarked();
|
||||||
return marked.parse(mdBody);
|
return marked.parse(mdBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2400,7 +2441,18 @@ function fmtDatetime(dtStr) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateMdcmsTags() {
|
function hydrateMdcmsTags() {
|
||||||
document.querySelectorAll('.mdcms-tag').forEach(function(tagEl) {
|
// 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 {
|
try {
|
||||||
var cfg = JSON.parse(tagEl.getAttribute('data-config'));
|
var cfg = JSON.parse(tagEl.getAttribute('data-config'));
|
||||||
if (/^callout-(info|warning|success|error)$/.test(cfg.tagName)) {
|
if (/^callout-(info|warning|success|error)$/.test(cfg.tagName)) {
|
||||||
|
|
@ -2419,6 +2471,7 @@ function fmtDatetime(dtStr) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Shell ────────────────────────────────────────────────
|
// ─── Shell ────────────────────────────────────────────────
|
||||||
function buildSidebar() {
|
function buildSidebar() {
|
||||||
|
|
@ -3067,9 +3120,23 @@ function fmtDatetime(dtStr) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Page loading ─────────────────────────────────────────
|
// ─── Page loading ─────────────────────────────────────────
|
||||||
async function navigateTo(file) {
|
// opts.replace: replace the current history entry instead of pushing a new
|
||||||
currentPage = file;
|
// one. Used for the initial load and for back/forward (popstate/hashchange),
|
||||||
|
// where pushing would corrupt the history stack. User navigations push, so
|
||||||
|
// the browser Back button returns to the previous page.
|
||||||
|
async function navigateTo(file, opts) {
|
||||||
|
const replace = !!(opts && opts.replace);
|
||||||
const contentEl = document.getElementById('pageContent');
|
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);
|
highlightNav(file);
|
||||||
|
|
||||||
// If the active category is "hidden" (no notfoundmessage, not visibilityifnocontent:visible)
|
// If the active category is "hidden" (no notfoundmessage, not visibilityifnocontent:visible)
|
||||||
|
|
@ -3107,7 +3174,7 @@ function fmtDatetime(dtStr) {
|
||||||
u.pathname = basePath;
|
u.pathname = basePath;
|
||||||
u.hash = '#' + file;
|
u.hash = '#' + file;
|
||||||
}
|
}
|
||||||
window.history.replaceState(null, '', u);
|
window.history[replace ? 'replaceState' : 'pushState'](null, '', u);
|
||||||
|
|
||||||
contentEl.innerHTML = '<div class="loading-spinner"></div>';
|
contentEl.innerHTML = '<div class="loading-spinner"></div>';
|
||||||
|
|
||||||
|
|
@ -3115,8 +3182,8 @@ function fmtDatetime(dtStr) {
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const offlineMsg = !navigator.onLine && localStorage.getItem('mdcms-offline');
|
const offlineMsg = !navigator.onLine && localStorage.getItem('mdcms-offline');
|
||||||
const bodyMsg = offlineMsg
|
const bodyMsg = offlineMsg
|
||||||
? `<p>${offlineMsg}</p>`
|
? `<p>${escapeHtml(offlineMsg)}</p>`
|
||||||
: `<p>${pageNotFoundMessage()}</p>`;
|
: `<p>${escapeHtml(pageNotFoundMessage())}</p>`;
|
||||||
contentEl.innerHTML = `<div class="error-message"><h2>Page not available</h2>${bodyMsg}</div>`;
|
contentEl.innerHTML = `<div class="error-message"><h2>Page not available</h2>${bodyMsg}</div>`;
|
||||||
document.title = (config.sitename || 'MD-CMS');
|
document.title = (config.sitename || 'MD-CMS');
|
||||||
refreshCategoryBar();
|
refreshCategoryBar();
|
||||||
|
|
@ -3127,9 +3194,9 @@ function fmtDatetime(dtStr) {
|
||||||
const { meta, body } = parseFrontmatter(result.text);
|
const { meta, body } = parseFrontmatter(result.text);
|
||||||
|
|
||||||
let html = `<div class="title-bar">
|
let html = `<div class="title-bar">
|
||||||
<span>${config.sitename || 'MD-CMS'}</span>
|
<span>${escapeHtml(config.sitename || 'MD-CMS')}</span>
|
||||||
<span class="title-bar-sep">›</span>
|
<span class="title-bar-sep">›</span>
|
||||||
<span>${meta.title || file}</span>
|
<span>${escapeHtml(meta.title || file)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
html += '<div class="md-content">' + renderMarkdown(body) + '</div>';
|
html += '<div class="md-content">' + renderMarkdown(body) + '</div>';
|
||||||
contentEl.innerHTML = html;
|
contentEl.innerHTML = html;
|
||||||
|
|
@ -3179,14 +3246,16 @@ function fmtDatetime(dtStr) {
|
||||||
|
|
||||||
window.addEventListener('hashchange', () => {
|
window.addEventListener('hashchange', () => {
|
||||||
const page = getPageFromHash();
|
const page = getPageFromHash();
|
||||||
if (page && page !== currentPage) navigateTo(page);
|
// 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, { replace: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('popstate', () => {
|
window.addEventListener('popstate', () => {
|
||||||
const slug = window.location.pathname.replace(basePath, '').replace(/^\//, '').replace(/\/$/, '');
|
const slug = window.location.pathname.replace(basePath, '').replace(/^\//, '').replace(/\/$/, '');
|
||||||
const pathPage = slug ? resolveSlugToFile(slug) : null;
|
const pathPage = slug ? resolveSlugToFile(slug) : null;
|
||||||
const page = pathPage || getPageFromHash();
|
const page = pathPage || getPageFromHash();
|
||||||
if (page && page !== currentPage) navigateTo(page);
|
if (page && page !== currentPage && isSafePagePath(page)) navigateTo(page, { replace: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Scroll to top ───────────────────────────────────────
|
// ─── Scroll to top ───────────────────────────────────────
|
||||||
|
|
@ -3219,6 +3288,11 @@ function fmtDatetime(dtStr) {
|
||||||
} catch (e) { /* fall back to hardcoded CSS defaults */ }
|
} 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'];
|
const offlineMsgCfg = config['offline-message'];
|
||||||
if (offlineMsgCfg) {
|
if (offlineMsgCfg) {
|
||||||
const offlineText = typeof offlineMsgCfg === 'string'
|
const offlineText = typeof offlineMsgCfg === 'string'
|
||||||
|
|
@ -3227,9 +3301,6 @@ function fmtDatetime(dtStr) {
|
||||||
if (offlineText) localStorage.setItem('mdcms-offline', offlineText);
|
if (offlineText) localStorage.setItem('mdcms-offline', offlineText);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFonts(themeConfig);
|
|
||||||
initCategories();
|
|
||||||
|
|
||||||
const iconsToPreload = [...STANDARD_ICONS];
|
const iconsToPreload = [...STANDARD_ICONS];
|
||||||
if (config['categories-selecticon']) iconsToPreload.push(config['categories-selecticon']);
|
if (config['categories-selecticon']) iconsToPreload.push(config['categories-selecticon']);
|
||||||
await Promise.all(iconsToPreload.map(name => loadIcon(name)));
|
await Promise.all(iconsToPreload.map(name => loadIcon(name)));
|
||||||
|
|
@ -3286,12 +3357,12 @@ function fmtDatetime(dtStr) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashPage = getPageFromHash();
|
const hashPage = getPageFromHash();
|
||||||
await navigateTo(routeFromPath || hashPage || defaultPage());
|
await navigateTo(routeFromPath || hashPage || defaultPage(), { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('app').innerHTML = `<div style="max-width:600px;margin:4rem auto;padding:2rem;text-align:center;font-family:system-ui;">
|
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>
|
<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: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>
|
<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 (run <code>mdcms serve</code> in the site directory).</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,18 @@ self.addEventListener('activate', event => {
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', event => {
|
self.addEventListener('fetch', event => {
|
||||||
if (event.request.method !== 'GET') return;
|
const req = event.request;
|
||||||
|
if (req.method !== 'GET') return;
|
||||||
|
// App-shell routing: serve cached index.html for every navigation, including
|
||||||
|
// clean URLs like /section-id on reload. Without this the static host returns
|
||||||
|
// 404 for those paths before any JavaScript runs. Works offline too.
|
||||||
|
if (req.mode === 'navigate') {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then(cached => cached || fetch(event.request))
|
caches.match('index.html').then(shell => shell || fetch(req))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(req).then(cached => cached || fetch(req))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
182
docs/hosting.md
Normal file
182
docs/hosting.md
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
# Clean URLs and hosting
|
||||||
|
|
||||||
|
MD-CMS gives pages whose filename matches a nav `section-id` a clean URL — `example.com/timesheet` instead of `example.com/#pages/timesheet.md`. This page explains why clean URLs need help from the web server, and how to set that up on every kind of host.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
Clean URLs are *virtual*: there is no file called `timesheet` on the server. The mapping from `/timesheet` to `pages/timesheet.md` exists only in the renderer's JavaScript inside `index.html`.
|
||||||
|
|
||||||
|
Navigation *within* the site always works — clicking a nav link never asks the server for `/timesheet`. But three things do ask the server directly:
|
||||||
|
|
||||||
|
- **Reloading** the page (Ctrl-R / F5)
|
||||||
|
- **Opening a shared link** to a clean URL in a fresh tab
|
||||||
|
- **Bookmarks** pointing at a clean URL
|
||||||
|
|
||||||
|
In all three cases the browser sends `GET /timesheet` to the server *before any JavaScript runs*. A plain static server looks for a file at that path, finds nothing, and returns **404** — `index.html` is never served, so the router that knows how to resolve the slug never executes.
|
||||||
|
|
||||||
|
The fix is always the same idea: get the server (or the browser) to answer unknown extension-less paths with `index.html`, so the client-side router can take over.
|
||||||
|
|
||||||
|
## Solutions by environment
|
||||||
|
|
||||||
|
| Environment | Solution | Setup |
|
||||||
|
|---|---|---|
|
||||||
|
| Local preview | `mdcms serve` | nothing — built in |
|
||||||
|
| Any host, PWA enabled | service worker app-shell fallback | `pwa: yes` in `config.yml` + `mdcms build` |
|
||||||
|
| GitHub Pages | `404.html` redirect | ships with the starter template |
|
||||||
|
| Netlify / Cloudflare Pages | `_redirects` file | one line, see below |
|
||||||
|
| nginx | `try_files` rule | see below |
|
||||||
|
| Apache | `.htaccess` rewrite | see below |
|
||||||
|
| Caddy | `try_files` rule | see below |
|
||||||
|
|
||||||
|
### Local preview: `mdcms serve`
|
||||||
|
|
||||||
|
`python3 -m http.server` is a "dumb" static server: no rewrites, no custom 404 page. Use the built-in preview server instead:
|
||||||
|
|
||||||
|
```
|
||||||
|
mdcms serve # serve the current directory
|
||||||
|
mdcms serve mysite # serve a registered site
|
||||||
|
mdcms serve --path ./site # serve an explicit path
|
||||||
|
mdcms serve --port 9000 # custom port (default: 8800)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:8800`. The server rewrites unknown extension-less paths (like `/timesheet`) to `index.html`, while requests *with* an extension (like a missing `pages/timesheet.nb.md` category variant) still return 404 — the renderer depends on that for its category-fallback logic. It also serves `.md` files with the correct `text/markdown` type.
|
||||||
|
|
||||||
|
Never open `index.html` directly from disk — browsers block local file access due to CORS.
|
||||||
|
|
||||||
|
### Any host: the service worker (PWA)
|
||||||
|
|
||||||
|
If `pwa: yes` is set in `config.yml`, the service worker generated by `mdcms build` answers every navigation request with the cached `index.html` app shell — the request never reaches the server. This makes clean-URL reloads work on **any** host, including ones where you can't configure rewrites, and it works offline.
|
||||||
|
|
||||||
|
Caveat: the service worker is only installed after the first visit, so the *very first* request for a clean URL in a fresh browser still hits the server. Pair it with one of the server-side solutions below for full coverage.
|
||||||
|
|
||||||
|
### GitHub Pages: `404.html`
|
||||||
|
|
||||||
|
The starter template ships an `app/404.html`. GitHub Pages serves it as the body of any 404 response; it encodes the intended path as `?_route=/timesheet` and redirects to the app root, where `index.html` picks the route up and cleans the URL. No configuration needed — just deploy the file with the rest of the site.
|
||||||
|
|
||||||
|
### Netlify and Cloudflare Pages: `_redirects`
|
||||||
|
|
||||||
|
Create a file called `_redirects` in the site root (next to `index.html`) with this single line:
|
||||||
|
|
||||||
|
```
|
||||||
|
/* /index.html 200
|
||||||
|
```
|
||||||
|
|
||||||
|
The `200` makes it a *rewrite* (the URL stays the same), not a redirect. The renderer detects these rewrites: a missing `.md` file that comes back as `index.html` is recognised by its `text/html` content type and treated as not found, so category fallback still works.
|
||||||
|
|
||||||
|
### nginx
|
||||||
|
|
||||||
|
Add a `try_files` rule to the site's `location` block:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name example.com;
|
||||||
|
root /var/www/mysite;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Serve real files when they exist; otherwise hand the request
|
||||||
|
# to index.html so the MD-CMS router can resolve the clean URL.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Correct content type for markdown (optional but recommended —
|
||||||
|
# the renderer rejects text/html responses for .md requests).
|
||||||
|
types {
|
||||||
|
text/markdown md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apache
|
||||||
|
|
||||||
|
Create an `.htaccess` file in the site root (requires `mod_rewrite`):
|
||||||
|
|
||||||
|
```apache
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
|
||||||
|
# Serve existing files and directories as-is
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
# Everything else goes to index.html for the client-side router
|
||||||
|
RewriteRule ^ index.html [L]
|
||||||
|
|
||||||
|
# Correct content type for markdown
|
||||||
|
AddType text/markdown .md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caddy
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
example.com {
|
||||||
|
root * /var/www/mysite
|
||||||
|
try_files {path} {path}/ /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sample script: standalone preview server
|
||||||
|
|
||||||
|
If `mdcms` is not installed (for example on a machine that only has Python), this standalone script reproduces what `mdcms serve` does. Save it as `serve.py` in the site root and run `python3 serve.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minimal static server with SPA clean-URL fallback for MD-CMS sites.
|
||||||
|
|
||||||
|
Unknown extension-less paths (e.g. /timesheet) are served index.html so the
|
||||||
|
MD-CMS client-side router can resolve them. Requests with a file extension
|
||||||
|
that don't exist (e.g. a missing .md category variant) still return 404,
|
||||||
|
which the renderer relies on.
|
||||||
|
"""
|
||||||
|
import http.server
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PORT = 8800
|
||||||
|
|
||||||
|
|
||||||
|
class SpaHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
extensions_map = {
|
||||||
|
**http.server.SimpleHTTPRequestHandler.extensions_map,
|
||||||
|
".md": "text/markdown; charset=utf-8",
|
||||||
|
".yml": "text/yaml; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _rewrite_spa(self):
|
||||||
|
if Path(self.translate_path(self.path)).exists():
|
||||||
|
return
|
||||||
|
clean = self.path.split("?", 1)[0].split("#", 1)[0]
|
||||||
|
last = clean.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
if "." not in last:
|
||||||
|
self.path = "/index.html"
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self._rewrite_spa()
|
||||||
|
super().do_GET()
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
self._rewrite_spa()
|
||||||
|
super().do_HEAD()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
with http.server.ThreadingHTTPServer(("127.0.0.1", PORT), SpaHandler) as httpd:
|
||||||
|
print(f"Serving on http://localhost:{PORT}/ (Ctrl-C to stop)")
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopped.")
|
||||||
|
```
|
||||||
|
|
||||||
|
## How the pieces interact
|
||||||
|
|
||||||
|
A request for a clean URL is resolved by the first layer that catches it:
|
||||||
|
|
||||||
|
1. **Service worker** (if installed) — serves the cached shell, even offline.
|
||||||
|
2. **Server rewrite** (`_redirects`, nginx, Apache, Caddy, `mdcms serve`) — serves `index.html` with status 200.
|
||||||
|
3. **`404.html`** (GitHub Pages) — redirects to the app root with `?_route=`.
|
||||||
|
|
||||||
|
Whichever layer answers, `index.html` boots, reads the path (or `?_route=`), matches the last segment against the nav `section-id` list, and renders the right page. If the segment matches nothing, the renderer shows its normal page-not-found message.
|
||||||
|
|
@ -4,6 +4,62 @@ Changes merged into `development` that have not yet been released to `main`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Security & bug fixes (v0.6.1)
|
||||||
|
|
||||||
|
### Renderer (`app/index.html`)
|
||||||
|
|
||||||
|
- **Router now rejects unsafe page paths.** `navigateTo` and the
|
||||||
|
`hashchange`/`popstate` handlers only load relative `.md` paths via a new
|
||||||
|
`isSafePagePath` check. Previously a crafted link such as
|
||||||
|
`#https://evil.example/x.md` made the renderer fetch and render an
|
||||||
|
attacker-controlled document on the site's own origin (stored/reflected XSS).
|
||||||
|
- **In-page heading anchors no longer 404.** A markdown link to `#some-heading`
|
||||||
|
used to be treated as a page file and blew the page away with a
|
||||||
|
"Page not available" error; such hashes are now ignored by the router so the
|
||||||
|
browser scrolls to the heading.
|
||||||
|
- **Escaped untrusted interpolation.** `meta.title` (title bar), link `href`
|
||||||
|
and `title` attributes, the page-not-found / offline messages, and the icon
|
||||||
|
fallback `<img>` are now HTML-escaped. Link hrefs with `javascript:`,
|
||||||
|
`data:`, and other non-allowlisted schemes are neutralised via `safeUrl`.
|
||||||
|
- **Nested `mdcms` tags now hydrate.** Tags emitted inside a tab, accordion, or
|
||||||
|
callout body (e.g. a post list inside a tab) are processed by re-sweeping
|
||||||
|
until none remain, instead of rendering as empty divs.
|
||||||
|
- **Browser Back/Forward now navigate within the app.** User navigations use
|
||||||
|
`history.pushState` (the initial load, back/forward, and category re-renders
|
||||||
|
still replace), so Back returns to the previous page instead of leaving the
|
||||||
|
site.
|
||||||
|
- **Clean-URL reloads (Ctrl-R) work.** The generated service worker now serves
|
||||||
|
the cached `index.html` app shell for any navigation request, so reloading a
|
||||||
|
pretty URL such as `example.com/section-id` no longer 404s before the
|
||||||
|
client-side router runs. This also makes pretty-URL reloads work offline.
|
||||||
|
(Generated by `mdcms build`; `app/service-worker.js` regenerated.)
|
||||||
|
- **`marked` is configured once** instead of re-registering the renderer on
|
||||||
|
every page render.
|
||||||
|
- Stored `md-cms-theme` value is validated against `light`/`dark` before use;
|
||||||
|
fixed invalid `text-align: centre` → `center` on the pagination jump input;
|
||||||
|
per-category `offline-message` is now resolved after categories initialise.
|
||||||
|
|
||||||
|
### CLI (`mdcms.py`)
|
||||||
|
|
||||||
|
- **New command: `mdcms serve`.** Local preview server with SPA clean-URL
|
||||||
|
fallback. Unknown extension-less paths (e.g. `/section-id`) are served
|
||||||
|
`index.html` so reloads and shared clean URLs work during preview; paths
|
||||||
|
*with* an extension still 404, preserving the renderer's category-variant
|
||||||
|
fallback. Serves `.md` as `text/markdown` and `.yml` as `text/yaml`.
|
||||||
|
Options: `[name]`, `--path`, `--port` (default 8800), `--bind`.
|
||||||
|
- **New docs page: `docs/hosting.md`.** Explains why clean URLs 404 on dumb
|
||||||
|
static hosts and documents the fix for every environment (`mdcms serve`,
|
||||||
|
service worker, GitHub Pages `404.html`, Netlify/Cloudflare `_redirects`,
|
||||||
|
nginx, Apache, Caddy), including a standalone Python preview script.
|
||||||
|
- **`mdcms fetch-deps` no longer crashes.** `CDN_DEPS`, `_WOFF2_URL_RE`,
|
||||||
|
`_fetch_bunny_fonts`, and `_patch_index_html` were lost in an earlier merge,
|
||||||
|
raising `NameError` on every invocation; they have been restored.
|
||||||
|
- **No more spurious "update available" warning.** Site markers are compared
|
||||||
|
against a dedicated `SITE_FORMAT_VERSION` (with zero-padded version
|
||||||
|
comparison) rather than `CLI_VERSION`, so a `v0.6` site no longer reports as
|
||||||
|
outdated against CLI `v0.6.x`, and CLI patch releases that share the site
|
||||||
|
format stay quiet.
|
||||||
|
|
||||||
## Tabs & Accordions (`app/index.html`)
|
## Tabs & Accordions (`app/index.html`)
|
||||||
|
|
||||||
Four new `mdcms` fenced-block types for rich content layout. All four variants read from the active theme automatically — no new config keys, no per-theme overrides needed.
|
Four new `mdcms` fenced-block types for rich content layout. All four variants read from the active theme automatically — no new config keys, no per-theme overrides needed.
|
||||||
|
|
|
||||||
259
mdcms.py
259
mdcms.py
|
|
@ -1,6 +1,6 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
#
|
#
|
||||||
# mdcms v0.6.0 — CLI companion
|
# mdcms v0.6.1 — CLI companion
|
||||||
#
|
#
|
||||||
# Copyright 2026 Kristian Benestad
|
# Copyright 2026 Kristian Benestad
|
||||||
#
|
#
|
||||||
|
|
@ -16,8 +16,9 @@
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
"""MD-CMS v0.6.0 — CLI tool for managing and building MD-CMS sites."""
|
"""MD-CMS v0.6.1 — CLI tool for managing and building MD-CMS sites."""
|
||||||
|
|
||||||
|
import http.server
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -32,8 +33,12 @@ import certifi
|
||||||
import click
|
import click
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
CLI_VERSION = "0.6.0"
|
CLI_VERSION = "0.6.1"
|
||||||
CLI_RELEASE_DATE = "7 June 2026"
|
CLI_RELEASE_DATE = "12 June 2026"
|
||||||
|
# Site file-format version this CLI emits/expects. Distinct from CLI_VERSION:
|
||||||
|
# many CLI releases share the same site format. Site markers are validated
|
||||||
|
# against this, not against the CLI version.
|
||||||
|
SITE_FORMAT_VERSION = "0.6"
|
||||||
MIN_SUPPORTED_VERSION = "0.3"
|
MIN_SUPPORTED_VERSION = "0.3"
|
||||||
|
|
||||||
MARKER_RE = re.compile(r"mdcms v(\d+\.\d+)", re.IGNORECASE)
|
MARKER_RE = re.compile(r"mdcms v(\d+\.\d+)", re.IGNORECASE)
|
||||||
|
|
@ -55,6 +60,19 @@ def _parse_ver(v: str) -> tuple:
|
||||||
return tuple(int(x) for x in v.split("."))
|
return tuple(int(x) for x in v.split("."))
|
||||||
|
|
||||||
|
|
||||||
|
def _cmp_ver(a: str, b: str) -> int:
|
||||||
|
"""Compare two dotted version strings, padding missing components with zeros.
|
||||||
|
|
||||||
|
Returns -1, 0, or 1. Padding means the site marker "0.6" and the CLI
|
||||||
|
version "0.6.0" compare equal rather than "0.6" being treated as older.
|
||||||
|
"""
|
||||||
|
ta, tb = _parse_ver(a), _parse_ver(b)
|
||||||
|
width = max(len(ta), len(tb))
|
||||||
|
ta += (0,) * (width - len(ta))
|
||||||
|
tb += (0,) * (width - len(tb))
|
||||||
|
return (ta > tb) - (ta < tb)
|
||||||
|
|
||||||
|
|
||||||
def read_site_version(site_path: Path) -> "str | None":
|
def read_site_version(site_path: Path) -> "str | None":
|
||||||
config = site_path / "config.yml"
|
config = site_path / "config.yml"
|
||||||
if not config.exists():
|
if not config.exists():
|
||||||
|
|
@ -69,14 +87,11 @@ def read_site_version(site_path: Path) -> "str | None":
|
||||||
|
|
||||||
def version_status(site_version: str) -> "tuple[str, str]":
|
def version_status(site_version: str) -> "tuple[str, str]":
|
||||||
"""Returns (status_code, display_message). status_code: 'ok', 'outdated', 'unsupported', 'newer'."""
|
"""Returns (status_code, display_message). status_code: 'ok', 'outdated', 'unsupported', 'newer'."""
|
||||||
sv = _parse_ver(site_version)
|
if _cmp_ver(site_version, MIN_SUPPORTED_VERSION) < 0:
|
||||||
min_sv = _parse_ver(MIN_SUPPORTED_VERSION)
|
|
||||||
cur = _parse_ver(CLI_VERSION)
|
|
||||||
if sv < min_sv:
|
|
||||||
return "unsupported", f"v{site_version} — below minimum supported v{MIN_SUPPORTED_VERSION}"
|
return "unsupported", f"v{site_version} — below minimum supported v{MIN_SUPPORTED_VERSION}"
|
||||||
if sv < cur:
|
if _cmp_ver(site_version, SITE_FORMAT_VERSION) < 0:
|
||||||
return "outdated", f"v{site_version} — update available (CLI is v{CLI_VERSION})"
|
return "outdated", f"v{site_version} — update available (CLI is v{CLI_VERSION})"
|
||||||
if sv > cur:
|
if _cmp_ver(site_version, SITE_FORMAT_VERSION) > 0:
|
||||||
return "newer", f"v{site_version} — site newer than CLI (consider upgrading mdcms)"
|
return "newer", f"v{site_version} — site newer than CLI (consider upgrading mdcms)"
|
||||||
return "ok", f"v{site_version}"
|
return "ok", f"v{site_version}"
|
||||||
|
|
||||||
|
|
@ -649,9 +664,19 @@ self.addEventListener('activate', event => {{
|
||||||
}});
|
}});
|
||||||
|
|
||||||
self.addEventListener('fetch', event => {{
|
self.addEventListener('fetch', event => {{
|
||||||
if (event.request.method !== 'GET') return;
|
const req = event.request;
|
||||||
|
if (req.method !== 'GET') return;
|
||||||
|
// App-shell routing: serve cached index.html for every navigation, including
|
||||||
|
// clean URLs like /section-id on reload. Without this the static host returns
|
||||||
|
// 404 for those paths before any JavaScript runs. Works offline too.
|
||||||
|
if (req.mode === 'navigate') {{
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then(cached => cached || fetch(event.request))
|
caches.match('index.html').then(shell => shell || fetch(req))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(req).then(cached => cached || fetch(req))
|
||||||
);
|
);
|
||||||
}});
|
}});
|
||||||
"""
|
"""
|
||||||
|
|
@ -703,7 +728,7 @@ def generate_site_manifest(site_path: Path):
|
||||||
empty_dirs.append(str(rel).replace("\\", "/"))
|
empty_dirs.append(str(rel).replace("\\", "/"))
|
||||||
|
|
||||||
manifest: dict = {
|
manifest: dict = {
|
||||||
"mdcms": read_site_version(site_path) or "0.4",
|
"mdcms": read_site_version(site_path) or SITE_FORMAT_VERSION,
|
||||||
"files": files,
|
"files": files,
|
||||||
}
|
}
|
||||||
if empty_dirs:
|
if empty_dirs:
|
||||||
|
|
@ -1033,6 +1058,123 @@ def build(name, path_override):
|
||||||
click.echo(click.style("Build complete.", fg="green"))
|
click.echo(click.style("Build complete.", fg="green"))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Dependency fetching (offline mode) ───────────────────────
|
||||||
|
|
||||||
|
CDN_DEPS = [
|
||||||
|
(
|
||||||
|
"https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js",
|
||||||
|
"assets/required/vendors/js-yaml.min.js",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js",
|
||||||
|
"assets/required/vendors/marked.min.js",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://cdn.jsdelivr.net/npm/fuse.js@7.0.0/dist/fuse.min.js",
|
||||||
|
"assets/required/vendors/fuse.min.js",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js",
|
||||||
|
"assets/required/vendors/highlight.min.js",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css",
|
||||||
|
"assets/required/vendors/github.min.css",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css",
|
||||||
|
"assets/required/vendors/github-dark.min.css",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
_WOFF2_URL_RE = re.compile(
|
||||||
|
r"""url\(\s*['"]?(https://fonts\.bunny\.net/[^'"\s)]+\.woff2)['"]?\s*\)""",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_bunny_fonts(site_path: Path, theme_file: str) -> list:
|
||||||
|
"""Download Bunny Fonts from theme.yml to assets/fonts/. Returns list of local CSS paths."""
|
||||||
|
theme_path = site_path / theme_file
|
||||||
|
if not theme_path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
theme_data = yaml.safe_load(theme_path.read_text(encoding="utf-8")) or {}
|
||||||
|
except (OSError, yaml.YAMLError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
fonts_dir = site_path / "assets" / "fonts"
|
||||||
|
fonts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
seen: set = set()
|
||||||
|
local_css_paths: list = []
|
||||||
|
|
||||||
|
for key in ("font-body", "font-heading", "font-code"):
|
||||||
|
spec = theme_data.get(key)
|
||||||
|
if not spec:
|
||||||
|
continue
|
||||||
|
parts = str(spec).split(":")
|
||||||
|
if len(parts) < 3 or parts[0].strip().lower() != "bunny":
|
||||||
|
continue
|
||||||
|
name = parts[1].strip()
|
||||||
|
weight = parts[-1].strip()
|
||||||
|
font_id = f"{name}:{weight}"
|
||||||
|
if font_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(font_id)
|
||||||
|
|
||||||
|
bunny_url = f"https://fonts.bunny.net/css?family={name.replace(' ', '+')}:{weight}"
|
||||||
|
click.echo(f" Fetching font: {name} {weight}")
|
||||||
|
try:
|
||||||
|
css_text = _http_get(bunny_url).decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(click.style(f" Warning: could not fetch {bunny_url}: {e}", fg="yellow"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _rewrite(m: "re.Match") -> str:
|
||||||
|
woff2_url = m.group(1)
|
||||||
|
filename = woff2_url.split("/")[-1].split("?")[0]
|
||||||
|
dest = fonts_dir / filename
|
||||||
|
if not dest.exists():
|
||||||
|
try:
|
||||||
|
dest.write_bytes(_http_get(woff2_url))
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(click.style(f" Warning: could not fetch {woff2_url}: {e}", fg="yellow"))
|
||||||
|
return m.group(0)
|
||||||
|
return f"url('../fonts/{filename}')"
|
||||||
|
|
||||||
|
local_css = _WOFF2_URL_RE.sub(_rewrite, css_text)
|
||||||
|
safe_name = name.lower().replace(" ", "-")
|
||||||
|
css_filename = f"{safe_name}-{weight}.css"
|
||||||
|
(fonts_dir / css_filename).write_text(local_css, encoding="utf-8")
|
||||||
|
local_css_paths.append(f"assets/fonts/{css_filename}")
|
||||||
|
click.echo(f" Wrote assets/fonts/{css_filename}")
|
||||||
|
|
||||||
|
return local_css_paths
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_index_html(site_path: Path, local_font_css: list):
|
||||||
|
"""Replace CDN tags with local paths and inject font link tags."""
|
||||||
|
index_path = site_path / "index.html"
|
||||||
|
if not index_path.exists():
|
||||||
|
raise click.ClickException("index.html not found in site directory.")
|
||||||
|
|
||||||
|
html = index_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for cdn_url, local_path in CDN_DEPS:
|
||||||
|
html = html.replace(cdn_url, local_path)
|
||||||
|
|
||||||
|
if local_font_css:
|
||||||
|
links = "\n".join(
|
||||||
|
f'<link rel="stylesheet" href="{p}" data-mdcms-fonts="1">'
|
||||||
|
for p in local_font_css
|
||||||
|
)
|
||||||
|
html = html.replace("</head>", f"{links}\n</head>", 1)
|
||||||
|
|
||||||
|
index_path.write_text(html, encoding="utf-8")
|
||||||
|
click.echo(" Patched index.html")
|
||||||
|
|
||||||
|
|
||||||
@cli.command("fetch-deps")
|
@cli.command("fetch-deps")
|
||||||
@click.argument("name", required=False, default=None)
|
@click.argument("name", required=False, default=None)
|
||||||
@click.option("--path", "path_override", default=None, type=click.Path(),
|
@click.option("--path", "path_override", default=None, type=click.Path(),
|
||||||
|
|
@ -1066,6 +1208,97 @@ def fetch_deps(name, path_override):
|
||||||
click.echo(click.style("Done. Site is ready for offline use.", fg="green"))
|
click.echo(click.style("Done. Site is ready for offline use.", fg="green"))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Local preview server ─────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_preview_handler(site_dir: Path):
|
||||||
|
"""Build a request handler that serves site_dir with SPA clean-URL fallback.
|
||||||
|
|
||||||
|
Static servers cannot resolve clean URLs like /section-id — the mapping to
|
||||||
|
pages/section-id.md exists only in the renderer's JavaScript. Any request
|
||||||
|
whose last path segment has no file extension and doesn't exist on disk is
|
||||||
|
rewritten to /index.html so the client-side router can resolve it. Requests
|
||||||
|
with an extension (e.g. missing .md category variants) still 404, which the
|
||||||
|
renderer relies on for its variant-fallback logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class PreviewHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
# Explicit types for the site's text formats. .md must never be served
|
||||||
|
# as text/html: the renderer treats text/html responses as "not found"
|
||||||
|
# to defeat SPA rewrites, including this one.
|
||||||
|
extensions_map = {
|
||||||
|
**http.server.SimpleHTTPRequestHandler.extensions_map,
|
||||||
|
".md": "text/markdown; charset=utf-8",
|
||||||
|
".yml": "text/yaml; charset=utf-8",
|
||||||
|
".yaml": "text/yaml; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".webmanifest": "application/manifest+json; charset=utf-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, directory=str(site_dir), **kwargs)
|
||||||
|
|
||||||
|
def _rewrite_spa(self):
|
||||||
|
local = Path(self.translate_path(self.path))
|
||||||
|
if local.exists():
|
||||||
|
return
|
||||||
|
clean = self.path.split("?", 1)[0].split("#", 1)[0]
|
||||||
|
last = clean.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
if "." not in last:
|
||||||
|
self.path = "/index.html"
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self._rewrite_spa()
|
||||||
|
super().do_GET()
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
self._rewrite_spa()
|
||||||
|
super().do_HEAD()
|
||||||
|
|
||||||
|
return PreviewHandler
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument("name", required=False)
|
||||||
|
@click.option("--path", "path_override", type=click.Path(), default=None,
|
||||||
|
help="Explicit site path (no registry lookup).")
|
||||||
|
@click.option("--port", default=8800, show_default=True, help="Port to listen on.")
|
||||||
|
@click.option("--bind", default="127.0.0.1", show_default=True,
|
||||||
|
help="Address to bind to (use 0.0.0.0 to expose on the network).")
|
||||||
|
def serve(name, path_override, port, bind):
|
||||||
|
"""Preview a site locally with clean-URL support.
|
||||||
|
|
||||||
|
Unlike a plain static server (e.g. python3 -m http.server), unknown
|
||||||
|
extension-less paths such as /section-id are served the app shell so
|
||||||
|
reloading or sharing clean URLs works during local preview.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
mdcms serve mysite # registered site by name
|
||||||
|
mdcms serve --path ./site # explicit path
|
||||||
|
mdcms serve # current directory
|
||||||
|
"""
|
||||||
|
site_path = resolve_site_path(name, path_override)
|
||||||
|
if not (site_path / "index.html").exists():
|
||||||
|
raise click.ClickException(f"No index.html found at {site_path}")
|
||||||
|
|
||||||
|
handler = _make_preview_handler(site_path)
|
||||||
|
try:
|
||||||
|
httpd = http.server.ThreadingHTTPServer((bind, port), handler)
|
||||||
|
except OSError as e:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"Could not bind {bind}:{port} ({e.strerror or e}). "
|
||||||
|
"Is another server already running? Try a different --port."
|
||||||
|
)
|
||||||
|
with httpd:
|
||||||
|
click.echo(f"Serving {site_path}")
|
||||||
|
click.echo(f" http://{'localhost' if bind in ('127.0.0.1', '0.0.0.0') else bind}:{port}/")
|
||||||
|
click.echo("Press Ctrl-C to stop.")
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
click.echo("\nStopped.")
|
||||||
|
|
||||||
|
|
||||||
# ─── Entry point ─────────────────────────────────────────────
|
# ─── Entry point ─────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mdcms"
|
name = "mdcms"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
description = "MD-CMS — Markdown-based CMS companion CLI"
|
description = "MD-CMS — Markdown-based CMS companion CLI"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "Apache-2.0" }
|
license = { text = "Apache-2.0" }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue