Remove category/year filtering, keep tag-only filtering (AI)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
91a24574a8
commit
730f16934d
11 files changed
+55
-125
No files matched your search
@@ -22,12 +22,12 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Dark mode** — automatically follows the OS `prefers-color-scheme` setting; no toggle needed.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
- **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`.
|
- **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
|
│ ├── cache.js # IndexedDB cache for discovered articles, keyed by article file path
|
||||||
│ ├── concurrency.js # mapWithConcurrency() — caps simultaneous per-article fetches
|
│ ├── concurrency.js # mapWithConcurrency() — caps simultaneous per-article fetches
|
||||||
│ ├── i18n.js # t(), loadStrings(), getLocale() — UI string lookup
|
│ ├── 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
|
│ └── router.js # Hash router and app entry point
|
||||||
├── vendor/
|
├── vendor/
|
||||||
│ └── marked.min.js # marked.js (local copy, loaded as a global) — the only 3rd-party code
|
│ └── 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
|
## Adding an article
|
||||||
|
|
||||||
An article is a single Markdown file placed directly in its author's directory. Its *file name*
|
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:
|
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),
|
as permanent: retagging or correcting the date later just means renaming the file (see below),
|
||||||
but changing the slug changes the article's URL.
|
but changing the slug changes the article's URL.
|
||||||
- `<DDMMYYYY>` — the publish date, 8 digits, no separators (e.g. `16062026` for 16 June 2026).
|
- `<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
|
- `<tag1>+<tag2>+…` — zero or more tag ids, joined with `+`. Each tag id is a normal topic tag,
|
||||||
treated as the article's "year"/category, shown as a badge linking to `#category/year-YYYY`;
|
shown as a pill linking to `#tag/tag-id`. Omit this whole segment (and its leading `~`) if the
|
||||||
every other tag id is a normal topic tag, shown as a pill linking to `#tag/tag-id`. Omit this
|
article has no tags.
|
||||||
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
|
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
|
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/`
|
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.
|
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
|
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`.
|
(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
|
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 |
|
| `#search/linux/page/2` | Same, page 2 |
|
||||||
| `#author/alice` | All articles by alice, page 1 |
|
| `#author/alice` | All articles by alice, page 1 |
|
||||||
| `#author/alice/page/2` | All articles by alice, page 2 |
|
| `#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` | All articles tagged "guides", page 1 |
|
||||||
| `#tag/guides/page/2` | Same, page 2 |
|
| `#tag/guides/page/2` | Same, page 2 |
|
||||||
| `#post/alice/my-article` | Single article at `articles/alice/my-article~DDMMYYYY~tags.md` |
|
| `#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
|
## Customising the layout
|
||||||
|
|
||||||
@@ -348,17 +345,9 @@ demo does.
|
|||||||
```html
|
```html
|
||||||
<nav id="tag-filters" aria-label="Filter by tag"></nav>
|
<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` is optional — omit it and nothing breaks, the article list and search still work
|
||||||
`#tag-filters`/`#year-filters` is present just gets filled in — omit either one (or both) and
|
standalone.
|
||||||
nothing breaks, the article list and search still work standalone.
|
|
||||||
|
|
||||||
### Minimal example
|
### Minimal example
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
font-style: italic;
|
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 {
|
.article-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Author + category row at the bottom of each card */
|
/* Author + tag row at the bottom of each card */
|
||||||
.card-meta {
|
.card-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shared category badge — used in cards and article headers */
|
/* Tag pill — also used in cards and article headers */
|
||||||
.tag {
|
.tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: var(--color-tag-bg);
|
background: var(--color-tag-bg);
|
||||||
@@ -134,8 +134,8 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Heading shown above a filtered list (author or category view) */
|
/* Heading shown above a filtered list (author, tag, or search results) */
|
||||||
.category-heading {
|
.list-heading {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-soft);
|
color: var(--color-text-soft);
|
||||||
|
|||||||
+2
-7
@@ -144,7 +144,7 @@ function excerpt(content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An article file's own name is the sole authoritative source of its slug,
|
// 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`,
|
// an article" section. Format: `<slug>~<DDMMYYYY>[~tag1+tag2+…].md`,
|
||||||
// optionally prefixed with `_` for draft. `~` (rather than e.g. "--") is
|
// optionally prefixed with `_` for draft. `~` (rather than e.g. "--") is
|
||||||
// deliberate: a slug is expected to be plain kebab-case, and "~" isn't a
|
// 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;
|
if (!parsed) return null;
|
||||||
const { slug, date, tags, draft } = parsed;
|
const { slug, date, tags, draft } = parsed;
|
||||||
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
|
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 {
|
return {
|
||||||
author,
|
author,
|
||||||
@@ -197,8 +194,6 @@ function buildStub(author, fileName, mtime, authorRoot) {
|
|||||||
hash: `${author}/${slug}`,
|
hash: `${author}/${slug}`,
|
||||||
path,
|
path,
|
||||||
mtime,
|
mtime,
|
||||||
category,
|
|
||||||
categoryYear: category ? Number(category.replace(/^year-/i, '')) : null,
|
|
||||||
date,
|
date,
|
||||||
tags,
|
tags,
|
||||||
published: !draft,
|
published: !draft,
|
||||||
@@ -336,7 +331,7 @@ async function runCrawl() {
|
|||||||
// mtimes. This is what builds the flat, cross-author list that drives
|
// mtimes. This is what builds the flat, cross-author list that drives
|
||||||
// global sorting and pagination — see the plan doc's "keep the flat
|
// global sorting and pagination — see the plan doc's "keep the flat
|
||||||
// feed" decision. It's also the *only* per-author network request
|
// 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
|
// draft status) is parsed straight out of each article file's own name
|
||||||
// (see buildStub/parseArticleFileName) — there's no per-article fetch
|
// (see buildStub/parseArticleFileName) — there's no per-article fetch
|
||||||
// at index time. The author's own bio file (`<author>.md`) and shared
|
// at index time. The author's own bio file (`<author>.md`) and shared
|
||||||
|
|||||||
+10
-13
@@ -6,16 +6,15 @@
|
|||||||
// first = "search", ends "/page/N" → search results, page N (#search/query/page/2)
|
// 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 = "page" → all articles, page N (#page/2)
|
||||||
// 2 segments, first = "author" → author filter, page 1 (#author/alice)
|
// 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)
|
// 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 = "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 = "tag", third = "page" → tag filter, page N (#tag/guides/page/2)
|
|
||||||
|
|
||||||
import { MARKED_URL } from './config-default.js';
|
import { MARKED_URL } from './config-default.js';
|
||||||
import { loadTemplates } from './templates.js';
|
import { loadTemplates } from './templates.js';
|
||||||
import { loadStrings, t } from './i18n.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.
|
// Dynamically inject marked.js as a plain global script before the app starts.
|
||||||
function loadScript(url) {
|
function loadScript(url) {
|
||||||
@@ -50,13 +49,11 @@ function router() {
|
|||||||
}
|
}
|
||||||
else if (parts.length === 0) loadArticleList();
|
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] === '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] === '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 === 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 === 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] === '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 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();
|
else loadArticleList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +74,7 @@ function init() {
|
|||||||
// Fail with a clear, catchable message here rather than a cryptic
|
// Fail with a clear, catchable message here rather than a cryptic
|
||||||
// uncaught TypeError the first time showListView()/showArticleView()
|
// uncaught TypeError the first time showListView()/showArticleView()
|
||||||
// run — both mount points are a hard requirement, unlike
|
// 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')) {
|
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.');
|
throw new Error('Missing required #list-view and/or #article-view element — see the "Embedding in an existing page" section of the README.');
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-70
@@ -48,7 +48,6 @@ function filterLabel(filter) {
|
|||||||
if (!filter) return '';
|
if (!filter) return '';
|
||||||
if (filter.type === 'author') return t('filterAuthor', { value: filter.value });
|
if (filter.type === 'author') return t('filterAuthor', { value: filter.value });
|
||||||
if (filter.type === 'tag') return t('filterTag', { 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;
|
return filter.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,54 +102,22 @@ function renderFilterLink(label, href, count, active = false) {
|
|||||||
return link;
|
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) {
|
function renderFilterNavigation(articles, activeFilter = null) {
|
||||||
const years = document.getElementById('year-filters');
|
|
||||||
const tags = document.getElementById('tag-filters');
|
const tags = document.getElementById('tag-filters');
|
||||||
if (!years && !tags) return;
|
if (!tags) return;
|
||||||
const allArticlesLink = () => renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter);
|
tags.innerHTML = '';
|
||||||
|
const tagCounts = new Map();
|
||||||
if (years) {
|
articles.forEach(article => (article.tags || []).forEach(tag => tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)));
|
||||||
years.innerHTML = '';
|
const tagList = document.createElement('div');
|
||||||
const yearCounts = new Map();
|
tagList.className = 'filter-list';
|
||||||
articles.forEach(article => {
|
tagList.appendChild(renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter));
|
||||||
const year = Number(article.categoryYear);
|
[...tagCounts.keys()]
|
||||||
if (year) yearCounts.set(year, (yearCounts.get(year) || 0) + 1);
|
.sort((a, b) => a.localeCompare(b, getLocale()))
|
||||||
|
.forEach(id => {
|
||||||
|
const active = activeFilter?.type === 'tag' && activeFilter.value === id;
|
||||||
|
tagList.appendChild(renderFilterLink(id, `#tag/${encodeURIComponent(id)}`, tagCounts.get(id), active));
|
||||||
});
|
});
|
||||||
const yearList = document.createElement('div');
|
tags.appendChild(tagList);
|
||||||
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) {
|
|
||||||
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.
|
|
||||||
[...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;
|
|
||||||
tagList.appendChild(renderFilterLink(id, `#tag/${encodeURIComponent(id)}`, tagCounts.get(id), active));
|
|
||||||
});
|
|
||||||
tags.appendChild(tagList);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds the #post/author/slug hash for an article, encoding each segment
|
// Builds the #post/author/slug hash for an article, encoding each segment
|
||||||
@@ -164,17 +131,11 @@ function postHref(article) {
|
|||||||
return `#post/${encodeURIComponent(article.author)}/${encodeURIComponent(article.slug)}`;
|
return `#post/${encodeURIComponent(article.author)}/${encodeURIComponent(article.slug)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared between renderCard() and loadArticle() — date, reading-time, and
|
// Shared between renderCard() and loadArticle() — date and reading-time are
|
||||||
// the category/year badge are populated identically in both places.
|
// populated identically in both places.
|
||||||
function applyArticleMetaFields(field, { date, readingTime, category, categoryYear }) {
|
function applyArticleMetaFields(field, { date, readingTime }) {
|
||||||
setField(field('date'), date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); });
|
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 }); });
|
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) {
|
function renderCard(article) {
|
||||||
@@ -250,7 +211,7 @@ function renderArticleListBody(view, { total, pageArticles, page, pages, filter,
|
|||||||
renderCrawlHealthWarning(view, done);
|
renderCrawlHealthWarning(view, done);
|
||||||
if (heading) {
|
if (heading) {
|
||||||
const headingElement = document.createElement('h2');
|
const headingElement = document.createElement('h2');
|
||||||
headingElement.className = 'category-heading';
|
headingElement.className = 'list-heading';
|
||||||
headingElement.textContent = heading;
|
headingElement.textContent = heading;
|
||||||
view.appendChild(headingElement);
|
view.appendChild(headingElement);
|
||||||
}
|
}
|
||||||
@@ -283,19 +244,16 @@ export async function loadArticleList(filter = null, page = 1) {
|
|||||||
const applyFilter = allArticles => {
|
const applyFilter = allArticles => {
|
||||||
renderFilterNavigation(allArticles, filter);
|
renderFilterNavigation(allArticles, filter);
|
||||||
if (!filter) return allArticles;
|
if (!filter) return allArticles;
|
||||||
return allArticles.filter(article => {
|
if (filter.type === 'tag') return allArticles.filter(article => article.tags.includes(filter.value));
|
||||||
if (filter.type === 'tag') return article.tags.includes(filter.value);
|
// Author names are exact, case-sensitive directory names (used
|
||||||
// Author names are exact, case-sensitive directory names (used
|
// as-is for paths and profile lookups elsewhere) — comparing them
|
||||||
// as-is for paths and profile lookups elsewhere) — comparing
|
// case-insensitively here could merge two distinctly-named author
|
||||||
// them case-insensitively here could merge two distinctly-named
|
// directories that differ only in case.
|
||||||
// author directories that differ only in case.
|
return allArticles.filter(article => article.author === filter.value);
|
||||||
if (filter.type === 'author') return article.author === filter.value;
|
|
||||||
return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sorting/filtering/pagination all work on stubs alone (tags, date,
|
// Sorting/filtering/pagination all work on stubs alone (tags, date —
|
||||||
// category — none of that needs a content fetch). Only the current
|
// none of that needs a content fetch). Only the current
|
||||||
// page's cards need real content, so only that slice gets hydrated —
|
// 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
|
// 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.
|
// 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);
|
export const loadTagView = (tag, page = 1) => loadArticleList({ type: 'tag', value: tag }, page);
|
||||||
|
|
||||||
// How many stubs to hydrate-and-check per scanning round (see loadSearch
|
// 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 };
|
const filter = { type: 'search', value: query };
|
||||||
try {
|
try {
|
||||||
const needle = query.toLocaleLowerCase(getLocale());
|
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));
|
.some(value => String(value || '').toLocaleLowerCase(getLocale()).includes(needle));
|
||||||
|
|
||||||
const stubs = await discoverArticles();
|
const stubs = await discoverArticles();
|
||||||
@@ -546,7 +503,7 @@ export async function loadArticle(hash) {
|
|||||||
field('title').textContent = meta.title || article.slug;
|
field('title').textContent = meta.title || article.slug;
|
||||||
// Publish date comes from the article file's own name (already
|
// Publish date comes from the article file's own name (already
|
||||||
// resolved onto `article.date`), not the raw frontmatter.
|
// 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; });
|
setField(field('description'), meta.description, (description, value) => { description.textContent = value; });
|
||||||
const image = field('image');
|
const image = field('image');
|
||||||
if (coverUrl) { image.src = coverUrl; image.alt = meta.title || article.slug; } else image.closest('figure')?.remove();
|
if (coverUrl) { image.src = coverUrl; image.alt = meta.title || article.slug; } else image.closest('figure')?.remove();
|
||||||
|
|||||||
@@ -5,9 +5,7 @@
|
|||||||
|
|
||||||
"filterAuthor": "Autor: {value}",
|
"filterAuthor": "Autor: {value}",
|
||||||
"filterTag": "Štítek: {value}",
|
"filterTag": "Štítek: {value}",
|
||||||
"filterYear": "Ročník: {value}",
|
|
||||||
"filterAllArticles": "Všechny články",
|
"filterAllArticles": "Všechny články",
|
||||||
"yearLabel": "Ročník {year}",
|
|
||||||
|
|
||||||
"paginationLabel": "Stránkování",
|
"paginationLabel": "Stránkování",
|
||||||
"paginationPrevious": "Předchozí",
|
"paginationPrevious": "Předchozí",
|
||||||
|
|||||||
@@ -5,9 +5,7 @@
|
|||||||
|
|
||||||
"filterAuthor": "Author: {value}",
|
"filterAuthor": "Author: {value}",
|
||||||
"filterTag": "Tag: {value}",
|
"filterTag": "Tag: {value}",
|
||||||
"filterYear": "Year: {value}",
|
|
||||||
"filterAllArticles": "All articles",
|
"filterAllArticles": "All articles",
|
||||||
"yearLabel": "Year {year}",
|
|
||||||
|
|
||||||
"paginationLabel": "Pagination",
|
"paginationLabel": "Pagination",
|
||||||
"paginationPrevious": "Previous",
|
"paginationPrevious": "Previous",
|
||||||
|
|||||||
@@ -5,9 +5,7 @@
|
|||||||
|
|
||||||
"filterAuthor": "Autor: {value}",
|
"filterAuthor": "Autor: {value}",
|
||||||
"filterTag": "Štítok: {value}",
|
"filterTag": "Štítok: {value}",
|
||||||
"filterYear": "Ročník: {value}",
|
|
||||||
"filterAllArticles": "Všetky články",
|
"filterAllArticles": "Všetky články",
|
||||||
"yearLabel": "Ročník {year}",
|
|
||||||
|
|
||||||
"paginationLabel": "Stránkovanie",
|
"paginationLabel": "Stránkovanie",
|
||||||
"paginationPrevious": "Predchádzajúca",
|
"paginationPrevious": "Predchádzajúca",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
<p class="description" data-field="description"></p>
|
<p class="description" data-field="description"></p>
|
||||||
<div class="article-meta">
|
<div class="article-meta">
|
||||||
<a class="author-link" data-field="author" href=""></a>
|
<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>
|
<span class="tag-list" data-field="tags"></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
<p data-field="description"></p>
|
<p data-field="description"></p>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
<a class="author-link" data-field="author" href=""></a>
|
<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>
|
<span class="tag-list" data-field="tags"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user