Remove category/year filtering, keep tag-only filtering (AI)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
biosiasandClaude Sonnet 5 committed 2026-09-22 15:19:59 +02:00
1 parent 91a24574a8
commit 730f16934d
11 files changed
+36 -106

No files matched your search

+11 -22
View File
@@ -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.
- `<DDMMYYYY>` — the publish date, 8 digits, no separators (e.g. `16062026` for 16 June 2026).
- `<tag1>+<tag2>+…` — 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.
- `<tag1>+<tag2>+…` — 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
<nav id="tag-filters" aria-label="Filter by tag"></nav>
```
- **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
<nav id="year-filters" aria-label="Filter by year"></nav>
```
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
+1 -1
View File
@@ -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;
+4 -4
View File
@@ -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);
+2 -7
View File
@@ -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: `<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
@@ -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 (`<author>.md`) and shared
+3 -6
View File
@@ -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)
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) {
@@ -51,11 +50,9 @@ 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 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.');
}
+15 -58
View File
@@ -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,47 +102,16 @@ function renderFilterLink(label, href, count, active = false) {
return link;
}
// Years and tags render independently — either `<nav>` can be absent from
// the page (e.g. a site that only wants tag filtering, like this project's
// own index.html) without the other silently stopping too. Whichever
// section renders first gets the "all articles" reset link, so it's never
// lost just because the years nav isn't on the page.
function renderFilterNavigation(articles, activeFilter = null) {
const years = document.getElementById('year-filters');
const tags = document.getElementById('tag-filters');
if (!years && !tags) return;
const allArticlesLink = () => renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter);
if (years) {
years.innerHTML = '';
const yearCounts = new Map();
articles.forEach(article => {
const year = Number(article.categoryYear);
if (year) yearCounts.set(year, (yearCounts.get(year) || 0) + 1);
});
const yearList = document.createElement('div');
yearList.className = 'filter-list';
yearList.appendChild(allArticlesLink());
for (let year = 2026; year >= 2009; year -= 1) {
const count = yearCounts.get(year) || 0;
const active = activeFilter?.type === 'category' && activeFilter.value.toLowerCase() === `year-${year}`;
yearList.appendChild(renderFilterLink(t('yearLabel', { year }), `#category/year-${year}`, count, active));
}
years.appendChild(yearList);
}
if (tags) {
if (!tags) return;
tags.innerHTML = '';
const tagCounts = new Map();
articles.forEach(article => (article.tags || []).forEach(tag => tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)));
const tagList = document.createElement('div');
tagList.className = 'filter-list';
if (!years) tagList.appendChild(allArticlesLink());
// A `year-YYYY` id is also a normal entry in `article.tags` (that's how
// it's recognised as the category — see buildStub() in discovery.js),
// but it belongs in the years nav above, not duplicated here.
tagList.appendChild(renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter));
[...tagCounts.keys()]
.filter(id => !/^year-20\d{2}$/i.test(id))
.sort((a, b) => a.localeCompare(b, getLocale()))
.forEach(id => {
const active = activeFilter?.type === 'tag' && activeFilter.value === id;
@@ -151,7 +119,6 @@ function renderFilterNavigation(articles, activeFilter = null) {
});
tags.appendChild(tagList);
}
}
// Builds the #post/author/slug hash for an article, encoding each segment
// individually. article.hash itself (a raw, unencoded "author/slug") is only
@@ -164,17 +131,11 @@ function postHref(article) {
return `#post/${encodeURIComponent(article.author)}/${encodeURIComponent(article.slug)}`;
}
// Shared between renderCard() and loadArticle() — date, reading-time, and
// the category/year badge are populated identically in both places.
function applyArticleMetaFields(field, { date, readingTime, category, categoryYear }) {
// Shared between renderCard() and loadArticle() — date and reading-time are
// populated identically in both places.
function applyArticleMetaFields(field, { date, readingTime }) {
setField(field('date'), date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); });
setField(field('reading-time'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
// A post only counts as a "category" when one of its directory-name
// tags happens to name a year — not every article has one.
setField(field('category'), category, (categoryEl, value) => {
categoryEl.textContent = t('yearLabel', { year: categoryYear });
categoryEl.href = `#category/${encodeURIComponent(value)}`;
});
}
function renderCard(article) {
@@ -250,7 +211,7 @@ function renderArticleListBody(view, { total, pageArticles, page, pages, filter,
renderCrawlHealthWarning(view, done);
if (heading) {
const headingElement = document.createElement('h2');
headingElement.className = 'category-heading';
headingElement.className = 'list-heading';
headingElement.textContent = heading;
view.appendChild(headingElement);
}
@@ -283,19 +244,16 @@ export async function loadArticleList(filter = null, page = 1) {
const applyFilter = allArticles => {
renderFilterNavigation(allArticles, filter);
if (!filter) return allArticles;
return allArticles.filter(article => {
if (filter.type === 'tag') return article.tags.includes(filter.value);
if (filter.type === 'tag') return allArticles.filter(article => article.tags.includes(filter.value));
// Author names are exact, case-sensitive directory names (used
// as-is for paths and profile lookups elsewhere) — comparing
// them case-insensitively here could merge two distinctly-named
// author directories that differ only in case.
if (filter.type === 'author') return article.author === filter.value;
return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase();
});
// as-is for paths and profile lookups elsewhere) — comparing them
// case-insensitively here could merge two distinctly-named author
// directories that differ only in case.
return allArticles.filter(article => article.author === filter.value);
};
// Sorting/filtering/pagination all work on stubs alone (tags, date,
// category — none of that needs a content fetch). Only the current
// Sorting/filtering/pagination all work on stubs alone (tags, date —
// none of that needs a content fetch). Only the current
// page's cards need real content, so only that slice gets hydrated —
// this is what keeps a large site's list fast: the rest of the tree's
// markdown is never fetched until the user actually pages to it.
@@ -393,7 +351,6 @@ export async function loadAuthorView(author, page = 1) {
}
}
export const loadCategoryView = (category, page = 1) => loadArticleList({ type: 'category', value: category }, page);
export const loadTagView = (tag, page = 1) => loadArticleList({ type: 'tag', value: tag }, page);
// How many stubs to hydrate-and-check per scanning round (see loadSearch
@@ -425,7 +382,7 @@ export async function loadSearch(query, page = 1) {
const filter = { type: 'search', value: query };
try {
const needle = query.toLocaleLowerCase(getLocale());
const matchesQuery = article => [article.title, article.description, article.author, article.authorName, article.category, ...article.tags]
const matchesQuery = article => [article.title, article.description, article.author, article.authorName, ...article.tags]
.some(value => String(value || '').toLocaleLowerCase(getLocale()).includes(needle));
const stubs = await discoverArticles();
@@ -546,7 +503,7 @@ export async function loadArticle(hash) {
field('title').textContent = meta.title || article.slug;
// Publish date comes from the article file's own name (already
// resolved onto `article.date`), not the raw frontmatter.
applyArticleMetaFields(field, { date: article.date, readingTime, category: article.category, categoryYear: article.categoryYear });
applyArticleMetaFields(field, { date: article.date, readingTime });
setField(field('description'), meta.description, (description, value) => { description.textContent = value; });
const image = field('image');
if (coverUrl) { image.src = coverUrl; image.alt = meta.title || article.slug; } else image.closest('figure')?.remove();
-2
View File
@@ -5,9 +5,7 @@
"filterAuthor": "Autor: {value}",
"filterTag": "Štítek: {value}",
"filterYear": "Ročník: {value}",
"filterAllArticles": "Všechny články",
"yearLabel": "Ročník {year}",
"paginationLabel": "Stránkování",
"paginationPrevious": "Předchozí",
-2
View File
@@ -5,9 +5,7 @@
"filterAuthor": "Author: {value}",
"filterTag": "Tag: {value}",
"filterYear": "Year: {value}",
"filterAllArticles": "All articles",
"yearLabel": "Year {year}",
"paginationLabel": "Pagination",
"paginationPrevious": "Previous",
-2
View File
@@ -5,9 +5,7 @@
"filterAuthor": "Autor: {value}",
"filterTag": "Štítok: {value}",
"filterYear": "Ročník: {value}",
"filterAllArticles": "Všetky články",
"yearLabel": "Ročník {year}",
"paginationLabel": "Stránkovanie",
"paginationPrevious": "Predchádzajúca",
-1
View File
@@ -8,7 +8,6 @@
<p class="description" data-field="description"></p>
<div class="article-meta">
<a class="author-link" data-field="author" href=""></a>
<a class="tag" data-field="category" href=""></a>
<span class="tag-list" data-field="tags"></span>
</div>
</header>
-1
View File
@@ -11,7 +11,6 @@
<p data-field="description"></p>
<div class="card-meta">
<a class="author-link" data-field="author" href=""></a>
<a class="tag" data-field="category" href=""></a>
<span class="tag-list" data-field="tags"></span>
</div>
</div>