Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b9eff8dff
|
||
|
|
4c384a9517
|
||
|
|
62f909ead8 | ||
|
|
78765b95f5 |
No files matched your search
@@ -5,10 +5,9 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil
|
||||
## 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,7 +15,7 @@ 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
|
||||
@@ -78,15 +77,20 @@ 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
|
||||
@@ -107,53 +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, tags, category, and draft status all 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 `+`. A tag id matching `year-YYYY` is
|
||||
treated as the article's "year"/category, shown as a badge linking to `#category/year-YYYY`;
|
||||
every other tag id is a normal topic tag, shown as a pill linking to `#tag/tag-id`. Omit this
|
||||
whole segment (and its leading `~`) if the article has no tags.
|
||||
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 `-`.
|
||||
|
||||
A leading underscore on the whole file name — `_<slug>~....md` — marks the article as a
|
||||
**draft**: it's excluded from the list, search, and pagination, but still reachable directly by
|
||||
URL. Remove the underscore to publish it.
|
||||
|
||||
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+year-2026.md`
|
||||
3. Optionally add a cover image to that author's shared `articles/author/media/` folder
|
||||
(creating it if needed), and reference it from frontmatter with `image: media/photo.jpg`.
|
||||
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
|
||||
|
||||
To change an article's date, tags, or draft status 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.
|
||||
@@ -166,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...
|
||||
@@ -176,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`, `tags`, and `draft` frontmatter fields, if present, are ignored — superseded by
|
||||
the directory 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
|
||||
|
||||
@@ -212,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 |
|
||||
@@ -227,11 +258,11 @@ The body text below the frontmatter is rendered as markdown and shown as the bio
|
||||
| `#search/linux/page/2` | Same, page 2 |
|
||||
| `#author/alice` | All articles by alice, page 1 |
|
||||
| `#author/alice/page/2` | All articles by alice, page 2 |
|
||||
| `#category/year-2026` | All articles carrying the "year-2026" year tag, page 1 |
|
||||
| `#category/year-2026/page/2` | Same, page 2 |
|
||||
| `#tag/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 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`.
|
||||
|
||||
@@ -350,7 +381,7 @@ demo does.
|
||||
```
|
||||
- **Year list — `#year-filters`** (optional, not used by this repo's own demo but supported the
|
||||
same way): a container that gets filled in with one link per year 2026 down to 2009 that has at
|
||||
least one article, linking to `#category/year-YYYY`. Can appear alongside `#tag-filters` or on
|
||||
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>
|
||||
@@ -386,6 +417,12 @@ nothing breaks, the article list and search still work standalone.
|
||||
|
||||
## nginx configuration
|
||||
|
||||
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
|
||||
@@ -459,11 +496,11 @@ 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, and shared `media/` folder together) along with each
|
||||
file's `mtime`. Non-article entries (the author's own `{author}.md`, the `media/` folder) are
|
||||
simply filtered out of that same listing. That's it: because an article file's *name* already
|
||||
encodes its slug, date, tags, and draft status (see "Adding an article" above), that single
|
||||
listing is enough to sort, filter, paginate, and populate the sidebar's tag/year counts, without
|
||||
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:
|
||||
@@ -474,17 +511,18 @@ 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.
|
||||
- **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `js/discovery.js` (default `10`) caps how
|
||||
@@ -506,7 +544,7 @@ of content is ever fetched per page view.
|
||||
page) until then.
|
||||
- **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 file name, or a total breakage like the wrong origin or CORS
|
||||
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`).
|
||||
@@ -514,8 +552,8 @@ of content is ever fetched per page view.
|
||||
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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
+10
-3
@@ -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,11 +32,11 @@ 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
|
||||
@@ -39,4 +44,6 @@ export const MARKED_URL = overrides.MARKED_URL ?? DEFAULT_MARKED_URL;
|
||||
// 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);
|
||||
+79
-35
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
@@ -58,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
|
||||
@@ -153,22 +185,19 @@ function excerpt(content) {
|
||||
// "-") 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.
|
||||
function parseArticleFileName(fileName) {
|
||||
if (!fileName.endsWith('.md')) return null;
|
||||
const base = fileName.slice(0, -3);
|
||||
const draft = base.startsWith('_');
|
||||
const core = draft ? base.slice(1) : base;
|
||||
const [slug, dateSegment, tagsSegment] = core.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) : [],
|
||||
draft,
|
||||
};
|
||||
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
|
||||
@@ -182,26 +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, draft } = parsed;
|
||||
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
|
||||
// "Category" isn't a separate concept anymore — it's just whichever tag
|
||||
// id (if any) looks like a `year-YYYY` id.
|
||||
const category = tags.find(id => /^year-20\d{2}$/i.test(id)) || null;
|
||||
const { slug, date, tags } = parsed;
|
||||
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(/^year-/i, '')) : null,
|
||||
categoryYear: category ? Number(category.replace(/^expa-/i, '')) : null,
|
||||
date,
|
||||
tags,
|
||||
published: !draft,
|
||||
published: true,
|
||||
hydrated: false,
|
||||
};
|
||||
}
|
||||
@@ -214,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
|
||||
@@ -225,11 +262,13 @@ function normalizeMtime(mtime) {
|
||||
// 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.
|
||||
function resolveStub(author, fileName, mtime, authorRoot, cacheSnapshot) {
|
||||
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
|
||||
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 && mtime != null && 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
|
||||
@@ -278,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);
|
||||
@@ -323,7 +364,7 @@ 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);
|
||||
@@ -352,10 +393,10 @@ async function runCrawl() {
|
||||
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 [];
|
||||
@@ -369,14 +410,17 @@ 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 = sortAndFilterPublished(partialResolved);
|
||||
|
||||
+15
-9
@@ -6,7 +6,7 @@
|
||||
// first = "search", ends "/page/N" → search results, page N (#search/query/page/2)
|
||||
// 2 segments, first = "page" → all articles, page N (#page/2)
|
||||
// 2 segments, first = "author" → author filter, page 1 (#author/alice)
|
||||
// 2 segments, first = "category" or "tag" → filtered list, page 1
|
||||
// 2 segments, first = "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 = "category", third = "page" → category filter, page N (#category/linux/page/2)
|
||||
@@ -34,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
|
||||
@@ -45,18 +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] === 'category') loadCategoryView(decodeURIComponent(parts[1]));
|
||||
else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodeURIComponent(parts[1]));
|
||||
else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodeURIComponent).join('/'));
|
||||
else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodeURIComponent(parts[1]), parsePage(parts[3]));
|
||||
else if (parts.length === 4 && parts[0] === 'category' && parts[2] === 'page') loadCategoryView(decodeURIComponent(parts[1]), parsePage(parts[3]));
|
||||
else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodeURIComponent(parts[1]), parsePage(parts[3]));
|
||||
else if (parts.length === 2 && parts[0] === 'author') loadAuthorView(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();
|
||||
}
|
||||
|
||||
|
||||
+34
-8
@@ -47,8 +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, '') });
|
||||
if (filter.type === 'category') return t('filterYear', { value: filter.value.replace(/^year-/i, '').replace(/^expa-/i, '') });
|
||||
return filter.value;
|
||||
}
|
||||
|
||||
@@ -78,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)}`;
|
||||
@@ -126,8 +128,9 @@ function renderFilterNavigation(articles, activeFilter = null) {
|
||||
yearList.appendChild(allArticlesLink());
|
||||
for (let year = 2026; year >= 2009; year -= 1) {
|
||||
const count = yearCounts.get(year) || 0;
|
||||
const active = activeFilter?.type === 'category' && activeFilter.value.toLowerCase() === `year-${year}`;
|
||||
yearList.appendChild(renderFilterLink(t('yearLabel', { year }), `#category/year-${year}`, count, active));
|
||||
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);
|
||||
}
|
||||
@@ -139,11 +142,11 @@ function renderFilterNavigation(articles, activeFilter = null) {
|
||||
const tagList = document.createElement('div');
|
||||
tagList.className = 'filter-list';
|
||||
if (!years) tagList.appendChild(allArticlesLink());
|
||||
// A `year-YYYY` id is also a normal entry in `article.tags` (that's how
|
||||
// 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 => !/^year-20\d{2}$/i.test(id))
|
||||
.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;
|
||||
@@ -173,7 +176,7 @@ function applyArticleMetaFields(field, { date, readingTime, category, categoryYe
|
||||
// tags happens to name a year — not every article has one.
|
||||
setField(field('category'), category, (categoryEl, value) => {
|
||||
categoryEl.textContent = t('yearLabel', { year: categoryYear });
|
||||
categoryEl.href = `#category/${encodeURIComponent(value)}`;
|
||||
categoryEl.href = `#tag/${encodeURIComponent(value)}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,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;
|
||||
}
|
||||
|
||||
@@ -290,6 +311,11 @@ export async function loadArticleList(filter = null, page = 1) {
|
||||
// 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();
|
||||
});
|
||||
};
|
||||
|
||||
+2
-2
@@ -5,9 +5,9 @@
|
||||
|
||||
"filterAuthor": "Autor: {value}",
|
||||
"filterTag": "Štítek: {value}",
|
||||
"filterYear": "Ročník: {value}",
|
||||
"filterYear": "Expedice {value}",
|
||||
"filterAllArticles": "Všechny články",
|
||||
"yearLabel": "Ročník {year}",
|
||||
"yearLabel": "Expedice {year}",
|
||||
|
||||
"paginationLabel": "Stránkování",
|
||||
"paginationPrevious": "Předchozí",
|
||||
|
||||
+2
-2
@@ -5,9 +5,9 @@
|
||||
|
||||
"filterAuthor": "Author: {value}",
|
||||
"filterTag": "Tag: {value}",
|
||||
"filterYear": "Year: {value}",
|
||||
"filterYear": "Expedition {value}",
|
||||
"filterAllArticles": "All articles",
|
||||
"yearLabel": "Year {year}",
|
||||
"yearLabel": "Expedition {year}",
|
||||
|
||||
"paginationLabel": "Pagination",
|
||||
"paginationPrevious": "Previous",
|
||||
|
||||
+2
-2
@@ -5,9 +5,9 @@
|
||||
|
||||
"filterAuthor": "Autor: {value}",
|
||||
"filterTag": "Štítok: {value}",
|
||||
"filterYear": "Ročník: {value}",
|
||||
"filterYear": "Expedícia {value}",
|
||||
"filterAllArticles": "Všetky články",
|
||||
"yearLabel": "Ročník {year}",
|
||||
"yearLabel": "Expedícia {year}",
|
||||
|
||||
"paginationLabel": "Stránkovanie",
|
||||
"paginationPrevious": "Predchádzajúca",
|
||||
|
||||
Reference in new issue
Block a user