From 5b9eff8dff1db2aa8377c0f1ec1a06e9aa8fa185 Mon Sep 17 00:00:00 2001 From: Jakub Pelc Date: Mon, 26 Oct 2026 18:40:36 +0100 Subject: [PATCH] update readme --- README.md | 108 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 73 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index cc59a1f..5392253 100644 --- a/README.md +++ b/README.md @@ -77,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) - ├── avatar.jpg # Optional author avatar - └── slug--DDMMYYYY--expa-YYYY+tag1/ # One directory per 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: + └── 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 @@ -115,24 +120,39 @@ An embedding page may configure a public DAV proxy without a local file: ## Adding an article 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 (`articles`, -`drafts`, or `archive`). The format is: +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//` | published | yes | +| `drafts//` | draft | no | +| `archive//` | 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: ``` ----expa-YYYY+++…/.md ``` -- `` — 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. +- `` — 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 `.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. - `` — the publish date, 8 digits, no separators (e.g. `16062026` for 16 June 2026). -- `++…` — zero or more normalized topic ids, joined with `+`. `expa-YYYY` is - treated as 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`. Omit this - omit the tag suffix only for a post with no topical tags. + An invalid date (e.g. `31022026`) makes the directory be skipped. +- `expa-YYYY+++…` — 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. -Images (cover and inline) live beside the Markdown file and are referenced by -relative paths such as `image: cover.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: @@ -147,7 +167,7 @@ To change an article's date, tags, or publication state later, move/rename its d 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. @@ -160,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... @@ -170,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 @@ -206,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 | @@ -225,7 +262,7 @@ The body text below the frontmatter is rendered as markdown and shown as the bio | `#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`. @@ -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.