Replace underscore draft convention with author/draft/ folder (AI)

Drafts now live in an author's own draft/ subdirectory instead of a
leading-underscore filename prefix. The crawler already skips
subdirectories when listing an author's files, so nothing under
draft/ is ever indexed; nginx should also deny that path outright so
drafts aren't fetchable by a guessed direct URL either.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
biosiasandClaude Sonnet 5 committed 2026-09-22 15:49:00 +02:00
1 parent 730f16934d
commit c8464398b9
2 files changed
+70 -46

No files matched your search

+44 -20
View File
@@ -110,8 +110,8 @@ export const ARTICLES_URL = new URL('/blog-content/articles/', location.origin).
## 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, tags, and draft status all live — not in separate files, and
not in frontmatter. The format is:
is where its publish date and tags live — not in separate files, and not in frontmatter. The
format is:
```
<slug>~<DDMMYYYY>~<tag1>+<tag2>+….md
@@ -129,9 +129,13 @@ The separator is `~` (tilde), not a hyphen — deliberately, since a slug is exp
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 — `_<slug>~....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.
**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
@@ -142,14 +146,15 @@ 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`
`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
5. Write your content — it will appear in the list automatically (once it's out of `draft/`)
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
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,
@@ -179,8 +184,8 @@ Your markdown content here...
| `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 |
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` and `tags` frontmatter fields, if present, are ignored — superseded by the file
name described above.
## Author profiles
@@ -300,7 +305,7 @@ The site-level CSS files (`reset.css`, `header.css`, `layout.css`) are optional
<div id="article-view" hidden></div>
```
Place these wherever you want the CMS to appear. The router toggles `hidden` between them on navigation. A search box and tag/year filter lists are optional and can go anywhere else in the page — see "Search, article list, and tag list as independent pieces" below. Unlike those, `#list-view`/`#article-view` are both required — router.js checks for them at startup and shows a clear error (scoped to whichever of the two is present, or the whole page if neither is) instead of silently failing if one is missing.
Place these wherever you want the CMS to appear. The router toggles `hidden` between them on navigation. A search box and tag filter list are optional and can go anywhere else in the page — see "Search, article list, and tag list as independent pieces" below. Unlike those, `#list-view`/`#article-view` are both required — router.js checks for them at startup and shows a clear error (scoped to whichever of the two is present, or the whole page if neither is) instead of silently failing if one is missing.
### 3. Script — before `</body>`
@@ -375,7 +380,10 @@ standalone.
## nginx configuration
Point nginx `root` at the repo root. The `autoindex` directive applies recursively to all subdirectories under `/articles/`.
Point nginx `root` at the repo root. The `autoindex` directive applies recursively to all
subdirectories under `/articles/` — **except** each author's own `draft/` folder, which must be
denied outright (see "Adding an article" above: this is what keeps a draft from being indexed or
fetched by a guessed direct URL).
```nginx
server {
@@ -388,6 +396,14 @@ server {
try_files $uri $uri/ /demo/index.html;
}
# Deny every author's draft/ folder outright — must come before the
# /articles/ block below (a regex location overrides a prefix location
# in nginx, so this still wins even though it's declared after it).
location ~ ^/articles/[^/]+/draft/ {
deny all;
return 404;
}
# Enable JSON directory listing for article discovery (applies to all subdirs)
location /articles/ {
autoindex on;
@@ -409,9 +425,15 @@ computed as a sibling of the `js/` folder). Articles don't have to live there
under the same origin (or a different one, CORS permitting).
Whatever it resolves to needs its own matching `location` block enabling the JSON (or HTML)
autoindex — it doesn't have to be named `articles/` or nested under wherever cmkiss itself lives:
autoindex, plus the matching `draft/` deny block (see above) — it doesn't have to be named
`articles/` or nested under wherever cmkiss itself lives:
```nginx
location ~ ^/blog-content/articles/[^/]+/draft/ {
deny all;
return 404;
}
location /blog-content/articles/ {
autoindex on;
autoindex_format json;
@@ -448,12 +470,14 @@ 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
any further request per article and without ever fetching an article's actual Markdown.
listing (their article files, bio file, shared `media/` folder, and `draft/` folder together)
along with each file's `mtime`. Only `.md` files listed directly inside an author's own directory
are kept as candidate articles — the bio file is filtered out by name, and any subdirectory
(`media/`, `draft/`) is skipped outright rather than being descended into, so nothing under
`draft/` is ever indexed. That's it: because an article file'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 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:
the current page's cards (`hydrateArticles()` in `js/discovery.js`), a single visited
+26 -26
View File
@@ -98,10 +98,8 @@ let crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootF
let partialResolved = [];
const progressListeners = new Set();
function sortAndFilterPublished(rawArticles) {
return rawArticles
.filter(article => article?.published && article.date)
.sort((a, b) => String(b.date).localeCompare(String(a.date)) || a.hash.localeCompare(b.hash));
function sortArticles(rawArticles) {
return [...rawArticles].sort((a, b) => String(b.date).localeCompare(String(a.date)) || a.hash.localeCompare(b.hash));
}
// Subscribes to incremental crawl progress so a large site's article list
@@ -114,12 +112,12 @@ function sortAndFilterPublished(rawArticles) {
// no longer showing.
export function onArticlesProgress(listener) {
progressListeners.add(listener);
if (partialResolved.length) listener(sortAndFilterPublished(partialResolved), false);
if (partialResolved.length) listener(sortArticles(partialResolved), false);
return () => progressListeners.delete(listener);
}
function notifyProgress(done) {
const snapshot = sortAndFilterPublished(partialResolved);
const snapshot = sortArticles(partialResolved);
progressListeners.forEach(listener => listener(snapshot, done));
}
@@ -144,21 +142,23 @@ function excerpt(content) {
}
// An article file's own name is the sole authoritative source of its slug,
// publish date, tags, and draft status — see the README's "Adding
// an article" section. Format: `<slug>~<DDMMYYYY>[~tag1+tag2+…].md`,
// optionally prefixed with `_` for draft. `~` (rather than e.g. "--") is
// deliberate: a slug is expected to be plain kebab-case, and "~" isn't a
// publish date, and tags — see the README's "Adding an article" section.
// Format: `<slug>~<DDMMYYYY>[~tag1+tag2+…].md`. `~` (rather than e.g. "--")
// is deliberate: a slug is expected to be plain kebab-case, and "~" isn't a
// character anyone would type by accident while writing one — unlike a
// double-hyphen separator, which collides with an easy slug typo (an extra
// "-") and silently drops the whole article instead of erroring. Returns
// null for any file name that doesn't match — callers treat that as a
// silent skip, e.g. the author's own bio file or a stray unrelated file.
// Drafts aren't a naming convention at all — they live in an author's own
// `draft/` subdirectory (see the README's "Adding an article" section),
// which the crawler never descends into (wave 2 below only reads `type ===
// 'file'` entries from an author's own listing) and which nginx should deny
// outright, so a draft is never indexed or fetchable in the first place.
function parseArticleFileName(fileName) {
if (!fileName.endsWith('.md')) return null;
const base = fileName.slice(0, -3);
const draft = base.startsWith('_');
const core = draft ? base.slice(1) : base;
const [slug, dateSegment, tagsSegment] = core.split('~');
const [slug, dateSegment, tagsSegment] = base.split('~');
if (!slug || !dateSegment || !/^\d{8}$/.test(dateSegment)) return null;
const day = dateSegment.slice(0, 2);
const month = dateSegment.slice(2, 4);
@@ -167,7 +167,6 @@ function parseArticleFileName(fileName) {
slug,
date: `${year}-${month}-${day}`,
tags: tagsSegment ? tagsSegment.split('+').filter(Boolean) : [],
draft,
};
}
@@ -185,7 +184,7 @@ function parseArticleFileName(fileName) {
function buildStub(author, fileName, mtime, authorRoot) {
const parsed = parseArticleFileName(fileName);
if (!parsed) return null;
const { slug, date, tags, draft } = parsed;
const { slug, date, tags } = parsed;
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
return {
@@ -196,7 +195,6 @@ function buildStub(author, fileName, mtime, authorRoot) {
mtime,
date,
tags,
published: !draft,
hydrated: false,
};
}
@@ -322,7 +320,7 @@ async function runCrawl() {
} catch {
crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: true };
notifyProgress(true);
return sortAndFilterPublished(partialResolved);
return sortArticles(partialResolved);
}
const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.'));
@@ -331,14 +329,16 @@ async function runCrawl() {
// mtimes. This is what builds the flat, cross-author list that drives
// global sorting and pagination — see the plan doc's "keep the flat
// feed" decision. It's also the *only* per-author network request
// indexing ever needs: every stub field (slug, date, tags,
// draft status) is parsed straight out of each article file's own name
// (see buildStub/parseArticleFileName) — there's no per-article fetch
// at index time. The author's own bio file (`<author>.md`) and shared
// `media/` folder live in the same listing and are simply filtered out
// here — only `.md` files other than the bio file are candidate
// articles. One author's listing failing (network blip, deleted
// mid-crawl) is silently skipped rather than failing every other
// indexing ever needs: every stub field (slug, date, tags) is parsed
// straight out of each article file's own name (see
// buildStub/parseArticleFileName) — there's no per-article fetch at
// index time. The `type === 'file'` filter below also means any
// subdirectory (the author's shared `media/` folder, a `draft/` folder —
// see "Adding an article" in the README) is skipped without ever being
// descended into. The author's own bio file (`<author>.md`) is filtered
// out by name in the same listing — only `.md` files other than the bio
// file are candidate articles. One author's listing failing (network
// blip, deleted mid-crawl) is silently skipped rather than failing every other
// author's list too, but is still counted, so a *total* breakage (wrong
// origin, CORS) remains visible via getCrawlStats() instead of just
// silently producing an empty article list.
@@ -374,7 +374,7 @@ async function runCrawl() {
await pruneCache(allFiles.map(file => `${file.authorRoot}${encodeURIComponent(file.fileName)}`));
crawlStats = { total: allFiles.length, loaded, authorsTotal: authors.length, authorsFailed, rootFailed: false };
const finalArticles = sortAndFilterPublished(partialResolved);
const finalArticles = sortArticles(partialResolved);
notifyProgress(true);
return finalArticles;
}