# cmkiss — 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 `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 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, 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 tag filtering** — clicking an author name or tag pill 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-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* cmkiss — 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. ``` ├── README.md ├── demo/ # Standalone demo/preview page — not needed to embed cmkiss 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/ │ ├── config-default.js # Shipped defaults for BASE_URL, MARKED_URL, ARTICLES_URL │ ├── config.js # Optional local overrides — gitignored; see "Local configuration" below │ ├── 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, loadTagView, 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" ``` ## Local configuration `js/config-default.js` holds the shipped defaults (`BASE_URL`, `MARKED_URL`, `ARTICLES_URL`) and is tracked in git; every other module imports from it. It also loads an optional `js/config.js` — gitignored, not part of cmkiss's own git history — to override any of them. **Nothing needs to be created before first use**: if `js/config.js` doesn't exist, the shipped defaults apply as-is. The split exists for using cmkiss as a git submodule (or otherwise pulling in a newer version some other way): since `config.js` is gitignored, updating cmkiss never touches it, so any override you've made survives. Only `config-default.js` gets updated. To override a setting, create `js/config.js` and export just the constant(s) you want to change — anything you don't export falls back to `config-default.js`. For example, to move where articles live (see "Pointing articles/ somewhere else" below for the matching nginx-side change this needs): ```js export const ARTICLES_URL = new URL('/blog-content/articles/', location.origin).href; ``` ## 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 and tags live — not in separate files, and not in frontmatter. The format is: ``` ~~++….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 `+`. Each 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 `-`. **Drafts** aren't a file-naming convention — they live in a separate `draft/` subdirectory inside the author's own directory: `articles/author/draft/my-article~16062026~guides.md`. The crawler only ever reads `.md` files directly inside an author's directory, so anything under `draft/` is never indexed, listed, searched, or paginated. nginx should also deny access to every author's `draft/` folder outright (see "nginx configuration" below), so a draft isn't reachable by a guessed direct URL either — not just hidden from the crawl. To publish an article, move its file out of `draft/` into the author directory itself. 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+tutorials.md` — or inside `articles/author/draft/` instead, if it's not ready to publish yet. 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 (once it's out of `draft/`) To change an article's date or tags 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 `js/config.js`). > **Subdirectory pages:** If the embedding page lives in a subdirectory (e.g. `/blog/index.html`) and cmkiss isn't installed at the site root, a plain relative path like `css/...` will resolve against the *page's* directory, not wherever cmkiss actually lives. Use a path (root-relative or otherwise) that correctly points at wherever you placed it, e.g. if cmkiss lives at `/cmkiss/` 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 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 `