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
- The browser traverses
cmjs/articles/via nginx JSON autoindex — discovering authors, categories, and article files. - Each
.mdfile's YAML frontmatter is parsed to build the article list. - Clicking an article fetches the Markdown file and renders it with marked.js.
- Hash-based routing handles navigation — back/forward works without page reloads.
Features
- Dark mode — automatically follows the OS
prefers-color-schemesetting; 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/queryso results are bookmarkable. - 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/h3headings 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 and category filtering — clicking an author name or category badge shows a filtered list. Both 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-visibleoutlines 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.
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
├── 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)
Adding an article
- Create the author and category directories if they don't exist:
cmjs/articles/author/category/ - Create a
.mdfile inside:cmjs/articles/author/category/my-article.md - Add a frontmatter block at the top
- Write your content — it will appear in the list automatically
author and category are derived from the directory path — no frontmatter fields needed for them.
Frontmatter format
---
title: My Article Title
date: 2026-06-16
description: A short summary shown in the article list.
draft: false
---
Your markdown content here...
| Field | Required | Notes |
|---|---|---|
title |
yes | Shown in the list and article header |
date |
yes | ISO format YYYY-MM-DD, used for sorting newest-first |
description |
no | Short summary shown under the title in the list |
image |
no | Filename of the preview image in the pictures/ directory (e.g. photo.jpg); shown as card thumbnail and article hero |
draft |
no | Set to true, yes, or 1 to hide from the list |
Reading time is computed automatically from the article word count — no frontmatter field needed.
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 in pictures/ — 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" |
#author/alice |
All articles by alice, page 1 |
#author/alice/page/2 |
All articles by alice, page 2 |
#category/linux |
All articles in "linux", page 1 |
#category/linux/page/2 |
All articles in "linux", page 2 |
#alice/linux/my-article |
Single article at cmjs/articles/alice/linux/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 and the full article view live in cmjs/templates/:
cmjs/templates/card.html— the card shown in the article listcmjs/templates/article.html— the full article view (header, hero image, content)
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.
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">
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.
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 likecmjs/...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.urland 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;
}
}
Note:
autoindex_format jsonrequires nginx 1.7.9+. Check withnginx -v.
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.serverreturns HTML directory listings, not JSON, so article discovery won't work with it. Use nginx.