diff --git a/README.md b/README.md index 5c0a7da..fa15122 100644 --- a/README.md +++ b/README.md @@ -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: ``` ~~++….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 — `_~....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 ``` -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 `` @@ -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 diff --git a/js/discovery.js b/js/discovery.js index dcbd7f9..69f37c9 100644 --- a/js/discovery.js +++ b/js/discovery.js @@ -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: `~[~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: `~[~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 (`.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 (`.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; }