Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b9eff8dff
|
||
|
|
4c384a9517
|
No files matched your search
@@ -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.
|
||||
|
||||
## How it works
|
||||
|
||||
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
|
||||
fallback.
|
||||
2. Sorting, filtering, and publish status all come from the article file's own *name*
|
||||
(`slug~DDMMYYYY~tags.md`) — knowable straight from the listing that discovers it, with no
|
||||
nested post directories; nginx JSON/HTML indexes and Nextcloud WebDAV PROPFIND are supported.
|
||||
2. Sorting and filtering come from each post directory's *name*
|
||||
(`slug--DDMMYYYY--expa-YYYY+tags/`) — knowable straight from the listing, with no
|
||||
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
|
||||
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/).
|
||||
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
|
||||
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.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dark mode** — automatically follows the OS `prefers-color-scheme` setting; no toggle needed.
|
||||
- **Client-side search** — the search box in the header filters all articles by title, description, author, 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.
|
||||
- **Table of contents** — auto-generated from `h2`/`h3` headings and inserted at the top of the article body. Only appears when the article has three or more headings.
|
||||
- **Back link** — a "← Back to articles" link above every article, returning to whatever list, filter, or search page (and page number) the reader actually came from rather than always resetting to page 1 of everything.
|
||||
- **Pagination** — article list paginates at 20 articles per page; page state is encoded in the hash so browser history works.
|
||||
- **Author 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).
|
||||
- **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`.
|
||||
@@ -36,14 +35,14 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil
|
||||
|
||||
## 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
|
||||
"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.
|
||||
|
||||
```
|
||||
├── 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
|
||||
│ └── css/
|
||||
│ ├── 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
|
||||
│ ├── concurrency.js # mapWithConcurrency() — caps simultaneous per-article fetches
|
||||
│ ├── 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
|
||||
├── vendor/
|
||||
│ └── 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
|
||||
│ ├── article.html # Full article view 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.md # Author profile (optional)
|
||||
├── media/ # Shared images for this author — see "Adding an article"
|
||||
│ ├── avatar.jpg # Referenced by author.md's `avatar:` field
|
||||
│ └── cover.jpg # Referenced by one or more articles' `image:` field
|
||||
└── slug~DDMMYYYY~tag1+tag2.md # One file per article — see "Adding an article"
|
||||
├── avatar.jpg # Optional author avatar, referenced by author.md
|
||||
└── slug--DDMMYYYY--expa-YYYY+tag1/ # One directory per article
|
||||
├── slug.md
|
||||
└── 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
|
||||
|
||||
`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`
|
||||
— 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.
|
||||
|
||||
The split exists for using cmkiss 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
|
||||
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 cmjs never touches it, so any override
|
||||
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 —
|
||||
@@ -107,57 +111,63 @@ needs):
|
||||
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
|
||||
|
||||
An article is a single Markdown file placed directly in its author's directory. Its *file name*
|
||||
is where its publish date and tags live — not in separate files, and not in frontmatter. The
|
||||
format is:
|
||||
An article is a Markdown file inside a post directory. Its *directory name* contains the stable
|
||||
slug, date, expedition year, and topic tags. Publication status comes from the root folder the
|
||||
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
|
||||
as permanent: retagging or correcting the date later just means renaming the file (see below),
|
||||
but changing the slug changes the article's URL.
|
||||
- `<slug>` — the stable identifier used in the article's URL (`#post/author/slug`), matching
|
||||
`[a-z0-9][a-z0-9-]{0,95}`. The Markdown file inside the directory must be named `<slug>.md`.
|
||||
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).
|
||||
- `<tag1>+<tag2>+…` — zero or more tag ids, joined with `+`. Each tag id is a normal topic tag,
|
||||
shown as a pill linking to `#tag/tag-id`. Omit this whole segment (and its leading `~`) if the
|
||||
article has no tags.
|
||||
An invalid date (e.g. `31022026`) makes the directory be skipped.
|
||||
- `expa-YYYY+<tag1>+<tag2>+…` — normalized tag ids (lowercase `[a-z0-9][a-z0-9.-]*`) joined
|
||||
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
|
||||
kebab-case (single hyphens only) and `~` isn't something you'd type by accident while writing
|
||||
one, unlike an extra `-`.
|
||||
|
||||
**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`.
|
||||
Images (cover and inline) live in the post directory, next to the Markdown file, and are
|
||||
referenced by paths relative to it — `image: cover.jpg`, ``, or a subfolder such
|
||||
as `media/photo.jpg`. Absolute `https://` URLs are used as-is.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Create the author directory if it doesn't exist: `articles/author/`
|
||||
2. Create the Markdown file directly inside it, using the naming convention above, e.g.
|
||||
`articles/author/my-article~16062026~guides+tutorials.md` — or inside `articles/author/draft/`
|
||||
instead, if it's not ready to publish yet.
|
||||
3. Optionally add a cover image to that author's shared `articles/author/media/` folder
|
||||
(creating it if needed), and reference it from frontmatter with `image: media/photo.jpg`.
|
||||
2. Create the post directory using the naming convention above, e.g.
|
||||
`articles/author/my-article--16062026--expa-2026+guides/`.
|
||||
3. Add `my-article.md` and optional media files inside that directory.
|
||||
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.
|
||||
`author` is derived from the containing directory — no frontmatter field needed
|
||||
To change an article's date, tags, or publication state later, move/rename its directory — its
|
||||
content doesn't need to change. `author` is derived from the containing directory — no frontmatter field needed
|
||||
for it either. Treat the directory name as case-sensitive and exact: it's matched exactly (not
|
||||
case-insensitively) against `#author/...` URLs and author-profile lookups, so avoid two author
|
||||
directories that differ only in case. Because every one of these is encoded in the file name,
|
||||
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
|
||||
that already reveals it — no further per-article request at all, which is what makes the "Article
|
||||
discovery & caching" section below possible.
|
||||
@@ -170,8 +180,12 @@ discovery & caching" section below possible.
|
||||
|
||||
```yaml
|
||||
---
|
||||
schema: 2
|
||||
title: My Article Title
|
||||
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...
|
||||
@@ -180,12 +194,14 @@ Your markdown content here...
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `title` | yes | Shown in the list and article header |
|
||||
| `description` | no | Short summary shown under the title in the list |
|
||||
| `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 |
|
||||
| `description` | no | Short summary shown under the title in the list; an excerpt of the body is used when missing |
|
||||
| `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
|
||||
needed. `date` and `tags` frontmatter fields, if present, are ignored — superseded by the file
|
||||
name described above.
|
||||
needed. `date`, `tags`, `status`, `author`, `expa`, and `draft` frontmatter fields, if present,
|
||||
are ignored — superseded by the directory name and root described above.
|
||||
|
||||
## Author profiles
|
||||
|
||||
@@ -216,11 +232,22 @@ Short bio rendered as markdown.
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| `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. |
|
||||
|
||||
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
|
||||
|
||||
| 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 |
|
||||
| `#author/alice` | All articles by alice, page 1 |
|
||||
| `#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/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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
search placeholder, the sidebar section headings) belong to the embedding page and are plain
|
||||
static HTML — translate them by editing the page directly (`demo/index.html` for this repo's own
|
||||
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.
|
||||
Site-specific branding that lives in the embedding page itself (the page `<title>`, the header
|
||||
search placeholder, the sidebar section headings) is plain static HTML, not JS-templated —
|
||||
translate it by editing that page directly (`demo/index.html` for this repo's own demo).
|
||||
|
||||
## 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>`
|
||||
|
||||
@@ -307,7 +334,7 @@ The site-level CSS files (`reset.css`, `header.css`, `layout.css`) are optional
|
||||
<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>`
|
||||
|
||||
@@ -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`).
|
||||
|
||||
> **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
|
||||
> <link rel="stylesheet" href="/cmkiss/css/components/card.css">
|
||||
> <script type="module" src="/cmkiss/js/router.js"></script>
|
||||
> <link rel="stylesheet" href="/cmjs/css/components/card.css">
|
||||
> <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.
|
||||
|
||||
@@ -352,9 +379,17 @@ demo does.
|
||||
```html
|
||||
<nav id="tag-filters" aria-label="Filter by tag"></nav>
|
||||
```
|
||||
- **Year list — `#year-filters`** (optional, not used by this repo's own demo but supported the
|
||||
same way): a container that gets filled in with one link per year 2026 down to 2009 that has at
|
||||
least one article, linking to `#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
|
||||
standalone.
|
||||
Both filter lists are populated by the same function on every list render, so whichever of
|
||||
`#tag-filters`/`#year-filters` is present just gets filled in — omit either one (or both) and
|
||||
nothing breaks, the article list and search still work standalone.
|
||||
|
||||
### Minimal example
|
||||
|
||||
@@ -382,30 +417,25 @@ standalone.
|
||||
|
||||
## nginx configuration
|
||||
|
||||
Point nginx `root` at the repo root. The `autoindex` directive applies recursively to all
|
||||
subdirectories under `/articles/` — **except** each author's own `draft/` folder, which must be
|
||||
denied outright (see "Adding an article" above: this is what keeps a draft from being indexed or
|
||||
fetched by a guessed direct URL).
|
||||
For a Nextcloud public share, proxy only the read-only `articles` share and
|
||||
allow `GET`, `HEAD`, and `PROPFIND`; set `window.CMJS_CONFIG.listingMode` to
|
||||
`webdav`. Nextcloud requires `X-Requested-With: XMLHttpRequest` on DAV
|
||||
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
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com;
|
||||
root /var/www/cmkiss;
|
||||
root /var/www/cmjs;
|
||||
|
||||
# Serve the demo page
|
||||
location /demo/ {
|
||||
try_files $uri $uri/ /demo/index.html;
|
||||
}
|
||||
|
||||
# Deny every author's draft/ folder outright — must come before the
|
||||
# /articles/ block below (a regex location overrides a prefix location
|
||||
# in nginx, so this still wins even though it's declared after it).
|
||||
location ~ ^/articles/[^/]+/draft/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# Enable JSON directory listing for article discovery (applies to all subdirs)
|
||||
location /articles/ {
|
||||
autoindex on;
|
||||
@@ -421,21 +451,15 @@ server {
|
||||
|
||||
### 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
|
||||
`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).
|
||||
|
||||
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
|
||||
`articles/` or nested under wherever cmkiss itself lives:
|
||||
autoindex — it doesn't have to be named `articles/` or nested under wherever cmjs itself lives:
|
||||
|
||||
```nginx
|
||||
location ~ ^/blog-content/articles/[^/]+/draft/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location /blog-content/articles/ {
|
||||
autoindex on;
|
||||
autoindex_format json;
|
||||
@@ -446,7 +470,7 @@ location /blog-content/articles/ {
|
||||
### Compression
|
||||
|
||||
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`
|
||||
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
|
||||
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)
|
||||
along with each file's `mtime`. Only `.md` files listed directly inside an author's own directory
|
||||
are kept as candidate articles — the bio file is filtered out by name, and any subdirectory
|
||||
(`media/`, `draft/`) is skipped outright rather than being descended into, so nothing under
|
||||
`draft/` is ever indexed. That's it: because an article file's *name* already encodes its slug,
|
||||
date, and tags (see "Adding an article" above), that single listing is enough to sort, filter,
|
||||
paginate, and populate the sidebar's tag counts, without any further request per article and
|
||||
without ever fetching an article's actual Markdown.
|
||||
listing (their post directories, bio file, and avatar together) along with each entry's ETag
|
||||
(WebDAV) or `mtime` (nginx). Anything that isn't a directory with a valid post name (the
|
||||
author's own `{author}.md`, the avatar, stray files) is simply filtered out of that same
|
||||
listing. That's it: because a post directory's *name* already encodes its slug, date, and tags
|
||||
(see "Adding an article" above), that single listing is enough to sort, filter, paginate, and populate the sidebar's tag/year counts, without
|
||||
any further request per article and without ever fetching an article's actual Markdown.
|
||||
**Hydration** is the separate, deferred step that fetches a specific article's content (title,
|
||||
description, cover image, reading time) — it only ever runs for articles about to be rendered:
|
||||
the current page's cards (`hydrateArticles()` in `js/discovery.js`), a single visited
|
||||
@@ -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.
|
||||
|
||||
- **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
|
||||
one — once *any* visit renders an article, every later visit skips its content fetch too, until
|
||||
the file is renamed (a rename changes the cache key, so a retagged/redated article is treated as
|
||||
new — see "Adding an article" above) or its own mtime changes. Indexing reads the entire cache
|
||||
keyed by the Markdown file's own path and invalidated by comparing the post directory's live
|
||||
ETag (or `mtime` when the listing has no ETags) against the cached one — once *any* visit
|
||||
renders an article, every later visit skips its content fetch too, until the directory is
|
||||
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
|
||||
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
|
||||
live on every visit.
|
||||
- **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
|
||||
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
|
||||
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
|
||||
store entirely, only needs `DB_VERSION` bumped (IndexedDB's own upgrade counter): `onupgradeneeded`
|
||||
creates whatever store is missing without touching the other one's existing data.
|
||||
- **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `js/discovery.js` (default `10`) caps both how
|
||||
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.
|
||||
the IndexedDB database name, so a bump invalidates every cached entry on the next deploy.
|
||||
- **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `js/discovery.js` (default `10`) caps how
|
||||
many hydration (content) fetches run at once — the only per-article requests left, since
|
||||
indexing no longer needs one; 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
|
||||
hydrated in the background (fire-and-forget), so clicking "next" is usually an instant cache
|
||||
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
|
||||
page count is exact once the whole set's been scanned, and a lower bound (at least one more
|
||||
page) until then.
|
||||
- **Persistent search-match cache** — alongside the resumable in-session scan above, each query's
|
||||
per-article match results (`{ mtime, matched }`, keyed by article path) are cached in IndexedDB
|
||||
too (`searchQueries` store in `js/cache.js`, keyed by the query text itself) and reused across
|
||||
page reloads and browser sessions, not just while paging through one visit. Searching the same
|
||||
term again only re-fetches and re-checks articles that are new or whose `mtime` has changed
|
||||
since — everything else is trusted from the cache without a content re-fetch at all. Capped at
|
||||
`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.
|
||||
- **Crawl health** — if the root author-listing fetch fails outright, if every individual
|
||||
author's own directory listing fails, or if very few of the known candidate files parsed
|
||||
successfully (a malformed directory name, or a total breakage like the wrong origin or 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`).
|
||||
- **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"
|
||||
constraints) is documented in
|
||||
[`.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
|
||||
into the file name instead, and articles no longer live in a directory of their own), but the
|
||||
alongside the code. The per-article listing fetch it describes no longer exists (all metadata
|
||||
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
|
||||
applies.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+11
-4
@@ -15,6 +15,13 @@
|
||||
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-media {
|
||||
@@ -90,7 +97,7 @@
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* Author + tag row at the bottom of each card */
|
||||
/* Author + category row at the bottom of each card */
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -115,7 +122,7 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Tag pill — also used in cards and article headers */
|
||||
/* Shared category badge — used in cards and article headers */
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: var(--color-tag-bg);
|
||||
@@ -134,8 +141,8 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Heading shown above a filtered list (author, tag, or search results) */
|
||||
.list-heading {
|
||||
/* Heading shown above a filtered list (author or category view) */
|
||||
.category-heading {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-soft);
|
||||
|
||||
@@ -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
@@ -1,11 +1,10 @@
|
||||
// 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 —
|
||||
// see setSearchCacheEntry() below). Raw IndexedDB (no wrapper library — see
|
||||
// Persistent cache for discovered articles, keyed by each article file's own
|
||||
// path. Raw IndexedDB (no wrapper library — see
|
||||
// .claude/nginx-sitemap-crawler-plan.md L4/L5 on the zero-dependency,
|
||||
// long-stable-API requirement). If IndexedDB is unavailable (private
|
||||
// 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
|
||||
// without needing their own availability branching (plan doc R6).
|
||||
// no-op so discovery.js gets live-fetch behaviour for free without needing
|
||||
// its own availability branching (plan doc R6).
|
||||
|
||||
// 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
|
||||
@@ -13,25 +12,8 @@
|
||||
// each file's mtime happens to change — which, on a "deploy and forget"
|
||||
// site, could be years.
|
||||
const CACHE_SCHEMA_VERSION = 3;
|
||||
// Renaming this prefix has the same effect as a version bump would — every
|
||||
// existing visitor's browser opens a fresh, empty database under the new
|
||||
// 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;
|
||||
const DB_NAME = `cmjs-cache-v${CACHE_SCHEMA_VERSION}`;
|
||||
const STORE_NAME = 'articleFiles';
|
||||
|
||||
let cacheAvailable = true;
|
||||
let dbPromise = null;
|
||||
@@ -40,13 +22,9 @@ function openDb() {
|
||||
if (!dbPromise) {
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
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 = () => {
|
||||
const db = request.result;
|
||||
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.result.createObjectStore(STORE_NAME, { keyPath: 'path' });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
@@ -59,7 +37,7 @@ function openDb() {
|
||||
// Opens a store and resolves/rejects with the given IDBRequest's own outcome
|
||||
// — the standard pattern, so result timing never depends on transaction
|
||||
// lifecycle details.
|
||||
async function runRequest(storeName, mode, makeRequest) {
|
||||
async function runRequest(mode, makeRequest) {
|
||||
if (!cacheAvailable) return undefined;
|
||||
let db;
|
||||
try {
|
||||
@@ -68,7 +46,7 @@ async function runRequest(storeName, mode, makeRequest) {
|
||||
return undefined; // openDb() itself already flipped cacheAvailable
|
||||
}
|
||||
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) => {
|
||||
const request = makeRequest(store);
|
||||
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
|
||||
// indexing. Empty Map on a miss or if the cache is unavailable.
|
||||
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]));
|
||||
}
|
||||
|
||||
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
|
||||
@@ -109,7 +87,7 @@ export async function pruneCache(validPaths) {
|
||||
return; // openDb() itself already flipped cacheAvailable
|
||||
}
|
||||
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) => {
|
||||
const cursorRequest = store.openCursor();
|
||||
cursorRequest.onsuccess = event => {
|
||||
@@ -125,54 +103,3 @@ export async function pruneCache(validPaths) {
|
||||
// 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
@@ -17,6 +17,11 @@
|
||||
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_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 = {};
|
||||
try {
|
||||
@@ -27,16 +32,18 @@ try {
|
||||
|
||||
// Base URL of the repo root — strings/ and templates/ live here, one level
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
// Whatever it resolves to must still be a directory nginx serves a JSON (or
|
||||
// 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
@@ -1,19 +1,16 @@
|
||||
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 { getAllCacheEntries, setCacheEntry, pruneCache } from './cache.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
// Caps simultaneous requests in the two places this module still fires one
|
||||
// per item: markdown content fetches during hydration (see
|
||||
// hydrateArticle()/hydrateArticles()) and each author's own directory
|
||||
// listing during wave 2 of indexing (see runCrawl()) — indexing no longer
|
||||
// needs a per-*article* fetch at all (every stub field is encoded in its
|
||||
// file's own name — see buildStub()), but still fetches one listing per
|
||||
// author. Raise it for a fast/local nginx, lower it for a slow or shared/
|
||||
// production instance — see .claude/nginx-sitemap-crawler-plan.md §2a for
|
||||
// the original rationale (written when this also governed per-article
|
||||
// indexing requests).
|
||||
// Caps simultaneous markdown content fetches during hydration (see
|
||||
// hydrateArticle()/hydrateArticles()) — the only per-article requests left,
|
||||
// since indexing no longer needs a per-article listing fetch at all (every
|
||||
// stub field is encoded in its file's own name — see buildStub()). Raise it
|
||||
// for a fast/local nginx, lower it for a slow or shared/production instance
|
||||
// — see .claude/nginx-sitemap-crawler-plan.md §2a for the original rationale
|
||||
// (written when this also governed indexing requests).
|
||||
const MAX_CONCURRENT_REQUESTS = 10;
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
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
|
||||
// directly in its author's own directory (see buildStub()), next to that
|
||||
// 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 = [];
|
||||
const progressListeners = new Set();
|
||||
|
||||
function sortArticles(rawArticles) {
|
||||
return [...rawArticles].sort((a, b) => String(b.date).localeCompare(String(a.date)) || a.hash.localeCompare(b.hash));
|
||||
function sortAndFilterPublished(rawArticles) {
|
||||
return rawArticles
|
||||
.filter(article => article?.published && article.date)
|
||||
.sort((a, b) => String(b.date).localeCompare(String(a.date)) || a.hash.localeCompare(b.hash));
|
||||
}
|
||||
|
||||
// Subscribes to incremental crawl progress so a large site's article list
|
||||
@@ -115,12 +146,12 @@ function sortArticles(rawArticles) {
|
||||
// no longer showing.
|
||||
export function onArticlesProgress(listener) {
|
||||
progressListeners.add(listener);
|
||||
if (partialResolved.length) listener(sortArticles(partialResolved), false);
|
||||
if (partialResolved.length) listener(sortAndFilterPublished(partialResolved), false);
|
||||
return () => progressListeners.delete(listener);
|
||||
}
|
||||
|
||||
function notifyProgress(done) {
|
||||
const snapshot = sortArticles(partialResolved);
|
||||
const snapshot = sortAndFilterPublished(partialResolved);
|
||||
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,
|
||||
// publish date, and tags — see the README's "Adding an article" section.
|
||||
// Format: `<slug>~<DDMMYYYY>[~tag1+tag2+…].md`. `~` (rather than e.g. "--")
|
||||
// is deliberate: a slug is expected to be plain kebab-case, and "~" isn't a
|
||||
// publish date, tags/category, and draft status — see the README's "Adding
|
||||
// an article" section. Format: `<slug>~<DDMMYYYY>[~tag1+tag2+…].md`,
|
||||
// optionally prefixed with `_` for draft. `~` (rather than e.g. "--") is
|
||||
// deliberate: a slug is expected to be plain kebab-case, and "~" isn't a
|
||||
// character anyone would type by accident while writing one — unlike a
|
||||
// double-hyphen separator, which collides with an easy slug typo (an extra
|
||||
// "-") and silently drops the whole article instead of erroring. Returns
|
||||
// null for any file name that doesn't match — callers treat that as a
|
||||
// silent skip, e.g. the author's own bio file or a stray unrelated file.
|
||||
// Drafts aren't a naming convention at all — they live in an author's own
|
||||
// `draft/` subdirectory (see the README's "Adding an article" section),
|
||||
// which the crawler never descends into (wave 2 below only reads `type ===
|
||||
// 'file'` entries from an author's own listing) and which nginx should deny
|
||||
// outright, so a draft is never indexed or fetchable in the first place.
|
||||
function parseArticleFileName(fileName) {
|
||||
if (!fileName.endsWith('.md')) return null;
|
||||
const base = fileName.slice(0, -3);
|
||||
const [slug, dateSegment, tagsSegment] = base.split('~');
|
||||
if (!slug || !dateSegment || !/^\d{8}$/.test(dateSegment)) return null;
|
||||
function parsePostDirectoryName(dirName) {
|
||||
const match = String(dirName).match(/^(.+?)--(\d{8})(?:--(.+))?$/);
|
||||
if (!match) return null;
|
||||
const [, slug, dateSegment, tagsSegment] = match;
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,95}$/.test(slug)) return null;
|
||||
const day = dateSegment.slice(0, 2);
|
||||
const month = dateSegment.slice(2, 4);
|
||||
const year = dateSegment.slice(4, 8);
|
||||
return {
|
||||
slug,
|
||||
date: `${year}-${month}-${day}`,
|
||||
tags: tagsSegment ? tagsSegment.split('+').filter(Boolean) : [],
|
||||
};
|
||||
const parsedDate = new Date(Date.UTC(+year, +month - 1, +day));
|
||||
if (parsedDate.getUTCFullYear() !== +year || parsedDate.getUTCMonth() !== +month - 1 || parsedDate.getUTCDate() !== +day) return null;
|
||||
const 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
|
||||
@@ -184,20 +211,32 @@ function parseArticleFileName(fileName) {
|
||||
// see cache.js — since every article now lives directly in its author's
|
||||
// directory, alongside that author's bio file and shared `media/` folder,
|
||||
// rather than in a directory of its own.
|
||||
function buildStub(author, fileName, mtime, authorRoot) {
|
||||
const parsed = parseArticleFileName(fileName);
|
||||
function buildStub(author, dirName, mtime, authorRoot, etag = null) {
|
||||
const parsed = parsePostDirectoryName(dirName);
|
||||
if (!parsed) return null;
|
||||
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 {
|
||||
author,
|
||||
slug,
|
||||
hash: `${author}/${slug}`,
|
||||
path,
|
||||
root,
|
||||
directory: dirName,
|
||||
mtime,
|
||||
etag,
|
||||
category,
|
||||
categoryYear: category ? Number(category.replace(/^expa-/i, '')) : null,
|
||||
date,
|
||||
tags,
|
||||
published: true,
|
||||
hydrated: false,
|
||||
};
|
||||
}
|
||||
@@ -210,7 +249,9 @@ function buildStub(author, fileName, mtime, authorRoot) {
|
||||
// server ever flips between the two formats, instead of every file looking
|
||||
// "changed" the moment JSON autoindex is lost.
|
||||
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
|
||||
@@ -220,19 +261,14 @@ function normalizeMtime(mtime) {
|
||||
// string. `cacheSnapshot` is the whole cache read once up front (see
|
||||
// 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;
|
||||
// kept anyway since it's still the same one cheap lookup. `mtime` is a
|
||||
// change-detection signal, not a required key: `cached.mtime === mtime`
|
||||
// already matches when both are `null` (a server whose listing never
|
||||
// yields a parseable mtime — see parseHtmlDirectoryListing), which is the
|
||||
// right call — there's no evidence the file changed, as opposed to a
|
||||
// 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)}`;
|
||||
// kept anyway since it's still the same one cheap lookup.
|
||||
function resolveStub(author, dirName, mtime, etag, authorRoot, cacheSnapshot) {
|
||||
const parsed = parsePostDirectoryName(dirName);
|
||||
if (!parsed) return null;
|
||||
const path = `${authorRoot}${encodeURIComponent(dirName)}/${encodeURIComponent(parsed.slug)}.md`;
|
||||
const cached = cacheSnapshot.get(path);
|
||||
if (cached && cached.mtime === mtime && cached.article?.hydrated) return cached.article;
|
||||
return buildStub(author, fileName, mtime, authorRoot);
|
||||
if (cached && ((etag && cached.article?.etag === etag) || (!etag && mtime != null && cached.mtime === mtime)) && cached.article?.hydrated) return cached.article;
|
||||
return buildStub(author, dirName, mtime, authorRoot, etag);
|
||||
}
|
||||
|
||||
// Fields only derivable from an article's actual markdown content (not its
|
||||
@@ -281,7 +317,9 @@ export function hydrateArticle(stub) {
|
||||
|
||||
async function hydrateArticleNow(stub) {
|
||||
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}`);
|
||||
const raw = await response.text();
|
||||
const { meta, content } = parseFrontmatter(raw);
|
||||
@@ -294,14 +332,7 @@ async function hydrateArticleNow(stub) {
|
||||
const authoritative = { ...stub };
|
||||
Object.assign(stub, meta, authoritative, deriveContentFields(meta, content, stub.path), { hydrated: true });
|
||||
await setCacheEntry(stub.path, { mtime: stub.mtime, article: stub });
|
||||
} catch (error) {
|
||||
// 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);
|
||||
}
|
||||
} catch { /* leave stub unhydrated — retried next time it's rendered */ }
|
||||
return stub;
|
||||
}
|
||||
|
||||
@@ -333,48 +364,44 @@ async function runCrawl() {
|
||||
// crawl-health message instead.
|
||||
let authorEntries;
|
||||
try {
|
||||
authorEntries = await fetchJson(ARTICLES_URL);
|
||||
authorEntries = await fetchDirectory(ARTICLES_URL);
|
||||
} catch {
|
||||
crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: true };
|
||||
notifyProgress(true);
|
||||
return sortArticles(partialResolved);
|
||||
return sortAndFilterPublished(partialResolved);
|
||||
}
|
||||
const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.'));
|
||||
|
||||
// Wave 2 — always fetched live, in full, for every author (not just the
|
||||
// current page): each author's own directory listing, with per-file
|
||||
// mtimes. Capped by MAX_CONCURRENT_REQUESTS like hydrateArticles() below
|
||||
// — a site with many authors would otherwise fire one listing request
|
||||
// per author simultaneously, with no limit. This is what builds the
|
||||
// flat, cross-author list that drives global sorting and pagination —
|
||||
// see the plan doc's "keep the flat feed" decision. It's also the *only*
|
||||
// per-author network request indexing ever needs: every stub field
|
||||
// (slug, date, tags) is parsed straight out of each article file's own
|
||||
// name (see buildStub/parseArticleFileName) — there's no per-article
|
||||
// fetch at index time. The `type === 'file'` filter below also means any
|
||||
// subdirectory (the author's shared `media/` folder, a `draft/` folder —
|
||||
// see "Adding an article" in the README) is skipped without ever being
|
||||
// descended into. The author's own bio file (`<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
|
||||
// mtimes. This is what builds the flat, cross-author list that drives
|
||||
// global sorting and pagination — see the plan doc's "keep the flat
|
||||
// feed" decision. It's also the *only* per-author network request
|
||||
// indexing ever needs: every stub field (slug, date, tags, category,
|
||||
// draft status) is parsed straight out of each article file's own name
|
||||
// (see buildStub/parseArticleFileName) — there's no per-article fetch
|
||||
// at index time. The author's own bio file (`<author>.md`) and shared
|
||||
// `media/` folder live in the same listing and are simply filtered out
|
||||
// here — only `.md` files other than the bio file are candidate
|
||||
// articles. One author's listing failing (network blip, deleted
|
||||
// mid-crawl) is silently skipped rather than failing every other
|
||||
// author's list too, but is still counted, so a *total* breakage (wrong
|
||||
// origin, CORS) remains visible via getCrawlStats() instead of just
|
||||
// silently producing an empty article list.
|
||||
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 bioFileName = `${author.name}.md`;
|
||||
try {
|
||||
const entries = await fetchJson(authorRoot);
|
||||
const entries = await fetchDirectory(authorRoot);
|
||||
return entries
|
||||
.filter(entry => entry.type === 'file' && entry.name.endsWith('.md') && entry.name !== bioFileName)
|
||||
.map(entry => ({ author: author.name, fileName: entry.name, mtime: normalizeMtime(entry.mtime), authorRoot }));
|
||||
.filter(entry => entry.type === 'directory' && parsePostDirectoryName(entry.name))
|
||||
.map(entry => ({ author: author.name, dirName: entry.name, mtime: normalizeMtime(entry.mtime), etag: entry.etag || null, authorRoot }));
|
||||
} catch {
|
||||
authorsFailed += 1;
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}));
|
||||
const allFiles = authorFileLists.flat();
|
||||
|
||||
// Every stub is parsed synchronously from data already in hand — no
|
||||
@@ -383,17 +410,20 @@ async function runCrawl() {
|
||||
const cacheSnapshot = await cacheSnapshotPromise;
|
||||
let loaded = 0;
|
||||
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) {
|
||||
loaded += 1;
|
||||
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 };
|
||||
|
||||
const finalArticles = sortArticles(partialResolved);
|
||||
const finalArticles = sortAndFilterPublished(partialResolved);
|
||||
notifyProgress(true);
|
||||
return finalArticles;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ export function parseFrontmatter(raw) {
|
||||
const meta = {};
|
||||
let arrayKey = null;
|
||||
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+(.*)$/);
|
||||
if (item && arrayKey) { meta[arrayKey].push(parseScalar(item[1])); continue; }
|
||||
const field = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(?:\s*(.*))?$/);
|
||||
|
||||
+22
-13
@@ -6,15 +6,16 @@
|
||||
// first = "search", ends "/page/N" → search results, page N (#search/query/page/2)
|
||||
// 2 segments, first = "page" → all articles, page N (#page/2)
|
||||
// 2 segments, first = "author" → author filter, page 1 (#author/alice)
|
||||
// 2 segments, first = "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)
|
||||
// 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 = "author", third = "page" → author filter, page N (#author/alice/page/2)
|
||||
// 4 segments, first = "category", third = "page" → category filter, page N (#category/linux/page/2)
|
||||
// 4 segments, first = "tag", third = "page" → tag filter, page N (#tag/guides/page/2)
|
||||
|
||||
import { MARKED_URL } from './config-default.js';
|
||||
import { loadTemplates } from './templates.js';
|
||||
import { loadStrings, t } from './i18n.js';
|
||||
import { loadArticleList, loadAuthorView, 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.
|
||||
function loadScript(url) {
|
||||
@@ -33,6 +34,12 @@ const parsePage = s => { const n = +s; return Number.isInteger(n) && n >= 1 ? n
|
||||
function router() {
|
||||
const hash = location.hash.slice(1); // strip leading #
|
||||
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/).
|
||||
// A trailing "/page/N" is peeled off for pagination first — the query
|
||||
@@ -44,16 +51,18 @@ function router() {
|
||||
const rest = parts.slice(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 q = decodeURIComponent(queryParts.join('/'));
|
||||
const q = decodePart(queryParts.join('/'));
|
||||
if (q) loadSearch(q, hasPage ? parsePage(rest[rest.length - 1]) : 1); else 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] === 'author') loadAuthorView(decodeURIComponent(parts[1]));
|
||||
else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodeURIComponent(parts[1]));
|
||||
else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodeURIComponent).join('/'));
|
||||
else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodeURIComponent(parts[1]), parsePage(parts[3]));
|
||||
else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodeURIComponent(parts[1]), parsePage(parts[3]));
|
||||
else if (parts.length === 2 && parts[0] === 'author') loadAuthorView(decodePart(parts[1]));
|
||||
else if (parts.length === 2 && parts[0] === 'category') loadCategoryView(decodePart(parts[1]));
|
||||
else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodePart(parts[1]));
|
||||
else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodePart).join('/'));
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -74,16 +83,16 @@ function init() {
|
||||
// Fail with a clear, catchable message here rather than a cryptic
|
||||
// uncaught TypeError the first time showListView()/showArticleView()
|
||||
// run — both mount points are a hard requirement, unlike
|
||||
// search/tag, which are optional.
|
||||
// search/tag/year, which are optional.
|
||||
if (!document.getElementById('list-view') || !document.getElementById('article-view')) {
|
||||
throw new Error('Missing required #list-view and/or #article-view element — see the "Embedding in an existing page" section of the README.');
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// only when neither mount point is present to show the message in.
|
||||
ready.catch(err => {
|
||||
|
||||
+115
-108
@@ -1,6 +1,5 @@
|
||||
import { templates } from './templates.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 { t, getLocale } from './i18n.js';
|
||||
|
||||
@@ -18,12 +17,6 @@ let currentGeneration = 0;
|
||||
// everything.
|
||||
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) {
|
||||
if (!value) return '';
|
||||
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
|
||||
@@ -33,7 +26,7 @@ function formatDate(value) {
|
||||
export function showListView() {
|
||||
document.getElementById('list-view').hidden = false;
|
||||
document.getElementById('article-view').hidden = true;
|
||||
document.title = pageTitle;
|
||||
document.title = t('siteName');
|
||||
}
|
||||
|
||||
export function showArticleView() {
|
||||
@@ -54,7 +47,9 @@ function clearSearchInput() {
|
||||
function filterLabel(filter) {
|
||||
if (!filter) return '';
|
||||
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 === 'category') return t('filterYear', { value: filter.value.replace(/^year-/i, '').replace(/^expa-/i, '') });
|
||||
return filter.value;
|
||||
}
|
||||
|
||||
@@ -84,8 +79,9 @@ function buildPaginationNav(page, totalPages, filter) {
|
||||
}
|
||||
|
||||
function renderTags(container, tags) {
|
||||
if (!container || !tags?.length) { container?.remove(); return; }
|
||||
tags.forEach(identifier => {
|
||||
const visibleTags = (tags || []).filter(identifier => !/^expa-20\d{2}$/i.test(identifier));
|
||||
if (!container || !visibleTags.length) { container?.remove(); return; }
|
||||
visibleTags.forEach(identifier => {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'tag';
|
||||
link.href = `#tag/${encodeURIComponent(identifier)}`;
|
||||
@@ -109,22 +105,55 @@ function renderFilterLink(label, href, count, active = false) {
|
||||
return link;
|
||||
}
|
||||
|
||||
// Years and tags render independently — either `<nav>` can be absent from
|
||||
// the page (e.g. a site that only wants tag filtering, like this project's
|
||||
// own index.html) without the other silently stopping too. Whichever
|
||||
// section renders first gets the "all articles" reset link, so it's never
|
||||
// lost just because the years nav isn't on the page.
|
||||
function renderFilterNavigation(articles, activeFilter = null) {
|
||||
const years = document.getElementById('year-filters');
|
||||
const tags = document.getElementById('tag-filters');
|
||||
if (!tags) return;
|
||||
tags.innerHTML = '';
|
||||
const tagCounts = new Map();
|
||||
articles.forEach(article => (article.tags || []).forEach(tag => tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)));
|
||||
const tagList = document.createElement('div');
|
||||
tagList.className = 'filter-list';
|
||||
tagList.appendChild(renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter));
|
||||
[...tagCounts.keys()]
|
||||
.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));
|
||||
if (!years && !tags) return;
|
||||
const allArticlesLink = () => renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter);
|
||||
|
||||
if (years) {
|
||||
years.innerHTML = '';
|
||||
const yearCounts = new Map();
|
||||
articles.forEach(article => {
|
||||
const year = Number(article.categoryYear);
|
||||
if (year) yearCounts.set(year, (yearCounts.get(year) || 0) + 1);
|
||||
});
|
||||
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
|
||||
@@ -138,11 +167,17 @@ function postHref(article) {
|
||||
return `#post/${encodeURIComponent(article.author)}/${encodeURIComponent(article.slug)}`;
|
||||
}
|
||||
|
||||
// Shared between renderCard() and loadArticle() — date and reading-time are
|
||||
// populated identically in both places.
|
||||
function applyArticleMetaFields(field, { date, readingTime }) {
|
||||
// Shared between renderCard() and loadArticle() — date, reading-time, and
|
||||
// the category/year badge are populated identically in both places.
|
||||
function applyArticleMetaFields(field, { date, readingTime, category, categoryYear }) {
|
||||
setField(field('date'), date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); });
|
||||
setField(field('reading-time'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
|
||||
// A post only counts as a "category" when one of its directory-name
|
||||
// tags happens to name a year — not every article has one.
|
||||
setField(field('category'), category, (categoryEl, value) => {
|
||||
categoryEl.textContent = t('yearLabel', { year: categoryYear });
|
||||
categoryEl.href = `#tag/${encodeURIComponent(value)}`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderCard(article) {
|
||||
@@ -172,6 +207,24 @@ function renderCard(article) {
|
||||
author.textContent = article.authorName || article.author;
|
||||
author.href = `#author/${encodeURIComponent(article.author)}`;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -179,29 +232,27 @@ function renderCard(article) {
|
||||
// autoindex disabled) should be visible rather than silently rendering an
|
||||
// empty/near-empty list forever — see .claude/nginx-sitemap-crawler-plan.md L3.
|
||||
// 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
|
||||
// skips individual author failures — see the wave-2 comment in
|
||||
// discovery.js). Unlike an older version of this check, a low loaded/total
|
||||
// ratio (candidate .md files that didn't match the article naming
|
||||
// convention) is deliberately *not* treated as a health problem: indexing
|
||||
// does no per-article fetch at all, so a non-conforming file name is a
|
||||
// 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.
|
||||
// discovery.js), and, for larger sites, a near-zero loaded/total ratio at
|
||||
// the article level. 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) {
|
||||
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
|
||||
// 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.
|
||||
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');
|
||||
warning.className = 'error';
|
||||
warning.textContent = t('crawlHealthAuthorsFailed');
|
||||
warning.textContent = authorsBroken
|
||||
? t('crawlHealthAuthorsFailed')
|
||||
: t('crawlHealthArticlesFailed', { loaded, total });
|
||||
view.appendChild(warning);
|
||||
}
|
||||
|
||||
@@ -220,7 +271,7 @@ function renderArticleListBody(view, { total, pageArticles, page, pages, filter,
|
||||
renderCrawlHealthWarning(view, done);
|
||||
if (heading) {
|
||||
const headingElement = document.createElement('h2');
|
||||
headingElement.className = 'list-heading';
|
||||
headingElement.className = 'category-heading';
|
||||
headingElement.textContent = heading;
|
||||
view.appendChild(headingElement);
|
||||
}
|
||||
@@ -253,16 +304,24 @@ export async function loadArticleList(filter = null, page = 1) {
|
||||
const applyFilter = allArticles => {
|
||||
renderFilterNavigation(allArticles, filter);
|
||||
if (!filter) return allArticles;
|
||||
if (filter.type === 'tag') return allArticles.filter(article => article.tags.includes(filter.value));
|
||||
// Author names are exact, case-sensitive directory names (used
|
||||
// as-is for paths and profile lookups elsewhere) — comparing them
|
||||
// case-insensitively here could merge two distinctly-named author
|
||||
// directories that differ only in case.
|
||||
return allArticles.filter(article => article.author === filter.value);
|
||||
return allArticles.filter(article => {
|
||||
if (filter.type === 'tag') return article.tags.includes(filter.value);
|
||||
// Author names are exact, case-sensitive directory names (used
|
||||
// as-is for paths and profile lookups elsewhere) — comparing
|
||||
// them case-insensitively here could merge two distinctly-named
|
||||
// author directories that differ only in case.
|
||||
if (filter.type === 'author') return article.author === filter.value;
|
||||
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 —
|
||||
// none of that needs a content fetch). Only the current
|
||||
// Sorting/filtering/pagination all work on stubs alone (tags, date,
|
||||
// category — none of that needs a content fetch). Only the current
|
||||
// page's cards need real content, so only that slice gets hydrated —
|
||||
// this is what keeps a large site's list fast: the rest of the tree's
|
||||
// markdown is never fetched until the user actually pages to it.
|
||||
@@ -291,19 +350,12 @@ export async function loadArticleList(filter = null, page = 1) {
|
||||
}
|
||||
};
|
||||
|
||||
// Same error handling as the final render below (not just the same
|
||||
// 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); });
|
||||
const unsubscribe = onArticlesProgress((partialArticles, done) => { render(partialArticles, done); });
|
||||
try {
|
||||
const allArticles = await discoverArticles();
|
||||
await render(allArticles, true);
|
||||
} catch (error) {
|
||||
showRenderError(error);
|
||||
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
|
||||
} finally {
|
||||
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);
|
||||
|
||||
// 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
|
||||
// stub order never changes. Reset whenever the query text changes; holds
|
||||
// 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;
|
||||
|
||||
export async function loadSearch(query, page = 1) {
|
||||
@@ -408,7 +451,7 @@ export async function loadSearch(query, page = 1) {
|
||||
const filter = { type: 'search', value: query };
|
||||
try {
|
||||
const needle = query.toLocaleLowerCase(getLocale());
|
||||
const matchesQuery = article => [article.title, article.description, article.author, article.authorName, ...article.tags]
|
||||
const matchesQuery = article => [article.title, article.description, article.author, article.authorName, article.category, ...article.tags]
|
||||
.some(value => String(value || '').toLocaleLowerCase(getLocale()).includes(needle));
|
||||
|
||||
const stubs = await discoverArticles();
|
||||
@@ -416,16 +459,7 @@ export async function loadSearch(query, page = 1) {
|
||||
renderFilterNavigation(stubs);
|
||||
|
||||
if (!searchState || searchState.query !== query || searchState.stubs !== stubs) {
|
||||
// Loaded once per new query. Pruned to the current stub set's
|
||||
// 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 };
|
||||
searchState = { query, stubs, cursor: 0, matches: [] };
|
||||
}
|
||||
const state = searchState;
|
||||
|
||||
@@ -477,38 +511,12 @@ export async function loadSearch(query, page = 1) {
|
||||
while (state.cursor < state.stubs.length && state.matches.length < target) {
|
||||
if (generation !== currentGeneration) return;
|
||||
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);
|
||||
if (generation !== currentGeneration) return;
|
||||
state.cursor += batch.length;
|
||||
batch.forEach(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);
|
||||
});
|
||||
batch.forEach(article => { if (matchesQuery(article)) state.matches.push(article); });
|
||||
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) {
|
||||
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
|
||||
// auto-excerpt) — the full content is right below it either way.
|
||||
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}${pageTitle ? ` | ${pageTitle}` : ''}`;
|
||||
document.title = `${meta.title || article.slug} | ${t('siteName')}`;
|
||||
const element = document.importNode(templates.article, true);
|
||||
const field = name => element.querySelector(`[data-field="${name}"]`);
|
||||
field('title').textContent = meta.title || article.slug;
|
||||
// Publish date comes from the article file's own name (already
|
||||
// resolved onto `article.date`), not the raw frontmatter.
|
||||
applyArticleMetaFields(field, { date: article.date, readingTime });
|
||||
applyArticleMetaFields(field, { date: article.date, readingTime, category: article.category, categoryYear: article.categoryYear });
|
||||
setField(field('description'), meta.description, (description, value) => { description.textContent = value; });
|
||||
const image = field('image');
|
||||
if (coverUrl) { image.src = coverUrl; image.alt = meta.title || article.slug; } else image.closest('figure')?.remove();
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"siteName": "Ukázkový blog",
|
||||
"appStartError": "Blog se nepodařilo spustit: {message}",
|
||||
"articleNotFound": "Článek nebyl nalezen",
|
||||
|
||||
"filterAuthor": "Autor: {value}",
|
||||
"filterTag": "Štítek: {value}",
|
||||
"filterYear": "Expedice {value}",
|
||||
"filterAllArticles": "Všechny články",
|
||||
"yearLabel": "Expedice {year}",
|
||||
|
||||
"paginationLabel": "Stránkování",
|
||||
"paginationPrevious": "Předchozí",
|
||||
@@ -15,6 +18,7 @@
|
||||
"readingTime": "{minutes} min čtení",
|
||||
|
||||
"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…",
|
||||
"loadingGeneric": "Načítám…",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"siteName": "Example Blog",
|
||||
"appStartError": "Failed to start the blog: {message}",
|
||||
"articleNotFound": "Article not found",
|
||||
|
||||
"filterAuthor": "Author: {value}",
|
||||
"filterTag": "Tag: {value}",
|
||||
"filterYear": "Expedition {value}",
|
||||
"filterAllArticles": "All articles",
|
||||
"yearLabel": "Expedition {year}",
|
||||
|
||||
"paginationLabel": "Pagination",
|
||||
"paginationPrevious": "Previous",
|
||||
@@ -15,6 +18,7 @@
|
||||
"readingTime": "{minutes} min read",
|
||||
|
||||
"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…",
|
||||
"loadingGeneric": "Loading…",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"siteName": "Ukážkový blog",
|
||||
"appStartError": "Blog sa nepodarilo spustiť: {message}",
|
||||
"articleNotFound": "Článok nebol nájdený",
|
||||
|
||||
"filterAuthor": "Autor: {value}",
|
||||
"filterTag": "Štítok: {value}",
|
||||
"filterYear": "Expedícia {value}",
|
||||
"filterAllArticles": "Všetky články",
|
||||
"yearLabel": "Expedícia {year}",
|
||||
|
||||
"paginationLabel": "Stránkovanie",
|
||||
"paginationPrevious": "Predchádzajúca",
|
||||
@@ -15,6 +18,7 @@
|
||||
"readingTime": "{minutes} min čítania",
|
||||
|
||||
"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…",
|
||||
"loadingGeneric": "Načítavam…",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<p class="description" data-field="description"></p>
|
||||
<div class="article-meta">
|
||||
<a class="author-link" data-field="author" href=""></a>
|
||||
<a class="tag" data-field="category" href=""></a>
|
||||
<span class="tag-list" data-field="tags"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<p data-field="description"></p>
|
||||
<div class="card-meta">
|
||||
<a class="author-link" data-field="author" href=""></a>
|
||||
<a class="tag" data-field="category" href=""></a>
|
||||
<span class="tag-list" data-field="tags"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user