Author SHA1 Message Date
jpelc 5b9eff8dff update readme 2026-10-26 18:40:36 +01:00
jpelc 4c384a9517 prepare for webdav integration 2026-09-13 01:23:42 +02:00
15 changed files with 452 additions and 432 deletions

No files matched your search

+144 -132
View File
@@ -1,14 +1,13 @@
# cmkiss — Minimal Client-Side CMS # cmjs — Minimal Client-Side CMS
A minimal CMS where articles are Markdown files rendered in the browser. No build step, no database, no server-side code. A minimal CMS where articles are Markdown files rendered in the browser. No build step, no database, no server-side code.
## How it works ## How it works
1. The browser traverses `articles/` via a server directory index — discovering authors and 1. The browser traverses `articles/` via a server directory index — discovering authors and
their article files; nginx JSON autoindex is preferred, with HTML autoindexes supported as a nested post directories; nginx JSON/HTML indexes and Nextcloud WebDAV PROPFIND are supported.
fallback. 2. Sorting and filtering come from each post directory's *name*
2. Sorting, filtering, and publish status all come from the article file's own *name* (`slug--DDMMYYYY--expa-YYYY+tags/`) — knowable straight from the listing, with no
(`slug~DDMMYYYY~tags.md`) — knowable straight from the listing that discovers it, with no
further request per article. A `.md` file's actual content (title, description, image) is further request per article. A `.md` file's actual content (title, description, image) is
only fetched once that article is about to be shown — the current page's cards, a single only fetched once that article is about to be shown — the current page's cards, a single
article, or a search — not for the whole site up front. See "Adding an article" and "Article article, or a search — not for the whole site up front. See "Adding an article" and "Article
@@ -16,18 +15,18 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil
3. Clicking an article fetches the Markdown file and renders it with [marked.js](https://marked.js.org/). 3. Clicking an article fetches the Markdown file and renders it with [marked.js](https://marked.js.org/).
4. Hash-based routing handles navigation — back/forward works without page reloads. 4. Hash-based routing handles navigation — back/forward works without page reloads.
5. Once an article's content has been fetched, it's cached in IndexedDB, keyed by the article 5. Once an article's content has been fetched, it's cached in IndexedDB, keyed by the article
file's own path and invalidated by that file's `mtime`, so repeat visits skip re-fetching file's own path and invalidated by its ETag (or mtime fallback), so repeat visits skip re-fetching
unchanged content — see "Article discovery & caching" below. unchanged content — see "Article discovery & caching" below.
## 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, 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, category, 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 and tag filtering** — clicking an author name or tag pill shows a filtered list. Both support pagination. - **Author, category, and tag filtering** — clicking an author name, category badge, or tag pill shows a filtered list. All three 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`.
@@ -36,14 +35,14 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil
## Project structure ## Project structure
This whole repo *is* cmkiss — clone it as-is into an existing site (or copy `css/`, `js/`, This whole repo *is* cmjs — clone it as-is into an existing site (or copy `css/`, `js/`,
`strings/`, `templates/`, and your own `articles/` into one) and embed it into a page there; see `strings/`, `templates/`, and your own `articles/` into one) and embed it into a page there; see
"Embedding in an existing page" below. `demo/` is a standalone demo/preview page, kept separate "Embedding in an existing page" below. `demo/` is a standalone demo/preview page, kept separate
from the library — not something an embedding page needs to bring along. from the library — not something an embedding page needs to bring along.
``` ```
├── README.md ├── README.md
├── demo/ # Standalone demo/preview page — not needed to embed cmkiss elsewhere ├── demo/ # Standalone demo/preview page — not needed to embed cmjs elsewhere
│ ├── index.html # Single-page shell │ ├── index.html # Single-page shell
│ └── css/ │ └── css/
│ ├── reset.css # CSS variables, dark mode, box-sizing reset, focus styles │ ├── reset.css # CSS variables, dark mode, box-sizing reset, focus styles
@@ -66,7 +65,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, loadTagView, loadSearch │ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadCategoryView, 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
@@ -78,24 +77,29 @@ from the library — not something an embedding page needs to bring along.
│ ├── card.html # Article list card layout │ ├── card.html # Article list card layout
│ ├── article.html # Full article view layout │ ├── article.html # Full article view layout
│ └── author-profile.html # Author profile card layout │ └── author-profile.html # Author profile card layout
└── articles/ └── articles/ # The only folder cmjs reads
├── _tags.json # Tag registry (optional, see "Tag registry")
└── author/ # One directory per author └── author/ # One directory per author
├── author.md # Author profile (optional) ├── author.md # Author profile (optional)
├── media/ # Shared images for this author — see "Adding an article" ├── avatar.jpg # Optional author avatar, referenced by author.md
│ ├── avatar.jpg # Referenced by author.md's `avatar:` field └── slug--DDMMYYYY--expa-YYYY+tag1/ # One directory per article
│ └── cover.jpg # Referenced by one or more articles' `image:` field ├── slug.md
└── slug~DDMMYYYY~tag1+tag2.md # One file per article — see "Adding an article" └── cover.jpg # Optional, referenced by image:
``` ```
Drafts and archived posts live in sibling `drafts/` and `archive/` folders with the same
structure (see "Adding an article"). They sit outside `articles/`, so they are never served or
listed.
## Local configuration ## Local configuration
`js/config-default.js` holds the shipped defaults (`BASE_URL`, `MARKED_URL`, `ARTICLES_URL`) and `js/config-default.js` holds the shipped defaults (`BASE_URL`, `MARKED_URL`, `ARTICLES_URL`) and
is tracked in git; every other module imports from it. It also loads an optional `js/config.js` is tracked in git; every other module imports from it. It also loads an optional `js/config.js`
— gitignored, not part of cmkiss's own git history — to override any of them. **Nothing needs to — gitignored, not part of cmjs's own git history — to override any of them. **Nothing needs to
be created before first use**: if `js/config.js` doesn't exist, the shipped defaults apply as-is. be created before first use**: if `js/config.js` doesn't exist, the shipped defaults apply as-is.
The split exists for using cmkiss as a git submodule (or otherwise pulling in a newer version some The split exists for using cmjs as a git submodule (or otherwise pulling in a newer version some
other way): since `config.js` is gitignored, updating cmkiss never touches it, so any override other way): since `config.js` is gitignored, updating cmjs never touches it, so any override
you've made survives. Only `config-default.js` gets updated. you've made survives. Only `config-default.js` gets updated.
To override a setting, create `js/config.js` and export just the constant(s) you want to change — To override a setting, create `js/config.js` and export just the constant(s) you want to change —
@@ -107,57 +111,63 @@ needs):
export const ARTICLES_URL = new URL('/blog-content/articles/', location.origin).href; export const ARTICLES_URL = new URL('/blog-content/articles/', location.origin).href;
``` ```
An embedding page may configure a public DAV proxy without a local file:
```html
<script>window.CMJS_CONFIG = { articlesUrl: '/blog-content/', listingMode: 'webdav', tagsUrl: '/blog-content/_tags.json' };</script>
```
## 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 Markdown file inside a post directory. Its *directory name* contains the stable
is where its publish date and tags live — not in separate files, and not in frontmatter. The slug, date, expedition year, and topic tags. Publication status comes from the root folder the
format is: post directory sits in:
| Root | Status | Shown by cmjs |
|------|--------|---------------|
| `articles/<author>/` | published | yes |
| `drafts/<author>/` | draft | no |
| `archive/<author>/` | archived | no |
Publishing, unpublishing, or archiving a post means moving its whole directory (Markdown and
images together) between these roots. cmjs only ever reads `articles/`.
The directory name format is:
``` ```
<slug>~<DDMMYYYY>~<tag1>+<tag2>+….md <slug>--<DDMMYYYY>--expa-YYYY+<tag1>+<tag2>+…/<slug>.md
``` ```
- `<slug>` — the stable identifier used in the article's URL (`#post/author/slug`). Treat this - `<slug>` — the stable identifier used in the article's URL (`#post/author/slug`), matching
as permanent: retagging or correcting the date later just means renaming the file (see below), `[a-z0-9][a-z0-9-]{0,95}`. The Markdown file inside the directory must be named `<slug>.md`.
but changing the slug changes the article's URL. Treat the slug as permanent: retagging or correcting the date later just means renaming the
directory (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). - `<DDMMYYYY>` — the publish date, 8 digits, no separators (e.g. `16062026` for 16 June 2026).
- `<tag1>+<tag2>+…` — zero or more tag ids, joined with `+`. Each tag id is a normal topic tag, An invalid date (e.g. `31022026`) makes the directory be skipped.
shown as a pill linking to `#tag/tag-id`. Omit this whole segment (and its leading `~`) if the - `expa-YYYY+<tag1>+<tag2>+…` — normalized tag ids (lowercase `[a-z0-9][a-z0-9.-]*`) joined
article has no tags. with `+`. `expa-YYYY` is the article's expedition/year marker, shown as a badge linking to
`#tag/expa-YYYY`; every other tag id is a normal topic tag, shown as a pill linking to
`#tag/tag-id`. Posts written by the blog editor always have exactly one `expa-YYYY` tag,
listed first. The whole `--…` tag suffix is optional for hand-made posts.
The separator is `~` (tilde), not a hyphen — deliberately, since a slug is expected to be plain Images (cover and inline) live in the post directory, next to the Markdown file, and are
kebab-case (single hyphens only) and `~` isn't something you'd type by accident while writing referenced by paths relative to it — `image: cover.jpg`, `![](photo.jpg)`, or a subfolder such
one, unlike an extra `-`. as `media/photo.jpg`. Absolute `https://` URLs are used as-is.
**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
several articles without keeping a duplicate copy per article. Reference one from frontmatter or
markdown with a path relative to the author directory, e.g. `image: media/photo.jpg`.
Steps: 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 post directory using the naming convention above, e.g.
`articles/author/my-article~16062026~guides+tutorials.md` — or inside `articles/author/draft/` `articles/author/my-article--16062026--expa-2026+guides/`.
instead, if it's not ready to publish yet. 3. Add `my-article.md` and optional media files inside that directory.
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 4. Add a frontmatter block at the top of the Markdown file
5. Write your content — it will appear in the list automatically (once it's out of `draft/`) 5. Write your content — it will appear in the list automatically
To change an article's date or tags later, rename its file — its content doesn't need to change. To change an article's date, tags, or publication state later, move/rename its directory — its
`author` is derived from the containing directory — no frontmatter field needed 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 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 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, directories that differ only in case. Because every one of these is encoded in the directory name,
discovering an article's metadata never requires fetching anything beyond the directory listing discovering an article's metadata never requires fetching anything beyond the directory listing
that already reveals it — no further per-article request at all, which is what makes the "Article that already reveals it — no further per-article request at all, which is what makes the "Article
discovery & caching" section below possible. discovery & caching" section below possible.
@@ -170,8 +180,12 @@ discovery & caching" section below possible.
```yaml ```yaml
--- ---
schema: 2
title: My Article Title title: My Article Title
description: A short summary shown in the article list. description: A short summary shown in the article list.
authorName: Jane Doe
image: cover.jpg
updatedAt: '2026-08-27T21:14:03+02:00'
--- ---
Your markdown content here... Your markdown content here...
@@ -180,12 +194,14 @@ Your markdown content here...
| Field | Required | Notes | | Field | Required | Notes |
|-------|----------|-------| |-------|----------|-------|
| `title` | yes | Shown in the list and article header | | `title` | yes | Shown in the list and article header |
| `description` | no | Short summary shown under the title in the list | | `description` | no | Short summary shown under the title in the list; an excerpt of the body is used when missing |
| `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 | | `authorName` | no | Display name of the author; falls back to the author directory name |
| `image` | no | Cover image, relative to the Markdown file (e.g. `cover.jpg`); shown as card thumbnail and article hero. `cover` is accepted as an alias |
| `schema`, `updatedAt` | no | Written by the blog editor; ignored by cmjs |
Reading time is computed automatically from the article word count — no frontmatter field Reading time is computed automatically from the article word count — no frontmatter field
needed. `date` and `tags` frontmatter fields, if present, are ignored — superseded by the file needed. `date`, `tags`, `status`, `author`, `expa`, and `draft` frontmatter fields, if present,
name described above. are ignored — superseded by the directory name and root described above.
## Author profiles ## Author profiles
@@ -216,11 +232,22 @@ Short bio rendered as markdown.
| Field | Notes | | Field | Notes |
|-------|-------| |-------|-------|
| `title` | Display name shown in the profile card. Falls back to the author directory name. | | `title` | Display name shown in the profile card. Falls back to the author directory name. |
| `avatar` | Path to an image, relative to the author directory — e.g. `media/avatar.jpg` if kept in the shared media folder alongside article images, or a bare filename if kept directly beside `{author}.md`. Displayed as a circular thumbnail. | | `avatar` | Path to an image, relative to the author directory — usually a bare filename such as `avatar.jpg` kept directly beside `{author}.md`. Displayed as a circular thumbnail. |
| `github` / `twitter` / `linkedin` / `mastodon` / `website` | Any subset of these renders as pill links below the bio. | | `github` / `twitter` / `linkedin` / `mastodon` / `website` | Any subset of these renders as pill links below the bio. |
The body text below the frontmatter is rendered as markdown and shown as the bio. The body text below the frontmatter is rendered as markdown and shown as the bio.
## Tag registry
`articles/_tags.json` (or whatever `TAGS_URL` points to) holds human-readable tag labels:
```json
{"schemaVersion": 1, "tags": [{"id": "guides", "label": "Guides", "description": "", "aliases": [], "active": true}]}
```
The blog editor maintains this file. cmjs reserves the `TAGS_URL` setting for it but doesn't read
it yet, so tag pills currently show the raw tag ids.
## Routing ## Routing
| URL hash | View | | URL hash | View |
@@ -231,11 +258,13 @@ 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 |
| `#tag/expa-2026` | All articles carrying the `expa-2026` expedition tag, page 1 |
| `#tag/expa-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--expa-YYYY+tags/my-article.md` |
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`. 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`.
## Customising the layout ## Customising the layout
@@ -271,15 +300,13 @@ The resolved locale also drives date formatting (`Intl.DateTimeFormat`), search
tag-label sorting (`localeCompare`), via `getLocale()` in the same module — so a translation tag-label sorting (`localeCompare`), via `getLocale()` in the same module — so a translation
file doesn't just relabel text, it affects how dates and sorting behave too. file doesn't just relabel text, it affects how dates and sorting behave too.
cmkiss never names the page: the `<title>`, the site header, and any other branding (the header Site-specific branding that lives in the embedding page itself (the page `<title>`, the header
search placeholder, the sidebar section headings) belong to the embedding page and are plain search placeholder, the sidebar section headings) is plain static HTML, not JS-templated —
static HTML — translate them by editing the page directly (`demo/index.html` for this repo's own translate it by editing that page directly (`demo/index.html` for this repo's own demo).
demo). The only change cmkiss makes to the tab title is in the article view, where it shows
`<article title> | <page's own title>`; list views restore the page's original `<title>` exactly.
## Embedding in an existing page ## Embedding in an existing page
To add cmkiss to any HTML page, three things are needed: the CSS, the two mount-point `<div>`s, and the script. To add cmjs to any HTML page, three things are needed: the CSS, the two mount-point `<div>`s, and the script.
### 1. CSS — in `<head>` ### 1. CSS — in `<head>`
@@ -307,7 +334,7 @@ The site-level CSS files (`reset.css`, `header.css`, `layout.css`) are optional
<div id="article-view" hidden></div> <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 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. 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.
### 3. Script — before `</body>` ### 3. Script — before `</body>`
@@ -317,10 +344,10 @@ Place these wherever you want the CMS to appear. The router toggles `hidden` bet
`router.js` is the only script tag needed. It loads `marked.min.js` automatically at startup (path configured in `js/config.js`). `router.js` is the only script tag needed. It loads `marked.min.js` automatically at startup (path configured in `js/config.js`).
> **Subdirectory pages:** If the embedding page lives in a subdirectory (e.g. `/blog/index.html`) and cmkiss isn't installed at the site root, a plain relative path like `css/...` will resolve against the *page's* directory, not wherever cmkiss actually lives. Use a path (root-relative or otherwise) that correctly points at wherever you placed it, e.g. if cmkiss lives at `/cmkiss/` on your site: > **Subdirectory pages:** If the embedding page lives in a subdirectory (e.g. `/blog/index.html`) and cmjs isn't installed at the site root, a plain relative path like `css/...` will resolve against the *page's* directory, not wherever cmjs actually lives. Use a path (root-relative or otherwise) that correctly points at wherever you placed it, e.g. if cmjs lives at `/cmjs/` on your site:
> ```html > ```html
> <link rel="stylesheet" href="/cmkiss/css/components/card.css"> > <link rel="stylesheet" href="/cmjs/css/components/card.css">
> <script type="module" src="/cmkiss/js/router.js"></script> > <script type="module" src="/cmjs/js/router.js"></script>
> ``` > ```
> All internal fetches (articles, templates, marked.js) resolve relative to the module files via `import.meta.url` and work automatically — only the HTML tags need a path pointing at the right place. > All internal fetches (articles, templates, marked.js) resolve relative to the module files via `import.meta.url` and work automatically — only the HTML tags need a path pointing at the right place.
@@ -352,9 +379,17 @@ 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 `#tag/expa-YYYY`. Can appear alongside `#tag-filters` or on
its own.
```html
<nav id="year-filters" aria-label="Filter by year"></nav>
```
`#tag-filters` is optional — omit it and nothing breaks, the article list and search still work Both filter lists are populated by the same function on every list render, so whichever of
standalone. `#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.
### Minimal example ### Minimal example
@@ -382,30 +417,25 @@ standalone.
## nginx configuration ## nginx configuration
Point nginx `root` at the repo root. The `autoindex` directive applies recursively to all For a Nextcloud public share, proxy only the read-only `articles` share and
subdirectories under `/articles/` — **except** each author's own `draft/` folder, which must be allow `GET`, `HEAD`, and `PROPFIND`; set `window.CMJS_CONFIG.listingMode` to
denied outright (see "Adding an article" above: this is what keeps a draft from being indexed or `webdav`. Nextcloud requires `X-Requested-With: XMLHttpRequest` on DAV
fetched by a guessed direct URL). requests. A site-specific template is included in
`astronomickaexpedice.cz/deploy/blog-content.nginx.conf.template`.
Point nginx `root` at the repo root. The `autoindex` directive applies recursively to all subdirectories under `/articles/`.
```nginx ```nginx
server { server {
listen 80; listen 80;
server_name example.com; server_name example.com;
root /var/www/cmkiss; root /var/www/cmjs;
# Serve the demo page # Serve the demo page
location /demo/ { location /demo/ {
try_files $uri $uri/ /demo/index.html; 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) # Enable JSON directory listing for article discovery (applies to all subdirs)
location /articles/ { location /articles/ {
autoindex on; autoindex on;
@@ -421,21 +451,15 @@ server {
### Pointing articles/ somewhere else ### Pointing articles/ somewhere else
By default the crawler looks for articles at `articles/` next to the rest of cmkiss (`ARTICLES_URL`, By default the crawler looks for articles at `articles/` next to the rest of cmjs (`ARTICLES_URL`,
computed as a sibling of the `js/` folder). Articles don't have to live there — override computed as a sibling of the `js/` folder). Articles don't have to live there — override
`ARTICLES_URL` in your local `js/config.js` (see "Local configuration" above) to point anywhere `ARTICLES_URL` in your local `js/config.js` (see "Local configuration" above) to point anywhere
under the same origin (or a different one, CORS permitting). 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) Whatever it resolves to needs its own matching `location` block enabling the JSON (or HTML)
autoindex, plus the matching `draft/` deny block (see above) — it doesn't have to be named autoindex — it doesn't have to be named `articles/` or nested under wherever cmjs itself lives:
`articles/` or nested under wherever cmkiss itself lives:
```nginx ```nginx
location ~ ^/blog-content/articles/[^/]+/draft/ {
deny all;
return 404;
}
location /blog-content/articles/ { location /blog-content/articles/ {
autoindex on; autoindex on;
autoindex_format json; autoindex_format json;
@@ -446,7 +470,7 @@ location /blog-content/articles/ {
### Compression ### Compression
Markdown and directory-listing JSON are both plain text, so gzip shrinks them well — and since Markdown and directory-listing JSON are both plain text, so gzip shrinks them well — and since
the browser decompresses transparently, this needs no changes on the cmkiss side. nginx doesn't the browser decompresses transparently, this needs no changes on the cmjs side. nginx doesn't
know `.md` is text by default (it falls back to `application/octet-stream`, which `gzip_types` know `.md` is text by default (it falls back to `application/octet-stream`, which `gzip_types`
won't match), so map it explicitly and enable gzip for both content types: won't match), so map it explicitly and enable gzip for both content types:
@@ -472,14 +496,12 @@ 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 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 lightweight "stub" for every article — the author directories, then each author's own directory
listing (their article files, bio file, shared `media/` folder, and `draft/` folder together) listing (their post directories, bio file, and avatar together) along with each entry's ETag
along with each file's `mtime`. Only `.md` files listed directly inside an author's own directory (WebDAV) or `mtime` (nginx). Anything that isn't a directory with a valid post name (the
are kept as candidate articles — the bio file is filtered out by name, and any subdirectory author's own `{author}.md`, the avatar, stray files) is simply filtered out of that same
(`media/`, `draft/`) is skipped outright rather than being descended into, so nothing under listing. That's it: because a post directory's *name* already encodes its slug, date, and tags
`draft/` is ever indexed. That's it: because an article file's *name* already encodes its slug, (see "Adding an article" above), that single listing is enough to sort, filter, paginate, and populate the sidebar's tag/year counts, without
date, and tags (see "Adding an article" above), that single listing is enough to sort, filter, any further request per article and without ever fetching an article's actual Markdown.
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, **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: 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 the current page's cards (`hydrateArticles()` in `js/discovery.js`), a single visited
@@ -489,26 +511,24 @@ articles it has — indexing costs one request per author, full stop, and only ~
of content is ever fetched per page view. of content is ever fetched per page view.
- **Cache** — hydrated articles are cached per article file in IndexedDB (`js/cache.js`), - **Cache** — hydrated articles are cached per article file in IndexedDB (`js/cache.js`),
keyed by the file's own path and invalidated by comparing its live `mtime` against the cached keyed by the Markdown file's own path and invalidated by comparing the post directory's live
one — once *any* visit renders an article, every later visit skips its content fetch too, until ETag (or `mtime` when the listing has no ETags) against the cached one — once *any* visit
the file is renamed (a rename changes the cache key, so a retagged/redated article is treated as renders an article, every later visit skips its content fetch too, until the directory is
new — see "Adding an article" above) or its own mtime changes. Indexing reads the entire cache renamed (a rename changes the cache key, so a retagged/redated article is treated as new — see
"Adding an article" above) or its contents change. Indexing reads the entire cache
once up front (`getAllCacheEntries()`, kicked off in parallel with the author/directory-listing once up front (`getAllCacheEntries()`, kicked off in parallel with the author/directory-listing
requests) so a warm cache doesn't cost an IndexedDB read per article. If IndexedDB is unavailable requests) so a warm cache doesn't cost an IndexedDB read per article. If IndexedDB is unavailable
(private browsing, disabled storage), discovery transparently falls back to fetching everything (private browsing, disabled storage), discovery transparently falls back to fetching everything
live on every visit. live on every visit.
- **Cache schema version** — the cached records store the full derived article shape, not raw - **Cache schema version** — the cached records store the full derived article shape, not raw
content, so a code change to how articles are derived (in `js/discovery.js`) can make old content, so a code change to how articles are derived (in `js/discovery.js`) can make old
cached entries stale in *structure*, not just content — and a file's `mtime` won't catch that. cached entries stale in *structure*, not just content — and an ETag or `mtime` won't catch that.
Bump `CACHE_SCHEMA_VERSION` in `js/cache.js` whenever that shape changes; it's baked into Bump `CACHE_SCHEMA_VERSION` in `js/cache.js` whenever that shape changes; it's baked into
the IndexedDB database name, so a bump invalidates every cached entry on the next deploy — the IndexedDB database name, so a bump invalidates every cached entry on the next deploy.
articles and cached searches alike. A change to the search-match record shape instead, or a new - **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `js/discovery.js` (default `10`) caps how
store entirely, only needs `DB_VERSION` bumped (IndexedDB's own upgrade counter): `onupgradeneeded` many hydration (content) fetches run at once — the only per-article requests left, since
creates whatever store is missing without touching the other one's existing data. indexing no longer needs one; raise it for a fast/local nginx, lower it for a slow or shared/
- **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `js/discovery.js` (default `10`) caps both how production instance.
many hydration (content) fetches run at once and how many authors' directory listings are
fetched at once during indexing; raise it for a fast/local nginx, lower it for a slow or
shared/production instance.
- **Prefetching** — once the current page has fully rendered, the next page's articles are - **Prefetching** — once the current page has fully rendered, the next page's articles are
hydrated in the background (fire-and-forget), so clicking "next" is usually an instant cache hydrated in the background (fire-and-forget), so clicking "next" is usually an instant cache
hit rather than a fresh round-trip. hit rather than a fresh round-trip.
@@ -522,26 +542,18 @@ of content is ever fetched per page view.
a large site to a handful of requests instead of hydrating the entire article set up front. The a large site to a handful of requests instead of hydrating the entire article set up front. The
page count is exact once the whole set's been scanned, and a lower bound (at least one more page count is exact once the whole set's been scanned, and a lower bound (at least one more
page) until then. page) until then.
- **Persistent search-match cache** — alongside the resumable in-session scan above, each query's - **Crawl health** — if the root author-listing fetch fails outright, if every individual
per-article match results (`{ mtime, matched }`, keyed by article path) are cached in IndexedDB author's own directory listing fails, or if very few of the known candidate files parsed
too (`searchQueries` store in `js/cache.js`, keyed by the query text itself) and reused across successfully (a malformed directory name, or a total breakage like the wrong origin or CORS
page reloads and browser sessions, not just while paging through one visit. Searching the same misconfigured), a small warning banner is shown above the article list instead of silently
term again only re-fetches and re-checks articles that are new or whose `mtime` has changed rendering an empty page (see `getCrawlStats()` / `renderCrawlHealthWarning()` in
since — everything else is trusted from the cache without a content re-fetch at all. Capped at `js/discovery.js` / `js/views.js`).
`MAX_CACHED_SEARCHES` (default `30`) distinct queries, evicting the least-recently-searched one
first, so trying many different search terms over time doesn't grow the cache unbounded.
- **Crawl health** — if the root author-listing fetch fails outright, or if every individual
author's own directory listing fails (wrong origin, CORS misconfigured), a small warning banner
is shown above the article list instead of silently rendering an empty page (see
`getCrawlStats()` / `renderCrawlHealthWarning()` in `js/discovery.js` / `js/views.js`).
Non-conforming `.md` file names aren't treated as a health problem — only as a per-file skip —
since indexing does no per-article fetch that could actually fail.
- **Design rationale** — the original crawler design (wave-based crawling with a per-article - **Design rationale** — the original crawler design (wave-based crawling with a per-article
listing fetch, recency prioritisation, HTML-autoindex fallback, long-term "deploy and forget" listing fetch, recency prioritisation, HTML-autoindex fallback, long-term "deploy and forget"
constraints) is documented in constraints) is documented in
[`.claude/nginx-sitemap-crawler-plan.md`](.claude/nginx-sitemap-crawler-plan.md), which ships [`.claude/nginx-sitemap-crawler-plan.md`](.claude/nginx-sitemap-crawler-plan.md), which ships
alongside the code. The per-article listing fetch it describes no longer exists (metadata moved alongside the code. The per-article listing fetch it describes no longer exists (all metadata
into the file name instead, and articles no longer live in a directory of their own), but the needed for indexing is in the post directory name, so the author listing is enough), but the
rest of that reasoning — the caching model, the longevity constraints, the HTML fallback — still rest of that reasoning — the caching model, the longevity constraints, the HTML fallback — still
applies. applies.
+1 -1
View File
@@ -64,7 +64,7 @@
font-style: italic; font-style: italic;
} }
/* Author + tag row in article header — .author-link and .tag defined in card.css */ /* Author + category 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;
+11 -4
View File
@@ -15,6 +15,13 @@
border-color: var(--color-accent); border-color: var(--color-accent);
} }
.card--clickable { cursor: pointer; }
.card--clickable:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 4px;
}
.card--no-image { grid-template-columns: 1fr; } .card--no-image { grid-template-columns: 1fr; }
.card-media { .card-media {
@@ -90,7 +97,7 @@
line-height: 1.65; line-height: 1.65;
} }
/* Author + tag row at the bottom of each card */ /* Author + category row at the bottom of each card */
.card-meta { .card-meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -115,7 +122,7 @@
text-decoration: underline; text-decoration: underline;
} }
/* Tag pill — also used in cards and article headers */ /* Shared category badge — 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 +141,8 @@
color: #fff; color: #fff;
} }
/* Heading shown above a filtered list (author, tag, or search results) */ /* Heading shown above a filtered list (author or category view) */
.list-heading { .category-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);
+8
View File
@@ -0,0 +1,8 @@
server {
listen 80;
root /usr/share/nginx/html;
autoindex on;
autoindex_format json;
autoindex_localtime off;
location / { try_files $uri $uri/ =404; }
}
+13 -86
View File
@@ -1,11 +1,10 @@
// Persistent cache for discovered articles (keyed by each article file's own // Persistent cache for discovered articles, keyed by each article file's own
// path) and for per-search-query match results (keyed by the query text — // path. Raw IndexedDB (no wrapper library — see
// see setSearchCacheEntry() below). Raw IndexedDB (no wrapper library — see
// .claude/nginx-sitemap-crawler-plan.md L4/L5 on the zero-dependency, // .claude/nginx-sitemap-crawler-plan.md L4/L5 on the zero-dependency,
// long-stable-API requirement). If IndexedDB is unavailable (private // long-stable-API requirement). If IndexedDB is unavailable (private
// browsing, old browser, blocked), every operation below silently becomes a // browsing, old browser, blocked), every operation below silently becomes a
// no-op so discovery.js/views.js get live-fetch/live-scan behaviour for free // no-op so discovery.js gets live-fetch behaviour for free without needing
// without needing their own availability branching (plan doc R6). // its own availability branching (plan doc R6).
// Bump this whenever discovery.js's derived article record shape changes. // Bump this whenever discovery.js's derived article record shape changes.
// The version is baked into the DB name so a code deploy invalidates every // The version is baked into the DB name so a code deploy invalidates every
@@ -13,25 +12,8 @@
// each file's mtime happens to change — which, on a "deploy and forget" // each file's mtime happens to change — which, on a "deploy and forget"
// site, could be years. // site, could be years.
const CACHE_SCHEMA_VERSION = 3; const CACHE_SCHEMA_VERSION = 3;
// Renaming this prefix has the same effect as a version bump would — every const DB_NAME = `cmjs-cache-v${CACHE_SCHEMA_VERSION}`;
// existing visitor's browser opens a fresh, empty database under the new const STORE_NAME = 'articleFiles';
// name, and the previous one is left behind as orphaned storage (harmless;
// this module degrades to live-fetch behaviour whenever the cache is empty
// or unavailable). Do this deliberately, not casually.
const DB_NAME = `cmkiss-cache-v${CACHE_SCHEMA_VERSION}`;
const ARTICLE_STORE = 'articleFiles';
const SEARCH_STORE = 'searchQueries';
// IndexedDB's own upgrade counter — separate from CACHE_SCHEMA_VERSION,
// which only governs the *article* record shape (and busts the cache by
// renaming the whole database). Bump this instead whenever a store or index
// is added/changed; onupgradeneeded below creates only what's missing, so
// existing article-cache data survives the upgrade.
const DB_VERSION = 2;
// Caps how many distinct queries' match results stay cached — otherwise
// every unique term ever typed into search would keep its own per-article
// map forever, growing unbounded on a long-lived site. Evicted
// least-recently-searched-first — see setSearchCacheEntry().
const MAX_CACHED_SEARCHES = 30;
let cacheAvailable = true; let cacheAvailable = true;
let dbPromise = null; let dbPromise = null;
@@ -40,13 +22,9 @@ function openDb() {
if (!dbPromise) { if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => { dbPromise = new Promise((resolve, reject) => {
if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB unavailable')); return; } if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB unavailable')); return; }
const request = indexedDB.open(DB_NAME, DB_VERSION); const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => { request.onupgradeneeded = () => {
const db = request.result; request.result.createObjectStore(STORE_NAME, { keyPath: 'path' });
if (!db.objectStoreNames.contains(ARTICLE_STORE)) db.createObjectStore(ARTICLE_STORE, { keyPath: 'path' });
if (!db.objectStoreNames.contains(SEARCH_STORE)) {
db.createObjectStore(SEARCH_STORE, { keyPath: 'query' }).createIndex('lastUsed', 'lastUsed');
}
}; };
request.onsuccess = () => resolve(request.result); request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
@@ -59,7 +37,7 @@ function openDb() {
// Opens a store and resolves/rejects with the given IDBRequest's own outcome // Opens a store and resolves/rejects with the given IDBRequest's own outcome
// — the standard pattern, so result timing never depends on transaction // — the standard pattern, so result timing never depends on transaction
// lifecycle details. // lifecycle details.
async function runRequest(storeName, mode, makeRequest) { async function runRequest(mode, makeRequest) {
if (!cacheAvailable) return undefined; if (!cacheAvailable) return undefined;
let db; let db;
try { try {
@@ -68,7 +46,7 @@ async function runRequest(storeName, mode, makeRequest) {
return undefined; // openDb() itself already flipped cacheAvailable return undefined; // openDb() itself already flipped cacheAvailable
} }
try { try {
const store = db.transaction(storeName, mode).objectStore(storeName); const store = db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const request = makeRequest(store); const request = makeRequest(store);
request.onsuccess = () => resolve(request.result); request.onsuccess = () => resolve(request.result);
@@ -89,12 +67,12 @@ async function runRequest(storeName, mode, makeRequest) {
// reads this once up front rather than doing a point lookup per file during // reads this once up front rather than doing a point lookup per file during
// indexing. Empty Map on a miss or if the cache is unavailable. // indexing. Empty Map on a miss or if the cache is unavailable.
export async function getAllCacheEntries() { export async function getAllCacheEntries() {
const records = await runRequest(ARTICLE_STORE, 'readonly', store => store.getAll()); const records = await runRequest('readonly', store => store.getAll());
return new Map((records || []).map(record => [record.path, record])); return new Map((records || []).map(record => [record.path, record]));
} }
export async function setCacheEntry(path, entry) { export async function setCacheEntry(path, entry) {
await runRequest(ARTICLE_STORE, 'readwrite', store => store.put({ ...entry, path })); await runRequest('readwrite', store => store.put({ ...entry, path }));
} }
// Removes any cached entry whose path is no longer in the live directory // Removes any cached entry whose path is no longer in the live directory
@@ -109,7 +87,7 @@ export async function pruneCache(validPaths) {
return; // openDb() itself already flipped cacheAvailable return; // openDb() itself already flipped cacheAvailable
} }
try { try {
const store = db.transaction(ARTICLE_STORE, 'readwrite').objectStore(ARTICLE_STORE); const store = db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME);
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const cursorRequest = store.openCursor(); const cursorRequest = store.openCursor();
cursorRequest.onsuccess = event => { cursorRequest.onsuccess = event => {
@@ -125,54 +103,3 @@ export async function pruneCache(validPaths) {
// mean IndexedDB itself is unavailable. // mean IndexedDB itself is unavailable.
} }
} }
// Per-query search-match cache: `entries` is a { [articlePath]: { mtime,
// matched } } map — see loadSearch() in views.js, which trusts an entry
// (skipping a content re-fetch entirely) whenever its mtime still matches
// the live article, and only re-checks what's new or changed. Keyed by the
// exact (locale-lowercased) text matchesQuery() itself compares against, so
// "Linux" and "linux" share one cached entry.
export async function getSearchCacheEntry(query) {
return runRequest(SEARCH_STORE, 'readonly', store => store.get(query));
}
export async function setSearchCacheEntry(query, entries) {
await runRequest(SEARCH_STORE, 'readwrite', store => store.put({ query, entries, lastUsed: Date.now() }));
await pruneSearchCache();
}
// Keeps the search-query cache bounded to MAX_CACHED_SEARCHES entries,
// dropping the least-recently-searched ones first — the same "don't grow
// unbounded" concern pruneCache() addresses for the article cache, just
// against searches instead of renamed/deleted files.
async function pruneSearchCache() {
if (!cacheAvailable) return;
let db;
try {
db = await openDb();
} catch {
return;
}
try {
const store = db.transaction(SEARCH_STORE, 'readwrite').objectStore(SEARCH_STORE);
let excess = await new Promise((resolve, reject) => {
const request = store.count();
request.onsuccess = () => resolve(request.result - MAX_CACHED_SEARCHES);
request.onerror = () => reject(request.error);
});
if (excess <= 0) return;
await new Promise((resolve, reject) => {
const cursorRequest = store.index('lastUsed').openCursor();
cursorRequest.onsuccess = event => {
const cursor = event.target.result;
if (!cursor || excess <= 0) { resolve(); return; }
cursor.delete();
excess -= 1;
cursor.continue();
};
cursorRequest.onerror = () => reject(cursorRequest.error);
});
} catch {
// Same reasoning as runRequest() above.
}
}
+11 -4
View File
@@ -17,6 +17,11 @@
const DEFAULT_BASE_URL = new URL('../', import.meta.url).href; const DEFAULT_BASE_URL = new URL('../', import.meta.url).href;
const DEFAULT_MARKED_URL = new URL('../vendor/marked.min.js', import.meta.url).href; const DEFAULT_MARKED_URL = new URL('../vendor/marked.min.js', import.meta.url).href;
const DEFAULT_ARTICLES_URL = new URL('articles/', DEFAULT_BASE_URL).href; const DEFAULT_ARTICLES_URL = new URL('articles/', DEFAULT_BASE_URL).href;
const pageConfig = (typeof window !== 'undefined' && window.CMJS_CONFIG) || {};
const resolveConfiguredUrl = value => {
try { return new URL(value, typeof document !== 'undefined' ? document.baseURI : DEFAULT_BASE_URL).href; }
catch { return value; }
};
let overrides = {}; let overrides = {};
try { try {
@@ -27,16 +32,18 @@ try {
// Base URL of the repo root — strings/ and templates/ live here, one level // Base URL of the repo root — strings/ and templates/ live here, one level
// up from this file (e.g. https://example.com/). // up from this file (e.g. https://example.com/).
export const BASE_URL = overrides.BASE_URL ?? DEFAULT_BASE_URL; export const BASE_URL = resolveConfiguredUrl(pageConfig.baseUrl ?? pageConfig.BASE_URL ?? overrides.BASE_URL ?? DEFAULT_BASE_URL);
// Path to the marked.js vendor script — lives in vendor/, a sibling of this // Path to the marked.js vendor script — lives in vendor/, a sibling of this
// file's own js/ directory, not alongside the app's own modules. // file's own js/ directory, not alongside the app's own modules.
export const MARKED_URL = overrides.MARKED_URL ?? DEFAULT_MARKED_URL; export const MARKED_URL = resolveConfiguredUrl(pageConfig.markedUrl ?? pageConfig.MARKED_URL ?? overrides.MARKED_URL ?? DEFAULT_MARKED_URL);
// Base URL of the articles/ folder. Defaults to a sibling of this file's own // Base URL of the articles/ folder. Defaults to a sibling of this file's own
// js/ directory (the repo root's own articles/), but articles don't have to // js/ directory (the repo root's own articles/), but articles don't have to
// live anywhere near the rest of cmkiss — override it in config.js to point // live anywhere near the rest of cmjs — override it in config.js to point
// anywhere under the same origin, or even a different one, CORS permitting. // anywhere under the same origin, or even a different one, CORS permitting.
// Whatever it resolves to must still be a directory nginx serves a JSON (or // Whatever it resolves to must still be a directory nginx serves a JSON (or
// HTML) autoindex listing for — see "nginx configuration" in the README. // HTML) autoindex listing for — see "nginx configuration" in the README.
export const ARTICLES_URL = overrides.ARTICLES_URL ?? DEFAULT_ARTICLES_URL; export const ARTICLES_URL = resolveConfiguredUrl(pageConfig.articlesUrl ?? pageConfig.ARTICLES_URL ?? overrides.ARTICLES_URL ?? DEFAULT_ARTICLES_URL);
export const LISTING_MODE = pageConfig.listingMode ?? pageConfig.LISTING_MODE ?? overrides.LISTING_MODE ?? 'auto';
export const TAGS_URL = resolveConfiguredUrl(pageConfig.tagsUrl ?? pageConfig.TAGS_URL ?? overrides.TAGS_URL ?? new URL('_tags.json', ARTICLES_URL).href);
+113 -83
View File
@@ -1,19 +1,16 @@
import { parseFrontmatter } from './frontmatter.js'; import { parseFrontmatter } from './frontmatter.js';
import { BASE_URL, ARTICLES_URL } from './config-default.js'; import { BASE_URL, ARTICLES_URL, LISTING_MODE } from './config-default.js';
import { mapWithConcurrency } from './concurrency.js'; import { mapWithConcurrency } from './concurrency.js';
import { getAllCacheEntries, setCacheEntry, pruneCache } from './cache.js'; import { getAllCacheEntries, setCacheEntry, pruneCache } from './cache.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
// Caps simultaneous requests in the two places this module still fires one // Caps simultaneous markdown content fetches during hydration (see
// per item: markdown content fetches during hydration (see // hydrateArticle()/hydrateArticles()) — the only per-article requests left,
// hydrateArticle()/hydrateArticles()) and each author's own directory // since indexing no longer needs a per-article listing fetch at all (every
// listing during wave 2 of indexing (see runCrawl()) — indexing no longer // stub field is encoded in its file's own name — see buildStub()). Raise it
// needs a per-*article* fetch at all (every stub field is encoded in its // for a fast/local nginx, lower it for a slow or shared/production instance
// file's own name — see buildStub()), but still fetches one listing per // — see .claude/nginx-sitemap-crawler-plan.md §2a for the original rationale
// author. Raise it for a fast/local nginx, lower it for a slow or shared/ // (written when this also governed indexing requests).
// production instance — see .claude/nginx-sitemap-crawler-plan.md §2a for
// the original rationale (written when this also governed per-article
// indexing requests).
const MAX_CONCURRENT_REQUESTS = 10; const MAX_CONCURRENT_REQUESTS = 10;
const MONTH_ABBREVIATIONS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const MONTH_ABBREVIATIONS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
@@ -61,6 +58,38 @@ export async function fetchJson(url) {
throw new Error(`Expected a directory listing at ${url}`); throw new Error(`Expected a directory listing at ${url}`);
} }
export async function fetchWebDav(url) {
const response = await fetch(url, { method: 'PROPFIND', headers: { Depth: '1', 'Content-Type': 'application/xml; charset=utf-8', 'X-Requested-With': 'XMLHttpRequest' } });
if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`);
const xml = new DOMParser().parseFromString(await response.text(), 'application/xml');
if (xml.querySelector('parsererror')) throw new Error(`Invalid WebDAV listing at ${url}`);
const requested = new URL(url, typeof document !== 'undefined' ? document.baseURI : url).pathname.replace(/\/$/, '');
const seen = new Set(), entries = [];
const children = (node, name) => [...node.getElementsByTagNameNS('*', name)];
[...xml.getElementsByTagNameNS('*', 'response')].forEach((node, index) => {
const href = children(node, 'href')[0]?.textContent;
if (!href) return;
const parsed = new URL(href, url);
let path;
try { path = decodeURIComponent(parsed.pathname).replace(/\/$/, ''); } catch { return; }
// A reverse proxy may leave Nextcloud's internal href untouched
// (e.g. /remote.php/dav/...) while the request URL is /blog-content/.
// In that case the first collection response is still the requested
// root, just under a different public path. Skip it just as we do
// when the paths match.
if (path === requested || (index === 0 && children(node, 'collection').length)) return;
const name = path.split('/').pop();
if (!name || seen.has(name)) return;
seen.add(name);
entries.push({ name, type: children(node, 'collection').length ? 'directory' : 'file', mtime: children(node, 'getlastmodified')[0]?.textContent || null, etag: children(node, 'getetag')[0]?.textContent || null });
});
return entries;
}
export function fetchDirectory(url) {
return LISTING_MODE === 'webdav' ? fetchWebDav(url) : fetchJson(url);
}
// Images live alongside whatever references them: every article file sits // Images live alongside whatever references them: every article file sits
// directly in its author's own directory (see buildStub()), next to that // directly in its author's own directory (see buildStub()), next to that
// author's shared `media/` folder — so `articlePath` (the referencing .md // author's shared `media/` folder — so `articlePath` (the referencing .md
@@ -101,8 +130,10 @@ let crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootF
let partialResolved = []; let partialResolved = [];
const progressListeners = new Set(); const progressListeners = new Set();
function sortArticles(rawArticles) { function sortAndFilterPublished(rawArticles) {
return [...rawArticles].sort((a, b) => String(b.date).localeCompare(String(a.date)) || a.hash.localeCompare(b.hash)); return rawArticles
.filter(article => article?.published && article.date)
.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 // Subscribes to incremental crawl progress so a large site's article list
@@ -115,12 +146,12 @@ function sortArticles(rawArticles) {
// no longer showing. // no longer showing.
export function onArticlesProgress(listener) { export function onArticlesProgress(listener) {
progressListeners.add(listener); progressListeners.add(listener);
if (partialResolved.length) listener(sortArticles(partialResolved), false); if (partialResolved.length) listener(sortAndFilterPublished(partialResolved), false);
return () => progressListeners.delete(listener); return () => progressListeners.delete(listener);
} }
function notifyProgress(done) { function notifyProgress(done) {
const snapshot = sortArticles(partialResolved); const snapshot = sortAndFilterPublished(partialResolved);
progressListeners.forEach(listener => listener(snapshot, done)); progressListeners.forEach(listener => listener(snapshot, done));
} }
@@ -145,32 +176,28 @@ 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, and tags — see the README's "Adding an article" section. // publish date, tags/category, and draft status — see the README's "Adding
// Format: `<slug>~<DDMMYYYY>[~tag1+tag2+…].md`. `~` (rather than e.g. "--") // an article" section. Format: `<slug>~<DDMMYYYY>[~tag1+tag2+…].md`,
// is deliberate: a slug is expected to be plain kebab-case, and "~" isn't a // optionally prefixed with `_` for draft. `~` (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 // character anyone would type by accident while writing one — unlike a
// double-hyphen separator, which collides with an easy slug typo (an extra // double-hyphen separator, which collides with an easy slug typo (an extra
// "-") and silently drops the whole article instead of erroring. Returns // "-") and silently drops the whole article instead of erroring. Returns
// null for any file name that doesn't match — callers treat that as a // 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. // 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 function parsePostDirectoryName(dirName) {
// `draft/` subdirectory (see the README's "Adding an article" section), const match = String(dirName).match(/^(.+?)--(\d{8})(?:--(.+))?$/);
// which the crawler never descends into (wave 2 below only reads `type === if (!match) return null;
// 'file'` entries from an author's own listing) and which nginx should deny const [, slug, dateSegment, tagsSegment] = match;
// outright, so a draft is never indexed or fetchable in the first place. if (!/^[a-z0-9][a-z0-9-]{0,95}$/.test(slug)) return null;
function parseArticleFileName(fileName) {
if (!fileName.endsWith('.md')) return null;
const base = fileName.slice(0, -3);
const [slug, dateSegment, tagsSegment] = base.split('~');
if (!slug || !dateSegment || !/^\d{8}$/.test(dateSegment)) return null;
const day = dateSegment.slice(0, 2); const day = dateSegment.slice(0, 2);
const month = dateSegment.slice(2, 4); const month = dateSegment.slice(2, 4);
const year = dateSegment.slice(4, 8); const year = dateSegment.slice(4, 8);
return { const parsedDate = new Date(Date.UTC(+year, +month - 1, +day));
slug, if (parsedDate.getUTCFullYear() !== +year || parsedDate.getUTCMonth() !== +month - 1 || parsedDate.getUTCDate() !== +day) return null;
date: `${year}-${month}-${day}`, const tags = tagsSegment ? tagsSegment.split('+').filter(Boolean) : [];
tags: tagsSegment ? tagsSegment.split('+').filter(Boolean) : [], if (tags.some(tag => !/^[a-z0-9][a-z0-9+.-]{0,95}$/.test(tag))) return null;
}; return { slug, date: `${year}-${month}-${day}`, tags };
} }
// Everything an article card/filter/sort needs, parsed entirely from its // Everything an article card/filter/sort needs, parsed entirely from its
@@ -184,20 +211,32 @@ function parseArticleFileName(fileName) {
// see cache.js — since every article now lives directly in its author's // see cache.js — since every article now lives directly in its author's
// directory, alongside that author's bio file and shared `media/` folder, // directory, alongside that author's bio file and shared `media/` folder,
// rather than in a directory of its own. // rather than in a directory of its own.
function buildStub(author, fileName, mtime, authorRoot) { function buildStub(author, dirName, mtime, authorRoot, etag = null) {
const parsed = parseArticleFileName(fileName); const parsed = parsePostDirectoryName(dirName);
if (!parsed) return null; if (!parsed) return null;
const { slug, date, tags } = parsed; const { slug, date, tags } = parsed;
const path = `${authorRoot}${encodeURIComponent(fileName)}`; const root = `${authorRoot}${encodeURIComponent(dirName)}/`;
const path = `${root}${encodeURIComponent(slug)}.md`;
// "Category" is represented by the reserved expedition tag itself.
// Keeping the real id (for example `expa-2027`) lets year links target
// the canonical tag route without inventing a second `year-YYYY` id.
const expaTag = tags.find(id => /^expa-20\d{2}$/i.test(id)) || null;
const category = expaTag;
return { return {
author, author,
slug, slug,
hash: `${author}/${slug}`, hash: `${author}/${slug}`,
path, path,
root,
directory: dirName,
mtime, mtime,
etag,
category,
categoryYear: category ? Number(category.replace(/^expa-/i, '')) : null,
date, date,
tags, tags,
published: true,
hydrated: false, hydrated: false,
}; };
} }
@@ -210,7 +249,9 @@ function buildStub(author, fileName, mtime, authorRoot) {
// server ever flips between the two formats, instead of every file looking // server ever flips between the two formats, instead of every file looking
// "changed" the moment JSON autoindex is lost. // "changed" the moment JSON autoindex is lost.
function normalizeMtime(mtime) { function normalizeMtime(mtime) {
return mtime ? mtime.slice(0, 16) : null; // "YYYY-MM-DDTHH:MM" prefix if (!mtime) return null;
const parsed = new Date(mtime);
return Number.isNaN(parsed.getTime()) ? String(mtime).slice(0, 16) : parsed.toISOString().slice(0, 16);
} }
// Cache-aware: a file whose mtime matches its cached entry's mtime reuses // Cache-aware: a file whose mtime matches its cached entry's mtime reuses
@@ -220,19 +261,14 @@ function normalizeMtime(mtime) {
// string. `cacheSnapshot` is the whole cache read once up front (see // string. `cacheSnapshot` is the whole cache read once up front (see
// runCrawl) — an in-memory lookup here instead of a separate IndexedDB read // runCrawl) — an in-memory lookup here instead of a separate IndexedDB read
// per file, which mattered when this also had to guard a listing fetch; // per file, which mattered when this also had to guard a listing fetch;
// kept anyway since it's still the same one cheap lookup. `mtime` is a // kept anyway since it's still the same one cheap lookup.
// change-detection signal, not a required key: `cached.mtime === mtime` function resolveStub(author, dirName, mtime, etag, authorRoot, cacheSnapshot) {
// already matches when both are `null` (a server whose listing never const parsed = parsePostDirectoryName(dirName);
// yields a parseable mtime — see parseHtmlDirectoryListing), which is the if (!parsed) return null;
// right call — there's no evidence the file changed, as opposed to a const path = `${authorRoot}${encodeURIComponent(dirName)}/${encodeURIComponent(parsed.slug)}.md`;
// mismatch, which is evidence that it did. Treating "unknown" as "assume
// changed" would defeat the cache on every single crawl instead of just
// when a file actually needs re-hydrating.
function resolveStub(author, fileName, mtime, authorRoot, cacheSnapshot) {
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
const cached = cacheSnapshot.get(path); const cached = cacheSnapshot.get(path);
if (cached && cached.mtime === mtime && cached.article?.hydrated) return cached.article; if (cached && ((etag && cached.article?.etag === etag) || (!etag && mtime != null && cached.mtime === mtime)) && cached.article?.hydrated) return cached.article;
return buildStub(author, fileName, mtime, authorRoot); return buildStub(author, dirName, mtime, authorRoot, etag);
} }
// Fields only derivable from an article's actual markdown content (not its // Fields only derivable from an article's actual markdown content (not its
@@ -281,7 +317,9 @@ export function hydrateArticle(stub) {
async function hydrateArticleNow(stub) { async function hydrateArticleNow(stub) {
try { try {
const response = await fetch(stub.path); const headers = stub.etag ? { 'If-None-Match': stub.etag } : {};
let response = await fetch(stub.path, { headers });
if (response.status === 304) response = await fetch(stub.path);
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const raw = await response.text(); const raw = await response.text();
const { meta, content } = parseFrontmatter(raw); const { meta, content } = parseFrontmatter(raw);
@@ -294,14 +332,7 @@ async function hydrateArticleNow(stub) {
const authoritative = { ...stub }; const authoritative = { ...stub };
Object.assign(stub, meta, authoritative, deriveContentFields(meta, content, stub.path), { hydrated: true }); Object.assign(stub, meta, authoritative, deriveContentFields(meta, content, stub.path), { hydrated: true });
await setCacheEntry(stub.path, { mtime: stub.mtime, article: stub }); await setCacheEntry(stub.path, { mtime: stub.mtime, article: stub });
} catch (error) { } catch { /* leave stub unhydrated — retried next time it's rendered */ }
// Leave stub unhydrated — retried next time it's rendered. Logged
// (not surfaced in the UI, which just shows the title-less card)
// so a persistent failure — a real bug in parseFrontmatter() or
// deriveContentFields(), not just a transient fetch blip — is still
// visible somewhere instead of silently retrying forever.
console.error(`Failed to hydrate article ${stub.path}:`, error);
}
return stub; return stub;
} }
@@ -333,48 +364,44 @@ async function runCrawl() {
// crawl-health message instead. // crawl-health message instead.
let authorEntries; let authorEntries;
try { try {
authorEntries = await fetchJson(ARTICLES_URL); authorEntries = await fetchDirectory(ARTICLES_URL);
} catch { } catch {
crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: true }; crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: true };
notifyProgress(true); notifyProgress(true);
return sortArticles(partialResolved); return sortAndFilterPublished(partialResolved);
} }
const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.')); const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.'));
// Wave 2 — always fetched live, in full, for every author (not just the // Wave 2 — always fetched live, in full, for every author (not just the
// current page): each author's own directory listing, with per-file // current page): each author's own directory listing, with per-file
// mtimes. Capped by MAX_CONCURRENT_REQUESTS like hydrateArticles() below // mtimes. This is what builds the flat, cross-author list that drives
// — a site with many authors would otherwise fire one listing request // global sorting and pagination — see the plan doc's "keep the flat
// per author simultaneously, with no limit. This is what builds the // feed" decision. It's also the *only* per-author network request
// flat, cross-author list that drives global sorting and pagination — // indexing ever needs: every stub field (slug, date, tags, category,
// see the plan doc's "keep the flat feed" decision. It's also the *only* // draft status) is parsed straight out of each article file's own name
// per-author network request indexing ever needs: every stub field // (see buildStub/parseArticleFileName) — there's no per-article fetch
// (slug, date, tags) is parsed straight out of each article file's own // at index time. The author's own bio file (`<author>.md`) and shared
// name (see buildStub/parseArticleFileName) — there's no per-article // `media/` folder live in the same listing and are simply filtered out
// fetch at index time. The `type === 'file'` filter below also means any // here — only `.md` files other than the bio file are candidate
// subdirectory (the author's shared `media/` folder, a `draft/` folder — // articles. One author's listing failing (network blip, deleted
// see "Adding an article" in the README) is skipped without ever being // mid-crawl) is silently skipped rather than failing every other
// 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 // author's list too, but is still counted, so a *total* breakage (wrong
// origin, CORS) remains visible via getCrawlStats() instead of just // origin, CORS) remains visible via getCrawlStats() instead of just
// silently producing an empty article list. // silently producing an empty article list.
let authorsFailed = 0; let authorsFailed = 0;
const authorFileLists = await mapWithConcurrency(authors, MAX_CONCURRENT_REQUESTS, async author => { const authorFileLists = await Promise.all(authors.map(async author => {
const authorRoot = `${ARTICLES_URL}${encodeURIComponent(author.name)}/`; const authorRoot = `${ARTICLES_URL}${encodeURIComponent(author.name)}/`;
const bioFileName = `${author.name}.md`; const bioFileName = `${author.name}.md`;
try { try {
const entries = await fetchJson(authorRoot); const entries = await fetchDirectory(authorRoot);
return entries return entries
.filter(entry => entry.type === 'file' && entry.name.endsWith('.md') && entry.name !== bioFileName) .filter(entry => entry.type === 'directory' && parsePostDirectoryName(entry.name))
.map(entry => ({ author: author.name, fileName: entry.name, mtime: normalizeMtime(entry.mtime), authorRoot })); .map(entry => ({ author: author.name, dirName: entry.name, mtime: normalizeMtime(entry.mtime), etag: entry.etag || null, authorRoot }));
} catch { } catch {
authorsFailed += 1; authorsFailed += 1;
return []; return [];
} }
}); }));
const allFiles = authorFileLists.flat(); const allFiles = authorFileLists.flat();
// Every stub is parsed synchronously from data already in hand — no // Every stub is parsed synchronously from data already in hand — no
@@ -383,17 +410,20 @@ async function runCrawl() {
const cacheSnapshot = await cacheSnapshotPromise; const cacheSnapshot = await cacheSnapshotPromise;
let loaded = 0; let loaded = 0;
for (const file of allFiles) { for (const file of allFiles) {
const stub = resolveStub(file.author, file.fileName, file.mtime, file.authorRoot, cacheSnapshot); const stub = resolveStub(file.author, file.dirName, file.mtime, file.etag, file.authorRoot, cacheSnapshot);
if (stub) { if (stub) {
loaded += 1; loaded += 1;
partialResolved.push(stub); partialResolved.push(stub);
} }
} }
await pruneCache(allFiles.map(file => `${file.authorRoot}${encodeURIComponent(file.fileName)}`)); await pruneCache(allFiles.map(file => {
const parsed = parsePostDirectoryName(file.dirName);
return `${file.authorRoot}${encodeURIComponent(file.dirName)}/${encodeURIComponent(parsed.slug)}.md`;
}));
crawlStats = { total: allFiles.length, loaded, authorsTotal: authors.length, authorsFailed, rootFailed: false }; crawlStats = { total: allFiles.length, loaded, authorsTotal: authors.length, authorsFailed, rootFailed: false };
const finalArticles = sortArticles(partialResolved); const finalArticles = sortAndFilterPublished(partialResolved);
notifyProgress(true); notifyProgress(true);
return finalArticles; return finalArticles;
} }
-1
View File
@@ -39,7 +39,6 @@ export function parseFrontmatter(raw) {
const meta = {}; const meta = {};
let arrayKey = null; let arrayKey = null;
for (const line of match[1].split(/\r?\n/)) { for (const line of match[1].split(/\r?\n/)) {
if (!line.trim()) continue; // blank line — doesn't close a block array
const item = line.match(/^\s*-\s+(.*)$/); const item = line.match(/^\s*-\s+(.*)$/);
if (item && arrayKey) { meta[arrayKey].push(parseScalar(item[1])); continue; } if (item && arrayKey) { meta[arrayKey].push(parseScalar(item[1])); continue; }
const field = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(?:\s*(.*))?$/); const field = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(?:\s*(.*))?$/);
+22 -13
View File
@@ -6,15 +6,16 @@
// 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 = "tag" → tag filter, page 1 // 2 segments, first = "category" (legacy) or "tag" → filtered list, 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 = "tag", third = "page" → tag filter, page N (#tag/guides/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 { 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, loadTagView, loadArticle, loadSearch } from './views.js'; import { loadArticleList, loadAuthorView, loadCategoryView, 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) {
@@ -33,6 +34,12 @@ const parsePage = s => { const n = +s; return Number.isInteger(n) && n >= 1 ? n
function router() { function router() {
const hash = location.hash.slice(1); // strip leading # const hash = location.hash.slice(1); // strip leading #
const parts = hash.split('/').filter(Boolean); const parts = hash.split('/').filter(Boolean);
// A hand-edited/legacy hash can contain a malformed percent escape. Do
// not let one bad segment abort routing (and make an otherwise valid
// article appear unclickable); keep the literal segment in that case.
const decodePart = value => {
try { return decodeURIComponent(value); } catch { return value; }
};
// Search: decode query and guard against empty string (#search or #search/). // Search: decode query and guard against empty string (#search or #search/).
// A trailing "/page/N" is peeled off for pagination first — the query // A trailing "/page/N" is peeled off for pagination first — the query
@@ -44,16 +51,18 @@ function router() {
const rest = parts.slice(1); const rest = parts.slice(1);
const hasPage = rest.length >= 3 && rest[rest.length - 2] === 'page' && /^\d+$/.test(rest[rest.length - 1]); const hasPage = rest.length >= 3 && rest[rest.length - 2] === 'page' && /^\d+$/.test(rest[rest.length - 1]);
const queryParts = hasPage ? rest.slice(0, -2) : rest; const queryParts = hasPage ? rest.slice(0, -2) : rest;
const q = decodeURIComponent(queryParts.join('/')); const q = decodePart(queryParts.join('/'));
if (q) loadSearch(q, hasPage ? parsePage(rest[rest.length - 1]) : 1); else loadArticleList(); if (q) loadSearch(q, hasPage ? parsePage(rest[rest.length - 1]) : 1); else loadArticleList();
} }
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(decodePart(parts[1]));
else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodeURIComponent(parts[1])); else if (parts.length === 2 && parts[0] === 'category') loadCategoryView(decodePart(parts[1]));
else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodeURIComponent).join('/')); else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodePart(parts[1]));
else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodeURIComponent(parts[1]), parsePage(parts[3])); else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodePart).join('/'));
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] === 'author' && parts[2] === 'page') loadAuthorView(decodePart(parts[1]), parsePage(parts[3]));
else if (parts.length === 4 && parts[0] === 'category' && parts[2] === 'page') loadCategoryView(decodePart(parts[1]), parsePage(parts[3]));
else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodePart(parts[1]), parsePage(parts[3]));
else loadArticleList(); else loadArticleList();
} }
@@ -74,16 +83,16 @@ 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, which are optional. // search/tag/year, 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.');
} }
}); });
// Show a top-level error if dependencies fail to load (or a mount point // Show a top-level error if dependencies fail to load (or a mount point
// is missing). Scoped to cmkiss's own mount points when at least one // is missing). Scoped to cmjs's own mount points when at least one
// exists, rather than replacing the whole page — this can run on a page // exists, rather than replacing the whole page — this can run on a page
// that embeds cmkiss alongside plenty of its own content, which wiping // that embeds cmjs alongside plenty of its own content, which wiping
// document.body would destroy too. Falls back to the full-page replacement // document.body would destroy too. Falls back to the full-page replacement
// only when neither mount point is present to show the message in. // only when neither mount point is present to show the message in.
ready.catch(err => { ready.catch(err => {
+115 -108
View File
@@ -1,6 +1,5 @@
import { templates } from './templates.js'; import { templates } from './templates.js';
import { deriveContentFields, discoverArticles, fetchAuthorProfile, findArticle, getCrawlStats, hydrateArticles, onArticlesProgress, resolveMediaUrl } from './discovery.js'; import { deriveContentFields, discoverArticles, fetchAuthorProfile, findArticle, getCrawlStats, hydrateArticles, onArticlesProgress, resolveMediaUrl } from './discovery.js';
import { getSearchCacheEntry, setSearchCacheEntry } from './cache.js';
import { parseFrontmatter } from './frontmatter.js'; import { parseFrontmatter } from './frontmatter.js';
import { t, getLocale } from './i18n.js'; import { t, getLocale } from './i18n.js';
@@ -18,12 +17,6 @@ let currentGeneration = 0;
// everything. // everything.
let lastListHash = '#'; let lastListHash = '#';
// The embedding page's own <title>, captured once when this module first
// runs (module scripts are deferred, so <title> is already parsed). cmkiss
// never names the page itself — list views restore this exactly, and the
// article view only prefixes it with the article's title.
const pageTitle = document.title;
function formatDate(value) { function formatDate(value) {
if (!value) return ''; if (!value) return '';
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`); const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
@@ -33,7 +26,7 @@ function formatDate(value) {
export function showListView() { export function showListView() {
document.getElementById('list-view').hidden = false; document.getElementById('list-view').hidden = false;
document.getElementById('article-view').hidden = true; document.getElementById('article-view').hidden = true;
document.title = pageTitle; document.title = t('siteName');
} }
export function showArticleView() { export function showArticleView() {
@@ -54,7 +47,9 @@ function clearSearchInput() {
function filterLabel(filter) { 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' && /^expa-20\d{2}$/i.test(filter.value)) return t('filterYear', { value: filter.value.slice(5) });
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, '').replace(/^expa-/i, '') });
return filter.value; return filter.value;
} }
@@ -84,8 +79,9 @@ function buildPaginationNav(page, totalPages, filter) {
} }
function renderTags(container, tags) { function renderTags(container, tags) {
if (!container || !tags?.length) { container?.remove(); return; } const visibleTags = (tags || []).filter(identifier => !/^expa-20\d{2}$/i.test(identifier));
tags.forEach(identifier => { if (!container || !visibleTags.length) { container?.remove(); return; }
visibleTags.forEach(identifier => {
const link = document.createElement('a'); const link = document.createElement('a');
link.className = 'tag'; link.className = 'tag';
link.href = `#tag/${encodeURIComponent(identifier)}`; link.href = `#tag/${encodeURIComponent(identifier)}`;
@@ -109,22 +105,55 @@ 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 (!tags) return; if (!years && !tags) return;
tags.innerHTML = ''; const allArticlesLink = () => renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter);
const tagCounts = new Map();
articles.forEach(article => (article.tags || []).forEach(tag => tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1))); if (years) {
const tagList = document.createElement('div'); years.innerHTML = '';
tagList.className = 'filter-list'; const yearCounts = new Map();
tagList.appendChild(renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter)); articles.forEach(article => {
[...tagCounts.keys()] const year = Number(article.categoryYear);
.sort((a, b) => a.localeCompare(b, getLocale())) if (year) yearCounts.set(year, (yearCounts.get(year) || 0) + 1);
.forEach(id => {
const active = activeFilter?.type === 'tag' && activeFilter.value === id;
tagList.appendChild(renderFilterLink(id, `#tag/${encodeURIComponent(id)}`, tagCounts.get(id), active));
}); });
tags.appendChild(tagList); 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 expeditionTag = `expa-${year}`;
const active = activeFilter?.type === 'tag' && activeFilter.value.toLowerCase() === expeditionTag;
yearList.appendChild(renderFilterLink(t('yearLabel', { year }), `#tag/${expeditionTag}`, 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());
// An `expa-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 => !/^expa-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
@@ -138,11 +167,17 @@ 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 and reading-time are // Shared between renderCard() and loadArticle() — date, reading-time, and
// populated identically in both places. // the category/year badge are populated identically in both places.
function applyArticleMetaFields(field, { date, readingTime }) { function applyArticleMetaFields(field, { date, readingTime, category, categoryYear }) {
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 = `#tag/${encodeURIComponent(value)}`;
});
} }
function renderCard(article) { function renderCard(article) {
@@ -172,6 +207,24 @@ function renderCard(article) {
author.textContent = article.authorName || article.author; author.textContent = article.authorName || article.author;
author.href = `#author/${encodeURIComponent(article.author)}`; author.href = `#author/${encodeURIComponent(article.author)}`;
renderTags(field('tags'), article.tags); renderTags(field('tags'), article.tags);
// The card itself is the hit target, not only the optional cover image or
// title link. Keep the metadata links (author/tags/year) independent, but
// let clicks in the body/description open the same stable post route.
element.classList.add('card--clickable');
element.tabIndex = 0;
element.setAttribute('role', 'link');
const openPost = () => { location.hash = postHref(article).slice(1); };
element.addEventListener('click', event => {
if (event.defaultPrevented || event.button !== 0 || event.target.closest('a')) return;
openPost();
});
element.addEventListener('keydown', event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
if (event.target !== element) return;
event.preventDefault();
openPost();
});
return element; return element;
} }
@@ -179,29 +232,27 @@ function renderCard(article) {
// autoindex disabled) should be visible rather than silently rendering an // autoindex disabled) should be visible rather than silently rendering an
// empty/near-empty list forever — see .claude/nginx-sitemap-crawler-plan.md L3. // empty/near-empty list forever — see .claude/nginx-sitemap-crawler-plan.md L3.
// Three independent signals feed this: the root author-listing fetch itself // Three independent signals feed this: the root author-listing fetch itself
// failing outright (rootFailed), or every individual author's own listing // failing outright (rootFailed), every individual author's own listing
// failing (checked regardless of site size, since discovery.js now silently // failing (checked regardless of site size, since discovery.js now silently
// skips individual author failures — see the wave-2 comment in // skips individual author failures — see the wave-2 comment in
// discovery.js). Unlike an older version of this check, a low loaded/total // discovery.js), and, for larger sites, a near-zero loaded/total ratio at
// ratio (candidate .md files that didn't match the article naming // the article level. Only meaningful once the crawl has actually finished
// convention) is deliberately *not* treated as a health problem: indexing // (`done`) — mid-crawl stats are incomplete and would misreport a healthy
// does no per-article fetch at all, so a non-conforming file name is a // crawl as broken.
// filename issue, not a network/CORS one — and legitimately non-conforming
// .md files (notes, leftovers) coexisting with real articles shouldn't
// trigger a false "something's broken" banner. Only meaningful once the
// crawl has actually finished (`done`) — mid-crawl stats are incomplete and
// would misreport a healthy crawl as broken.
function renderCrawlHealthWarning(view, done) { function renderCrawlHealthWarning(view, done) {
if (!done) return; if (!done) return;
const { authorsTotal, authorsFailed, rootFailed } = getCrawlStats(); const { total, loaded, authorsTotal, authorsFailed, rootFailed } = getCrawlStats();
// rootFailed means the very first listing fetch (the author directories // rootFailed means the very first listing fetch (the author directories
// themselves) couldn't be read at all — a total breakage same in spirit // themselves) couldn't be read at all — a total breakage same in spirit
// as every author's own listing failing, just one level higher up. // as every author's own listing failing, just one level higher up.
const authorsBroken = rootFailed || (authorsTotal > 0 && authorsFailed === authorsTotal); const authorsBroken = rootFailed || (authorsTotal > 0 && authorsFailed === authorsTotal);
if (!authorsBroken) return; const articlesBroken = total >= 5 && loaded / total < 0.1;
if (!authorsBroken && !articlesBroken) return;
const warning = document.createElement('p'); const warning = document.createElement('p');
warning.className = 'error'; warning.className = 'error';
warning.textContent = t('crawlHealthAuthorsFailed'); warning.textContent = authorsBroken
? t('crawlHealthAuthorsFailed')
: t('crawlHealthArticlesFailed', { loaded, total });
view.appendChild(warning); view.appendChild(warning);
} }
@@ -220,7 +271,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 = 'list-heading'; headingElement.className = 'category-heading';
headingElement.textContent = heading; headingElement.textContent = heading;
view.appendChild(headingElement); view.appendChild(headingElement);
} }
@@ -253,16 +304,24 @@ 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;
if (filter.type === 'tag') return allArticles.filter(article => article.tags.includes(filter.value)); return allArticles.filter(article => {
// Author names are exact, case-sensitive directory names (used if (filter.type === 'tag') return article.tags.includes(filter.value);
// as-is for paths and profile lookups elsewhere) — comparing them // Author names are exact, case-sensitive directory names (used
// case-insensitively here could merge two distinctly-named author // as-is for paths and profile lookups elsewhere) — comparing
// directories that differ only in case. // them case-insensitively here could merge two distinctly-named
return allArticles.filter(article => article.author === filter.value); // author directories that differ only in case.
if (filter.type === 'author') return article.author === filter.value;
if (filter.type === 'category') {
const value = filter.value.toLowerCase();
return value === String(article.category || '').toLowerCase()
|| value === `year-${article.categoryYear}`;
}
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,
// none of that needs a content fetch). Only the current // category — 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.
@@ -291,19 +350,12 @@ export async function loadArticleList(filter = null, page = 1) {
} }
}; };
// Same error handling as the final render below (not just the same const unsubscribe = onArticlesProgress((partialArticles, done) => { render(partialArticles, done); });
// message — an error thrown here, e.g. a malformed template, would
// otherwise be an unhandled rejection that leaves the list stuck on its
// loading state instead of ever showing articlesLoadError).
const showRenderError = error => {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
};
const unsubscribe = onArticlesProgress((partialArticles, done) => { render(partialArticles, done).catch(showRenderError); });
try { try {
const allArticles = await discoverArticles(); const allArticles = await discoverArticles();
await render(allArticles, true); await render(allArticles, true);
} catch (error) { } catch (error) {
showRenderError(error); if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
} finally { } finally {
unsubscribe(); unsubscribe();
} }
@@ -367,6 +419,7 @@ 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
@@ -381,16 +434,6 @@ const SEARCH_SCAN_BATCH = PAGE_SIZE;
// same page) skip scanning entirely, since `matches` only ever grows and // same page) skip scanning entirely, since `matches` only ever grows and
// stub order never changes. Reset whenever the query text changes; holds // stub order never changes. Reset whenever the query text changes; holds
// only the most recent query, so a new search simply replaces it. // only the most recent query, so a new search simply replaces it.
//
// `entries` is this same session's in-memory mirror of the persistent
// per-query cache (see cache.js's getSearchCacheEntry()/
// setSearchCacheEntry()): a { [articlePath]: { mtime, matched } } map,
// loaded once when the query first changes and written back whenever a scan
// round adds something new to it (`dirty`). Reusing it — instead of only
// `cursor`/`matches` — means even a *first* search after a page reload can
// skip re-fetching and re-checking every article whose content hasn't
// changed since it was last searched for, no matter how this query's own
// scan happened to leave off last time.
let searchState = null; let searchState = null;
export async function loadSearch(query, page = 1) { export async function loadSearch(query, page = 1) {
@@ -408,7 +451,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.tags] const matchesQuery = article => [article.title, article.description, article.author, article.authorName, article.category, ...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();
@@ -416,16 +459,7 @@ export async function loadSearch(query, page = 1) {
renderFilterNavigation(stubs); renderFilterNavigation(stubs);
if (!searchState || searchState.query !== query || searchState.stubs !== stubs) { if (!searchState || searchState.query !== query || searchState.stubs !== stubs) {
// Loaded once per new query. Pruned to the current stub set's searchState = { query, stubs, cursor: 0, matches: [] };
// paths so a renamed/deleted article's old cached result can't
// linger — cheap, since `stubs` (unlike hydrating them) is
// already in hand with no further fetch.
const cached = await getSearchCacheEntry(needle);
if (generation !== currentGeneration) return;
const validPaths = new Set(stubs.map(article => article.path));
const entries = {};
for (const [path, entry] of Object.entries(cached?.entries || {})) if (validPaths.has(path)) entries[path] = entry;
searchState = { query, stubs, cursor: 0, matches: [], entries, dirty: false };
} }
const state = searchState; const state = searchState;
@@ -477,38 +511,12 @@ export async function loadSearch(query, page = 1) {
while (state.cursor < state.stubs.length && state.matches.length < target) { while (state.cursor < state.stubs.length && state.matches.length < target) {
if (generation !== currentGeneration) return; if (generation !== currentGeneration) return;
const batch = state.stubs.slice(state.cursor, state.cursor + SEARCH_SCAN_BATCH); const batch = state.stubs.slice(state.cursor, state.cursor + SEARCH_SCAN_BATCH);
// Still hydrates the whole batch — a no-op per already-hydrated
// stub (see hydrateArticle() in discovery.js), which a cache hit
// below always is: it can only exist for a path this exact
// query already matched content against, at this same mtime,
// which means it's already sitting in the article-level cache
// too. Rendering pageArticles further down never re-hydrates,
// so this stays unconditional rather than trusting that chain
// of reasoning to never drift out of sync.
await hydrateArticles(batch); await hydrateArticles(batch);
if (generation !== currentGeneration) return; if (generation !== currentGeneration) return;
state.cursor += batch.length; state.cursor += batch.length;
batch.forEach(article => { batch.forEach(article => { if (matchesQuery(article)) state.matches.push(article); });
// A cached result only counts as known if its mtime still
// matches the live stub — an edited-since-last-search
// article is re-checked, same as one with no entry at all.
const known = state.entries[article.path];
const isKnown = known?.mtime === article.mtime;
const matched = isKnown ? known.matched : matchesQuery(article);
if (!isKnown) { state.entries[article.path] = { mtime: article.mtime, matched }; state.dirty = true; }
if (matched) state.matches.push(article);
});
renderResults(); renderResults();
} }
// Persist whatever's new this round — fire-and-forget, since a
// failed/slow write should never hold up rendering (cache.js
// already swallows its own storage errors). Next time this exact
// query is searched, anything covered here needs no re-fetch at
// all, only what's new or changed since.
if (state.dirty) {
state.dirty = false;
setSearchCacheEntry(needle, state.entries);
}
} catch (error) { } catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('searchError', { message: error.message })}</p>`; if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('searchError', { message: error.message })}</p>`;
} }
@@ -558,14 +566,13 @@ export async function loadArticle(hash) {
// cards, the description field here stays frontmatter-only (no // cards, the description field here stays frontmatter-only (no
// auto-excerpt) — the full content is right below it either way. // auto-excerpt) — the full content is right below it either way.
const { readingTime, coverUrl } = deriveContentFields(meta, content, article.path); const { readingTime, coverUrl } = deriveContentFields(meta, content, article.path);
// Skip the separator when the embedding page has no <title> of its own. document.title = `${meta.title || article.slug} | ${t('siteName')}`;
document.title = `${meta.title || article.slug}${pageTitle ? ` | ${pageTitle}` : ''}`;
const element = document.importNode(templates.article, true); const element = document.importNode(templates.article, true);
const field = name => element.querySelector(`[data-field="${name}"]`); const field = name => element.querySelector(`[data-field="${name}"]`);
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 }); applyArticleMetaFields(field, { date: article.date, readingTime, category: article.category, categoryYear: article.categoryYear });
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();
+4
View File
@@ -1,10 +1,13 @@
{ {
"siteName": "Ukázkový blog",
"appStartError": "Blog se nepodařilo spustit: {message}", "appStartError": "Blog se nepodařilo spustit: {message}",
"articleNotFound": "Článek nebyl nalezen", "articleNotFound": "Článek nebyl nalezen",
"filterAuthor": "Autor: {value}", "filterAuthor": "Autor: {value}",
"filterTag": "Štítek: {value}", "filterTag": "Štítek: {value}",
"filterYear": "Expedice {value}",
"filterAllArticles": "Všechny články", "filterAllArticles": "Všechny články",
"yearLabel": "Expedice {year}",
"paginationLabel": "Stránkování", "paginationLabel": "Stránkování",
"paginationPrevious": "Předchozí", "paginationPrevious": "Předchozí",
@@ -15,6 +18,7 @@
"readingTime": "{minutes} min čtení", "readingTime": "{minutes} min čtení",
"crawlHealthAuthorsFailed": "Pozor: nepodařilo se načíst žádného autora — zkontrolujte nastavení serveru.", "crawlHealthAuthorsFailed": "Pozor: nepodařilo se načíst žádného autora — zkontrolujte nastavení serveru.",
"crawlHealthArticlesFailed": "Pozor: načtena jen {loaded} z {total} položek — zkontrolujte nastavení serveru.",
"loadingArticles": "Načítám články…", "loadingArticles": "Načítám články…",
"loadingGeneric": "Načítám…", "loadingGeneric": "Načítám…",
+4
View File
@@ -1,10 +1,13 @@
{ {
"siteName": "Example Blog",
"appStartError": "Failed to start the blog: {message}", "appStartError": "Failed to start the blog: {message}",
"articleNotFound": "Article not found", "articleNotFound": "Article not found",
"filterAuthor": "Author: {value}", "filterAuthor": "Author: {value}",
"filterTag": "Tag: {value}", "filterTag": "Tag: {value}",
"filterYear": "Expedition {value}",
"filterAllArticles": "All articles", "filterAllArticles": "All articles",
"yearLabel": "Expedition {year}",
"paginationLabel": "Pagination", "paginationLabel": "Pagination",
"paginationPrevious": "Previous", "paginationPrevious": "Previous",
@@ -15,6 +18,7 @@
"readingTime": "{minutes} min read", "readingTime": "{minutes} min read",
"crawlHealthAuthorsFailed": "Warning: couldn't load any authors — check your server configuration.", "crawlHealthAuthorsFailed": "Warning: couldn't load any authors — check your server configuration.",
"crawlHealthArticlesFailed": "Warning: only loaded {loaded} of {total} items — check your server configuration.",
"loadingArticles": "Loading articles…", "loadingArticles": "Loading articles…",
"loadingGeneric": "Loading…", "loadingGeneric": "Loading…",
+4
View File
@@ -1,10 +1,13 @@
{ {
"siteName": "Ukážkový blog",
"appStartError": "Blog sa nepodarilo spustiť: {message}", "appStartError": "Blog sa nepodarilo spustiť: {message}",
"articleNotFound": "Článok nebol nájdený", "articleNotFound": "Článok nebol nájdený",
"filterAuthor": "Autor: {value}", "filterAuthor": "Autor: {value}",
"filterTag": "Štítok: {value}", "filterTag": "Štítok: {value}",
"filterYear": "Expedícia {value}",
"filterAllArticles": "Všetky články", "filterAllArticles": "Všetky články",
"yearLabel": "Expedícia {year}",
"paginationLabel": "Stránkovanie", "paginationLabel": "Stránkovanie",
"paginationPrevious": "Predchádzajúca", "paginationPrevious": "Predchádzajúca",
@@ -15,6 +18,7 @@
"readingTime": "{minutes} min čítania", "readingTime": "{minutes} min čítania",
"crawlHealthAuthorsFailed": "Pozor: nepodarilo sa načítať žiadneho autora — skontrolujte nastavenie servera.", "crawlHealthAuthorsFailed": "Pozor: nepodarilo sa načítať žiadneho autora — skontrolujte nastavenie servera.",
"crawlHealthArticlesFailed": "Pozor: načítaných len {loaded} z {total} položiek — skontrolujte nastavenie servera.",
"loadingArticles": "Načítavam články…", "loadingArticles": "Načítavam články…",
"loadingGeneric": "Načítavam…", "loadingGeneric": "Načítavam…",
+1
View File
@@ -8,6 +8,7 @@
<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>
+1
View File
@@ -11,6 +11,7 @@
<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>