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:
1 parent
730f16934d
commit
c8464398b9
2 files changed
+70
-46
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user