diff --git a/.claude/nginx-sitemap-crawler-plan.md b/.claude/nginx-sitemap-crawler-plan.md new file mode 100644 index 0000000..7ff6cf8 --- /dev/null +++ b/.claude/nginx-sitemap-crawler-plan.md @@ -0,0 +1,392 @@ +# Nginx Sitemap Crawler — Design Plan + +_Last updated: 2026-09-10_ + +## Tech stack + +- **Language:** pure JavaScript, running **client-side inside a + webpage** (no Node, no server-side execution anywhere in this + project — nginx serves static listings only). + +## Goal + +Maintain a browsable, near-real-time sitemap of a two-level nginx-served +directory tree, using only stock nginx config (no server-side scripts on +the nginx host), minimizing redundant requests over time, and surfacing +the most recently changed content first. + +## Constraints + +- No server-side execution on the nginx host — plain `nginx.conf` only. +- Client-side script/app is where all logic (crawling, sorting, + pagination, caching) lives. +- Tree is two levels deep (root → level-1 dirs → level-2 dirs). + +--- + +## 1. Nginx setup + +Enable machine-readable directory listings on the relevant locations: + +```nginx +location /files/ { + autoindex on; + autoindex_format json; + autoindex_localtime off; +} +``` + +- `autoindex_format json;` — makes listings parseable instead of scraping HTML. +- `autoindex_localtime off;` — keeps timestamps in GMT, consistent and + unambiguous for comparison logic (avoids DST/timezone drift issues). + +Each listing entry includes `name`, `type` (`file`/`directory`), `size`, +and `mtime`. + +--- + +## 2. Wave-based crawling + +Directory names at level 2 aren't known until level 1 is fetched, so the +crawl naturally happens in sequential **waves**, each internally +**concurrent**: + +- **Wave 1 (root)** — always fetched live on every run. Single request, + cheapest possible cost, and the sole source of truth for what + currently exists and what's changed. +- **Wave 2 (level-1 → level-2)** — fetched only as needed (see §5 + caching) and only for directories relevant to the current page (see + §4 pagination). Requests within a wave are fired concurrently, not + sequentially, to keep latency low regardless of directory count. + +--- + +### 2a. Concurrency limits — **decision: configurable, not hardcoded** + +Rather than a fixed worker count baked into the logic, concurrency is +exposed as a **single tunable setting** (e.g. `maxConcurrentRequests`) +that governs how many wave-2 requests are in flight at once within a +wave. Rationale and shape: + +- **Why configurable:** the right concurrency value depends on factors + that vary by deployment and can't be known in advance — network + distance to the nginx host (same-region vs. cross-region), how many + directories are typically in play per page (tied to §4a page size), + how many browser tabs/users might be hitting the same nginx instance + at once, and how conservative the operator wants to be about log + noise or perceived "scanning" behavior (as noted in earlier + discussion of request bursts). +- **Where it lives:** a single config value/constant near the top of + the client script (or exposed as a UI setting/advanced option), + rather than scattered magic numbers through the crawl logic. +- **Sensible default:** start conservative (e.g. 10) — safe for both + same-region and cross-region cases, and already shown earlier in + this plan to keep even a full ~500-directory cold-start crawl under + a few seconds. Users/operators who know their network is fast and + local, or who page through smaller slices (page size 10, see §4a), + can raise it; those on slower/cross-region links or shared/production + nginx instances can lower it. +- **Interaction with pagination:** since wave-2 is now scoped to just + the current page's directories (§4a), the concurrency setting mostly + matters relative to page size — e.g. at page size 10 with + concurrency 10, an entire page's wave-2 fetches resolve in a single + batch (~1 RTT); at page size 30 with concurrency 10, the same page + takes ~3 batches. Setting concurrency ≥ page size effectively + guarantees single-batch page loads, which is a reasonable rule of + thumb for operators tuning this value. +- **No hard ceiling imposed by nginx** — as established earlier, + nginx's default `worker_connections` comfortably absorbs dozens to + low hundreds of concurrent client connections, so the practical + upper bound is really about being a good citizen (log noise, shared + hosting) rather than anything nginx itself will choke on. + +--- + +## 3. Recency-based prioritization + +- A directory's `mtime` reflects changes to its **direct** children only + (additions, removals, renames) — not deeper nested edits. This is + sufficient for this use case, since the concern is new content + appearing, not deep nested edits (see "Known limitation" below). +- After wave 1 resolves, sort level-1 entries by `mtime` descending, + with directory name ascending as a stable tie-breaker (see R5). + This ordering drives both crawl priority and display order. + +--- + +## 4. Pagination + +- Slice the sorted level-1 list into fixed-size pages (default 10, + user-selectable — see §4a). +- Only fire wave-2 requests for the directories in the **currently + viewed page** — not the whole tree. +- Page navigation reuses the already-fetched, already-sorted wave-1 + list in memory; no repeated root requests needed when paging. +- Optional: prefetch the next page's wave-2 requests in the background + while the current page is being viewed, so navigation feels instant. + +--- + +### 4a. Page size — **decision: user-selectable, default 10** + +- Default page size: **10** items. +- User-selectable alternatives: **15, 20, 30**. +- Changing page size resets pagination to page 1 (avoids ambiguous + partial pages / confusing item overlap when the slice boundaries + shift). +- Since wave 1 is already fully fetched and sorted in memory (see §2, + §3), changing page size is a **pure client-side re-slice** of the + existing sorted list — no new wave-1 request needed. +- Wave-2 fetch behavior (§5, cache-aware) is unaffected by page size + itself — only which/how many level-1 directories fall into the + current slice changes, which in turn determines which cache + entries are read or fetched. + +--- + +## 5. Sitemap cache + +Persist a cache entry per directory: + +```json +{ + "path": "/files/project-a/", + "mtime": "Wed, 10 Sep 2026 14:32:01 GMT", + "children": [ "...level-2 entries..." ], + "children_fetched_at": "2026-09-10T15:00:00Z" +} +``` + +### 5a. Storage mechanism — **decision: IndexedDB** + +This runs purely client-side in a webpage — no filesystem access, so +the real choice is between browser storage APIs: **localStorage** vs. +**IndexedDB**. IndexedDB was chosen: + +- **Storage ceiling** — localStorage is capped around 5-10MB + (browser-dependent) and shared across the whole origin; IndexedDB's + ceiling is far higher (typically hundreds of MB+), removing a + constraint that could otherwise bite as the tree grows. +- **Per-record updates** — IndexedDB is an object store keyed by + record (naturally: path → cache entry), so updating one directory's + entry doesn't require re-serializing/rewriting the entire cache, the + way localStorage (full blob, `JSON.stringify` every write) would. +- **Non-blocking** — IndexedDB is async by nature, which fits a cache + that's written to progressively as crawl waves resolve, without + freezing the UI thread. localStorage is synchronous and would block + rendering on every read/write as the cache grows. +- **No manual serialization** — IndexedDB stores structured JS objects + directly; no need to `JSON.stringify`/`parse` on every access. +- **Still zero external dependencies** — native browser API, no + library required (though a thin promise-wrapper like `idb` is a + reasonable, optional ergonomics improvement later if the raw API + proves too verbose). + +**Practical shape:** +- One object store, e.g. `directoryCache`, keyed by directory `path`. +- Each record holds `{ path, mtime, children, children_fetched_at }` + as described below. +- Reads/writes are async (`Promise`-based or wrapped in one), which + aligns naturally with the already-async wave-2 fetches. + +On each run, compare each level-1 directory's **live** `mtime` (from +wave 1) against the **cached** `mtime`: + +- **Match** → skip wave 2 for that directory; serve `children` from + cache. +- **Newer or missing** → fetch wave 2 live, then update the cache entry + with the new `mtime` and `children`. + +Additional cache maintenance: + +- **Pruning** — after wave 1, any cached level-1 entries no longer + present in the live listing should be removed (handles deletions). +- **Wave 1 is never cached** — it has no parent to diff against, and + it's already the cheapest possible request (1 per run, regardless of + tree size). +- **Cold start** — first run has no cache, so it pays the full crawl + cost once. Every subsequent run should be dramatically cheaper, + bounded roughly by how much actually changed since the last run. + +--- + +## 6. Why this combination works well together + +- Sorting by `mtime` descending means the directories most likely to + have a **stale cache** (recent changes) are exactly the ones + prioritized to the front — so page 1 concentrates the real crawl + cost, while later pages are almost pure cache hits. +- Over repeated runs, total request cost trends toward being + proportional to **how much changed**, not the size of the tree. +- Progressive/skeleton rendering (page renders immediately with cached + or partial data, updates in place as fresher wave-2 results arrive) + keeps the experience responsive even while cache misses are being + resolved in the background. + +--- + +## Known accepted limitation + +Directory `mtime` only reflects direct-child changes, not deeper nested +edits (e.g. a file modified inside an existing level-2 directory won't +bump the level-1 directory's `mtime`). Accepted as a non-issue here, +since the priority signal needed is "new subdirectories appearing," +which this correctly captures. + +--- + +## Longevity Considerations + +_This project is intended as "deploy and forget" for a ~30 year +horizon. That's a materially different constraint than "get this +working now," and shapes several decisions beyond what pure +functionality would require._ + +### L1. IndexedDB eviction — not a concern +Some browsers (e.g. Safari ITP) may evict IndexedDB data for +infrequently-visited origins. **Explicitly accepted as a non-issue**: +R6's uncached fallback mode already handles any IndexedDB +unavailability gracefully (falls back to live fetching), so eviction +simply means the cache benefit doesn't materialize on that visit — not +a failure. No action needed beyond what's already in R6. + +### L2. HTML fallback if JSON listing breaks — **decision: included** +`autoindex_format json` depends on nginx config staying intact over +the project's lifetime. Configs can drift — a server migration, +reconfiguration, or someone disabling `autoindex_format json` (while +leaving `autoindex on;` itself in place) is realistic over 30 years. + +To survive this, the crawler includes an **HTML fallback parser**: + +- On each request, if the response isn't valid JSON (parse fails, or + `Content-Type` isn't JSON), fall back to treating the response as + nginx's **default HTML autoindex output** instead of failing the + request outright. +- Parse the HTML using the browser's native `DOMParser` (built-in, no + dependency) — extract entries from the `` listing, + using the link `href` for name/type (trailing `/` = directory) and + the surrounding text for `mtime`/size where present. +- This is strictly a **fallback path**, not the primary path — JSON + remains preferred when available, since it's more reliable to parse + and already carries structured `mtime`. The HTML fallback exists + purely so the tool degrades to "still works, just via a different + parse path" rather than "breaks entirely" if `autoindex_format json` + is ever lost from the config. +- This directly follows the same silent-skip philosophy as R3: prefer + degraded function over hard failure, wherever degrading is possible + without violating the zero-dependency principle. + +### L3. Total-failure visibility +Silent-skip (R3) is correct for individual transient request failures, +but should not extend to masking a *total* breakage (e.g. CORS +misconfigured after a migration, autoindex disabled entirely, wrong +origin). The crawler tracks a simple "N of M directories loaded" +count; if that ratio is very low (e.g. near-zero) after a crawl +completes, a minimal, dependency-free visible indicator (a status line +in the UI, no alerting infrastructure) should communicate that +something is broken, so the project doesn't fail silently for years +before a human notices. + +### L4. Zero dependencies, deliberately +No npm packages, no build step, no framework — stated explicitly as a +guiding principle, not just an incidental outcome of earlier +decisions. Any dependency introduces a 30-year liability: registries +can vanish, packages can go unmaintained, and even a "small" library +can break under a future browser change with nobody left to patch it. +Future contributors should treat this as a hard constraint, not a +preference to be relaxed for convenience. + +### L5. Prefer long-stable JS/browser APIs over new syntax +Favor well-established, broadly-supported features (`fetch`, +`Promise`, `async`/`await`, basic IndexedDB, `DOMParser`) over newer or +experimental JS/browser APIs. The newer something is today, the more +likely it is to be deprecated, changed, or need a polyfill by the time +this project is 15-20 years old. Nothing currently behind a flag or +"experimental" label should be used, even if convenient now. + +### L6. Keep this plan attached to the code +For a deploy-and-forget project, the largest real risk isn't +technical — it's a future maintainer with zero context. This document +should ship alongside the implementation (same repository, or +inlined as a comment block) rather than live separately, so the +reasoning behind each decision (why JSON over HTML, why IndexedDB, +why silent-skip, etc.) survives together with the code itself. + +### L7. nginx version/config drift +`autoindex_format json` requires a reasonably modern nginx (long +available, but not guaranteed present on every install). Document a +minimum nginx version alongside the deployment instructions, and treat +"response isn't valid JSON" as an expected, handled case (see L2) +rather than an exceptional one — since over 30 years, drift in the +server config is a "when," not an "if." + +--- + +## Risks & Edge Cases + +### R1. CORS +If the webpage is served from a different origin than the nginx +directory listing, browser `fetch()` calls will be blocked without +proper CORS headers. Requires an `add_header Access-Control-Allow-Origin +...;` (or equivalent) on the relevant nginx `location` block, unless +the page and the file server share an origin. Needs to be decided/set +up alongside the nginx config in §1. + +### R2. Trailing slashes on directory requests +nginx's autoindex only serves a listing cleanly when the request URL +ends in `/`. Requesting a directory path without the trailing slash +typically triggers a redirect (e.g. 301) instead of returning the +listing directly, adding an extra round-trip. The crawler must +consistently append `/` when building wave-2 request URLs from the +`name` field returned in wave-1's JSON. + +### R3. Error handling for failed requests — **decision: silent skip** +If a wave-2 request fails (timeout, 404, host briefly unreachable), +it is silently skipped: no retry, no visible error marker. The +directory in question falls back to whatever is already in the +IndexedDB cache (stale-but-present data), or is simply omitted from +that page's render if nothing is cached yet. Accepted trade-off for +simplicity — a failed fetch does not block or degrade the rest of the +page. + +### R4. Race conditions from rapid navigation +Changing page size or flipping pages quickly can cause in-flight +requests from a previous page/size to resolve *after* newer ones, +overwriting current state with stale data. Mitigated with a +generation/token counter: each navigation action increments a +"current generation" value, and any response that arrives tagged with +an older generation than the current one is discarded rather than +rendered. + +### R5. `mtime` tie-breaking +autoindex JSON timestamps are second-precision, so directories touched +within the same second (e.g. a batch of subdirectories created +together by a script) can tie on `mtime`. The sort in §3 uses `mtime` +descending as the primary key with **directory name ascending** as a +stable secondary key, so ordering — and therefore pagination — stays +consistent across runs instead of jittering on ties. + +### R6. IndexedDB availability — **decision: uncached fallback mode** +IndexedDB may be unavailable or restricted (private/incognito modes in +some browsers) or subject to storage eviction under pressure. The +crawler treats any IndexedDB access failure (open error, blocked +transaction, etc.) as a signal to fall back to an **uncached mode**: +every wave-2 request is fetched live for the duration of that session, +with no persistence attempted. This keeps the sitemap fully functional +— just without the performance benefit of cache hits on repeat visits +— rather than erroring out or blocking the page. Detection happens +once at startup (attempt to open the DB; on failure, set an in-memory +`cacheAvailable = false` flag that the rest of the logic checks before +any cache read/write). + +--- + +## Open items / future considerations + +_All resolved — no open items remain._ + +Ruled out: WebDAV / `PROPFIND` (`ngx_http_dav_ext_module`) as a +single-request full-tree alternative — requires a non-stock nginx +build/module, which conflicts with the "ordinary nginx only" and +simplicity constraints. Wave-based crawling with caching (§2, §5) +remains the approach. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..439dc65 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/articles/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..369d154 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ +# Git Overrides +* Do not push commited changes in this project diff --git a/README.md b/README.md index 65d446e..c636baa 100644 --- a/README.md +++ b/README.md @@ -4,78 +4,149 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil ## How it works -1. The browser traverses `cmjs/articles/` via nginx JSON autoindex — discovering authors, categories, and article files. -2. Each `.md` file's YAML frontmatter is parsed to build the article list. +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 + 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 + discovery & caching" 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. 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 + unchanged content — see "Article discovery & caching" below. ## Features - **Dark mode** — automatically follows the OS `prefers-color-scheme` setting; no toggle needed. -- **Client-side search** — the search box in the header filters all articles by title, description, author, and category in-memory. Navigates to `#search/query` so results are bookmarkable. +- **Client-side search** — the search box in the header filters all articles by title, description, author, category, and tags in-memory. Navigates to `#search/query` so results are bookmarkable; results paginate the same way the article list does. - **Reading time** — estimated from the article word count (200 wpm) and shown on list cards and in article headers. Computed automatically; no frontmatter field needed. - **Table of contents** — auto-generated from `h2`/`h3` headings and inserted at the top of the article body. Only appears when the article has three or more headings. -- **Back link** — a "← Back to articles" link above every article for one-click navigation back to the list. +- **Back link** — a "← Back to articles" link above every article, returning to whatever list, filter, or search page (and page number) the reader actually came from rather than always resetting to page 1 of everything. - **Pagination** — article list paginates at 20 articles per page; page state is encoded in the hash so browser history works. -- **Author and category filtering** — clicking an author name or category badge shows a filtered list. Both support pagination. +- **Author, category, and tag filtering** — clicking an author name, category badge, or tag pill shows a filtered list. All three support pagination. - **Responsive layout** — layout, cards, images, and pagination all adapt to narrow screens (breakpoint at 640 px). - **Keyboard navigation** — all interactive elements have visible `:focus-visible` outlines for keyboard users. - **Author profiles** — a profile card with bio and social links is shown at the top of each author's article list. Created by adding `articles/{author}/{author}.md`. +- **Offline-friendly caching** — discovered articles are cached in IndexedDB, so repeat visits skip re-fetching content that hasn't changed. +- **Translatable UI** — every UI string is looked up from a locale JSON file (English, Czech, and Slovak ship by default); adding a language needs no code changes. ## Project structure +This whole repo *is* cmjs — clone it as-is into an existing site (or copy `css/`, `js/`, +`strings/`, `templates/`, and your own `articles/` into one) and embed it into a page there; see +"Embedding in an existing page" below. `demo/` is a standalone demo/preview page, kept separate +from the library — not something an embedding page needs to bring along. + ``` -cmjs/ ├── README.md -├── index.html # Single-page shell — served from the repo root -├── css/ # Site-level styles for index.html -│ ├── reset.css # CSS variables, dark mode, box-sizing reset, focus styles -│ ├── header.css # Site header, title link, search form -│ └── layout.css # Main content wrapper, state messages, responsive +├── demo/ # Standalone demo/preview page — not needed to embed cmjs elsewhere +│ ├── index.html # Single-page shell +│ └── css/ +│ ├── reset.css # CSS variables, dark mode, box-sizing reset, focus styles +│ ├── header.css # Site header, title link, search form +│ └── layout.css # Main content wrapper, state messages, responsive +├── css/ +│ └── components/ # Component styles — needed by any embedding page +│ ├── card.css # Article list cards and filter heading +│ ├── article.css # Single article view, back link, hero image +│ ├── content.css # Rendered markdown styles (.content *) +│ ├── pagination.css # Prev/next page navigation +│ ├── toc.css # Auto-generated table of contents +│ └── author-profile.css # Author profile card (avatar, bio, social links) ├── js/ -│ └── marked.min.js # marked.js (local copy, loaded as a global) -└── cmjs/ # App content — articles, templates, component styles - ├── css/ - │ └── components/ - │ ├── card.css # Article list cards and filter heading - │ ├── article.css # Single article view, back link, hero image - │ ├── content.css # Rendered markdown styles (.content *) - │ ├── pagination.css # Prev/next page navigation - │ └── toc.css # Auto-generated table of contents - ├── js/ - │ ├── config.js # BASE_URL, MARKED_URL, PICTURES_URL (all via import.meta.url) - │ ├── frontmatter.js # parseFrontmatter() — pure YAML-front parser - │ ├── templates.js # loadTemplates(), templates cache - │ ├── discovery.js # discoverArticles(), fetchJson(), article cache - │ ├── 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 - │ └── category/ # One directory per category - │ └── slug.md # Article file - └── pictures/ # Article images (referenced via image: frontmatter field) +│ ├── config.js # BASE_URL, MARKED_URL, ARTICLES_URL (all via import.meta.url) +│ ├── 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 article file path +│ ├── concurrency.js # mapWithConcurrency() — caps simultaneous per-article fetches +│ ├── i18n.js # t(), loadStrings(), getLocale() — UI string lookup +│ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadCategoryView, loadSearch +│ └── router.js # Hash router and app entry point +├── vendor/ +│ └── marked.min.js # marked.js (local copy, loaded as a global) — the only 3rd-party code +├── strings/ +│ ├── en.json # UI strings (default/fallback locale) — see "Translations" below +│ ├── cs.json # Czech translation +│ └── sk.json # Slovak translation +├── templates/ +│ ├── card.html # Article list card layout +│ ├── article.html # Full article view layout +│ └── author-profile.html # Author profile card layout +└── articles/ + └── 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" ``` ## Adding an article -1. Create the author and category directories if they don't exist: `cmjs/articles/author/category/` -2. Create a `.md` file inside: `cmjs/articles/author/category/my-article.md` -3. Add a frontmatter block at the top -4. Write your content — it will appear in the list automatically +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: -`author` and `category` are derived from the directory path — no frontmatter fields needed for them. +``` +~~++….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 publish date, 8 digits, no separators (e.g. `16062026` for 16 June 2026). +- `++…` — 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. + +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 — `_~....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`. + +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`. +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 +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, +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. + +> **A note on trust:** article and author-bio Markdown is rendered directly via marked.js with no +> HTML-sanitization pass — anyone who can write a `.md` file here can include raw HTML, including +> ` + ``` -`router.js` is the only script tag needed. It loads `marked.min.js` automatically at startup (path configured in `cmjs/js/config.js`). +`router.js` is the only script tag needed. It loads `marked.min.js` automatically at startup (path configured in `js/config.js`). -> **Subdirectory pages:** If the embedding page lives in a subdirectory (e.g. `/blog/index.html`), relative paths like `cmjs/...` will resolve incorrectly. Use root-relative paths with a leading `/` for the CSS links and script tag: +> **Subdirectory pages:** If the embedding page lives in a subdirectory (e.g. `/blog/index.html`) and cmjs isn't installed at the site root, a plain relative path like `css/...` will resolve against the *page's* directory, not wherever cmjs actually lives. Use a path (root-relative or otherwise) that correctly points at wherever you placed it, e.g. if cmjs lives at `/cmjs/` on your site: > ```html > > > ``` -> All internal fetches (articles, templates, marked.js) resolve relative to the module files via `import.meta.url` and work automatically — only the HTML tags need the `/` prefix. +> All internal fetches (articles, templates, marked.js) resolve relative to the module files via `import.meta.url` and work automatically — only the HTML tags need a path pointing at the right place. + +### Search, article list, and tag list as independent pieces + +The mount points above don't have to sit next to each other, or even all be present — each is a +plain element the router looks up by `id`, wherever it happens to be in your page. This lets you +scatter search, the article list, and the tag list across a custom layout (search box in the site +header, tag list in a sidebar, articles in the main column) instead of stacking them the way the +demo does. + +- **Article list + single article — `#list-view` / `#article-view`.** These two are the only + required pair; the router toggles `hidden` between them and always needs both present somewhere + on the page. This pair is also where **search results** render — there's no separate results + container, so any page using the search box below still needs `#list-view` on it somewhere to + actually show what was searched for. +- **Search box — `#search-form` / `#search-input`.** Fully optional and independent; place it + anywhere (e.g. a site-wide header, not necessarily near the list). Submitting it navigates to + `#search/query`; matches render into `#list-view`. + ```html + + ``` +- **Tag list — `#tag-filters`.** Optional; put a container (a `