biosiasandClaude Sonnet 5 36297616a5 Paginate search results (AI)
router.js: recognises a trailing /page/N on a #search/... hash,
falling back to treating the whole remainder as the query for
hand-typed/legacy links (the app itself always encodes the query as
one segment, so this is unambiguous for real usage).

views.js: renderArticleListBody() is generalized to take an explicit
heading/emptyKey/loadingKey instead of assuming the article-list's
own copy, so loadSearch() can reuse it directly with a
{type:'search', value:query} pseudo-filter driving the same
pageHash()-based pagination links as author/category/tag views.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 13:51:52 +02:00
2026-09-11 12:37:41 +02:00
2026-09-11 13:51:52 +02:00
2026-09-10 16:42:21 +02:00
2026-08-23 21:54:17 +02:00
2026-09-10 16:42:21 +02:00
2026-09-11 12:37:41 +02:00
2026-09-11 13:51:52 +02:00

cmjs — Minimal Client-Side CMS

A minimal CMS where articles are Markdown files rendered in the browser. No build step, no database, no server-side code.

How it works

  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. Sorting, filtering, and publish status all come from empty marker files beside each article (.date, .tag, draft) — knowable from the directory listing alone. 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.
  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 post directory and invalidated by directory 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, 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.
  • Pagination — article list paginates at 20 articles per page; page state is encoded in the hash so browser history works.
  • 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

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
├── 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
    │       └── author-profile.css  # Author profile card (avatar, bio, social links)
    ├── js/
    │   ├── config.js       # BASE_URL, MARKED_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 post directory
    │   ├── 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
    ├── 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
    ├── blog-config/
    │   └── tags.json        # Tag id → human-readable label registry — see "Tag labels" below
    └── articles/
        └── author/              # One directory per author
            ├── author.md        # Author profile (optional)
            ├── avatar.jpg       # Optional — referenced by author.md's `avatar:` field
            └── slug/            # One directory per post
                ├── slug.md            # Article content + frontmatter
                ├── cover.jpg          # Optional — referenced by slug.md's `image:` field
                ├── tag-id.tag         # Zero or more empty tag/category markers
                ├── DD-MM-YYYY.date    # Publish date marker (exactly one)
                └── draft              # Optional — presence hides the article from the list

Adding an article

  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, shown as a badge on each card/article linking to #category/expa-YYYY; every other tag id is a normal topic tag, shown as a pill linking to #tag/tag-id.
  5. Optionally add a cover image beside it too — cmjs/articles/author/my-article/photo.jpg — and reference it from frontmatter with image: photo.jpg. Images live next to the article that uses them, not in a shared folder.
  6. To keep it out of the published list for now, add an empty file named exactly cmjs/articles/author/my-article/draft (no extension) — remove it (or rename it) when the article is ready to publish. This is the sole source of draft/publish status; a draft: frontmatter field is no longer read.
  7. Add a frontmatter block at the top of the Markdown file
  8. Write your content — it will appear in the list automatically

author is derived from the directory path — no frontmatter field needed for it. Publish date, tags, category, and draft status are all derived from marker files, not frontmatter — this keeps every one of them knowable from a directory listing alone, without fetching an article's content, which is what makes the page-scoped loading described in "Article discovery & caching" below possible.

Tag labels

A .tag marker's filename is just an id (e.g. kometa) — the human-readable label shown in the sidebar and on tag pills comes from cmjs/blog-config/tags.json:

{
  "schemaVersion": 1,
  "tags": [
    { "id": "kometa", "label": "Comet", "description": "", "aliases": [], "active": true }
  ]
}

Registering an id here is optional — an unregistered tag id still works, it just displays as its raw id instead of a friendly label. expa-YYYY year ids don't need to be registered here; they're recognised automatically by pattern and don't use this label registry.

Frontmatter format

---
title: My Article Title
description: A short summary shown in the article list.
---

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 Filename of an image file placed in this same post directory (e.g. photo.jpg); shown as card thumbnail and article hero

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 .date/.tag/draft marker files described above.

Author profiles

To add a profile for an author, create a file named {author}.md directly inside the author's directory:

cmjs/articles/bob/bob.md

The profile appears as a card above the article list when a visitor filters by that author. It is optional — authors without a profile file work normally.

Profile frontmatter

---
title: Bob Johnson
avatar: bob-avatar.jpg
github: https://github.com/bob
twitter: https://twitter.com/bob
linkedin: https://linkedin.com/in/bob
mastodon: https://mastodon.social/@bob
website: https://bob.example.com
---

Short bio rendered as markdown.
Field Notes
title Display name shown in the profile card. Falls back to the author directory name.
avatar Filename of an image file placed in this same author directory — 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.

Routing

URL hash View
(empty) All articles, page 1
#page/2 All articles, page 2
#search/linux Search results for "linux", page 1
#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/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.

Customising the layout

The HTML structure for the article list card, the full article view, and the author profile card live in cmjs/templates/:

  • cmjs/templates/card.html — the card shown in the article list
  • cmjs/templates/article.html — the full article view (header, hero image, content)
  • cmjs/templates/author-profile.html — the profile card (avatar, bio, social links)

Edit these files directly to change layout — no JS changes needed. Elements are identified by data-field attributes, which is how the JS modules find and populate them. Keep existing data-field values when rearranging; add new ones if you add new fields.

Translations

Every UI string the JS renders (loading/error states, pagination, filter labels, the table of contents heading, etc.) is looked up through t(key, params) in cmjs/js/i18n.js rather than hardcoded — cmjs/strings/en.json is the default/fallback string table.

Available locales: en (default), cs (Czech), sk (Slovak).

To add another translation:

  1. Set <html lang="xx"> in your index.html to the target language code (e.g. cs).
  2. Add cmjs/strings/xx.json with the same keys as en.json (copy it as a starting point) — you only need to include the keys you're translating; anything missing quietly falls back to English rather than breaking.
  3. {placeholder} tokens in a string (e.g. "{page} of {totalPages}") are substituted at render time — keep them in your translated string, in whatever order/position reads naturally in that language.

The resolved locale also drives date formatting (Intl.DateTimeFormat), search matching, and tag-label sorting (localeCompare), via getLocale() in the same module — so a translation file doesn't just relabel text, it affects how dates and sorting behave too.

Site-specific branding that lives in index.html itself (the page <title>, the header search placeholder, the sidebar section headings) is plain static HTML, not JS-templated — translate it by editing index.html directly, the same way you'd customise it for any other embedding site.

Embedding in an existing page

To add cmjs to any HTML page, three things are needed: the CSS, the two mount-point <div>s, and the script.

1. CSS — in <head>

<!-- Site-level styles (or replace with your own) -->
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/header.css">
<link rel="stylesheet" href="css/layout.css">

<!-- Component styles -->
<link rel="stylesheet" href="cmjs/css/components/card.css">
<link rel="stylesheet" href="cmjs/css/components/article.css">
<link rel="stylesheet" href="cmjs/css/components/content.css">
<link rel="stylesheet" href="cmjs/css/components/pagination.css">
<link rel="stylesheet" href="cmjs/css/components/toc.css">
<link rel="stylesheet" href="cmjs/css/components/author-profile.css">

The site-level CSS files (reset.css, header.css, layout.css) are optional — they style the shell around the CMS. If your page already has a layout and reset, you can skip them and only load the component stylesheets. Note that reset.css also defines the CSS custom properties (colours, radius) used throughout — if you skip it, define the variables yourself. author-profile.css is only needed if any of your authors have a profile file (see "Author profiles" above) — safe to skip otherwise.

2. Mount points — in <body>

<div id="list-view"></div>
<div id="article-view" hidden></div>

Place these wherever you want the CMS to appear. The router toggles hidden between them on navigation.

Optionally add a search form anywhere in the page:

<form id="search-form" role="search">
    <input type="search" id="search-input" placeholder="Search articles…" autocomplete="off">
</form>

3. Script — before </body>

<script type="module" src="cmjs/js/router.js"></script>

router.js is the only script tag needed. It loads marked.min.js automatically at startup (path configured in cmjs/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:

<link rel="stylesheet" href="/cmjs/css/components/card.css">
<script type="module" src="/cmjs/js/router.js"></script>

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.

Minimal example

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Site</title>
    <link rel="stylesheet" href="cmjs/css/components/card.css">
    <link rel="stylesheet" href="cmjs/css/components/article.css">
    <link rel="stylesheet" href="cmjs/css/components/content.css">
    <link rel="stylesheet" href="cmjs/css/components/pagination.css">
    <link rel="stylesheet" href="cmjs/css/components/toc.css">
</head>
<body>
    <div id="list-view"></div>
    <div id="article-view" hidden></div>

    <script type="module" src="cmjs/js/router.js"></script>
</body>
</html>

nginx configuration

Point nginx root at the repo root. The autoindex directive applies recursively to all subdirectories under /cmjs/articles/.

server {
    listen 80;
    server_name example.com;
    root /var/www/cmjs;

    # Serve the SPA
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Enable JSON directory listing for article discovery (applies to all subdirs)
    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. 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

Discovery happens in two phases. Indexing crawls cmjs/articles/ client-side to build a lightweight "stub" for every article — 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, read for its .tag/.date/draft markers. This alone is enough to sort, filter, paginate, and populate the sidebar's tag/year counts, 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: the current page's cards (hydrateArticles() in cmjs/js/discovery.js), a single visited article, or (since full-text search needs real content) every result of a search. A large site's list is therefore fast to show regardless of how many articles it has — only ~20 articles' worth of content is ever fetched per page view.

  • Cache — every resolved stub is 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 — as soon as that comparison matches, indexing reuses the cached entry directly and skips the listing fetch too, whether or not the article has ever actually been hydrated. Hydration re-caches the same entry with the fetched content added, so once any visit renders an article, every later visit skips its content fetch as well, until its directory's mtime changes. Indexing reads the entire cache once up front (getAllCacheEntries(), kicked off in parallel with the author/post-directory requests) rather than a separate IndexedDB lookup per directory, so a warm cache doesn't cost hundreds of individual reads. 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 cmjs/js/discovery.js) can make old cached entries stale in structure, not just content — and a directory's mtime won't catch that. Bump CACHE_SCHEMA_VERSION in cmjs/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 cmjs/js/discovery.js (default 10) caps how many requests (indexing listings or hydration content fetches) run at once; raise it for a fast/local nginx, lower it for a slow or shared/production instance.
  • Progressive indexing — on a large site, the article list doesn't wait for the entire tree to finish indexing before showing anything: discoverArticles() streams incremental stub results via onArticlesProgress(), so the list (and pagination, and the sidebar tag/year counts) render as soon as the first articles are indexed and keep filling in while the rest index in the background — with a small "still indexing…" note shown until it's done. Indexing also visits directories with the most recent mtime first (a best-effort heuristic, not the final display order — that's always the authoritative .date marker), so the first results tend to already be close to correct.
  • Prefetching — once the current page has fully rendered, the next page's articles are hydrated in the background (fire-and-forget), so clicking "next" is usually an instant cache hit rather than a fresh round-trip.
  • Progressive search — search hydrates every indexed article (see above — it needs real text, unlike the list view), but does so incrementally rather than in one blocking batch: matches appear and the result list grows as hydration resolves, with a "still indexing…" note until every article has been checked. Results paginate at the same PAGE_SIZE as the article list, and the page count can shift while hydration is still filling in matches, settling once it's done.
  • Crawl health — if every author's directory listing fails outright, or very few of the known post directories loaded, a small warning banner is shown above the article list instead of silently rendering an empty page (see getCrawlStats() / renderCrawlHealthWarning() in cmjs/js/discovery.js / cmjs/js/views.js).
  • 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, which ships alongside the code.

Running locally

Using nginx locally (example for Linux):

# Symlink or copy the repo to your nginx webroot, then reload:
sudo nginx -s reload

Python's http.server returns HTML directory listings, not JSON, so article discovery won't work with it. Use nginx.

S
Description
No description provided
Readme
812 KiB
0 Stars 4 Watchers 0 Forks
Languages
JavaScript 85.1%
CSS 13%
HTML 1.9%