From b92bbc7129dfc4bab84163d00e882ab6e2475510 Mon Sep 17 00:00:00 2001 From: AI Date: Fri, 11 Sep 2026 11:18:17 +0200 Subject: [PATCH] Rewrite article discovery for author/slug layout with IndexedDB cache (AI) Drops the expa-YYYY category directory in favor of author/slug/slug.md with .tag and .date marker files; adds mtime-based IndexedDB caching, concurrency-limited crawling, an HTML-autoindex mtime fallback, and the #post// route this layout change requires. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd --- README.md | 75 +++++-- .../admin/hello-world/16-06-2026.date | 0 .../{general => hello-world}/hello-world.md | 2 - cmjs/js/cache.js | 84 ++++++++ cmjs/js/concurrency.js | 15 ++ cmjs/js/discovery.js | 188 +++++++++++++----- cmjs/js/router.js | 6 +- cmjs/js/views.js | 37 +++- 8 files changed, 322 insertions(+), 85 deletions(-) create mode 100644 cmjs/articles/admin/hello-world/16-06-2026.date rename cmjs/articles/admin/{general => hello-world}/hello-world.md (98%) create mode 100644 cmjs/js/cache.js create mode 100644 cmjs/js/concurrency.js diff --git a/README.md b/README.md index f9825d2..201433d 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil ## How it works -1. The browser traverses `cmjs/articles/` via a server directory index — discovering authors, year categories, and article files. Articles may be direct Markdown files or use the nested `author/category/post/post.md` layout; nginx JSON autoindex is preferred, with HTML autoindexes supported as a fallback. -2. Each `.md` file's YAML frontmatter is parsed to build the article list. +1. The browser traverses `cmjs/articles/` via a server directory index — discovering authors and their post directories (`author/slug/slug.md`); nginx JSON autoindex is preferred, with HTML autoindexes supported as a fallback. +2. Each `.md` file's YAML frontmatter is parsed for title/description/image; publish date and tags/category come from empty `.date`/`.tag` marker files beside it (see "Adding an article" below). 3. Clicking an article fetches the Markdown file and renders it with [marked.js](https://marked.js.org/). 4. Hash-based routing handles navigation — back/forward works without page reloads. +5. Discovered articles are cached in IndexedDB, keyed by post directory and invalidated by directory `mtime`, so repeat visits skip re-fetching unchanged content — see "Article discovery & caching" below. ## Features @@ -47,35 +48,45 @@ cmjs/ │ ├── frontmatter.js # parseFrontmatter() — pure YAML-front parser │ ├── templates.js # loadTemplates(), templates cache │ ├── discovery.js # discoverArticles(), fetchJson(), article cache + │ ├── cache.js # IndexedDB cache for discovered articles, keyed by post directory + │ ├── concurrency.js # mapWithConcurrency() — caps simultaneous per-article fetches │ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadCategoryView, loadSearch │ └── router.js # Hash router and app entry point ├── templates/ │ ├── card.html # Article list card layout │ └── article.html # Full article view layout ├── articles/ - │ └── author/ # One directory per author - │ └── expa-YYYY/ # Main expedition-year category - │ └── slug/ # One directory per post - │ └── slug.md + │ └── author/ # One directory per author + │ ├── author.md # Author profile (optional) + │ └── slug/ # One directory per post + │ ├── slug.md # Article content + frontmatter + │ ├── tag-id.tag # Zero or more empty tag/category markers + │ └── DD-MM-YYYY.date # Publish date marker (exactly one) └── pictures/ # Article images (referenced via image: frontmatter field) ``` ## Adding an article -1. Create the author, year, and post directories if they don't exist: `cmjs/articles/author/expa-YYYY/my-article/` -2. Create the Markdown file inside: `cmjs/articles/author/expa-YYYY/my-article/my-article.md` -3. Optional tag markers can sit beside the Markdown file as `tag-name.tag`. -4. Add a frontmatter block at the top -5. Write your content — it will appear in the list automatically +1. Create the author and post directories if they don't exist: `cmjs/articles/author/my-article/` +2. Create the Markdown file inside: `cmjs/articles/author/my-article/my-article.md` +3. Add a publish-date marker beside it: `cmjs/articles/author/my-article/DD-MM-YYYY.date` (e.g. + `16-06-2026.date`) — an empty file, exactly one per article. This is the sole source of the + publish date; a `date:` frontmatter field is no longer read. +4. Add tag/category markers as needed: `cmjs/articles/author/my-article/tag-id.tag` (e.g. + `kometa.tag`, `expa-2026.tag`) — empty files, any number. A tag id matching `expa-YYYY` is + treated as the article's "year"/category and shown in the Ročníky sidebar; every other tag + id is a normal topic tag. +5. Add a frontmatter block at the top of the Markdown file +6. Write your content — it will appear in the list automatically -`author` and `category` are derived from the directory path — no frontmatter fields needed for them. +`author` is derived from the directory path — no frontmatter field needed for it. Publish date, +tags, and category are derived from the `.date`/`.tag` marker files, not frontmatter. ### Frontmatter format ```yaml --- title: My Article Title -date: 2026-06-16 description: A short summary shown in the article list. draft: false --- @@ -86,12 +97,13 @@ Your markdown content here... | Field | Required | Notes | |-------|----------|-------| | `title` | yes | Shown in the list and article header | -| `date` | yes | ISO format `YYYY-MM-DD`, used for sorting newest-first | | `description` | no | Short summary shown under the title in the list | | `image` | no | Filename of the preview image in the `pictures/` directory (e.g. `photo.jpg`); shown as card thumbnail and article hero | | `draft` | no | Set to `true`, `yes`, or `1` to hide from the list | -Reading time is computed automatically from the article word count — no frontmatter field needed. +Reading time is computed automatically from the article word count — no frontmatter field +needed. `date` and `tags` frontmatter fields, if present, are ignored — superseded by the +`.date`/`.tag` marker files above. ## Author profiles @@ -136,10 +148,11 @@ The body text below the frontmatter is rendered as markdown and shown as the bio | `#search/linux` | Search results for "linux" | | `#author/alice` | All articles by alice, page 1 | | `#author/alice/page/2` | All articles by alice, page 2 | -| `#category/linux` | All articles in "linux", page 1 | -| `#category/linux/page/2` | All articles in "linux", page 2 | -| `#alice/linux/my-article` | Single legacy article at `cmjs/articles/alice/linux/my-article.md` | -| `#alice/expa-2024/my-article/my-article` | Single nested article at `cmjs/articles/alice/expa-2024/my-article/my-article.md` | +| `#category/expa-2026` | All articles carrying the "expa-2026" year tag, page 1 | +| `#category/expa-2026/page/2` | Same, page 2 | +| `#tag/kometa` | All articles tagged "kometa", page 1 | +| `#tag/kometa/page/2` | Same, page 2 | +| `#post/alice/my-article` | Single article at `cmjs/articles/alice/my-article/my-article.md` | Author names and category tags on each card and article are clickable links to their respective filter views. A search box in the header navigates to `#search/query`. @@ -249,11 +262,33 @@ server { location /cmjs/articles/ { autoindex on; autoindex_format json; + autoindex_localtime off; # keep mtimes in GMT — the crawler's cache relies on consistent, unambiguous timestamps } } ``` -> **Note:** `autoindex_format json` requires nginx 1.7.9+. Check with `nginx -v`. +> **Note:** `autoindex_format json` requires nginx 1.7.9+. Check with `nginx -v`. If +> `autoindex_format json` is ever lost (config drift, an older nginx), the crawler falls back +> to parsing nginx's default HTML directory listing instead of breaking outright. + +## Article discovery & caching + +Articles are discovered by crawling `cmjs/articles/` client-side: fetch the author +directories, then each author's post directories along with their `mtime`, then — only for +directories whose `mtime` has changed since the last visit — that directory's own listing (to +read its `.tag`/`.date` markers) and its Markdown content. + +- **Cache** — results are cached per post directory in IndexedDB (`cmjs/js/cache.js`), keyed + by directory path and invalidated by comparing the live `mtime` against the cached one. If + IndexedDB is unavailable (private browsing, disabled storage), the crawler transparently + falls back to fetching everything live on every visit. +- **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `cmjs/js/discovery.js` (default `10`) caps how + many post directories are crawled at once; raise it for a fast/local nginx, lower it for a + slow or shared/production instance. +- **Design rationale** — the full reasoning (wave-based crawling, recency prioritisation, + HTML-autoindex fallback, and long-term "deploy and forget" constraints) is documented in + [`.claude/nginx-sitemap-crawler-plan.md`](.claude/nginx-sitemap-crawler-plan.md), which ships + alongside the code. ## Running locally diff --git a/cmjs/articles/admin/hello-world/16-06-2026.date b/cmjs/articles/admin/hello-world/16-06-2026.date new file mode 100644 index 0000000..e69de29 diff --git a/cmjs/articles/admin/general/hello-world.md b/cmjs/articles/admin/hello-world/hello-world.md similarity index 98% rename from cmjs/articles/admin/general/hello-world.md rename to cmjs/articles/admin/hello-world/hello-world.md index 72bf7e9..51c585f 100644 --- a/cmjs/articles/admin/general/hello-world.md +++ b/cmjs/articles/admin/hello-world/hello-world.md @@ -1,6 +1,5 @@ --- title: Hello World -date: 2026-06-16 description: A sample article to demonstrate the CMS. image: hello-world-preview.webp draft: false @@ -15,7 +14,6 @@ Articles are plain Markdown files stored in the `articles/` directory. Each file ```yaml --- title: My Article Title -date: 2026-06-16 description: A short summary shown in the article list. draft: false --- diff --git a/cmjs/js/cache.js b/cmjs/js/cache.js new file mode 100644 index 0000000..5a8dbe6 --- /dev/null +++ b/cmjs/js/cache.js @@ -0,0 +1,84 @@ +// Persistent cache for discovered articles, keyed by slug-directory path. +// Raw IndexedDB (no wrapper library — see .claude/nginx-sitemap-crawler-plan.md +// L4/L5 on the zero-dependency, long-stable-API requirement). If IndexedDB is +// unavailable (private browsing, old browser, blocked), every operation below +// silently becomes a no-op so discovery.js gets live-fetch behaviour for free +// without needing its own availability branching (plan doc R6). + +const DB_NAME = 'cmjs-cache'; +const STORE_NAME = 'slugDirs'; + +let cacheAvailable = true; +let dbPromise = null; + +function openDb() { + if (!dbPromise) { + dbPromise = new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB unavailable')); return; } + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE_NAME, { keyPath: 'path' }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error('IndexedDB open blocked')); + }).catch(error => { cacheAvailable = false; throw error; }); + } + return dbPromise; +} + +// Opens a store and resolves/rejects with the given IDBRequest's own outcome +// — the standard pattern, so result timing never depends on transaction +// lifecycle details. +async function runRequest(mode, makeRequest) { + if (!cacheAvailable) return undefined; + try { + const db = await openDb(); + const store = db.transaction(STORE_NAME, mode).objectStore(STORE_NAME); + return await new Promise((resolve, reject) => { + const request = makeRequest(store); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } catch { + cacheAvailable = false; + return undefined; + } +} + +export function isCacheAvailable() { + return cacheAvailable; +} + +// Returns the cached { path, mtime, article } record, or undefined on a miss +// (or if the cache is unavailable). +export async function getCacheEntry(path) { + return runRequest('readonly', store => store.get(path)); +} + +export async function setCacheEntry(path, entry) { + await runRequest('readwrite', store => store.put({ ...entry, path })); +} + +// Removes any cached entry whose path is no longer in the live directory +// listing (renamed/deleted slugs), so the cache doesn't grow unbounded. +export async function pruneCache(validPaths) { + if (!cacheAvailable) return; + const valid = new Set(validPaths); + try { + const db = await openDb(); + const store = db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME); + await new Promise((resolve, reject) => { + const cursorRequest = store.openCursor(); + cursorRequest.onsuccess = event => { + const cursor = event.target.result; + if (!cursor) { resolve(); return; } + if (!valid.has(cursor.value.path)) cursor.delete(); + cursor.continue(); + }; + cursorRequest.onerror = () => reject(cursorRequest.error); + }); + } catch { + cacheAvailable = false; + } +} diff --git a/cmjs/js/concurrency.js b/cmjs/js/concurrency.js new file mode 100644 index 0000000..6e37edb --- /dev/null +++ b/cmjs/js/concurrency.js @@ -0,0 +1,15 @@ +// Runs `fn` over `items` with at most `limit` calls in flight at once. +// A worker-pool: each worker pulls the next index off a shared cursor until +// the list is exhausted, so slow items don't stall workers behind them. +export async function mapWithConcurrency(items, limit, fn) { + const results = new Array(items.length); + let cursor = 0; + async function worker() { + while (cursor < items.length) { + const index = cursor++; + results[index] = await fn(items[index], index); + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; +} diff --git a/cmjs/js/discovery.js b/cmjs/js/discovery.js index 408ad2a..21bfb18 100644 --- a/cmjs/js/discovery.js +++ b/cmjs/js/discovery.js @@ -1,10 +1,27 @@ import { parseFrontmatter } from './frontmatter.js'; import { BASE_URL, PICTURES_URL } from './config.js'; +import { mapWithConcurrency } from './concurrency.js'; +import { getCacheEntry, setCacheEntry, pruneCache } from './cache.js'; + +// Caps simultaneous per-article fetches (see the "Wave 3+4" step below) so a +// large site doesn't burst hundreds of requests at once. Raise it for a +// fast/local nginx, lower it for a slow or shared/production instance — see +// .claude/nginx-sitemap-crawler-plan.md §2a for the full rationale. +const MAX_CONCURRENT_REQUESTS = 10; + +const MONTH_ABBREVIATIONS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +// A slug directory's own `.date` marker file (see README). +const DATE_FILE_PATTERN = /^(\d{2})-(\d{2})-(\d{4})\.date$/; function parseHtmlDirectoryListing(raw) { const entries = []; const seen = new Set(); - const anchors = /]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/gi; + const anchors = /]*\bhref\s*=\s*["']([^"']+)["'][^>]*>[\s\S]*?<\/a>/gi; + // nginx's default autoindex puts "DD-Mon-YYYY HH:MM" right after the + // on the same line — used as a best-effort mtime when JSON autoindex + // isn't available (see the "mtime" note in the README). + const datePattern = /(\d{2})-([A-Za-z]{3})-(\d{4})\s+(\d{2}):(\d{2})/; let match; while ((match = anchors.exec(raw))) { const href = match[1].replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'"); @@ -13,7 +30,16 @@ function parseHtmlDirectoryListing(raw) { const name = decodeURIComponent(clean.replace(/\/$/, '')).split('/').pop(); if (!name || name === '.' || name === '..' || seen.has(name)) continue; seen.add(name); - entries.push({ name, type: clean.endsWith('/') ? 'directory' : 'file' }); + + const lineEnd = raw.indexOf('\n', anchors.lastIndex); + const tail = raw.slice(anchors.lastIndex, lineEnd === -1 ? raw.length : lineEnd); + const dateMatch = datePattern.exec(tail); + const monthIndex = dateMatch ? MONTH_ABBREVIATIONS.indexOf(dateMatch[2]) : -1; + const mtime = monthIndex >= 0 + ? new Date(Date.UTC(+dateMatch[3], monthIndex, +dateMatch[1], +dateMatch[4], +dateMatch[5])).toISOString() + : null; + + entries.push({ name, type: clean.endsWith('/') ? 'directory' : 'file', mtime }); } return entries; } @@ -31,12 +57,6 @@ export async function fetchJson(url) { throw new Error(`Expected a directory listing at ${url}`); } -export function asList(value) { - if (Array.isArray(value)) return value.map(String).filter(Boolean); - if (value == null || value === '') return []; - return String(value).split(',').map(item => item.trim()).filter(Boolean); -} - export function resolveMediaUrl(value, articlePath = '') { if (!value) return ''; let path = String(value).trim(); @@ -68,6 +88,15 @@ export async function fetchTagRegistry() { } let articleCache = null; +let crawlStats = { total: 0, loaded: 0 }; + +// How many of the last crawl's slug directories actually produced an article +// vs. how many were candidates — lets the UI show a "something's badly wrong" +// indicator instead of silently rendering an empty/near-empty list forever. +export function getCrawlStats() { + return { ...crawlStats }; +} + function isPublished(meta) { if (meta.status != null) return ['published', 'publish'].includes(String(meta.status).toLowerCase()); return !['true', 'yes', '1'].includes(String(meta.draft ?? '').toLowerCase().trim()); @@ -85,56 +114,111 @@ function excerpt(content) { return `${text.slice(0, 277).replace(/\s+\S*$/, '')}…`; } +// A single `.date` marker file is the sole authoritative source +// of an article's publish date; a missing/ambiguous one falls back to the +// (deprecated) frontmatter `date:` field so older, unmigrated content still +// works, resolved by the caller. +function parseDateFromFiles(files) { + const matches = files.map(file => file.name.match(DATE_FILE_PATTERN)).filter(Boolean); + if (matches.length !== 1) return null; + const [, day, month, year] = matches[0]; + return `${year}-${month}-${day}`; +} + +// Fetches a slug directory's own listing, resolves its markdown file and +// `.tag` marker files, and builds the full article record. Returns null on +// any failure (missing/unreadable markdown, network error) — the caller +// treats that as a silent skip, matching the rest of this module's approach. +async function buildArticle(dir) { + try { + const entries = await fetchJson(dir.root); + const files = entries.filter(entry => entry.type === 'file'); + const tagIds = files.filter(entry => entry.name.endsWith('.tag')).map(entry => entry.name.replace(/\.tag$/, '')); + const mdFiles = files.filter(entry => entry.name.endsWith('.md') && !entry.name.startsWith('_')); + const mdFile = mdFiles.find(entry => entry.name === `${dir.slug}.md`) + || mdFiles.find(entry => entry.name === 'index.md') + || (mdFiles.length === 1 ? mdFiles[0] : null); + if (!mdFile) return null; + + const path = `${dir.root}${encodeURIComponent(mdFile.name)}`; + const response = await fetch(path); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const raw = await response.text(); + const { meta, content } = parseFrontmatter(raw); + const words = content.trim().split(/\s+/).filter(Boolean).length; + // "Category" isn't a separate concept anymore — it's just whichever + // tag id (if any) looks like an expedition year. + const category = tagIds.find(id => /^expa-20\d{2}$/i.test(id)) || null; + + return { + ...meta, + author: dir.author, + category, + expa: category ? Number(category.replace(/^expa-/i, '')) : null, + slug: dir.slug, + path, + hash: `${dir.author}/${dir.slug}`, + date: parseDateFromFiles(files) || meta.date || null, + readingTime: Math.max(1, Math.round(words / 200)), + description: meta.description || excerpt(content), + tags: tagIds, + coverUrl: resolveMediaUrl(meta.cover || meta.image, path), + published: isPublished(meta), + }; + } catch { + return null; + } +} + export async function discoverArticles() { if (articleCache) return articleCache; + + // Wave 1 (root) — always fetched live: the author directories. const authorEntries = await fetchJson(`${BASE_URL}articles/`); const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.')); - const categoryLists = await Promise.all(authors.map(async author => { - const entries = await fetchJson(`${BASE_URL}articles/${encodeURIComponent(author.name)}/`); - const directories = entries.filter(entry => entry.type === 'directory' && entry.name !== '_history'); - const years = directories.filter(entry => /^expa-20\d{2}$/i.test(entry.name)); - return (years.length ? years : directories).map(category => ({ author: author.name, category: category.name })); - })); - const sources = (await Promise.all(categoryLists.flat().map(async ({ author, category }) => { - const root = `${BASE_URL}articles/${encodeURIComponent(author)}/${encodeURIComponent(category)}/`; - const entries = await fetchJson(root); - const direct = entries.filter(entry => entry.type === 'file' && entry.name.endsWith('.md')) - .map(entry => ({ author, category, slug: entry.name.replace(/\.md$/, ''), name: entry.name, root })); - const nested = await Promise.all(entries.filter(entry => entry.type === 'directory' && entry.name !== '_history').map(async post => { - const postRoot = `${root}${encodeURIComponent(post.name)}/`; - const files = (await fetchJson(postRoot)).filter(entry => entry.type === 'file' && entry.name.endsWith('.md') && !entry.name.startsWith('_')); - const selected = files.find(entry => entry.name === 'index.md') || files.find(entry => entry.name === `${post.name}.md`) || (files.length === 1 ? files[0] : null); - return selected ? { author, category, slug: post.name, name: selected.name, root: postRoot } : null; - })); - return [...direct, ...nested.filter(Boolean)]; - }))).flat(); - - const articles = await Promise.all(sources.map(async source => { - const path = `${source.root}${encodeURIComponent(source.name)}`; - try { - const response = await fetch(path); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const raw = await response.text(); - const { meta, content } = parseFrontmatter(raw); - const words = content.trim().split(/\s+/).filter(Boolean).length; - return { - ...meta, - author: source.author, - category: source.category, - expa: Number(meta.expa || source.category.replace(/^expa-/i, '')) || null, - slug: source.slug, - path, - hash: `${source.author}/${source.category}/${source.slug}`, - readingTime: Math.max(1, Math.round(words / 200)), - description: meta.description || excerpt(content), - tags: asList(meta.tags), - coverUrl: resolveMediaUrl(meta.cover || meta.image, path), - published: isPublished(meta), - }; - } catch { return null; } + // Wave 2 — always fetched live, in full, for every author (not just the + // current page): each author's slug directories, with their mtimes. This + // is what builds the flat, cross-author list that drives global sorting + // and pagination — see the plan doc's "keep the flat feed" decision. + const slugDirLists = await Promise.all(authors.map(async author => { + const authorRoot = `${BASE_URL}articles/${encodeURIComponent(author.name)}/`; + const entries = await fetchJson(authorRoot); + return entries + .filter(entry => entry.type === 'directory' && entry.name !== '_history') + .map(entry => ({ + author: author.name, + slug: entry.name, + mtime: entry.mtime ?? null, + root: `${authorRoot}${encodeURIComponent(entry.name)}/`, + })); })); - articleCache = articles.filter(article => article?.published).sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))); + const slugDirs = slugDirLists.flat(); + + // Wave 3+4 — per slug directory, cache-aware and concurrency-limited: a + // directory whose mtime matches its cached entry skips both the listing + // fetch (for .tag/.date files) and the markdown content fetch entirely. + let loaded = 0; + const articles = await mapWithConcurrency(slugDirs, MAX_CONCURRENT_REQUESTS, async dir => { + const cached = await getCacheEntry(dir.root); + if (cached && dir.mtime != null && cached.mtime === dir.mtime) { + if (cached.article) loaded += 1; + return cached.article; + } + const article = await buildArticle(dir); + if (article) { + loaded += 1; + await setCacheEntry(dir.root, { mtime: dir.mtime, article }); + } + return article; + }); + + await pruneCache(slugDirs.map(dir => dir.root)); + crawlStats = { total: slugDirs.length, loaded }; + + articleCache = articles + .filter(article => article?.published && article.date) + .sort((a, b) => String(b.date).localeCompare(String(a.date)) || a.hash.localeCompare(b.hash)); return articleCache; } diff --git a/cmjs/js/router.js b/cmjs/js/router.js index 787b639..74d02d5 100644 --- a/cmjs/js/router.js +++ b/cmjs/js/router.js @@ -6,9 +6,10 @@ // 2 segments, first = "page" → all articles, page N (#page/2) // 2 segments, first = "author" → author filter, page 1 (#author/alice) // 2 segments, first = "category" or "tag" → filtered list, page 1 -// 3 segments → single article (#author/category/slug) +// 3 segments, first = "post" → single article (#post/alice/my-article) // 4 segments, first = "author", third = "page" → author filter, page N (#author/alice/page/2) // 4 segments, first = "category", third = "page" → category filter, page N (#category/linux/page/2) +// 4 segments, first = "tag", third = "page" → tag filter, page N (#tag/kometa/page/2) import { MARKED_URL } from './config.js'; import { loadTemplates } from './templates.js'; @@ -42,11 +43,10 @@ function router() { else if (parts.length === 2 && parts[0] === 'author') loadAuthorView(decodeURIComponent(parts[1])); else if (parts.length === 2 && parts[0] === 'category') loadCategoryView(decodeURIComponent(parts[1])); else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodeURIComponent(parts[1])); - else if (parts.length === 3) loadArticle(parts.map(decodeURIComponent).join('/')); + else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodeURIComponent).join('/')); else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodeURIComponent(parts[1]), parsePage(parts[3])); else if (parts.length === 4 && parts[0] === 'category' && parts[2] === 'page') loadCategoryView(decodeURIComponent(parts[1]), parsePage(parts[3])); else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodeURIComponent(parts[1]), parsePage(parts[3])); - else if (parts.length === 4) loadArticle(parts.map(decodeURIComponent).join('/')); else loadArticleList(); } diff --git a/cmjs/js/views.js b/cmjs/js/views.js index 5f6c590..9dd9df3 100644 --- a/cmjs/js/views.js +++ b/cmjs/js/views.js @@ -1,5 +1,5 @@ import { templates } from './templates.js'; -import { discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, resolveMediaUrl } from './discovery.js'; +import { discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, getCrawlStats, resolveMediaUrl } from './discovery.js'; import { parseFrontmatter } from './frontmatter.js'; import { PICTURES_URL } from './config.js'; @@ -141,7 +141,7 @@ function renderCard(article) { image.src = value; image.alt = article.title || article.slug; if (imageLink) { - imageLink.href = `#${article.hash}`; + imageLink.href = `#post/${article.hash}`; imageLink.removeAttribute('aria-hidden'); imageLink.removeAttribute('tabindex'); } @@ -150,7 +150,7 @@ function renderCard(article) { imageLink?.remove(); element.classList.add('card--no-image'); } - field('link').href = `#${article.hash}`; + field('link').href = `#post/${article.hash}`; field('title').textContent = article.title || article.slug; setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = czechDate(value); }); setField(field('description'), article.description, (description, value) => { description.textContent = value; }); @@ -158,13 +158,28 @@ function renderCard(article) { const author = field('author'); author.textContent = article.authorName || article.author; author.href = `#author/${encodeURIComponent(article.author)}`; - const category = field('category'); - category.textContent = `Expa ${article.expa || article.category.replace(/^expa-/i, '')}`; - category.href = `#category/${encodeURIComponent(article.category)}`; + // A slug directory only counts as a "category" when one of its .tag + // markers happens to name an expedition year — not every article has one. + setField(field('category'), article.category, (category, value) => { + category.textContent = `Expa ${article.expa}`; + category.href = `#category/${encodeURIComponent(value)}`; + }); renderTags(field('tags'), article.tags); return element; } +// Near-total crawl failure (wrong origin after a migration, CORS broken, +// autoindex disabled) should be visible rather than silently rendering an +// empty/near-empty list forever — see .claude/nginx-sitemap-crawler-plan.md L3. +function renderCrawlHealthWarning(view) { + const { total, loaded } = getCrawlStats(); + if (total < 5 || loaded / total >= 0.1) return; + const warning = document.createElement('p'); + warning.className = 'error'; + warning.textContent = `Pozor: načtena jen ${loaded} z ${total} položek — zkontrolujte nastavení serveru.`; + view.appendChild(warning); +} + export async function loadArticleList(filter = null, page = 1) { showListView(); window.scrollTo(0, 0); @@ -181,6 +196,7 @@ export async function loadArticleList(filter = null, page = 1) { return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase(); }); view.innerHTML = ''; + renderCrawlHealthWarning(view); if (filter) { const heading = document.createElement('h2'); heading.className = 'category-heading'; @@ -289,13 +305,18 @@ export async function loadArticle(hash) { const element = document.importNode(templates.article, true); const field = name => element.querySelector(`[data-field="${name}"]`); field('title').textContent = meta.title || article.slug; - setField(field('date'), meta.date, (time, value) => { time.dateTime = String(value); time.textContent = czechDate(value); }); + // Publish date comes from the slug directory's `.date` marker file + // (already resolved onto `article.date`), not the raw frontmatter. + setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = czechDate(value); }); setField(field('description'), meta.description, (description, value) => { description.textContent = value; }); setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = `${value} min čtení`; }); const image = field('image'); if (article.coverUrl) { image.src = article.coverUrl; image.alt = meta.title || article.slug; } else image.closest('figure')?.remove(); const author = field('author'); author.textContent = meta.authorName || article.author; author.href = `#author/${encodeURIComponent(article.author)}`; - const category = field('category'); category.textContent = `Expa ${article.expa}`; category.href = `#category/${encodeURIComponent(article.category)}`; + setField(field('category'), article.category, (category, value) => { + category.textContent = `Expa ${article.expa}`; + category.href = `#category/${encodeURIComponent(value)}`; + }); renderTags(field('tags'), article.tags); const contentElement = field('content'); contentElement.innerHTML = marked.parse(content);