diff --git a/README.md b/README.md index c00df4a..5c0a7da 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,12 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil ## 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, category, and tags in-memory. Navigates to `#search/query` so results are bookmarkable; results paginate the same way the article list does. +- **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, category, and tag filtering** — clicking an author name, category badge, or tag pill shows a filtered list. All three support pagination. +- **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`. @@ -66,7 +66,7 @@ from the library — not something an embedding page needs to bring along. │ ├── 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, loadCategoryView, loadSearch +│ ├── 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 @@ -110,7 +110,7 @@ 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, category, and draft status all live — not in separate files, and +is where its publish date, tags, and draft status all live — not in separate files, and not in frontmatter. The format is: ``` @@ -121,10 +121,9 @@ not in frontmatter. The format is: 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 `+`. A tag id matching `year-YYYY` is - treated as the article's "year"/category, shown as a badge linking to `#category/year-YYYY`; - every other 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. +- `++…` — 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 @@ -143,7 +142,7 @@ 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+year-2026.md` + `articles/author/my-article~16062026~guides+tutorials.md` 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 @@ -227,13 +226,11 @@ The body text below the frontmatter is rendered as markdown and shown as the bio | `#search/linux/page/2` | Same, page 2 | | `#author/alice` | All articles by alice, page 1 | | `#author/alice/page/2` | All articles by alice, page 2 | -| `#category/year-2026` | All articles carrying the "year-2026" year tag, page 1 | -| `#category/year-2026/page/2` | Same, page 2 | | `#tag/guides` | All articles tagged "guides", page 1 | | `#tag/guides/page/2` | Same, page 2 | | `#post/alice/my-article` | Single article at `articles/alice/my-article~DDMMYYYY~tags.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`. +Author names and 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 @@ -348,17 +345,9 @@ demo does. ```html ``` -- **Year list — `#year-filters`** (optional, not used by this repo's own demo but supported the - same way): a container that gets filled in with one link per year 2026 down to 2009 that has at - least one article, linking to `#category/year-YYYY`. Can appear alongside `#tag-filters` or on - its own. - ```html - - ``` -Both filter lists are populated by the same function on every list render, so whichever of -`#tag-filters`/`#year-filters` is present just gets filled in — omit either one (or both) and -nothing breaks, the article list and search still work standalone. +`#tag-filters` is optional — omit it and nothing breaks, the article list and search still work +standalone. ### Minimal example diff --git a/css/components/article.css b/css/components/article.css index 4128d07..a805224 100644 --- a/css/components/article.css +++ b/css/components/article.css @@ -64,7 +64,7 @@ font-style: italic; } -/* Author + category row in article header — .author-link and .tag defined in card.css */ +/* Author + tag row in article header — .author-link and .tag defined in card.css */ .article-meta { display: flex; align-items: center; diff --git a/css/components/card.css b/css/components/card.css index 9b7ec78..02d5d23 100644 --- a/css/components/card.css +++ b/css/components/card.css @@ -90,7 +90,7 @@ line-height: 1.65; } -/* Author + category row at the bottom of each card */ +/* Author + tag row at the bottom of each card */ .card-meta { display: flex; align-items: center; @@ -115,7 +115,7 @@ text-decoration: underline; } -/* Shared category badge — used in cards and article headers */ +/* Tag pill — also used in cards and article headers */ .tag { display: inline-block; background: var(--color-tag-bg); @@ -134,8 +134,8 @@ color: #fff; } -/* Heading shown above a filtered list (author or category view) */ -.category-heading { +/* Heading shown above a filtered list (author, tag, or search results) */ +.list-heading { font-size: 1.1rem; font-weight: 600; color: var(--color-text-soft); diff --git a/js/discovery.js b/js/discovery.js index 4632ef6..dcbd7f9 100644 --- a/js/discovery.js +++ b/js/discovery.js @@ -144,7 +144,7 @@ function excerpt(content) { } // An article file's own name is the sole authoritative source of its slug, -// publish date, tags/category, and draft status — see the README's "Adding +// 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 @@ -187,9 +187,6 @@ function buildStub(author, fileName, mtime, authorRoot) { if (!parsed) return null; const { slug, date, tags, draft } = parsed; const path = `${authorRoot}${encodeURIComponent(fileName)}`; - // "Category" isn't a separate concept anymore — it's just whichever tag - // id (if any) looks like a `year-YYYY` id. - const category = tags.find(id => /^year-20\d{2}$/i.test(id)) || null; return { author, @@ -197,8 +194,6 @@ function buildStub(author, fileName, mtime, authorRoot) { hash: `${author}/${slug}`, path, mtime, - category, - categoryYear: category ? Number(category.replace(/^year-/i, '')) : null, date, tags, published: !draft, @@ -336,7 +331,7 @@ 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, category, + // 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 diff --git a/js/router.js b/js/router.js index 87e801b..bcdf633 100644 --- a/js/router.js +++ b/js/router.js @@ -6,16 +6,15 @@ // first = "search", ends "/page/N" → search results, page N (#search/query/page/2) // 2 segments, first = "page" → all articles, page N (#page/2) // 2 segments, first = "author" → author filter, page 1 (#author/alice) -// 2 segments, first = "category" or "tag" → filtered list, page 1 +// 2 segments, first = "tag" → tag filter, page 1 // 3 segments, first = "post" → single article (#post/alice/my-article) -// 4 segments, first = "author", third = "page" → author filter, page N (#author/alice/page/2) -// 4 segments, first = "category", third = "page" → category filter, page N (#category/linux/page/2) -// 4 segments, first = "tag", third = "page" → tag filter, page N (#tag/guides/page/2) +// 4 segments, first = "author", third = "page" → author filter, page N (#author/alice/page/2) +// 4 segments, first = "tag", third = "page" → tag filter, page N (#tag/guides/page/2) import { MARKED_URL } from './config-default.js'; import { loadTemplates } from './templates.js'; import { loadStrings, t } from './i18n.js'; -import { loadArticleList, loadAuthorView, loadCategoryView, loadTagView, loadArticle, loadSearch } from './views.js'; +import { loadArticleList, loadAuthorView, loadTagView, loadArticle, loadSearch } from './views.js'; // Dynamically inject marked.js as a plain global script before the app starts. function loadScript(url) { @@ -50,13 +49,11 @@ function router() { } else if (parts.length === 0) loadArticleList(); else if (parts.length === 2 && parts[0] === 'page') loadArticleList(null, parsePage(parts[1])); - else if (parts.length === 2 && parts[0] === 'author') loadAuthorView(decodeURIComponent(parts[1])); - else if (parts.length === 2 && parts[0] === 'category') loadCategoryView(decodeURIComponent(parts[1])); - else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodeURIComponent(parts[1])); - else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodeURIComponent).join('/')); - else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodeURIComponent(parts[1]), parsePage(parts[3])); - else if (parts.length === 4 && parts[0] === 'category' && parts[2] === 'page') loadCategoryView(decodeURIComponent(parts[1]), parsePage(parts[3])); - else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodeURIComponent(parts[1]), parsePage(parts[3])); + else if (parts.length === 2 && parts[0] === 'author') loadAuthorView(decodeURIComponent(parts[1])); + else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodeURIComponent(parts[1])); + else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodeURIComponent).join('/')); + else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodeURIComponent(parts[1]), parsePage(parts[3])); + else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodeURIComponent(parts[1]), parsePage(parts[3])); else loadArticleList(); } @@ -77,7 +74,7 @@ function init() { // Fail with a clear, catchable message here rather than a cryptic // uncaught TypeError the first time showListView()/showArticleView() // run — both mount points are a hard requirement, unlike - // search/tag/year, which are optional. + // search/tag, which are optional. if (!document.getElementById('list-view') || !document.getElementById('article-view')) { throw new Error('Missing required #list-view and/or #article-view element — see the "Embedding in an existing page" section of the README.'); } diff --git a/js/views.js b/js/views.js index 003fbae..2a69883 100644 --- a/js/views.js +++ b/js/views.js @@ -48,7 +48,6 @@ function filterLabel(filter) { if (!filter) return ''; if (filter.type === 'author') return t('filterAuthor', { value: filter.value }); if (filter.type === 'tag') return t('filterTag', { value: filter.value }); - if (filter.type === 'category') return t('filterYear', { value: filter.value.replace(/^year-/i, '') }); return filter.value; } @@ -103,54 +102,22 @@ function renderFilterLink(label, href, count, active = false) { return link; } -// Years and tags render independently — either `