diff --git a/app/404.html b/app/404.html
new file mode 100644
index 0000000..b92cb97
--- /dev/null
+++ b/app/404.html
@@ -0,0 +1,30 @@
+
+
+
+
+Redirecting…
+
+
+
+
diff --git a/app/index.html b/app/index.html
index de8a6ab..284dd17 100644
--- a/app/index.html
+++ b/app/index.html
@@ -1082,6 +1082,22 @@ body {
'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 = [];
@@ -3022,6 +3038,34 @@ function fmtDatetime(dtStr) {
});
}
+ // ─── 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) {
currentPage = file;
@@ -3046,14 +3090,23 @@ function fmtDatetime(dtStr) {
}
}
- // Build a clean URL: keep origin + path, set ?cat only when non-default, set hash to conceptual file
+ // 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');
}
- u.hash = '#' + file;
+ 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 = '';
@@ -3129,6 +3182,13 @@ function fmtDatetime(dtStr) {
if (page && page !== currentPage) 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) navigateTo(page);
+ });
+
// ─── Scroll to top ───────────────────────────────────────
const scrollBtn = document.getElementById('scrollTop');
window.addEventListener('scroll', () => {
@@ -3197,6 +3257,7 @@ function fmtDatetime(dtStr) {
}
renderNav();
}
+ const routeFromPath = initBasePath();
if (config.search !== false) {
try {
@@ -3225,7 +3286,7 @@ function fmtDatetime(dtStr) {
}
const hashPage = getPageFromHash();
- await navigateTo(hashPage || defaultPage());
+ await navigateTo(routeFromPath || hashPage || defaultPage());
} catch (err) {
document.getElementById('app').innerHTML = `
MD-CMS Error
diff --git a/docs/unreleased.md b/docs/unreleased.md
index 646d081..4a525ed 100644
--- a/docs/unreleased.md
+++ b/docs/unreleased.md
@@ -197,6 +197,32 @@ When a site uses category-suffixed page files (e.g. `page.current.md`) and is ho
---
+## Clean URLs for section-id pages (`app/index.html`, `app/404.html`)
+
+Pages whose filename matches a nav section-id can now be accessed at a clean URL path (e.g. `example.com/timesheet`) instead of the hash-based URL (`example.com/#pages/timesheet.md`).
+
+### How it works
+
+When you navigate to a page whose base filename (`timesheet`) matches a `code` entry in the `sections:` block of `nav.yml`, the renderer uses `history.replaceState` to rewrite the URL from `/#pages/timesheet.md` to `/timesheet`. All other pages continue to use hash-based URLs unchanged.
+
+On startup, if the URL pathname already contains a section-id slug (because the user typed or was linked to `example.com/timesheet` directly), the renderer detects it, sets the correct base path, and loads the matching page.
+
+Subpath deployments (e.g. `example.com/mysite/`) are handled automatically: the renderer determines the base from the initial pathname.
+
+### 404.html for GitHub Pages
+
+A new `app/404.html` file enables direct clean-URL access on GitHub Pages. When GitHub Pages serves the 404 page for an unknown path (e.g. `/timesheet`), `404.html` encodes the path as `?_route=/timesheet` and redirects to the app root. `index.html` reads `_route`, cleans up the URL, and routes to the right page. For other static hosts (Netlify, Cloudflare Pages, etc.) a `/*` → `/index.html` rewrite rule in the host's config achieves the same result.
+
+### Condition
+
+Only pages files that are both:
+1. located in `pages/` with a name matching a section `code` in `nav.yml`, and
+2. present in the `pages:` list in `nav.yml`
+
+…get a clean URL. All other pages continue to use `#` routing.
+
+---
+
## Fix: `config.yml` YAML parse errors now abort the build with a clear message
A malformed `config.yml` (e.g. a stray tab character, which YAML forbids as a token starter) previously caused `read_config` to silently return an empty dict. The build would proceed with no config — categories disabled, no default category code — producing a broken `nav.yml` with wrong filenames and missing `variants` fields, so category-variant pages would not appear in the sidebar.