From 31330d19e20f3ea0f5d373e27b80f0cad7c29c0b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 17:23:30 +0000 Subject: [PATCH] feat: clean URLs for section-id pages Pages whose filename matches a nav section-id now get a clean pathname URL (e.g. /timesheet) instead of the hash-based /#pages/timesheet.md. - _initialPathname captured at IIFE start; handles ?_route= from 404.html - basePath determined by initBasePath() after nav data loads; subpath deployments (e.g. /mysite/) handled automatically - navigateTo() uses replaceState to /slug for section-id pages and falls back to #hash for everything else - popstate listener handles browser history if a clean URL was the entry - resolveSlugToFile() validates that slug is both a section code and has a pages/{slug}.md entry in navData - app/404.html added for GitHub Pages SPA routing https://claude.ai/code/session_01Ai8xRvmrzdhuTKiRQ2fnn9 --- app/404.html | 30 +++++++++++++++++++++ app/index.html | 67 +++++++++++++++++++++++++++++++++++++++++++--- docs/unreleased.md | 26 ++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 app/404.html 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.