10 changed files with 259 additions and 123 deletions

No files matched your search

+100 -62
View File
@@ -5,10 +5,9 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil
## How it works
1. The browser traverses `articles/` via a server directory index — discovering authors and
their article files; nginx JSON autoindex is preferred, with HTML autoindexes supported as a
fallback.
2. Sorting, filtering, and publish status all come from the article file's own *name*
(`slug~DDMMYYYY~tags.md`) — knowable straight from the listing that discovers it, with no
nested post directories; nginx JSON/HTML indexes and Nextcloud WebDAV PROPFIND are supported.
2. Sorting and filtering come from each post directory's *name*
(`slug--DDMMYYYY--expa-YYYY+tags/`) — knowable straight from the listing, with no
further request per article. A `.md` file's actual content (title, description, image) is
only fetched once that article is about to be shown — the current page's cards, a single
article, or a search — not for the whole site up front. See "Adding an article" and "Article
@@ -16,7 +15,7 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil
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. Once an article's content has been fetched, it's cached in IndexedDB, keyed by the article
file's own path and invalidated by that file's `mtime`, so repeat visits skip re-fetching
file's own path and invalidated by its ETag (or mtime fallback), so repeat visits skip re-fetching
unchanged content — see "Article discovery & caching" below.
## Features
@@ -78,15 +77,20 @@ from the library — not something an embedding page needs to bring along.
│ ├── card.html # Article list card layout
│ ├── article.html # Full article view layout
│ └── author-profile.html # Author profile card layout
└── articles/
└── articles/ # The only folder cmjs reads
├── _tags.json # Tag registry (optional, see "Tag registry")
└── author/ # One directory per author
├── author.md # Author profile (optional)
├── media/ # Shared images for this author — see "Adding an article"
│ ├── avatar.jpg # Referenced by author.md's `avatar:` field
│ └── cover.jpg # Referenced by one or more articles' `image:` field
└── slug~DDMMYYYY~tag1+tag2.md # One file per article — see "Adding an article"
├── avatar.jpg # Optional author avatar, referenced by author.md
└── slug--DDMMYYYY--expa-YYYY+tag1/ # One directory per article
├── slug.md
└── cover.jpg # Optional, referenced by image:
```
Drafts and archived posts live in sibling `drafts/` and `archive/` folders with the same
structure (see "Adding an article"). They sit outside `articles/`, so they are never served or
listed.
## Local configuration
`js/config-default.js` holds the shipped defaults (`BASE_URL`, `MARKED_URL`, `ARTICLES_URL`) and
@@ -107,53 +111,63 @@ needs):
export const ARTICLES_URL = new URL('/blog-content/articles/', location.origin).href;
```
An embedding page may configure a public DAV proxy without a local file:
```html
<script>window.CMJS_CONFIG = { articlesUrl: '/blog-content/', listingMode: 'webdav', tagsUrl: '/blog-content/_tags.json' };</script>
```
## Adding an article
An article is a single Markdown file placed directly in its author's directory. Its *file name*
is where its publish date, tags, category, and draft status all live — not in separate files, and
not in frontmatter. The format is:
An article is a Markdown file inside a post directory. Its *directory name* contains the stable
slug, date, expedition year, and topic tags. Publication status comes from the root folder the
post directory sits in:
| Root | Status | Shown by cmjs |
|------|--------|---------------|
| `articles/<author>/` | published | yes |
| `drafts/<author>/` | draft | no |
| `archive/<author>/` | archived | no |
Publishing, unpublishing, or archiving a post means moving its whole directory (Markdown and
images together) between these roots. cmjs only ever reads `articles/`.
The directory name format is:
```
<slug>~<DDMMYYYY>~<tag1>+<tag2>+….md
<slug>--<DDMMYYYY>--expa-YYYY+<tag1>+<tag2>+…/<slug>.md
```
- `<slug>` — the stable identifier used in the article's URL (`#post/author/slug`). Treat this
as permanent: retagging or correcting the date later just means renaming the file (see below),
but changing the slug changes the article's URL.
- `<slug>` — the stable identifier used in the article's URL (`#post/author/slug`), matching
`[a-z0-9][a-z0-9-]{0,95}`. The Markdown file inside the directory must be named `<slug>.md`.
Treat the slug as permanent: retagging or correcting the date later just means renaming the
directory (see below), but changing the slug changes the article's URL.
- `<DDMMYYYY>` — the publish date, 8 digits, no separators (e.g. `16062026` for 16 June 2026).
- `<tag1>+<tag2>+…` — zero or more tag ids, joined with `+`. A tag id matching `year-YYYY` is
treated as the article's "year"/category, shown as a badge linking to `#category/year-YYYY`;
every other tag id is a normal topic tag, shown as a pill linking to `#tag/tag-id`. Omit this
whole segment (and its leading `~`) if the article has no tags.
An invalid date (e.g. `31022026`) makes the directory be skipped.
- `expa-YYYY+<tag1>+<tag2>+…` — normalized tag ids (lowercase `[a-z0-9][a-z0-9.-]*`) joined
with `+`. `expa-YYYY` is the article's expedition/year marker, shown as a badge linking to
`#tag/expa-YYYY`; every other tag id is a normal topic tag, shown as a pill linking to
`#tag/tag-id`. Posts written by the blog editor always have exactly one `expa-YYYY` tag,
listed first. The whole `--…` tag suffix is optional for hand-made posts.
The separator is `~` (tilde), not a hyphen — deliberately, since a slug is expected to be plain
kebab-case (single hyphens only) and `~` isn't something you'd type by accident while writing
one, unlike an extra `-`.
A leading underscore on the whole file name — `_<slug>~....md` — marks the article as a
**draft**: it's excluded from the list, search, and pagination, but still reachable directly by
URL. Remove the underscore to publish it.
Images (a cover photo, inline pictures) live in a `media/` folder shared by all of that author's
articles, not next to each individual article file — so the same picture can be reused across
several articles without keeping a duplicate copy per article. Reference one from frontmatter or
markdown with a path relative to the author directory, e.g. `image: media/photo.jpg`.
Images (cover and inline) live in the post directory, next to the Markdown file, and are
referenced by paths relative to it — `image: cover.jpg`, `![](photo.jpg)`, or a subfolder such
as `media/photo.jpg`. Absolute `https://` URLs are used as-is.
Steps:
1. Create the author directory if it doesn't exist: `articles/author/`
2. Create the Markdown file directly inside it, using the naming convention above, e.g.
`articles/author/my-article~16062026~guides+year-2026.md`
3. Optionally add a cover image to that author's shared `articles/author/media/` folder
(creating it if needed), and reference it from frontmatter with `image: media/photo.jpg`.
2. Create the post directory using the naming convention above, e.g.
`articles/author/my-article--16062026--expa-2026+guides/`.
3. Add `my-article.md` and optional media files inside that directory.
4. Add a frontmatter block at the top of the Markdown file
5. Write your content — it will appear in the list automatically
To change an article's date, tags, or draft status later, rename its file — its content doesn't
need to change. `author` is derived from the containing directory — no frontmatter field needed
To change an article's date, tags, or publication state later, move/rename its directory — its
content doesn't need to change. `author` is derived from the containing directory — no frontmatter field needed
for it either. Treat the directory name as case-sensitive and exact: it's matched exactly (not
case-insensitively) against `#author/...` URLs and author-profile lookups, so avoid two author
directories that differ only in case. Because every one of these is encoded in the file name,
directories that differ only in case. Because every one of these is encoded in the directory name,
discovering an article's metadata never requires fetching anything beyond the directory listing
that already reveals it — no further per-article request at all, which is what makes the "Article
discovery & caching" section below possible.
@@ -166,8 +180,12 @@ discovery & caching" section below possible.
```yaml
---
schema: 2
title: My Article Title
description: A short summary shown in the article list.
authorName: Jane Doe
image: cover.jpg
updatedAt: '2026-08-27T21:14:03+02:00'
---
Your markdown content here...
@@ -176,12 +194,14 @@ Your markdown content here...
| Field | Required | Notes |
|-------|----------|-------|
| `title` | yes | Shown in the list and article header |
| `description` | no | Short summary shown under the title in the list |
| `image` | no | Path to an image in the author's shared `media/` folder (e.g. `media/photo.jpg`); shown as card thumbnail and article hero |
| `description` | no | Short summary shown under the title in the list; an excerpt of the body is used when missing |
| `authorName` | no | Display name of the author; falls back to the author directory name |
| `image` | no | Cover image, relative to the Markdown file (e.g. `cover.jpg`); shown as card thumbnail and article hero. `cover` is accepted as an alias |
| `schema`, `updatedAt` | no | Written by the blog editor; ignored by cmjs |
Reading time is computed automatically from the article word count — no frontmatter field
needed. `date`, `tags`, and `draft` frontmatter fields, if present, are ignored — superseded by
the directory name described above.
needed. `date`, `tags`, `status`, `author`, `expa`, and `draft` frontmatter fields, if present,
are ignored — superseded by the directory name and root described above.
## Author profiles
@@ -212,11 +232,22 @@ Short bio rendered as markdown.
| Field | Notes |
|-------|-------|
| `title` | Display name shown in the profile card. Falls back to the author directory name. |
| `avatar` | Path to an image, relative to the author directory — e.g. `media/avatar.jpg` if kept in the shared media folder alongside article images, or a bare filename if kept directly beside `{author}.md`. Displayed as a circular thumbnail. |
| `avatar` | Path to an image, relative to the author directory — usually a bare filename such as `avatar.jpg` kept directly beside `{author}.md`. Displayed as a circular thumbnail. |
| `github` / `twitter` / `linkedin` / `mastodon` / `website` | Any subset of these renders as pill links below the bio. |
The body text below the frontmatter is rendered as markdown and shown as the bio.
## Tag registry
`articles/_tags.json` (or whatever `TAGS_URL` points to) holds human-readable tag labels:
```json
{"schemaVersion": 1, "tags": [{"id": "guides", "label": "Guides", "description": "", "aliases": [], "active": true}]}
```
The blog editor maintains this file. cmjs reserves the `TAGS_URL` setting for it but doesn't read
it yet, so tag pills currently show the raw tag ids.
## Routing
| URL hash | View |
@@ -227,11 +258,11 @@ The body text below the frontmatter is rendered as markdown and shown as the bio
| `#search/linux/page/2` | Same, page 2 |
| `#author/alice` | All articles by alice, page 1 |
| `#author/alice/page/2` | All articles by alice, page 2 |
| `#category/year-2026` | All articles carrying the "year-2026" year tag, page 1 |
| `#category/year-2026/page/2` | Same, page 2 |
| `#tag/expa-2026` | All articles carrying the `expa-2026` expedition tag, page 1 |
| `#tag/expa-2026/page/2` | Same, page 2 |
| `#tag/guides` | All articles tagged "guides", page 1 |
| `#tag/guides/page/2` | Same, page 2 |
| `#post/alice/my-article` | Single article at `articles/alice/my-article~DDMMYYYY~tags.md` |
| `#post/alice/my-article` | Single article at `articles/alice/my-article--DDMMYYYY--expa-YYYY+tags/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`.
@@ -350,7 +381,7 @@ demo does.
```
- **Year list — `#year-filters`** (optional, not used by this repo's own demo but supported the
same way): a container that gets filled in with one link per year 2026 down to 2009 that has at
least one article, linking to `#category/year-YYYY`. Can appear alongside `#tag-filters` or on
least one article, linking to `#tag/expa-YYYY`. Can appear alongside `#tag-filters` or on
its own.
```html
<nav id="year-filters" aria-label="Filter by year"></nav>
@@ -386,6 +417,12 @@ nothing breaks, the article list and search still work standalone.
## nginx configuration
For a Nextcloud public share, proxy only the read-only `articles` share and
allow `GET`, `HEAD`, and `PROPFIND`; set `window.CMJS_CONFIG.listingMode` to
`webdav`. Nextcloud requires `X-Requested-With: XMLHttpRequest` on DAV
requests. A site-specific template is included in
`astronomickaexpedice.cz/deploy/blog-content.nginx.conf.template`.
Point nginx `root` at the repo root. The `autoindex` directive applies recursively to all subdirectories under `/articles/`.
```nginx
@@ -459,11 +496,11 @@ not built into stock nginx — only worth it if you're already building custom n
Discovery happens in two phases. **Indexing** crawls `articles/` client-side to build a
lightweight "stub" for every article — the author directories, then each author's own directory
listing (their article files, bio file, and shared `media/` folder together) along with each
file's `mtime`. Non-article entries (the author's own `{author}.md`, the `media/` folder) are
simply filtered out of that same listing. That's it: because an article file's *name* already
encodes its slug, date, tags, and draft status (see "Adding an article" above), that single
listing is enough to sort, filter, paginate, and populate the sidebar's tag/year counts, without
listing (their post directories, bio file, and avatar together) along with each entry's ETag
(WebDAV) or `mtime` (nginx). Anything that isn't a directory with a valid post name (the
author's own `{author}.md`, the avatar, stray files) is simply filtered out of that same
listing. That's it: because a post directory's *name* already encodes its slug, date, and tags
(see "Adding an article" above), that single listing is enough to sort, filter, paginate, and populate the sidebar's tag/year counts, without
any further request per article and without ever fetching an article's actual Markdown.
**Hydration** is the separate, deferred step that fetches a specific article's content (title,
description, cover image, reading time) — it only ever runs for articles about to be rendered:
@@ -474,17 +511,18 @@ articles it has — indexing costs one request per author, full stop, and only ~
of content is ever fetched per page view.
- **Cache** — hydrated articles are cached per article file in IndexedDB (`js/cache.js`),
keyed by the file's own path and invalidated by comparing its live `mtime` against the cached
one — once *any* visit renders an article, every later visit skips its content fetch too, until
the file is renamed (a rename changes the cache key, so a retagged/redated article is treated as
new — see "Adding an article" above) or its own mtime changes. Indexing reads the entire cache
keyed by the Markdown file's own path and invalidated by comparing the post directory's live
ETag (or `mtime` when the listing has no ETags) against the cached one — once *any* visit
renders an article, every later visit skips its content fetch too, until the directory is
renamed (a rename changes the cache key, so a retagged/redated article is treated as new — see
"Adding an article" above) or its contents change. Indexing reads the entire cache
once up front (`getAllCacheEntries()`, kicked off in parallel with the author/directory-listing
requests) so a warm cache doesn't cost an IndexedDB read per article. If IndexedDB is unavailable
(private browsing, disabled storage), discovery transparently falls back to fetching everything
live on every visit.
- **Cache schema version** — the cached records store the full derived article shape, not raw
content, so a code change to how articles are derived (in `js/discovery.js`) can make old
cached entries stale in *structure*, not just content — and a file's `mtime` won't catch that.
cached entries stale in *structure*, not just content — and an ETag or `mtime` won't catch that.
Bump `CACHE_SCHEMA_VERSION` in `js/cache.js` whenever that shape changes; it's baked into
the IndexedDB database name, so a bump invalidates every cached entry on the next deploy.
- **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `js/discovery.js` (default `10`) caps how
@@ -506,7 +544,7 @@ of content is ever fetched per page view.
page) until then.
- **Crawl health** — if the root author-listing fetch fails outright, if every individual
author's own directory listing fails, or if very few of the known candidate files parsed
successfully (a malformed file name, or a total breakage like the wrong origin or CORS
successfully (a malformed directory name, or a total breakage like the wrong origin or CORS
misconfigured), a small warning banner is shown above the article list instead of silently
rendering an empty page (see `getCrawlStats()` / `renderCrawlHealthWarning()` in
`js/discovery.js` / `js/views.js`).
@@ -514,8 +552,8 @@ of content is ever fetched per page view.
listing fetch, recency prioritisation, HTML-autoindex fallback, 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. The per-article listing fetch it describes no longer exists (metadata moved
into the file name instead, and articles no longer live in a directory of their own), but the
alongside the code. The per-article listing fetch it describes no longer exists (all metadata
needed for indexing is in the post directory name, so the author listing is enough), but the
rest of that reasoning — the caching model, the longevity constraints, the HTML fallback — still
applies.
+7
View File
@@ -15,6 +15,13 @@
border-color: var(--color-accent);
}
.card--clickable { cursor: pointer; }
.card--clickable:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 4px;
}
.card--no-image { grid-template-columns: 1fr; }
.card-media {
+8
View File
@@ -0,0 +1,8 @@
server {
listen 80;
root /usr/share/nginx/html;
autoindex on;
autoindex_format json;
autoindex_localtime off;
location / { try_files $uri $uri/ =404; }
}
+10 -3
View File
@@ -17,6 +17,11 @@
const DEFAULT_BASE_URL = new URL('../', import.meta.url).href;
const DEFAULT_MARKED_URL = new URL('../vendor/marked.min.js', import.meta.url).href;
const DEFAULT_ARTICLES_URL = new URL('articles/', DEFAULT_BASE_URL).href;
const pageConfig = (typeof window !== 'undefined' && window.CMJS_CONFIG) || {};
const resolveConfiguredUrl = value => {
try { return new URL(value, typeof document !== 'undefined' ? document.baseURI : DEFAULT_BASE_URL).href; }
catch { return value; }
};
let overrides = {};
try {
@@ -27,11 +32,11 @@ try {
// Base URL of the repo root — strings/ and templates/ live here, one level
// up from this file (e.g. https://example.com/).
export const BASE_URL = overrides.BASE_URL ?? DEFAULT_BASE_URL;
export const BASE_URL = resolveConfiguredUrl(pageConfig.baseUrl ?? pageConfig.BASE_URL ?? overrides.BASE_URL ?? DEFAULT_BASE_URL);
// Path to the marked.js vendor script — lives in vendor/, a sibling of this
// file's own js/ directory, not alongside the app's own modules.
export const MARKED_URL = overrides.MARKED_URL ?? DEFAULT_MARKED_URL;
export const MARKED_URL = resolveConfiguredUrl(pageConfig.markedUrl ?? pageConfig.MARKED_URL ?? overrides.MARKED_URL ?? DEFAULT_MARKED_URL);
// Base URL of the articles/ folder. Defaults to a sibling of this file's own
// js/ directory (the repo root's own articles/), but articles don't have to
@@ -39,4 +44,6 @@ export const MARKED_URL = overrides.MARKED_URL ?? DEFAULT_MARKED_URL;
// anywhere under the same origin, or even a different one, CORS permitting.
// Whatever it resolves to must still be a directory nginx serves a JSON (or
// HTML) autoindex listing for — see "nginx configuration" in the README.
export const ARTICLES_URL = overrides.ARTICLES_URL ?? DEFAULT_ARTICLES_URL;
export const ARTICLES_URL = resolveConfiguredUrl(pageConfig.articlesUrl ?? pageConfig.ARTICLES_URL ?? overrides.ARTICLES_URL ?? DEFAULT_ARTICLES_URL);
export const LISTING_MODE = pageConfig.listingMode ?? pageConfig.LISTING_MODE ?? overrides.LISTING_MODE ?? 'auto';
export const TAGS_URL = resolveConfiguredUrl(pageConfig.tagsUrl ?? pageConfig.TAGS_URL ?? overrides.TAGS_URL ?? new URL('_tags.json', ARTICLES_URL).href);
+79 -35
View File
@@ -1,5 +1,5 @@
import { parseFrontmatter } from './frontmatter.js';
import { BASE_URL, ARTICLES_URL } from './config-default.js';
import { BASE_URL, ARTICLES_URL, LISTING_MODE } from './config-default.js';
import { mapWithConcurrency } from './concurrency.js';
import { getAllCacheEntries, setCacheEntry, pruneCache } from './cache.js';
import { t } from './i18n.js';
@@ -58,6 +58,38 @@ export async function fetchJson(url) {
throw new Error(`Expected a directory listing at ${url}`);
}
export async function fetchWebDav(url) {
const response = await fetch(url, { method: 'PROPFIND', headers: { Depth: '1', 'Content-Type': 'application/xml; charset=utf-8', 'X-Requested-With': 'XMLHttpRequest' } });
if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`);
const xml = new DOMParser().parseFromString(await response.text(), 'application/xml');
if (xml.querySelector('parsererror')) throw new Error(`Invalid WebDAV listing at ${url}`);
const requested = new URL(url, typeof document !== 'undefined' ? document.baseURI : url).pathname.replace(/\/$/, '');
const seen = new Set(), entries = [];
const children = (node, name) => [...node.getElementsByTagNameNS('*', name)];
[...xml.getElementsByTagNameNS('*', 'response')].forEach((node, index) => {
const href = children(node, 'href')[0]?.textContent;
if (!href) return;
const parsed = new URL(href, url);
let path;
try { path = decodeURIComponent(parsed.pathname).replace(/\/$/, ''); } catch { return; }
// A reverse proxy may leave Nextcloud's internal href untouched
// (e.g. /remote.php/dav/...) while the request URL is /blog-content/.
// In that case the first collection response is still the requested
// root, just under a different public path. Skip it just as we do
// when the paths match.
if (path === requested || (index === 0 && children(node, 'collection').length)) return;
const name = path.split('/').pop();
if (!name || seen.has(name)) return;
seen.add(name);
entries.push({ name, type: children(node, 'collection').length ? 'directory' : 'file', mtime: children(node, 'getlastmodified')[0]?.textContent || null, etag: children(node, 'getetag')[0]?.textContent || null });
});
return entries;
}
export function fetchDirectory(url) {
return LISTING_MODE === 'webdav' ? fetchWebDav(url) : fetchJson(url);
}
// Images live alongside whatever references them: every article file sits
// directly in its author's own directory (see buildStub()), next to that
// author's shared `media/` folder — so `articlePath` (the referencing .md
@@ -153,22 +185,19 @@ function excerpt(content) {
// "-") and silently drops the whole article instead of erroring. Returns
// null for any file name that doesn't match — callers treat that as a
// silent skip, e.g. the author's own bio file or a stray unrelated file.
function parseArticleFileName(fileName) {
if (!fileName.endsWith('.md')) return null;
const base = fileName.slice(0, -3);
const draft = base.startsWith('_');
const core = draft ? base.slice(1) : base;
const [slug, dateSegment, tagsSegment] = core.split('~');
if (!slug || !dateSegment || !/^\d{8}$/.test(dateSegment)) return null;
function parsePostDirectoryName(dirName) {
const match = String(dirName).match(/^(.+?)--(\d{8})(?:--(.+))?$/);
if (!match) return null;
const [, slug, dateSegment, tagsSegment] = match;
if (!/^[a-z0-9][a-z0-9-]{0,95}$/.test(slug)) return null;
const day = dateSegment.slice(0, 2);
const month = dateSegment.slice(2, 4);
const year = dateSegment.slice(4, 8);
return {
slug,
date: `${year}-${month}-${day}`,
tags: tagsSegment ? tagsSegment.split('+').filter(Boolean) : [],
draft,
};
const parsedDate = new Date(Date.UTC(+year, +month - 1, +day));
if (parsedDate.getUTCFullYear() !== +year || parsedDate.getUTCMonth() !== +month - 1 || parsedDate.getUTCDate() !== +day) return null;
const tags = tagsSegment ? tagsSegment.split('+').filter(Boolean) : [];
if (tags.some(tag => !/^[a-z0-9][a-z0-9+.-]{0,95}$/.test(tag))) return null;
return { slug, date: `${year}-${month}-${day}`, tags };
}
// Everything an article card/filter/sort needs, parsed entirely from its
@@ -182,26 +211,32 @@ function parseArticleFileName(fileName) {
// see cache.js — since every article now lives directly in its author's
// directory, alongside that author's bio file and shared `media/` folder,
// rather than in a directory of its own.
function buildStub(author, fileName, mtime, authorRoot) {
const parsed = parseArticleFileName(fileName);
function buildStub(author, dirName, mtime, authorRoot, etag = null) {
const parsed = parsePostDirectoryName(dirName);
if (!parsed) return null;
const { slug, date, tags, draft } = parsed;
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
// "Category" isn't a separate concept anymore — it's just whichever tag
// id (if any) looks like a `year-YYYY` id.
const category = tags.find(id => /^year-20\d{2}$/i.test(id)) || null;
const { slug, date, tags } = parsed;
const root = `${authorRoot}${encodeURIComponent(dirName)}/`;
const path = `${root}${encodeURIComponent(slug)}.md`;
// "Category" is represented by the reserved expedition tag itself.
// Keeping the real id (for example `expa-2027`) lets year links target
// the canonical tag route without inventing a second `year-YYYY` id.
const expaTag = tags.find(id => /^expa-20\d{2}$/i.test(id)) || null;
const category = expaTag;
return {
author,
slug,
hash: `${author}/${slug}`,
path,
root,
directory: dirName,
mtime,
etag,
category,
categoryYear: category ? Number(category.replace(/^year-/i, '')) : null,
categoryYear: category ? Number(category.replace(/^expa-/i, '')) : null,
date,
tags,
published: !draft,
published: true,
hydrated: false,
};
}
@@ -214,7 +249,9 @@ function buildStub(author, fileName, mtime, authorRoot) {
// server ever flips between the two formats, instead of every file looking
// "changed" the moment JSON autoindex is lost.
function normalizeMtime(mtime) {
return mtime ? mtime.slice(0, 16) : null; // "YYYY-MM-DDTHH:MM" prefix
if (!mtime) return null;
const parsed = new Date(mtime);
return Number.isNaN(parsed.getTime()) ? String(mtime).slice(0, 16) : parsed.toISOString().slice(0, 16);
}
// Cache-aware: a file whose mtime matches its cached entry's mtime reuses
@@ -225,11 +262,13 @@ function normalizeMtime(mtime) {
// runCrawl) — an in-memory lookup here instead of a separate IndexedDB read
// per file, which mattered when this also had to guard a listing fetch;
// kept anyway since it's still the same one cheap lookup.
function resolveStub(author, fileName, mtime, authorRoot, cacheSnapshot) {
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
function resolveStub(author, dirName, mtime, etag, authorRoot, cacheSnapshot) {
const parsed = parsePostDirectoryName(dirName);
if (!parsed) return null;
const path = `${authorRoot}${encodeURIComponent(dirName)}/${encodeURIComponent(parsed.slug)}.md`;
const cached = cacheSnapshot.get(path);
if (cached && mtime != null && cached.mtime === mtime && cached.article?.hydrated) return cached.article;
return buildStub(author, fileName, mtime, authorRoot);
if (cached && ((etag && cached.article?.etag === etag) || (!etag && mtime != null && cached.mtime === mtime)) && cached.article?.hydrated) return cached.article;
return buildStub(author, dirName, mtime, authorRoot, etag);
}
// Fields only derivable from an article's actual markdown content (not its
@@ -278,7 +317,9 @@ export function hydrateArticle(stub) {
async function hydrateArticleNow(stub) {
try {
const response = await fetch(stub.path);
const headers = stub.etag ? { 'If-None-Match': stub.etag } : {};
let response = await fetch(stub.path, { headers });
if (response.status === 304) response = await fetch(stub.path);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const raw = await response.text();
const { meta, content } = parseFrontmatter(raw);
@@ -323,7 +364,7 @@ async function runCrawl() {
// crawl-health message instead.
let authorEntries;
try {
authorEntries = await fetchJson(ARTICLES_URL);
authorEntries = await fetchDirectory(ARTICLES_URL);
} catch {
crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: true };
notifyProgress(true);
@@ -352,10 +393,10 @@ async function runCrawl() {
const authorRoot = `${ARTICLES_URL}${encodeURIComponent(author.name)}/`;
const bioFileName = `${author.name}.md`;
try {
const entries = await fetchJson(authorRoot);
const entries = await fetchDirectory(authorRoot);
return entries
.filter(entry => entry.type === 'file' && entry.name.endsWith('.md') && entry.name !== bioFileName)
.map(entry => ({ author: author.name, fileName: entry.name, mtime: normalizeMtime(entry.mtime), authorRoot }));
.filter(entry => entry.type === 'directory' && parsePostDirectoryName(entry.name))
.map(entry => ({ author: author.name, dirName: entry.name, mtime: normalizeMtime(entry.mtime), etag: entry.etag || null, authorRoot }));
} catch {
authorsFailed += 1;
return [];
@@ -369,14 +410,17 @@ async function runCrawl() {
const cacheSnapshot = await cacheSnapshotPromise;
let loaded = 0;
for (const file of allFiles) {
const stub = resolveStub(file.author, file.fileName, file.mtime, file.authorRoot, cacheSnapshot);
const stub = resolveStub(file.author, file.dirName, file.mtime, file.etag, file.authorRoot, cacheSnapshot);
if (stub) {
loaded += 1;
partialResolved.push(stub);
}
}
await pruneCache(allFiles.map(file => `${file.authorRoot}${encodeURIComponent(file.fileName)}`));
await pruneCache(allFiles.map(file => {
const parsed = parsePostDirectoryName(file.dirName);
return `${file.authorRoot}${encodeURIComponent(file.dirName)}/${encodeURIComponent(parsed.slug)}.md`;
}));
crawlStats = { total: allFiles.length, loaded, authorsTotal: authors.length, authorsFailed, rootFailed: false };
const finalArticles = sortAndFilterPublished(partialResolved);
+15 -9
View File
@@ -6,7 +6,7 @@
// first = "search", ends "/page/N" → search results, page N (#search/query/page/2)
// 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
// 2 segments, first = "category" (legacy) or "tag" → filtered list, page 1
// 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)
@@ -34,6 +34,12 @@ const parsePage = s => { const n = +s; return Number.isInteger(n) && n >= 1 ? n
function router() {
const hash = location.hash.slice(1); // strip leading #
const parts = hash.split('/').filter(Boolean);
// A hand-edited/legacy hash can contain a malformed percent escape. Do
// not let one bad segment abort routing (and make an otherwise valid
// article appear unclickable); keep the literal segment in that case.
const decodePart = value => {
try { return decodeURIComponent(value); } catch { return value; }
};
// Search: decode query and guard against empty string (#search or #search/).
// A trailing "/page/N" is peeled off for pagination first — the query
@@ -45,18 +51,18 @@ function router() {
const rest = parts.slice(1);
const hasPage = rest.length >= 3 && rest[rest.length - 2] === 'page' && /^\d+$/.test(rest[rest.length - 1]);
const queryParts = hasPage ? rest.slice(0, -2) : rest;
const q = decodeURIComponent(queryParts.join('/'));
const q = decodePart(queryParts.join('/'));
if (q) loadSearch(q, hasPage ? parsePage(rest[rest.length - 1]) : 1); else loadArticleList();
}
else if (parts.length === 0) loadArticleList();
else if (parts.length === 2 && parts[0] === 'page') loadArticleList(null, parsePage(parts[1]));
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 && 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 === 2 && parts[0] === 'author') loadAuthorView(decodePart(parts[1]));
else if (parts.length === 2 && parts[0] === 'category') loadCategoryView(decodePart(parts[1]));
else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodePart(parts[1]));
else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodePart).join('/'));
else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodePart(parts[1]), parsePage(parts[3]));
else if (parts.length === 4 && parts[0] === 'category' && parts[2] === 'page') loadCategoryView(decodePart(parts[1]), parsePage(parts[3]));
else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodePart(parts[1]), parsePage(parts[3]));
else loadArticleList();
}
+34 -8
View File
@@ -47,8 +47,9 @@ function clearSearchInput() {
function filterLabel(filter) {
if (!filter) return '';
if (filter.type === 'author') return t('filterAuthor', { value: filter.value });
if (filter.type === 'tag' && /^expa-20\d{2}$/i.test(filter.value)) return t('filterYear', { value: filter.value.slice(5) });
if (filter.type === 'tag') return t('filterTag', { value: filter.value });
if (filter.type === 'category') return t('filterYear', { value: filter.value.replace(/^year-/i, '') });
if (filter.type === 'category') return t('filterYear', { value: filter.value.replace(/^year-/i, '').replace(/^expa-/i, '') });
return filter.value;
}
@@ -78,8 +79,9 @@ function buildPaginationNav(page, totalPages, filter) {
}
function renderTags(container, tags) {
if (!container || !tags?.length) { container?.remove(); return; }
tags.forEach(identifier => {
const visibleTags = (tags || []).filter(identifier => !/^expa-20\d{2}$/i.test(identifier));
if (!container || !visibleTags.length) { container?.remove(); return; }
visibleTags.forEach(identifier => {
const link = document.createElement('a');
link.className = 'tag';
link.href = `#tag/${encodeURIComponent(identifier)}`;
@@ -126,8 +128,9 @@ function renderFilterNavigation(articles, activeFilter = null) {
yearList.appendChild(allArticlesLink());
for (let year = 2026; year >= 2009; year -= 1) {
const count = yearCounts.get(year) || 0;
const active = activeFilter?.type === 'category' && activeFilter.value.toLowerCase() === `year-${year}`;
yearList.appendChild(renderFilterLink(t('yearLabel', { year }), `#category/year-${year}`, count, active));
const expeditionTag = `expa-${year}`;
const active = activeFilter?.type === 'tag' && activeFilter.value.toLowerCase() === expeditionTag;
yearList.appendChild(renderFilterLink(t('yearLabel', { year }), `#tag/${expeditionTag}`, count, active));
}
years.appendChild(yearList);
}
@@ -139,11 +142,11 @@ function renderFilterNavigation(articles, activeFilter = null) {
const tagList = document.createElement('div');
tagList.className = 'filter-list';
if (!years) tagList.appendChild(allArticlesLink());
// A `year-YYYY` id is also a normal entry in `article.tags` (that's how
// An `expa-YYYY` id is also a normal entry in `article.tags` (that's how
// it's recognised as the category — see buildStub() in discovery.js),
// but it belongs in the years nav above, not duplicated here.
[...tagCounts.keys()]
.filter(id => !/^year-20\d{2}$/i.test(id))
.filter(id => !/^expa-20\d{2}$/i.test(id))
.sort((a, b) => a.localeCompare(b, getLocale()))
.forEach(id => {
const active = activeFilter?.type === 'tag' && activeFilter.value === id;
@@ -173,7 +176,7 @@ function applyArticleMetaFields(field, { date, readingTime, category, categoryYe
// tags happens to name a year — not every article has one.
setField(field('category'), category, (categoryEl, value) => {
categoryEl.textContent = t('yearLabel', { year: categoryYear });
categoryEl.href = `#category/${encodeURIComponent(value)}`;
categoryEl.href = `#tag/${encodeURIComponent(value)}`;
});
}
@@ -204,6 +207,24 @@ function renderCard(article) {
author.textContent = article.authorName || article.author;
author.href = `#author/${encodeURIComponent(article.author)}`;
renderTags(field('tags'), article.tags);
// The card itself is the hit target, not only the optional cover image or
// title link. Keep the metadata links (author/tags/year) independent, but
// let clicks in the body/description open the same stable post route.
element.classList.add('card--clickable');
element.tabIndex = 0;
element.setAttribute('role', 'link');
const openPost = () => { location.hash = postHref(article).slice(1); };
element.addEventListener('click', event => {
if (event.defaultPrevented || event.button !== 0 || event.target.closest('a')) return;
openPost();
});
element.addEventListener('keydown', event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
if (event.target !== element) return;
event.preventDefault();
openPost();
});
return element;
}
@@ -290,6 +311,11 @@ export async function loadArticleList(filter = null, page = 1) {
// them case-insensitively here could merge two distinctly-named
// author directories that differ only in case.
if (filter.type === 'author') return article.author === filter.value;
if (filter.type === 'category') {
const value = filter.value.toLowerCase();
return value === String(article.category || '').toLowerCase()
|| value === `year-${article.categoryYear}`;
}
return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase();
});
};
+2 -2
View File
@@ -5,9 +5,9 @@
"filterAuthor": "Autor: {value}",
"filterTag": "Štítek: {value}",
"filterYear": "Ročník: {value}",
"filterYear": "Expedice {value}",
"filterAllArticles": "Všechny články",
"yearLabel": "Ročník {year}",
"yearLabel": "Expedice {year}",
"paginationLabel": "Stránkování",
"paginationPrevious": "Předchozí",
+2 -2
View File
@@ -5,9 +5,9 @@
"filterAuthor": "Author: {value}",
"filterTag": "Tag: {value}",
"filterYear": "Year: {value}",
"filterYear": "Expedition {value}",
"filterAllArticles": "All articles",
"yearLabel": "Year {year}",
"yearLabel": "Expedition {year}",
"paginationLabel": "Pagination",
"paginationPrevious": "Previous",
+2 -2
View File
@@ -5,9 +5,9 @@
"filterAuthor": "Autor: {value}",
"filterTag": "Štítok: {value}",
"filterYear": "Ročník: {value}",
"filterYear": "Expedícia {value}",
"filterAllArticles": "Všetky články",
"yearLabel": "Ročník {year}",
"yearLabel": "Expedícia {year}",
"paginationLabel": "Stránkovanie",
"paginationPrevious": "Predchádzajúca",