Merge pull request 'Finalizing the code' (#1) from claude into main

Reviewed-on: Astronomicka_Expedice/cmjs#1
This commit was merged in pull request #1.
This commit is contained in:
biosias committed 2026-09-12 16:35:28 +02:00
commit 78765b95f5
48 files changed
+2467 -1404

No files matched your search

+392
View File
@@ -0,0 +1,392 @@
# Nginx Sitemap Crawler — Design Plan
_Last updated: 2026-09-10_
## Tech stack
- **Language:** pure JavaScript, running **client-side inside a
webpage** (no Node, no server-side execution anywhere in this
project — nginx serves static listings only).
## Goal
Maintain a browsable, near-real-time sitemap of a two-level nginx-served
directory tree, using only stock nginx config (no server-side scripts on
the nginx host), minimizing redundant requests over time, and surfacing
the most recently changed content first.
## Constraints
- No server-side execution on the nginx host — plain `nginx.conf` only.
- Client-side script/app is where all logic (crawling, sorting,
pagination, caching) lives.
- Tree is two levels deep (root → level-1 dirs → level-2 dirs).
---
## 1. Nginx setup
Enable machine-readable directory listings on the relevant locations:
```nginx
location /files/ {
autoindex on;
autoindex_format json;
autoindex_localtime off;
}
```
- `autoindex_format json;` — makes listings parseable instead of scraping HTML.
- `autoindex_localtime off;` — keeps timestamps in GMT, consistent and
unambiguous for comparison logic (avoids DST/timezone drift issues).
Each listing entry includes `name`, `type` (`file`/`directory`), `size`,
and `mtime`.
---
## 2. Wave-based crawling
Directory names at level 2 aren't known until level 1 is fetched, so the
crawl naturally happens in sequential **waves**, each internally
**concurrent**:
- **Wave 1 (root)** — always fetched live on every run. Single request,
cheapest possible cost, and the sole source of truth for what
currently exists and what's changed.
- **Wave 2 (level-1 → level-2)** — fetched only as needed (see §5
caching) and only for directories relevant to the current page (see
§4 pagination). Requests within a wave are fired concurrently, not
sequentially, to keep latency low regardless of directory count.
---
### 2a. Concurrency limits — **decision: configurable, not hardcoded**
Rather than a fixed worker count baked into the logic, concurrency is
exposed as a **single tunable setting** (e.g. `maxConcurrentRequests`)
that governs how many wave-2 requests are in flight at once within a
wave. Rationale and shape:
- **Why configurable:** the right concurrency value depends on factors
that vary by deployment and can't be known in advance — network
distance to the nginx host (same-region vs. cross-region), how many
directories are typically in play per page (tied to §4a page size),
how many browser tabs/users might be hitting the same nginx instance
at once, and how conservative the operator wants to be about log
noise or perceived "scanning" behavior (as noted in earlier
discussion of request bursts).
- **Where it lives:** a single config value/constant near the top of
the client script (or exposed as a UI setting/advanced option),
rather than scattered magic numbers through the crawl logic.
- **Sensible default:** start conservative (e.g. 10) — safe for both
same-region and cross-region cases, and already shown earlier in
this plan to keep even a full ~500-directory cold-start crawl under
a few seconds. Users/operators who know their network is fast and
local, or who page through smaller slices (page size 10, see §4a),
can raise it; those on slower/cross-region links or shared/production
nginx instances can lower it.
- **Interaction with pagination:** since wave-2 is now scoped to just
the current page's directories (§4a), the concurrency setting mostly
matters relative to page size — e.g. at page size 10 with
concurrency 10, an entire page's wave-2 fetches resolve in a single
batch (~1 RTT); at page size 30 with concurrency 10, the same page
takes ~3 batches. Setting concurrency ≥ page size effectively
guarantees single-batch page loads, which is a reasonable rule of
thumb for operators tuning this value.
- **No hard ceiling imposed by nginx** — as established earlier,
nginx's default `worker_connections` comfortably absorbs dozens to
low hundreds of concurrent client connections, so the practical
upper bound is really about being a good citizen (log noise, shared
hosting) rather than anything nginx itself will choke on.
---
## 3. Recency-based prioritization
- A directory's `mtime` reflects changes to its **direct** children only
(additions, removals, renames) — not deeper nested edits. This is
sufficient for this use case, since the concern is new content
appearing, not deep nested edits (see "Known limitation" below).
- After wave 1 resolves, sort level-1 entries by `mtime` descending,
with directory name ascending as a stable tie-breaker (see R5).
This ordering drives both crawl priority and display order.
---
## 4. Pagination
- Slice the sorted level-1 list into fixed-size pages (default 10,
user-selectable — see §4a).
- Only fire wave-2 requests for the directories in the **currently
viewed page** — not the whole tree.
- Page navigation reuses the already-fetched, already-sorted wave-1
list in memory; no repeated root requests needed when paging.
- Optional: prefetch the next page's wave-2 requests in the background
while the current page is being viewed, so navigation feels instant.
---
### 4a. Page size — **decision: user-selectable, default 10**
- Default page size: **10** items.
- User-selectable alternatives: **15, 20, 30**.
- Changing page size resets pagination to page 1 (avoids ambiguous
partial pages / confusing item overlap when the slice boundaries
shift).
- Since wave 1 is already fully fetched and sorted in memory (see §2,
§3), changing page size is a **pure client-side re-slice** of the
existing sorted list — no new wave-1 request needed.
- Wave-2 fetch behavior (§5, cache-aware) is unaffected by page size
itself — only which/how many level-1 directories fall into the
current slice changes, which in turn determines which cache
entries are read or fetched.
---
## 5. Sitemap cache
Persist a cache entry per directory:
```json
{
"path": "/files/project-a/",
"mtime": "Wed, 10 Sep 2026 14:32:01 GMT",
"children": [ "...level-2 entries..." ],
"children_fetched_at": "2026-09-10T15:00:00Z"
}
```
### 5a. Storage mechanism — **decision: IndexedDB**
This runs purely client-side in a webpage — no filesystem access, so
the real choice is between browser storage APIs: **localStorage** vs.
**IndexedDB**. IndexedDB was chosen:
- **Storage ceiling** — localStorage is capped around 5-10MB
(browser-dependent) and shared across the whole origin; IndexedDB's
ceiling is far higher (typically hundreds of MB+), removing a
constraint that could otherwise bite as the tree grows.
- **Per-record updates** — IndexedDB is an object store keyed by
record (naturally: path → cache entry), so updating one directory's
entry doesn't require re-serializing/rewriting the entire cache, the
way localStorage (full blob, `JSON.stringify` every write) would.
- **Non-blocking** — IndexedDB is async by nature, which fits a cache
that's written to progressively as crawl waves resolve, without
freezing the UI thread. localStorage is synchronous and would block
rendering on every read/write as the cache grows.
- **No manual serialization** — IndexedDB stores structured JS objects
directly; no need to `JSON.stringify`/`parse` on every access.
- **Still zero external dependencies** — native browser API, no
library required (though a thin promise-wrapper like `idb` is a
reasonable, optional ergonomics improvement later if the raw API
proves too verbose).
**Practical shape:**
- One object store, e.g. `directoryCache`, keyed by directory `path`.
- Each record holds `{ path, mtime, children, children_fetched_at }`
as described below.
- Reads/writes are async (`Promise`-based or wrapped in one), which
aligns naturally with the already-async wave-2 fetches.
On each run, compare each level-1 directory's **live** `mtime` (from
wave 1) against the **cached** `mtime`:
- **Match** → skip wave 2 for that directory; serve `children` from
cache.
- **Newer or missing** → fetch wave 2 live, then update the cache entry
with the new `mtime` and `children`.
Additional cache maintenance:
- **Pruning** — after wave 1, any cached level-1 entries no longer
present in the live listing should be removed (handles deletions).
- **Wave 1 is never cached** — it has no parent to diff against, and
it's already the cheapest possible request (1 per run, regardless of
tree size).
- **Cold start** — first run has no cache, so it pays the full crawl
cost once. Every subsequent run should be dramatically cheaper,
bounded roughly by how much actually changed since the last run.
---
## 6. Why this combination works well together
- Sorting by `mtime` descending means the directories most likely to
have a **stale cache** (recent changes) are exactly the ones
prioritized to the front — so page 1 concentrates the real crawl
cost, while later pages are almost pure cache hits.
- Over repeated runs, total request cost trends toward being
proportional to **how much changed**, not the size of the tree.
- Progressive/skeleton rendering (page renders immediately with cached
or partial data, updates in place as fresher wave-2 results arrive)
keeps the experience responsive even while cache misses are being
resolved in the background.
---
## Known accepted limitation
Directory `mtime` only reflects direct-child changes, not deeper nested
edits (e.g. a file modified inside an existing level-2 directory won't
bump the level-1 directory's `mtime`). Accepted as a non-issue here,
since the priority signal needed is "new subdirectories appearing,"
which this correctly captures.
---
## Longevity Considerations
_This project is intended as "deploy and forget" for a ~30 year
horizon. That's a materially different constraint than "get this
working now," and shapes several decisions beyond what pure
functionality would require._
### L1. IndexedDB eviction — not a concern
Some browsers (e.g. Safari ITP) may evict IndexedDB data for
infrequently-visited origins. **Explicitly accepted as a non-issue**:
R6's uncached fallback mode already handles any IndexedDB
unavailability gracefully (falls back to live fetching), so eviction
simply means the cache benefit doesn't materialize on that visit — not
a failure. No action needed beyond what's already in R6.
### L2. HTML fallback if JSON listing breaks — **decision: included**
`autoindex_format json` depends on nginx config staying intact over
the project's lifetime. Configs can drift — a server migration,
reconfiguration, or someone disabling `autoindex_format json` (while
leaving `autoindex on;` itself in place) is realistic over 30 years.
To survive this, the crawler includes an **HTML fallback parser**:
- On each request, if the response isn't valid JSON (parse fails, or
`Content-Type` isn't JSON), fall back to treating the response as
nginx's **default HTML autoindex output** instead of failing the
request outright.
- Parse the HTML using the browser's native `DOMParser` (built-in, no
dependency) — extract entries from the `<a href="...">` listing,
using the link `href` for name/type (trailing `/` = directory) and
the surrounding text for `mtime`/size where present.
- This is strictly a **fallback path**, not the primary path — JSON
remains preferred when available, since it's more reliable to parse
and already carries structured `mtime`. The HTML fallback exists
purely so the tool degrades to "still works, just via a different
parse path" rather than "breaks entirely" if `autoindex_format json`
is ever lost from the config.
- This directly follows the same silent-skip philosophy as R3: prefer
degraded function over hard failure, wherever degrading is possible
without violating the zero-dependency principle.
### L3. Total-failure visibility
Silent-skip (R3) is correct for individual transient request failures,
but should not extend to masking a *total* breakage (e.g. CORS
misconfigured after a migration, autoindex disabled entirely, wrong
origin). The crawler tracks a simple "N of M directories loaded"
count; if that ratio is very low (e.g. near-zero) after a crawl
completes, a minimal, dependency-free visible indicator (a status line
in the UI, no alerting infrastructure) should communicate that
something is broken, so the project doesn't fail silently for years
before a human notices.
### L4. Zero dependencies, deliberately
No npm packages, no build step, no framework — stated explicitly as a
guiding principle, not just an incidental outcome of earlier
decisions. Any dependency introduces a 30-year liability: registries
can vanish, packages can go unmaintained, and even a "small" library
can break under a future browser change with nobody left to patch it.
Future contributors should treat this as a hard constraint, not a
preference to be relaxed for convenience.
### L5. Prefer long-stable JS/browser APIs over new syntax
Favor well-established, broadly-supported features (`fetch`,
`Promise`, `async`/`await`, basic IndexedDB, `DOMParser`) over newer or
experimental JS/browser APIs. The newer something is today, the more
likely it is to be deprecated, changed, or need a polyfill by the time
this project is 15-20 years old. Nothing currently behind a flag or
"experimental" label should be used, even if convenient now.
### L6. Keep this plan attached to the code
For a deploy-and-forget project, the largest real risk isn't
technical — it's a future maintainer with zero context. This document
should ship alongside the implementation (same repository, or
inlined as a comment block) rather than live separately, so the
reasoning behind each decision (why JSON over HTML, why IndexedDB,
why silent-skip, etc.) survives together with the code itself.
### L7. nginx version/config drift
`autoindex_format json` requires a reasonably modern nginx (long
available, but not guaranteed present on every install). Document a
minimum nginx version alongside the deployment instructions, and treat
"response isn't valid JSON" as an expected, handled case (see L2)
rather than an exceptional one — since over 30 years, drift in the
server config is a "when," not an "if."
---
## Risks & Edge Cases
### R1. CORS
If the webpage is served from a different origin than the nginx
directory listing, browser `fetch()` calls will be blocked without
proper CORS headers. Requires an `add_header Access-Control-Allow-Origin
...;` (or equivalent) on the relevant nginx `location` block, unless
the page and the file server share an origin. Needs to be decided/set
up alongside the nginx config in §1.
### R2. Trailing slashes on directory requests
nginx's autoindex only serves a listing cleanly when the request URL
ends in `/`. Requesting a directory path without the trailing slash
typically triggers a redirect (e.g. 301) instead of returning the
listing directly, adding an extra round-trip. The crawler must
consistently append `/` when building wave-2 request URLs from the
`name` field returned in wave-1's JSON.
### R3. Error handling for failed requests — **decision: silent skip**
If a wave-2 request fails (timeout, 404, host briefly unreachable),
it is silently skipped: no retry, no visible error marker. The
directory in question falls back to whatever is already in the
IndexedDB cache (stale-but-present data), or is simply omitted from
that page's render if nothing is cached yet. Accepted trade-off for
simplicity — a failed fetch does not block or degrade the rest of the
page.
### R4. Race conditions from rapid navigation
Changing page size or flipping pages quickly can cause in-flight
requests from a previous page/size to resolve *after* newer ones,
overwriting current state with stale data. Mitigated with a
generation/token counter: each navigation action increments a
"current generation" value, and any response that arrives tagged with
an older generation than the current one is discarded rather than
rendered.
### R5. `mtime` tie-breaking
autoindex JSON timestamps are second-precision, so directories touched
within the same second (e.g. a batch of subdirectories created
together by a script) can tie on `mtime`. The sort in §3 uses `mtime`
descending as the primary key with **directory name ascending** as a
stable secondary key, so ordering — and therefore pagination — stays
consistent across runs instead of jittering on ties.
### R6. IndexedDB availability — **decision: uncached fallback mode**
IndexedDB may be unavailable or restricted (private/incognito modes in
some browsers) or subject to storage eviction under pressure. The
crawler treats any IndexedDB access failure (open error, blocked
transaction, etc.) as a signal to fall back to an **uncached mode**:
every wave-2 request is fetched live for the duration of that session,
with no persistence attempted. This keeps the sitemap fully functional
— just without the performance benefit of cache hits on repeat visits
— rather than erroring out or blocking the page. Detection happens
once at startup (attempt to open the DB; on failure, set an in-memory
`cacheAvailable = false` flag that the rest of the logic checks before
any cache read/write).
---
## Open items / future considerations
_All resolved — no open items remain._
Ruled out: WebDAV / `PROPFIND` (`ngx_http_dav_ext_module`) as a
single-request full-tree alternative — requires a non-stock nginx
build/module, which conflicts with the "ordinary nginx only" and
simplicity constraints. Wave-based crawling with caching (§2, §5)
remains the approach.
+1
View File
@@ -0,0 +1 @@
/articles/
+2
View File
@@ -0,0 +1,2 @@
# Git Overrides
* Do not push commited changes in this project
+340 -89
View File
@@ -4,78 +4,149 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil
## How it works
1. The browser traverses `cmjs/articles/` via nginx JSON autoindex — discovering authors, categories, and article files.
2. Each `.md` file's YAML frontmatter is parsed to build the article list.
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
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
discovery & caching" below.
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
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 category in-memory. Navigates to `#search/query` so results are bookmarkable.
- **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 for one-click navigation back to the list.
- **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 category filtering** — clicking an author name or category badge 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`.
- **Offline-friendly caching** — discovered articles are cached in IndexedDB, so repeat visits skip re-fetching content that hasn't changed.
- **Translatable UI** — every UI string is looked up from a locale JSON file (English, Czech, and Slovak ship by default); adding a language needs no code changes.
## Project structure
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.
```
cmjs/
├── README.md
├── index.html # Single-page shell — served from the repo root
├── css/ # Site-level styles for index.html
│ ├── reset.css # CSS variables, dark mode, box-sizing reset, focus styles
│ ├── header.css # Site header, title link, search form
│ └── layout.css # Main content wrapper, state messages, responsive
├── 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
│ ├── header.css # Site header, title link, search form
│ └── layout.css # Main content wrapper, state messages, responsive
├── css/
│ └── components/ # Component styles — needed by any embedding page
│ ├── card.css # Article list cards and filter heading
│ ├── article.css # Single article view, back link, hero image
│ ├── content.css # Rendered markdown styles (.content *)
│ ├── pagination.css # Prev/next page navigation
│ ├── toc.css # Auto-generated table of contents
│ └── author-profile.css # Author profile card (avatar, bio, social links)
├── js/
│ └── marked.min.js # marked.js (local copy, loaded as a global)
└── cmjs/ # App content — articles, templates, component styles
├── css/
│ └── components/
│ ├── card.css # Article list cards and filter heading
│ ├── article.css # Single article view, back link, hero image
│ ├── content.css # Rendered markdown styles (.content *)
│ ├── pagination.css # Prev/next page navigation
│ └── toc.css # Auto-generated table of contents
├── js/
│ ├── config.js # BASE_URL, MARKED_URL, PICTURES_URL (all via import.meta.url)
│ ├── frontmatter.js # parseFrontmatter() — pure YAML-front parser
│ ├── templates.js # loadTemplates(), templates cache
│ ├── discovery.js # discoverArticles(), fetchJson(), article cache
│ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadCategoryView, loadSearch
│ └── router.js # Hash router and app entry point
├── templates/
│ ├── card.html # Article list card layout
│ └── article.html # Full article view layout
├── articles/
│ └── author/ # One directory per author
│ └── category/ # One directory per category
│ └── slug.md # Article file
└── pictures/ # Article images (referenced via image: frontmatter field)
│ ├── config.js # BASE_URL, MARKED_URL, ARTICLES_URL (all via import.meta.url)
│ ├── frontmatter.js # parseFrontmatter() — pure YAML-front parser
│ ├── templates.js # loadTemplates(), templates cache
│ ├── discovery.js # discoverArticles(), fetchJson(), article cache
│ ├── cache.js # IndexedDB cache for discovered articles, keyed by article file path
│ ├── concurrency.js # mapWithConcurrency() — caps simultaneous per-article fetches
│ ├── i18n.js # t(), loadStrings(), getLocale() — UI string lookup
│ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadCategoryView, loadSearch
│ └── 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
├── strings/
│ ├── en.json # UI strings (default/fallback locale) — see "Translations" below
│ ├── cs.json # Czech translation
│ └── sk.json # Slovak translation
├── templates/
│ ├── card.html # Article list card layout
│ ├── article.html # Full article view layout
│ └── author-profile.html # Author profile card layout
└── articles/
└── 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"
```
## Adding an article
1. Create the author and category directories if they don't exist: `cmjs/articles/author/category/`
2. Create a `.md` file inside: `cmjs/articles/author/category/my-article.md`
3. Add a frontmatter block at the top
4. Write your content — it will appear in the list automatically
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:
`author` and `category` are derived from the directory path — no frontmatter fields needed for them.
```
<slug>~<DDMMYYYY>~<tag1>+<tag2>+….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.
- `<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.
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`.
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`.
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
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,
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.
> **A note on trust:** article and author-bio Markdown is rendered directly via marked.js with no
> HTML-sanitization pass — anyone who can write a `.md` file here can include raw HTML, including
> `<script>` tags. Only let people you trust add or edit content.
### Frontmatter format
```yaml
---
title: My Article Title
date: 2026-06-16
description: A short summary shown in the article list.
draft: false
---
Your markdown content here...
@@ -84,19 +155,19 @@ Your markdown content here...
| Field | Required | Notes |
|-------|----------|-------|
| `title` | yes | Shown in the list and article header |
| `date` | yes | ISO format `YYYY-MM-DD`, used for sorting newest-first |
| `description` | no | Short summary shown under the title in the list |
| `image` | no | Filename of the preview image in the `pictures/` directory (e.g. `photo.jpg`); shown as card thumbnail and article hero |
| `draft` | no | Set to `true`, `yes`, or `1` to hide from 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 |
Reading time is computed automatically from the article word count — no frontmatter field needed.
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.
## Author profiles
To add a profile for an author, create a file named `{author}.md` directly inside the author's directory:
```
cmjs/articles/bob/bob.md
articles/bob/bob.md
```
The profile appears as a card above the article list when a visitor filters by that author. It is optional — authors without a profile file work normally.
@@ -120,7 +191,7 @@ Short bio rendered as markdown.
| Field | Notes |
|-------|-------|
| `title` | Display name shown in the profile card. Falls back to the author directory name. |
| `avatar` | Filename in `pictures/` — displayed as a circular thumbnail. |
| `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. |
| `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.
@@ -131,24 +202,56 @@ The body text below the frontmatter is rendered as markdown and shown as the bio
|----------|------|
| *(empty)* | All articles, page 1 |
| `#page/2` | All articles, page 2 |
| `#search/linux` | Search results for "linux" |
| `#search/linux` | Search results for "linux", page 1 |
| `#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/linux` | All articles in "linux", page 1 |
| `#category/linux/page/2` | All articles in "linux", page 2 |
| `#alice/linux/my-article` | Single article at `cmjs/articles/alice/linux/my-article.md` |
| `#category/year-2026` | All articles carrying the "year-2026" year tag, page 1 |
| `#category/year-2026/page/2` | Same, page 2 |
| `#tag/guides` | All articles tagged "guides", page 1 |
| `#tag/guides/page/2` | Same, page 2 |
| `#post/alice/my-article` | Single article at `articles/alice/my-article~DDMMYYYY~tags.md` |
Author names and category tags on each card and article are clickable links to their respective filter views. A search box in the header navigates to `#search/query`.
## Customising the layout
The HTML structure for the article list card and the full article view live in `cmjs/templates/`:
The HTML structure for the article list card, the full article view, and the author profile
card live in `templates/`:
- `cmjs/templates/card.html` — the card shown in the article list
- `cmjs/templates/article.html` — the full article view (header, hero image, content)
- `templates/card.html` — the card shown in the article list
- `templates/article.html` — the full article view (header, hero image, content)
- `templates/author-profile.html` — the profile card (avatar, bio, social links)
Edit these files directly to change layout — no JS changes needed. Elements are identified by `data-field` attributes, which is how the JS modules find and populate them. Keep existing `data-field` values when rearranging; add new ones if you add new fields.
## Translations
Every UI string the JS renders (loading/error states, pagination, filter labels, the table of
contents heading, etc.) is looked up through `t(key, params)` in `js/i18n.js` rather than
hardcoded — `strings/en.json` is the default/fallback string table.
Available locales: `en` (default), `cs` (Czech), `sk` (Slovak).
To add another translation:
1. Set `<html lang="xx">` on the embedding page (or `demo/index.html` for this repo's own demo)
to the target language code (e.g. `cs`).
2. Add `strings/xx.json` with the same keys as `en.json` (copy it as a starting point) —
you only need to include the keys you're translating; anything missing quietly falls back to
English rather than breaking.
3. `{placeholder}` tokens in a string (e.g. `"{page} of {totalPages}"`) are substituted at
render time — keep them in your translated string, in whatever order/position reads
naturally in that language.
The resolved locale also drives date formatting (`Intl.DateTimeFormat`), search matching, and
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.
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 cmjs to any HTML page, three things are needed: the CSS, the two mount-point `<div>`s, and the script.
@@ -157,19 +260,20 @@ To add cmjs to any HTML page, three things are needed: the CSS, the two mount-po
```html
<!-- Site-level styles (or replace with your own) -->
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/header.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="demo/css/reset.css">
<link rel="stylesheet" href="demo/css/header.css">
<link rel="stylesheet" href="demo/css/layout.css">
<!-- Component styles -->
<link rel="stylesheet" href="cmjs/css/components/card.css">
<link rel="stylesheet" href="cmjs/css/components/article.css">
<link rel="stylesheet" href="cmjs/css/components/content.css">
<link rel="stylesheet" href="cmjs/css/components/pagination.css">
<link rel="stylesheet" href="cmjs/css/components/toc.css">
<link rel="stylesheet" href="css/components/card.css">
<link rel="stylesheet" href="css/components/article.css">
<link rel="stylesheet" href="css/components/content.css">
<link rel="stylesheet" href="css/components/pagination.css">
<link rel="stylesheet" href="css/components/toc.css">
<link rel="stylesheet" href="css/components/author-profile.css">
```
The site-level CSS files (`reset.css`, `header.css`, `layout.css`) are optional — they style the shell around the CMS. If your page already has a layout and reset, you can skip them and only load the component stylesheets. Note that `reset.css` also defines the CSS custom properties (colours, radius) used throughout — if you skip it, define the variables yourself.
The site-level CSS files (`reset.css`, `header.css`, `layout.css`) are optional — they style the shell around the CMS. If your page already has a layout and reset, you can skip them and only load the component stylesheets. Note that `reset.css` also defines the CSS custom properties (colours, radius) used throughout — if you skip it, define the variables yourself. `author-profile.css` is only needed if any of your authors have a profile file (see "Author profiles" above) — safe to skip otherwise.
### 2. Mount points — in `<body>`
@@ -178,30 +282,62 @@ 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.
Optionally add a search form anywhere in the page:
```html
<form id="search-form" role="search">
<input type="search" id="search-input" placeholder="Search articles…" autocomplete="off">
</form>
```
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>`
```html
<script type="module" src="cmjs/js/router.js"></script>
<script type="module" src="js/router.js"></script>
```
`router.js` is the only script tag needed. It loads `marked.min.js` automatically at startup (path configured in `cmjs/js/config.js`).
`router.js` is the only script tag needed. It loads `marked.min.js` automatically at startup (path configured in `js/config.js`).
> **Subdirectory pages:** If the embedding page lives in a subdirectory (e.g. `/blog/index.html`), relative paths like `cmjs/...` will resolve incorrectly. Use root-relative paths with a leading `/` for the CSS links and script tag:
> **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="/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 the `/` prefix.
> 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.
### Search, article list, and tag list as independent pieces
The mount points above don't have to sit next to each other, or even all be present — each is a
plain element the router looks up by `id`, wherever it happens to be in your page. This lets you
scatter search, the article list, and the tag list across a custom layout (search box in the site
header, tag list in a sidebar, articles in the main column) instead of stacking them the way the
demo does.
- **Article list + single article — `#list-view` / `#article-view`.** These two are the only
required pair; the router toggles `hidden` between them and always needs both present somewhere
on the page. This pair is also where **search results** render — there's no separate results
container, so any page using the search box below still needs `#list-view` on it somewhere to
actually show what was searched for.
- **Search box — `#search-form` / `#search-input`.** Fully optional and independent; place it
anywhere (e.g. a site-wide header, not necessarily near the list). Submitting it navigates to
`#search/query`; matches render into `#list-view`.
```html
<form id="search-form" role="search">
<input type="search" id="search-input" placeholder="Search articles…" autocomplete="off">
</form>
```
- **Tag list — `#tag-filters`.** Optional; put a container (a `<nav>` works well) anywhere, e.g. a
sidebar. It's automatically filled in with one link per tag in use (alphabetical, with an
article count) every time a list view renders — no manual updates needed. Clicking a tag
navigates to `#tag/tag-id`, rendering the filtered list into `#list-view`.
```html
<nav id="tag-filters" aria-label="Filter by tag"></nav>
```
- **Year list — `#year-filters`** (optional, not used by this repo's own demo but supported the
same way): a container that gets filled in with one link per year 2026 down to 2009 that has at
least one article, linking to `#category/year-YYYY`. Can appear alongside `#tag-filters` or on
its own.
```html
<nav id="year-filters" aria-label="Filter by year"></nav>
```
Both filter lists are populated by the same function on every list render, so whichever of
`#tag-filters`/`#year-filters` is present just gets filled in — omit either one (or both) and
nothing breaks, the article list and search still work standalone.
### Minimal example
@@ -212,24 +348,24 @@ Optionally add a search form anywhere in the page:
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Site</title>
<link rel="stylesheet" href="cmjs/css/components/card.css">
<link rel="stylesheet" href="cmjs/css/components/article.css">
<link rel="stylesheet" href="cmjs/css/components/content.css">
<link rel="stylesheet" href="cmjs/css/components/pagination.css">
<link rel="stylesheet" href="cmjs/css/components/toc.css">
<link rel="stylesheet" href="css/components/card.css">
<link rel="stylesheet" href="css/components/article.css">
<link rel="stylesheet" href="css/components/content.css">
<link rel="stylesheet" href="css/components/pagination.css">
<link rel="stylesheet" href="css/components/toc.css">
</head>
<body>
<div id="list-view"></div>
<div id="article-view" hidden></div>
<script type="module" src="cmjs/js/router.js"></script>
<script type="module" src="js/router.js"></script>
</body>
</html>
```
## nginx configuration
Point nginx `root` at the repo root. The `autoindex` directive applies recursively to all subdirectories under `/cmjs/articles/`.
Point nginx `root` at the repo root. The `autoindex` directive applies recursively to all subdirectories under `/articles/`.
```nginx
server {
@@ -237,20 +373,134 @@ server {
server_name example.com;
root /var/www/cmjs;
# Serve the SPA
location / {
try_files $uri $uri/ /index.html;
# Serve the demo page
location /demo/ {
try_files $uri $uri/ /demo/index.html;
}
# Enable JSON directory listing for article discovery (applies to all subdirs)
location /cmjs/articles/ {
location /articles/ {
autoindex on;
autoindex_format json;
autoindex_localtime off; # keep mtimes in GMT — the crawler's cache relies on consistent, unambiguous timestamps
}
}
```
> **Note:** `autoindex_format json` requires nginx 1.7.9+. Check with `nginx -v`.
> **Note:** `autoindex_format json` requires nginx 1.7.9+. Check with `nginx -v`. If
> `autoindex_format json` is ever lost (config drift, an older nginx), the crawler falls back
> to parsing nginx's default HTML directory listing instead of breaking outright.
### Pointing articles/ somewhere else
By default the crawler looks for articles at `articles/` next to the rest of cmjs (`ARTICLES_URL`
in `js/config.js`, computed as a sibling of the `js/` folder). Articles don't have to live there —
edit `ARTICLES_URL` in `js/config.js` to point anywhere under the same origin (or a different one,
CORS permitting), e.g.:
```js
export const ARTICLES_URL = new URL('/blog-content/articles/', location.origin).href;
```
Whatever it resolves to needs its own matching `location` block enabling the JSON (or HTML)
autoindex — it doesn't have to be named `articles/` or nested under wherever cmjs itself lives:
```nginx
location /blog-content/articles/ {
autoindex on;
autoindex_format json;
autoindex_localtime off;
}
```
### 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 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:
```nginx
# in http {} or a shared mime block
types {
text/markdown md;
}
# in http {} or the server block above
gzip on;
gzip_types text/markdown text/plain application/json; # json covers the autoindex_format listings
gzip_min_length 256;
gzip_vary on;
```
Pre-compressing with `gzip_static` is possible but needs a `.md.gz` alongside every article, which
is a build step this project deliberately has none of — plain runtime `gzip on` is the better fit
here. Brotli usually compresses text better still, but needs the separate `ngx_brotli` module,
not built into stock nginx — only worth it if you're already building custom nginx modules.
## Article discovery & caching
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
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
article, or (since full-text search needs real content) as many search results as the page being
viewed actually needs. A large site's list is therefore fast to show regardless of how many
articles it has — indexing costs one request per author, full stop, and only ~20 articles' worth
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
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.
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
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.
- **Paused/resumable search** — search needs real text (title, description), unlike the list
view, so it hydrates articles rather than just reading stubs — but only as many as the page
being viewed needs, in the same newest-first order as everything else (`loadSearch()` in
`js/views.js`). It scans stubs in small batches, stops as soon as it's found enough matches
to fill the current page (plus one, just to know whether a next page exists), and resumes
exactly where it left off — no re-checking — if the user pages further into the *same* query.
Paging back needs no scanning at all, since matches only ever accumulate. This keeps a search on
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.
- **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
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
rest of that reasoning — the caching model, the longevity constraints, the HTML fallback — still
applies.
## Running locally
@@ -259,5 +509,6 @@ Using nginx locally (example for Linux):
# Symlink or copy the repo to your nginx webroot, then reload:
sudo nginx -s reload
```
Then open `/demo/` in a browser to preview.
> Python's `http.server` returns HTML directory listings, not JSON, so article discovery won't work with it. Use nginx.
@@ -1,56 +0,0 @@
---
title: Hello World
date: 2026-06-16
description: A sample article to demonstrate the CMS.
image: hello-world-preview.webp
draft: false
---
Welcome to **cmjs** — a minimal client-side CMS powered by Markdown.
## How it works
Articles are plain Markdown files stored in the `articles/` directory. Each file carries its own metadata via YAML frontmatter, similar to [Hugo](https://gohugo.io/):
```yaml
---
title: My Article Title
date: 2026-06-16
description: A short summary shown in the article list.
draft: false
---
```
The app fetches the directory listing from the server, reads frontmatter from each `.md` file, and renders the list. Clicking an article fetches the file and renders the Markdown body client-side using [marked.js](https://marked.js.org/).
## Adding an article
1. Create a new `.md` file in `articles/`
2. Add a frontmatter block at the top
3. Write your content below the closing `---`
No rebuild, no CMS dashboard — the article appears in the list automatically.
## Frontmatter fields
| Field | Required | Description |
|-------|----------|-------------|
| `title` | yes | Display name shown in the list and article header |
| `date` | yes | ISO date (`YYYY-MM-DD`), used for sorting |
| `description` | no | Short summary shown under the title in the list |
| `draft` | no | Set to `true` to hide from the article list |
## Markdown features
Markdown renders fully — headings, lists, **bold**, _italic_, `inline code`, and fenced code blocks:
```javascript
// Frontmatter is parsed with a small built-in regex parser
function parseFrontmatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { meta: {}, content: raw };
// ...
}
```
> Articles are just files. No database, no server-side code, no login.
-7
View File
@@ -1,7 +0,0 @@
---
title: Alice Smith
github: https://github.com/alice
twitter: https://twitter.com/alice
---
Linux enthusiast and Python developer. I cover everything from shell scripting to beginner programming guides.
@@ -1,59 +0,0 @@
---
title: Getting Started with Linux
date: 2026-06-10
description: A beginner's guide to the Linux operating system and its core concepts.
draft: false
---
Linux is a free and open-source operating system kernel first released by Linus Torvalds in 1991. Today it powers everything from smartphones to supercomputers.
## What is a distribution?
A Linux distribution (distro) bundles the kernel with software, a package manager, and a desktop environment. Popular choices include:
- **Ubuntu** — beginner-friendly, large community
- **Fedora** — cutting-edge packages, sponsored by Red Hat
- **Arch Linux** — minimal base, user-assembled
- **Debian** — stability-focused, the base for many other distros
## The terminal
The terminal is your most powerful tool. A few commands to get started:
```bash
# Print current directory
pwd
# List files
ls -lah
# Change directory
cd /home/user
# Copy a file
cp source.txt destination.txt
```
## File system layout
Lorem ipsum dolor sit amet, consectetur adipiscing elit. The Linux file system follows the Filesystem Hierarchy Standard (FHS):
| Directory | Purpose |
|-----------|---------|
| `/` | Root of the entire file system |
| `/home` | User home directories |
| `/etc` | System configuration files |
| `/var` | Variable data (logs, caches) |
| `/usr` | User-installed programs and libraries |
## Package management
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. On Debian/Ubuntu systems, packages are managed with `apt`:
```bash
sudo apt update # refresh package index
sudo apt install vim # install a package
sudo apt upgrade # upgrade all packages
```
> The best way to learn Linux is to use it daily. Break things, fix them, and you'll learn more than any book can teach.
@@ -1,69 +0,0 @@
---
title: Understanding Linux File Permissions
date: 2026-05-22
description: Learn how Linux controls access to files and directories with users, groups, and permission bits.
draft: false
---
Every file and directory in Linux has an owner, a group, and a set of permission bits. Understanding these is essential for keeping a system secure.
## Reading permission strings
Run `ls -l` and you'll see output like this:
```
-rwxr-xr-- 1 alice developers 4096 May 22 09:14 deploy.sh
```
The first column breaks down as:
- `-` — file type (`d` for directory, `l` for symlink)
- `rwx` — owner permissions (read, write, execute)
- `r-x` — group permissions
- `r--` — other (world) permissions
## Changing permissions with chmod
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.
```bash
# Give owner execute permission
chmod u+x script.sh
# Remove write from group and others
chmod go-w file.txt
# Set exact permissions with octal notation
chmod 644 config.ini # rw-r--r--
chmod 755 bin/ # rwxr-xr-x
```
## Changing ownership with chown
```bash
# Change owner
chown alice file.txt
# Change owner and group
chown alice:developers project/
# Recursive change
chown -R www-data:www-data /var/www/html
```
## Special permission bits
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris:
| Bit | Name | Effect on files | Effect on directories |
|-----|------|-----------------|-----------------------|
| `4` | setuid | Run as file owner | — |
| `2` | setgid | Run as file group | New files inherit group |
| `1` | sticky | — | Only owner can delete |
```bash
# Set sticky bit on shared directory
chmod +t /tmp/shared
```
> When in doubt, use the least privilege necessary. A file that only needs to be read should not be executable.
@@ -1,79 +0,0 @@
---
title: Python for Beginners
date: 2026-06-01
description: An introduction to Python programming with practical examples and clear explanations.
draft: false
---
Python is a high-level, general-purpose programming language known for its readable syntax and large standard library. It's a great first language.
## Variables and types
Python is dynamically typed — you don't declare types explicitly:
```python
name = "Alice"
age = 30
height = 1.72
is_admin = True
```
## Control flow
Lorem ipsum dolor sit amet, consectetur adipiscing elit:
```python
# Conditionals
if age >= 18:
print("Adult")
elif age >= 13:
print("Teenager")
else:
print("Child")
# Loops
for i in range(5):
print(i)
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
print(fruit)
```
## Functions
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua:
```python
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(greet("Alice")) # Hello, Alice!
print(greet("Bob", "Hi")) # Hi, Bob!
```
## Lists and dictionaries
```python
# List — ordered, mutable
numbers = [3, 1, 4, 1, 5, 9]
numbers.append(2)
numbers.sort()
# Dictionary — key-value pairs
user = {
"name": "Alice",
"age": 30,
"roles": ["admin", "editor"]
}
print(user["name"]) # Alice
```
## Why Python?
- Clean, readable syntax
- Huge standard library
- Active ecosystem (pip, PyPI)
- Used in web, data science, automation, AI
> Write code that is easy to read. You'll spend far more time reading code than writing it.
-7
View File
@@ -1,7 +0,0 @@
---
title: Bob Johnson
github: https://github.com/bob
website: https://bob.example.com
---
DevOps engineer with a passion for containers, automation, and clean infrastructure. I write about Docker, nginx, and everything in between.
@@ -1,92 +0,0 @@
---
title: Introduction to Docker
date: 2026-06-14
description: Understand containers, images, and how Docker simplifies application deployment.
draft: false
---
Docker is a platform for building, shipping, and running applications in containers. A container packages your code together with its dependencies, so it runs the same everywhere.
## Containers vs virtual machines
| | Container | Virtual Machine |
|---|-----------|-----------------|
| Start time | Milliseconds | Minutes |
| Size | Megabytes | Gigabytes |
| Isolation | Process-level | Hardware-level |
| Overhead | Very low | Higher |
## Core concepts
- **Image** — a read-only template (like a class)
- **Container** — a running instance of an image (like an object)
- **Registry** — a storage and distribution system for images (Docker Hub)
- **Dockerfile** — instructions for building an image
## Basic commands
```bash
# Pull an image
docker pull nginx:alpine
# Run a container
docker run -d -p 8080:80 --name my-nginx nginx:alpine
# List running containers
docker ps
# Stop and remove
docker stop my-nginx
docker rm my-nginx
```
## Writing a Dockerfile
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor:
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
```
Build and run it:
```bash
docker build -t my-app:latest .
docker run -d -p 3000:3000 my-app:latest
```
## Docker Compose
Ut enim ad minim veniam for multi-container applications:
```yaml
services:
web:
build: .
ports:
- "3000:3000"
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
```
```bash
docker compose up -d
docker compose logs -f
docker compose down
```
> Containers solve "works on my machine" by making your machine part of the deliverable.
@@ -1,94 +0,0 @@
---
title: Setting Up an nginx Reverse Proxy
date: 2026-05-30
description: Use nginx as a reverse proxy to route traffic to multiple backend services on a single server.
draft: false
---
A reverse proxy sits in front of your backend services, forwarding client requests to the appropriate server. nginx excels at this role — it's fast, stable, and highly configurable.
## Why use a reverse proxy?
- Route multiple domains/paths to different backends
- Terminate TLS in one place
- Add rate limiting, caching, and access control
- Hide backend server details from clients
## Basic reverse proxy configuration
```nginx
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
## Proxying multiple services
Lorem ipsum dolor sit amet, consectetur adipiscing elit:
```nginx
server {
listen 80;
server_name example.com;
# Main app
location / {
proxy_pass http://localhost:3000;
}
# API on a subpath
location /api/ {
proxy_pass http://localhost:4000/;
}
# Static files served directly
location /static/ {
root /var/www;
expires 30d;
}
}
```
## Adding TLS with Certbot
Sed do eiusmod tempor incididunt ut labore:
```bash
# Install Certbot
sudo apt install certbot python3-certbot-nginx
# Obtain and install certificate
sudo certbot --nginx -d example.com -d www.example.com
# Certbot auto-renews via systemd timer — verify it
sudo systemctl status certbot.timer
```
## Upstream blocks for load balancing
```nginx
upstream backend {
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
keepalive 32;
}
server {
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
```
> Always set `proxy_set_header X-Forwarded-For` so your application knows the real client IP, not the proxy address.
@@ -1,85 +0,0 @@
---
title: Bash Scripting Basics
date: 2026-06-08
description: Write your first shell scripts to automate repetitive tasks on Linux and macOS.
draft: false
---
A shell script is a plain text file containing a sequence of commands. The shell reads and executes them in order — a simple but powerful form of automation.
## Your first script
```bash
#!/usr/bin/env bash
# The shebang line above tells the OS which interpreter to use
echo "Hello, world!"
```
Save it as `hello.sh`, make it executable, and run it:
```bash
chmod +x hello.sh
./hello.sh
```
## Variables
Lorem ipsum dolor sit amet, consectetur adipiscing elit:
```bash
NAME="Bob"
echo "Hello, $NAME"
# Command substitution
TODAY=$(date +%Y-%m-%d)
echo "Today is $TODAY"
```
## Conditionals and loops
```bash
# If / else
if [ -f "/etc/hosts" ]; then
echo "hosts file exists"
else
echo "hosts file missing"
fi
# For loop over files
for file in *.log; do
echo "Processing $file"
gzip "$file"
done
# While loop
COUNT=0
while [ $COUNT -lt 5 ]; do
echo "Count: $COUNT"
COUNT=$((COUNT + 1))
done
```
## Functions
Ut enim ad minim veniam, quis nostrud exercitation:
```bash
backup() {
local src="$1"
local dst="$2"
cp -r "$src" "$dst/$(basename $src)-$(date +%Y%m%d)"
echo "Backed up $src to $dst"
}
backup /etc/nginx /var/backups
```
## Practical tips
- Always quote variables: `"$VAR"` not `$VAR`
- Use `set -e` to exit on error
- Use `set -u` to catch undefined variables
- Prefer `[[ ]]` over `[ ]` for conditionals in bash
> Automate anything you do more than twice. Your future self will thank you.
-11
View File
@@ -1,11 +0,0 @@
// All paths are derived from this module's own location via import.meta.url,
// so they resolve correctly regardless of where the embedding HTML page lives.
// Base URL of the cmjs/ directory (e.g. https://example.com/cmjs/)
export const BASE_URL = new URL('../', import.meta.url).href;
// Path to the marked.js vendor script (lives one level above cmjs/)
export const MARKED_URL = new URL('../../js/marked.min.js', import.meta.url).href;
// Base URL of the pictures directory — prefix article image filenames with this
export const PICTURES_URL = new URL('../pictures/', import.meta.url).href;
-98
View File
@@ -1,98 +0,0 @@
import { parseFrontmatter } from './frontmatter.js';
import { BASE_URL } from './config.js';
// Fetch a URL and parse the response as JSON, throwing on non-OK status.
export async function fetchJson(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
return res.json();
}
// ---------------------------------------------------------------------------
// Article discovery
// Traverses the 3-level directory tree: author → category → article files.
// Results are cached after the first load.
// ---------------------------------------------------------------------------
let articleCache = null;
export async function discoverArticles() {
if (articleCache) return articleCache;
// Level 1: author directories
const authorEntries = await fetchJson(`${BASE_URL}articles/`);
const authorDirs = authorEntries.filter(e => e.type === 'directory');
// Level 2: category directories (parallel across all authors)
const categoryLists = await Promise.all(
authorDirs.map(async author => {
const entries = await fetchJson(`${BASE_URL}articles/${author.name}/`);
return entries
.filter(e => e.type === 'directory')
.map(cat => ({ author: author.name, category: cat.name }));
})
);
const allCategories = categoryLists.flat();
// Level 3: .md files (parallel across all categories)
const fileLists = await Promise.all(
allCategories.map(async ({ author, category }) => {
const entries = await fetchJson(`${BASE_URL}articles/${author}/${category}/`);
return entries
.filter(e => e.type === 'file' && e.name.endsWith('.md'))
.map(f => ({ author, category, name: f.name }));
})
);
const allFiles = fileLists.flat();
// Level 4: fetch frontmatter and compute reading time for each article (parallel)
const articles = await Promise.all(
allFiles.map(async ({ author, category, name }) => {
const slug = name.replace(/\.md$/, '');
const path = `${BASE_URL}articles/${author}/${category}/${name}`;
const hash = `${author}/${category}/${slug}`;
try {
const res = await fetch(path);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw = await res.text();
const { meta, content } = parseFrontmatter(raw);
const words = content.trim().split(/\s+/).filter(Boolean).length;
const readingTime = Math.max(1, Math.round(words / 200));
return { slug, path, hash, author, category, readingTime, ...meta };
} catch {
return { slug, path, hash, author, category, title: slug };
}
})
);
// Filter drafts — treat 'true', 'yes', '1' (case-insensitive) as draft
// Sort newest-first; articles without a date fall to the end
articleCache = articles
.filter(a => {
const d = String(a.draft ?? '').toLowerCase().trim();
return d !== 'true' && d !== 'yes' && d !== '1';
})
.sort((a, b) => {
if (!a.date && !b.date) return 0;
if (!a.date) return 1;
if (!b.date) return -1;
return b.date.localeCompare(a.date);
});
return articleCache;
}
// ---------------------------------------------------------------------------
// Author profile
// Tries to fetch articles/{author}/{author}.md — returns parsed profile or null.
// No cache; called at most once per author view navigation.
// ---------------------------------------------------------------------------
export async function fetchAuthorProfile(author) {
try {
const res = await fetch(`${BASE_URL}articles/${author}/${author}.md`);
if (!res.ok) return null;
const { meta, content } = parseFrontmatter(await res.text());
return { author, ...meta, content };
} catch {
return null;
}
}
-22
View File
@@ -1,22 +0,0 @@
// Parses Hugo-style YAML frontmatter from a raw markdown string.
// Returns { meta: { key: value, ... }, content: '...' }
export function parseFrontmatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { meta: {}, content: raw };
const meta = {};
match[1].split('\n').forEach(line => {
const colon = line.indexOf(':');
if (colon === -1) return;
const key = line.slice(0, colon).trim();
const rawVal = line.slice(colon + 1).trim();
// Strip surrounding quotes only when they form a matched pair
const val = (rawVal.startsWith('"') && rawVal.endsWith('"')) ||
(rawVal.startsWith("'") && rawVal.endsWith("'"))
? rawVal.slice(1, -1)
: rawVal;
meta[key] = val;
});
return { meta, content: match[2] };
}
-78
View File
@@ -1,78 +0,0 @@
// Entry point — loads templates then dispatches routes on hash changes.
//
// Hash routing scheme:
// 0 segments → all articles, page 1
// first = "search" → search results (#search/query)
// 2 segments, first = "page" → all articles, page N (#page/2)
// 2 segments, first = "author" → author filter, page 1 (#author/alice)
// 2 segments, first = "category" → category filter, page 1 (#category/linux)
// 3 segments → single article (#author/category/slug)
// 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)
import { MARKED_URL } from './config.js';
import { loadTemplates } from './templates.js';
import { loadArticleList, loadAuthorView, loadCategoryView, loadArticle, loadSearch } from './views.js';
// Dynamically inject marked.js as a plain global script before the app starts.
function loadScript(url) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.onload = resolve;
s.onerror = () => reject(new Error(`Failed to load ${url}`));
document.head.appendChild(s);
});
}
// Parse a page-number segment — returns 1 for any non-positive-integer value.
const parsePage = s => { const n = +s; return Number.isInteger(n) && n >= 1 ? n : 1; };
function router() {
const hash = location.hash.slice(1); // strip leading #
const parts = hash.split('/').filter(Boolean);
// Search: decode query and guard against empty string (#search or #search/)
if (parts[0] === 'search') {
const q = decodeURIComponent(parts.slice(1).join('/'));
if (q) loadSearch(q); 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 === 3) loadArticle(parts.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 loadArticleList();
}
// All routing — initial and hashchange — waits for this before running.
let ready;
function init() {
// Wire up search form submission → navigate to #search/query
document.getElementById('search-form')?.addEventListener('submit', e => {
e.preventDefault();
const q = document.getElementById('search-input')?.value?.trim();
if (q) location.hash = `#search/${encodeURIComponent(q)}`;
});
ready = loadScript(MARKED_URL).then(loadTemplates);
// Show a top-level error if dependencies fail to load
ready.catch(err => {
document.body.innerHTML = `<p class="error" style="padding:2rem">Failed to initialise: ${err.message}</p>`;
});
ready.then(router).catch(() => {});
}
// readyState guard in case DOMContentLoaded already fired
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.addEventListener('hashchange', () => ready.then(router).catch(() => {}));
-469
View File
@@ -1,469 +0,0 @@
import { templates } from './templates.js';
import { discoverArticles, fetchAuthorProfile } from './discovery.js';
import { parseFrontmatter } from './frontmatter.js';
import { BASE_URL, PICTURES_URL } from './config.js';
// marked is loaded as a plain global from marked.min.js
const PAGE_SIZE = 20;
// ---------------------------------------------------------------------------
// View helpers — toggle which panel is visible
// ---------------------------------------------------------------------------
export function showListView() {
document.getElementById('list-view').hidden = false;
document.getElementById('article-view').hidden = true;
document.title = 'cmjs';
}
export function showArticleView() {
document.getElementById('list-view').hidden = true;
document.getElementById('article-view').hidden = false;
}
// Set a data-field element's value, or remove it if there is no value.
function setField(el, value, apply) {
if (value != null && value !== '') apply(el, value);
else el.remove();
}
// Clear the search box when navigating away from a search view.
const clearSearchInput = () => {
const el = document.getElementById('search-input');
if (el) el.value = '';
};
// ---------------------------------------------------------------------------
// Pagination nav builder
// Builds hash links that match the router's URL scheme:
// no filter → # (page 1) or #page/N
// author → #author/name (page 1) or #author/name/page/N
// category → #category/name (page 1) or #category/name/page/N
// ---------------------------------------------------------------------------
function pageHash(page, filter) {
if (!filter) return page === 1 ? '#' : `#page/${page}`;
const base = `${filter.type}/${filter.value}`;
return page === 1 ? `#${base}` : `#${base}/page/${page}`;
}
function buildPaginationNav(page, totalPages, filter) {
const nav = document.createElement('nav');
nav.className = 'pagination';
const prev = document.createElement('a');
prev.textContent = '← Previous';
if (page > 1) {
prev.href = pageHash(page - 1, filter);
} else {
prev.classList.add('disabled');
prev.setAttribute('aria-disabled', 'true');
}
const info = document.createElement('span');
info.className = 'page-info';
info.textContent = `Page ${page} of ${totalPages}`;
const next = document.createElement('a');
next.textContent = 'Next →';
if (page < totalPages) {
next.href = pageHash(page + 1, filter);
} else {
next.classList.add('disabled');
next.setAttribute('aria-disabled', 'true');
}
nav.appendChild(prev);
nav.appendChild(info);
nav.appendChild(next);
return nav;
}
// ---------------------------------------------------------------------------
// Card rendering — shared by loadArticleList and loadSearch
// ---------------------------------------------------------------------------
function renderCard(a) {
const el = document.importNode(templates.card, true);
const f = name => el.querySelector(`[data-field="${name}"]`);
setField(f('image'), a.image, (el, v) => {
el.src = `${PICTURES_URL}${v}`;
el.alt = a.title || a.slug;
});
f('link').href = `#${a.hash}`;
f('title').textContent = a.title || a.slug;
setField(f('date'), a.date, (el, v) => {
el.dateTime = v;
el.textContent = v;
});
setField(f('description'), a.description, (el, v) => { el.textContent = v; });
setField(f('reading-time'), a.readingTime, (el, v) => {
el.textContent = `${v} min read`;
});
const authorLink = f('author');
authorLink.textContent = a.author;
authorLink.href = `#author/${a.author}`;
const catLink = f('category');
catLink.textContent = a.category;
catLink.href = `#category/${a.category}`;
return el;
}
// ---------------------------------------------------------------------------
// Article list — with optional author or category filter and pagination
// filter: { type: 'author'|'category', value: string } or null for all
// ---------------------------------------------------------------------------
export async function loadArticleList(filter, page = 1) {
showListView();
window.scrollTo(0, 0);
const listView = document.getElementById('list-view');
listView.innerHTML = '<p class="loading">Loading articles…</p>';
clearSearchInput();
let articles;
try {
articles = await discoverArticles();
} catch (err) {
listView.innerHTML = `<p class="error">Could not load articles: ${err.message}</p>`;
return;
}
// Apply optional author or category filter (case-insensitive)
if (filter) {
articles = articles.filter(
a => a[filter.type] && a[filter.type].toLowerCase() === filter.value.toLowerCase()
);
}
if (articles.length === 0) {
listView.innerHTML = '<p class="empty">No articles found.</p>';
return;
}
// Pagination — clamp page to valid range
const totalPages = Math.ceil(articles.length / PAGE_SIZE);
page = Math.max(1, Math.min(page, totalPages));
const visible = articles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
listView.innerHTML = '';
// Filter heading
if (filter) {
const h = document.createElement('h2');
h.className = 'category-heading';
h.textContent = `${filter.type === 'author' ? 'Author' : 'Category'}: ${filter.value}`;
listView.appendChild(h);
}
visible.forEach(a => listView.appendChild(renderCard(a)));
if (totalPages > 1) {
listView.appendChild(buildPaginationNav(page, totalPages, filter));
}
}
// ---------------------------------------------------------------------------
// Author profile card renderer
// Known social fields rendered as labelled pill links.
// ---------------------------------------------------------------------------
const SOCIAL_LABELS = {
github: 'GitHub',
twitter: 'Twitter',
linkedin: 'LinkedIn',
mastodon: 'Mastodon',
website: 'Website',
};
function renderAuthorProfile(profile) {
const el = document.importNode(templates.authorProfile, true);
const f = name => el.querySelector(`[data-field="${name}"]`);
setField(f('avatar'), profile.avatar, (el, v) => {
el.src = `${PICTURES_URL}${v}`;
el.alt = profile.title || profile.author;
});
f('name').textContent = profile.title || profile.author;
const bioEl = f('bio');
if (profile.content && profile.content.trim()) {
// marked output is trusted (author-controlled markdown), innerHTML is intentional
bioEl.innerHTML = marked.parse(profile.content);
} else {
bioEl.remove();
}
const socialEl = f('social');
const links = Object.entries(SOCIAL_LABELS)
.filter(([key]) => profile[key])
.map(([key, label]) => {
const a = document.createElement('a');
a.href = profile[key];
a.textContent = label;
a.target = '_blank';
a.rel = 'noopener noreferrer';
return a;
});
if (links.length > 0) {
links.forEach(a => socialEl.appendChild(a));
} else {
socialEl.remove();
}
return el;
}
// ---------------------------------------------------------------------------
// Author view — profile card (page 1 only) + filtered article list
// ---------------------------------------------------------------------------
export async function loadAuthorView(author, page = 1) {
showListView();
window.scrollTo(0, 0);
const listView = document.getElementById('list-view');
listView.innerHTML = '<p class="loading">Loading…</p>';
clearSearchInput();
let articles, profile;
try {
[articles, profile] = await Promise.all([
discoverArticles(),
fetchAuthorProfile(author),
]);
} catch (err) {
listView.innerHTML = `<p class="error">Could not load articles: ${err.message}</p>`;
return;
}
articles = articles.filter(
a => a.author.toLowerCase() === author.toLowerCase()
);
listView.innerHTML = '';
// Profile card — page 1 only
if (page === 1 && profile) {
listView.appendChild(renderAuthorProfile(profile));
}
// Author heading
const h = document.createElement('h2');
h.className = 'category-heading';
h.textContent = `Author: ${author}`;
listView.appendChild(h);
if (articles.length === 0) {
const empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = 'No articles found.';
listView.appendChild(empty);
return;
}
const totalPages = Math.ceil(articles.length / PAGE_SIZE);
page = Math.max(1, Math.min(page, totalPages));
const visible = articles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
visible.forEach(a => listView.appendChild(renderCard(a)));
if (totalPages > 1) {
listView.appendChild(buildPaginationNav(page, totalPages, { type: 'author', value: author }));
}
}
export function loadCategoryView(category, page = 1) {
return loadArticleList({ type: 'category', value: category }, page);
}
// ---------------------------------------------------------------------------
// Search — filters all articles in-memory across title, description, author, category
// ---------------------------------------------------------------------------
export async function loadSearch(query) {
showListView();
window.scrollTo(0, 0);
const listView = document.getElementById('list-view');
listView.innerHTML = '<p class="loading">Searching…</p>';
// Populate the search box with the active query
const searchInput = document.getElementById('search-input');
if (searchInput) searchInput.value = query;
let articles;
try {
articles = await discoverArticles();
} catch (err) {
listView.innerHTML = `<p class="error">Could not load articles: ${err.message}</p>`;
return;
}
const q = query.toLowerCase();
const results = articles.filter(a =>
(a.title || '').toLowerCase().includes(q) ||
(a.description || '').toLowerCase().includes(q) ||
a.author.toLowerCase().includes(q) ||
a.category.toLowerCase().includes(q)
);
listView.innerHTML = '';
const heading = document.createElement('h2');
heading.className = 'category-heading';
heading.textContent = `Search: "${query}"`;
listView.appendChild(heading);
if (results.length === 0) {
const empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = 'No articles match your search.';
listView.appendChild(empty);
return;
}
results.forEach(a => listView.appendChild(renderCard(a)));
}
// ---------------------------------------------------------------------------
// Table of contents — built from h2/h3 headings in the rendered content.
// Only generated when there are 3 or more headings.
// ---------------------------------------------------------------------------
function buildTOC(contentEl) {
const headings = [...contentEl.querySelectorAll('h2, h3')];
if (headings.length < 3) return null;
// Assign unique IDs so anchor links work; append -2/-3 on collisions
const seen = new Map();
headings.forEach(h => {
if (!h.id) {
const base = h.textContent.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
const count = (seen.get(base) ?? 0) + 1;
seen.set(base, count);
h.id = count === 1 ? base : `${base}-${count}`;
}
});
const nav = document.createElement('nav');
nav.className = 'toc';
const title = document.createElement('p');
title.className = 'toc-title';
title.textContent = 'Contents';
nav.appendChild(title);
const ol = document.createElement('ol');
ol.className = 'toc-list';
headings.forEach(h => {
const li = document.createElement('li');
if (h.tagName === 'H3') li.classList.add('toc-h3');
const a = document.createElement('a');
a.href = `#${h.id}`;
a.textContent = h.textContent;
li.appendChild(a);
ol.appendChild(li);
});
nav.appendChild(ol);
return nav;
}
// ---------------------------------------------------------------------------
// Single article view
// hash is "author/category/slug"; fetch path is "BASE_URL/articles/author/category/slug.md"
// ---------------------------------------------------------------------------
export async function loadArticle(hash) {
showArticleView();
window.scrollTo(0, 0);
const articleView = document.getElementById('article-view');
articleView.innerHTML = '<p class="loading">Loading…</p>';
clearSearchInput();
const [author, category] = hash.split('/');
// Fetch article and author profile in parallel (author is known from hash)
let raw, profile;
try {
[raw, profile] = await Promise.all([
fetch(`${BASE_URL}articles/${hash}.md`).then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
}),
fetchAuthorProfile(author),
]);
} catch (err) {
articleView.innerHTML = `<p class="error">Could not load article: ${err.message}</p>`;
return;
}
const { meta, content } = parseFrontmatter(raw);
// Reading time computed from the raw content
const words = content.trim().split(/\s+/).filter(Boolean).length;
const readingTime = Math.max(1, Math.round(words / 200));
document.title = meta.title ? `${meta.title} — cmjs` : 'cmjs';
const el = document.importNode(templates.article, true);
const f = name => el.querySelector(`[data-field="${name}"]`);
f('title').textContent = meta.title || hash;
setField(f('date'), meta.date, (el, v) => {
el.dateTime = v;
el.textContent = v;
});
setField(f('description'), meta.description, (el, v) => { el.textContent = v; });
setField(f('reading-time'), readingTime, (el, v) => {
el.textContent = `${v} min read`;
});
// Hero image is inside <figure> — remove the whole figure when there's no image
const imgEl = f('image');
if (meta.image) {
imgEl.src = `${PICTURES_URL}${meta.image}`;
imgEl.alt = meta.title || hash;
} else {
imgEl.closest('figure')?.remove();
}
const authorLink = f('author');
authorLink.textContent = author;
authorLink.href = `#author/${author}`;
const catLink = f('category');
catLink.textContent = category;
catLink.href = `#category/${category}`;
// Render markdown then build TOC from the resulting headings
const contentEl = f('content');
// marked output is trusted (author-controlled markdown), innerHTML is intentional
contentEl.innerHTML = marked.parse(content);
const toc = buildTOC(contentEl);
if (toc) contentEl.insertBefore(toc, contentEl.firstChild);
// Back link above the article
const backLink = document.createElement('a');
backLink.href = '#';
backLink.className = 'back-link';
backLink.textContent = '← Back to articles';
articleView.innerHTML = '';
articleView.appendChild(backLink);
articleView.appendChild(el);
if (profile) {
const divider = document.createElement('hr');
divider.className = 'article-divider';
articleView.appendChild(divider);
articleView.appendChild(renderAuthorProfile(profile));
}
}
View File
Whitespace-only changes.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

@@ -68,6 +68,7 @@
.article-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.85rem;
}
File renamed without changes.
@@ -2,36 +2,53 @@
Article list cards
------------------------------------------------------------------------- */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden; /* clip card-image to rounded corners */
margin-bottom: 1rem;
transition: border-color 0.15s, box-shadow 0.15s;
display: grid;
grid-template-columns: minmax(220px, 34%) minmax(0, 1fr);
gap: 1.75rem;
padding: 1.1rem 0 1.75rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1.75rem;
transition: border-color 0.15s;
}
.card:hover {
border-color: var(--color-text-muted);
box-shadow: var(--shadow-card);
border-color: var(--color-accent);
}
.card--no-image { grid-template-columns: 1fr; }
.card-media {
display: block;
align-self: start;
overflow: hidden;
aspect-ratio: 16 / 10;
background: var(--color-code-bg);
}
/* Preview thumbnail — shown only when article has an image: field */
.card-image {
display: block;
width: 100%;
height: 200px;
height: 100%;
object-fit: cover;
transition: transform 0.25s ease;
}
.card:hover .card-image { transform: scale(1.025); }
.card-media:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
}
/* Text content wrapper */
.card-body {
padding: 1.25rem 1.5rem;
padding: 0.1rem 0 0;
}
.card h2 {
margin: 0 0 0.2rem;
font-size: 1.15rem;
font-weight: 600;
margin: 0 0 0.35rem;
font-size: clamp(1.35rem, 2.2vw, 1.8rem);
font-weight: 500;
}
.card h2 a {
@@ -67,20 +84,26 @@
}
.card p {
margin: 0 0 0.6rem;
margin: 1rem 0 0.8rem;
color: var(--color-text-soft);
font-size: 0.95rem;
font-size: 1rem;
line-height: 1.65;
}
/* Author + category row at the bottom of each card */
.card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.82rem;
}
.tag-list {
display: contents;
}
/* Shared author link — used in cards and article headers */
.author-link {
color: var(--color-text-muted);
@@ -120,6 +143,12 @@
}
@media (max-width: 640px) {
.card-image { height: 150px; }
.card-body { padding: 1rem; }
.card {
grid-template-columns: 1fr;
gap: 0.9rem;
padding-top: 0;
}
.card-media { aspect-ratio: 16 / 9; }
.card-body { padding: 0; }
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
-28
View File
@@ -1,28 +0,0 @@
/* Main content wrapper */
main {
max-width: 720px;
margin: 2rem auto;
padding: 0 1.5rem;
}
@media (max-width: 640px) {
main {
margin: 1rem auto;
padding: 0 1rem;
}
}
/* State messages (loading / error / empty) */
.loading {
color: var(--color-text-muted);
font-style: italic;
}
.error {
color: var(--color-error);
}
.empty {
color: var(--color-text-muted);
font-style: italic;
}
+22 -4
View File
@@ -2,14 +2,14 @@
body > header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 0.8rem 1.5rem;
padding: 1rem 2rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
#site-title {
font-size: 1.2rem;
font-size: 1.35rem;
font-weight: 700;
color: var(--color-text);
text-decoration: none;
@@ -25,17 +25,35 @@ body > header {
#search-form {
flex: 1;
max-width: 360px;
display: flex;
gap: 0.5rem;
}
#search-input {
width: 100%;
padding: 0.35rem 0.75rem;
padding: 0.55rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
min-width: 0;
}
#search-form button {
border: 1px solid var(--color-accent);
border-radius: var(--radius-sm);
padding: 0.55rem 0.9rem;
background: var(--color-accent);
color: #fff;
font: inherit;
cursor: pointer;
}
#search-form button:hover,
#search-form button:focus-visible {
filter: brightness(1.12);
}
#search-input::placeholder {
@@ -55,7 +73,7 @@ body > header {
body > header {
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.75rem 1rem;
padding: 0.8rem 1rem;
}
#search-form {
+120
View File
@@ -0,0 +1,120 @@
/* Main content wrapper and blog filter rail */
main.blog-shell {
max-width: 1180px;
margin: 3rem auto 5rem;
padding: 0 2rem;
display: grid;
grid-template-columns: minmax(0, 1fr) 270px;
gap: 4rem;
align-items: start;
}
.blog-content { min-width: 0; }
.blog-sidebar {
border: 1px solid var(--color-border);
padding: 1.5rem 1.35rem;
position: sticky;
top: 1.5rem;
background: color-mix(in srgb, var(--color-bg) 92%, white 8%);
}
.filter-section + .filter-section {
border-top: 1px solid var(--color-border);
margin-top: 1.5rem;
padding-top: 1.5rem;
}
.filter-section h2 {
color: var(--color-text-soft);
font-size: 1.05rem;
font-weight: 500;
letter-spacing: 0.08em;
margin: 0 0 1rem;
}
.filter-list {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.filter-link {
display: block;
padding: 0.35rem 0.5rem;
color: var(--color-text-muted);
text-decoration: none;
border-left: 2px solid transparent;
}
.filter-link:hover,
.filter-link:focus-visible,
.filter-link.is-active {
color: var(--color-accent);
border-left-color: var(--color-accent);
}
.filter-count {
color: var(--color-text-muted);
font-size: 0.8em;
margin-left: 0.3rem;
}
@media (max-width: 900px) {
main.blog-shell {
margin-top: 2rem;
grid-template-columns: 1fr;
gap: 2rem;
}
.blog-sidebar {
position: static;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
}
.filter-section + .filter-section {
border-top: 0;
border-left: 1px solid var(--color-border);
margin-top: 0;
padding-top: 0;
padding-left: 1.5rem;
}
}
@media (max-width: 640px) {
main.blog-shell {
margin: 1.5rem auto 3rem;
padding: 0 1rem;
}
.blog-sidebar {
display: block;
}
.filter-section + .filter-section {
border-left: 0;
border-top: 1px solid var(--color-border);
margin-top: 1.5rem;
padding-top: 1.5rem;
padding-left: 0;
}
}
/* State messages (loading / error / empty) */
.loading {
color: var(--color-text-muted);
font-style: italic;
}
.error {
color: var(--color-error);
}
.empty {
color: var(--color-text-muted);
font-style: italic;
}
+17
View File
@@ -50,6 +50,23 @@ body {
padding: 0;
}
/* The demo blog follows its own restrained night-sky palette,
independently of the visitor's operating-system theme. */
body.blog-page {
--color-bg: #050506;
--color-surface: #101114;
--color-border: #292b31;
--color-text: #f4f1ed;
--color-text-soft: #c6c4c2;
--color-text-muted: #92959d;
--color-accent: #e25555;
--color-tag-bg: #191b21;
--color-tag-border: #363941;
--color-code-bg: #131519;
--color-error: #ff7777;
--shadow-card: 0 12px 35px rgba(0, 0, 0, 0.28);
}
/* Global keyboard-navigation focus indicator */
:focus-visible {
outline: 2px solid var(--color-accent);
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1">
<title>Example Blog</title>
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/header.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="../css/components/card.css">
<link rel="stylesheet" href="../css/components/article.css">
<link rel="stylesheet" href="../css/components/content.css">
<link rel="stylesheet" href="../css/components/pagination.css">
<link rel="stylesheet" href="../css/components/toc.css">
<link rel="stylesheet" href="../css/components/author-profile.css">
</head>
<body class="blog-page">
<header>
<!-- Site title always links back to the article list -->
<a href="#" id="site-title">Example Blog</a>
<!-- Search form — submits to #search/query via router.js -->
<form id="search-form" role="search">
<input type="search" id="search-input" placeholder="Search articles…" autocomplete="off" aria-label="Search articles">
<button type="submit">Search</button>
</form>
</header>
<main class="blog-shell">
<section class="blog-content" aria-label="Articles">
<!-- Article list: shown when no hash is present -->
<div id="list-view"></div>
<!-- Article view: shown when a slug hash is present -->
<div id="article-view" hidden></div>
</section>
<aside class="blog-sidebar" aria-label="Blog filters">
<section class="filter-section">
<h2>Tags</h2>
<nav id="tag-filters" aria-label="Filter by tag"></nav>
</section>
</aside>
</main>
<script type="module" src="../js/router.js"></script>
</body>
</html>
-38
View File
@@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cmjs</title>
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/header.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="cmjs/css/components/card.css">
<link rel="stylesheet" href="cmjs/css/components/article.css">
<link rel="stylesheet" href="cmjs/css/components/content.css">
<link rel="stylesheet" href="cmjs/css/components/pagination.css">
<link rel="stylesheet" href="cmjs/css/components/toc.css">
<link rel="stylesheet" href="cmjs/css/components/author-profile.css">
</head>
<body>
<header>
<!-- Site title always links back to the article list -->
<a href="#" id="site-title">cmjs</a>
<!-- Search form — submits to #search/query via router.js -->
<form id="search-form" role="search">
<input type="search" id="search-input" placeholder="Search articles…" autocomplete="off">
</form>
</header>
<main>
<!-- Article list: shown when no hash is present -->
<div id="list-view"></div>
<!-- Article view: shown when a slug hash is present -->
<div id="article-view" hidden></div>
</main>
<script type="module" src="cmjs/js/router.js"></script>
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
// 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 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
// cached entry immediately, instead of stale-shaped records lingering until
// each file's mtime happens to change — which, on a "deploy and forget"
// site, could be years.
const CACHE_SCHEMA_VERSION = 3;
const DB_NAME = `cmjs-cache-v${CACHE_SCHEMA_VERSION}`;
const STORE_NAME = 'articleFiles';
let cacheAvailable = true;
let dbPromise = null;
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, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE_NAME, { keyPath: 'path' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () => reject(new Error('IndexedDB open blocked'));
}).catch(error => { cacheAvailable = false; throw error; });
}
return dbPromise;
}
// 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(mode, makeRequest) {
if (!cacheAvailable) return undefined;
let db;
try {
db = await openDb();
} catch {
return undefined; // openDb() itself already flipped cacheAvailable
}
try {
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);
request.onerror = () => reject(request.error);
});
} catch {
// A single request/transaction failing (e.g. one write hitting a
// storage quota) doesn't mean IndexedDB itself is gone — only
// openDb() failing disables the cache wholesale. Leave
// cacheAvailable as-is so later calls can still try.
return undefined;
}
}
// Returns every cached { path, mtime, article } record as a Map keyed by
// path — one bulk read instead of a separate IndexedDB round-trip per
// article file, which matters once there are hundreds of them: discovery.js
// 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('readonly', store => store.getAll());
return new Map((records || []).map(record => [record.path, record]));
}
export async function setCacheEntry(path, entry) {
await runRequest('readwrite', store => store.put({ ...entry, path }));
}
// Removes any cached entry whose path is no longer in the live directory
// listing (renamed/deleted slugs), so the cache doesn't grow unbounded.
export async function pruneCache(validPaths) {
if (!cacheAvailable) return;
const valid = new Set(validPaths);
let db;
try {
db = await openDb();
} catch {
return; // openDb() itself already flipped cacheAvailable
}
try {
const store = db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME);
await new Promise((resolve, reject) => {
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = event => {
const cursor = event.target.result;
if (!cursor) { resolve(); return; }
if (!valid.has(cursor.value.path)) cursor.delete();
cursor.continue();
};
cursorRequest.onerror = () => reject(cursorRequest.error);
});
} catch {
// Same reasoning as runRequest() above — a failed prune pass doesn't
// mean IndexedDB itself is unavailable.
}
}
+15
View File
@@ -0,0 +1,15 @@
// Runs `fn` over `items` with at most `limit` calls in flight at once.
// A worker-pool: each worker pulls the next index off a shared cursor until
// the list is exhausted, so slow items don't stall workers behind them.
export async function mapWithConcurrency(items, limit, fn) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await fn(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
+19
View File
@@ -0,0 +1,19 @@
// All paths are derived from this module's own location via import.meta.url,
// so they resolve correctly regardless of where the embedding HTML page lives.
// 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 = new URL('../', import.meta.url).href;
// 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 = new URL('../vendor/marked.min.js', import.meta.url).href;
// 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 cmjs — replace this line to point anywhere
// under the same origin (`new URL('/blog-content/articles/', location.origin).href`)
// 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 = new URL('articles/', BASE_URL).href;
+415
View File
@@ -0,0 +1,415 @@
import { parseFrontmatter } from './frontmatter.js';
import { BASE_URL, ARTICLES_URL } from './config.js';
import { mapWithConcurrency } from './concurrency.js';
import { getAllCacheEntries, setCacheEntry, pruneCache } from './cache.js';
import { t } from './i18n.js';
// 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'];
function parseHtmlDirectoryListing(raw) {
const entries = [];
const seen = new Set();
const anchors = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>[\s\S]*?<\/a>/gi;
// nginx's default autoindex puts "DD-Mon-YYYY HH:MM" right after the </a>
// on the same line — used as a best-effort mtime when JSON autoindex
// isn't available (see the "mtime" note in the README).
const datePattern = /(\d{2})-([A-Za-z]{3})-(\d{4})\s+(\d{2}):(\d{2})/;
let match;
while ((match = anchors.exec(raw))) {
const href = match[1].replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
if (!href || href.startsWith('../') || href.startsWith('/') || href.startsWith('#') || /^[a-z][a-z\d+.-]*:/i.test(href)) continue;
const clean = href.split(/[?#]/, 1)[0];
const name = decodeURIComponent(clean.replace(/\/$/, '')).split('/').pop();
if (!name || name === '.' || name === '..' || seen.has(name)) continue;
seen.add(name);
const lineEnd = raw.indexOf('\n', anchors.lastIndex);
const tail = raw.slice(anchors.lastIndex, lineEnd === -1 ? raw.length : lineEnd);
const dateMatch = datePattern.exec(tail);
const monthIndex = dateMatch ? MONTH_ABBREVIATIONS.indexOf(dateMatch[2]) : -1;
const mtime = monthIndex >= 0
? new Date(Date.UTC(+dateMatch[3], monthIndex, +dateMatch[1], +dateMatch[4], +dateMatch[5])).toISOString()
: null;
entries.push({ name, type: clean.endsWith('/') ? 'directory' : 'file', mtime });
}
return entries;
}
export async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`);
const raw = await response.text();
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
} catch { /* HTML autoindex fallback */ }
const entries = parseHtmlDirectoryListing(raw);
if (entries.length || /<(?:title|h1)[^>]*>\s*(?:index of|directory listing)/i.test(raw)) return entries;
throw new Error(`Expected a directory listing at ${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
// file's own URL) is the resolution base. A relative path in frontmatter
// like `media/cover.jpg` resolves against the article file's own directory,
// which is the author directory itself, landing it in that shared folder —
// letting multiple articles reference the same image without duplicating it
// per article.
export function resolveMediaUrl(value, articlePath = '') {
if (!value) return '';
let path = String(value).trim();
if (/^(data:|blob:)/i.test(path)) return path;
if (/^https?:\/\//i.test(path)) {
try {
const url = new URL(path);
// Legacy compatibility, not a general-purpose feature: content
// migrated from a WordPress export sometimes still carries an
// absolute upload URL. Reduce it to a bare filename so it
// resolves against the article's own directory like any other
// relative image path, assuming the file itself was migrated
// alongside the article. A no-op for anything that isn't
// WordPress-derived content — most deployments never hit this.
if (url.pathname.includes('/wp-content/uploads/')) path = url.pathname.split('/wp-content/uploads/')[1];
else return path;
} catch { return path; }
}
return new URL(path.replace(/^\//, ''), articlePath || BASE_URL).href;
}
let articleCache = null;
let crawlPromise = null;
let crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: false };
// Raw (unfiltered/unsorted) article records resolved so far by the crawl
// currently or most recently in flight — lets a late subscriber (a view
// that mounts mid-crawl) immediately see what's already available instead
// of waiting for the next progress tick.
let partialResolved = [];
const progressListeners = new Set();
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
// can render as soon as the first articles are indexed instead of blocking
// on the whole tree. `listener(articles, done)` is called immediately with
// whatever has resolved so far, again as more of the crawl resolves, and a
// final time with `done: true`. Returns an unsubscribe function — always
// call it once the caller stops rendering (e.g. the user navigated away),
// otherwise a background crawl keeps calling a listener for a view that's
// no longer showing.
export function onArticlesProgress(listener) {
progressListeners.add(listener);
if (partialResolved.length) listener(sortAndFilterPublished(partialResolved), false);
return () => progressListeners.delete(listener);
}
function notifyProgress(done) {
const snapshot = sortAndFilterPublished(partialResolved);
progressListeners.forEach(listener => listener(snapshot, done));
}
// How many of the last crawl's candidate files actually produced an article
// vs. how many were listed, plus how many authors' listings failed
// outright — lets the UI show a "something's badly wrong" indicator instead
// of silently rendering an empty/near-empty list forever.
export function getCrawlStats() {
return { ...crawlStats };
}
function excerpt(content) {
const text = String(content || '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/`{1,3}[^`]*`{1,3}/g, '')
.replace(/[>#*_~\\]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (text.length <= 280) return text;
return `${text.slice(0, 277).replace(/\s+\S*$/, '')}…`;
}
// An article file's own name is the sole authoritative source of its slug,
// publish date, tags/category, and draft status — see the README's "Adding
// 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.
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;
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,
};
}
// Everything an article card/filter/sort needs, parsed entirely from its
// file's own name and its listing entry's mtime — no fetch of any kind.
// This is what runs for every article at index time; the (expensive)
// content fetch is deferred to hydrateArticle(), which only ever runs for
// articles actually being rendered. Returns null if the file name doesn't
// match the naming convention (see parseArticleFileName) — the caller
// treats that as a silent skip, matching the rest of this module's
// approach. `path` (the article file's own URL) doubles as its cache key —
// 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);
if (!parsed) return null;
const { slug, date, tags, draft } = parsed;
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
// "Category" isn't a separate concept anymore — it's just whichever tag
// id (if any) looks like a `year-YYYY` id.
const category = tags.find(id => /^year-20\d{2}$/i.test(id)) || null;
return {
author,
slug,
hash: `${author}/${slug}`,
path,
mtime,
category,
categoryYear: category ? Number(category.replace(/^year-/i, '')) : null,
date,
tags,
published: !draft,
hydrated: false,
};
}
// nginx's JSON autoindex reports mtimes with second (or finer) precision,
// but the HTML-autoindex fallback (see parseHtmlDirectoryListing) only ever
// has minute precision. Truncating both to the same minute-level grain here
// — the one place a raw listing mtime enters the system — means the cache
// comparison in resolveStub() below still matches consistently even if a
// 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
}
// Cache-aware: a file whose mtime matches its cached entry's mtime reuses
// that entry directly — worthwhile only when it's already hydrated
// (skipping a real content re-fetch); a merely-parsed stub is free to
// rebuild, so there's no point caching one just to skip re-parsing a
// 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.
function resolveStub(author, fileName, mtime, authorRoot, cacheSnapshot) {
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
const cached = cacheSnapshot.get(path);
if (cached && mtime != null && cached.mtime === mtime && cached.article?.hydrated) return cached.article;
return buildStub(author, fileName, mtime, authorRoot);
}
// Fields only derivable from an article's actual markdown content (not its
// directory listing) — shared by hydrateArticle() (page-scoped list cards)
// and loadArticle() in views.js (which always fetches its own content
// directly, regardless of what's been hydrated for list rendering).
export function deriveContentFields(meta, content, path) {
const words = content.trim().split(/\s+/).filter(Boolean).length;
return {
readingTime: Math.max(1, Math.round(words / 200)),
description: meta.description || excerpt(content),
coverUrl: resolveMediaUrl(meta.cover || meta.image, path),
};
}
// Fetches and parses a stub's markdown content (title, description, cover
// image, reading time) — the one fetch this module defers until an article
// is actually about to be rendered (a visible list card, or the article
// itself), rather than for every article at index time. Mutates `stub` in
// place (rather than returning a new object) so every other reference to
// this same article — the shared discoverArticles() list, other pending
// hydrateArticle() calls for it — sees the update too, and a later
// re-render of the same page doesn't re-fetch it. No-ops if already
// hydrated. Leaves the stub as-is (title-less but still linkable/sortable)
// on any failure, matching this module's silent-skip approach elsewhere.
//
// De-duplicates concurrent calls for the *same* stub: without this, two
// callers racing on a not-yet-hydrated stub (e.g. a page's own hydration
// racing the next page's background prefetch) would each fire their own
// fetch for the same article file. `inFlightHydrations` tracks the shared
// promise for as long as one is in flight, keyed by the stub object itself
// (each article has exactly one stub instance for its whole lifetime — see
// buildStub()/resolveStub()) — removed again once settled, success or
// failure, so a failed hydration is still retried on the next call rather
// than permanently returning the same rejected attempt.
const inFlightHydrations = new WeakMap();
export function hydrateArticle(stub) {
if (stub.hydrated) return Promise.resolve(stub);
const existing = inFlightHydrations.get(stub);
if (existing) return existing;
const promise = hydrateArticleNow(stub).finally(() => inFlightHydrations.delete(stub));
inFlightHydrations.set(stub, promise);
return promise;
}
async function hydrateArticleNow(stub) {
try {
const response = await fetch(stub.path);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const raw = await response.text();
const { meta, content } = parseFrontmatter(raw);
// Snapshot stub's own fields *before* mutating, then reapply them
// after merging in meta — so a stray same-named frontmatter field
// (a leftover `tags:`/`date:`) can never override the authoritative
// marker-derived values. (stub can't be used as its own "source of
// truth" mid-Object.assign — by the time that argument is read it'd
// already reflect this same mutation.)
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 { /* leave stub unhydrated — retried next time it's rendered */ }
return stub;
}
// Concurrency-limited batch hydration. Processing order follows `stubs`'
// own array order (each worker below just claims the next unclaimed index)
// — since discoverArticles() already returns stubs sorted newest-first by
// the authoritative `date` field, that means hydration (and thus which
// search results fill in first) is already newest-first too, for free.
export function hydrateArticles(stubs) {
return mapWithConcurrency(stubs, MAX_CONCURRENT_REQUESTS, hydrateArticle);
}
async function runCrawl() {
partialResolved = [];
// Independent of waves 1/2 below — kicked off in parallel with them and
// only awaited once it's actually needed, so its latency is hidden
// behind the (already-required) directory fetches rather than adding
// to the total.
const cacheSnapshotPromise = getAllCacheEntries();
// Wave 1 (root) — always fetched live: the author directories. Unlike
// wave 2 below, a failure here means discovery can't proceed at all
// (wrong origin, CORS, autoindex disabled) — rather than letting that
// reject all the way out to a generic "failed to load" message, treat it
// the same as "every author's listing failed": resolve to an empty,
// fully-indexed (done) result so the list view renders normally and
// renderCrawlHealthWarning() (via rootFailed below) shows the specific
// crawl-health message instead.
let authorEntries;
try {
authorEntries = await fetchJson(ARTICLES_URL);
} catch {
crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: true };
notifyProgress(true);
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. 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 Promise.all(authors.map(async author => {
const authorRoot = `${ARTICLES_URL}${encodeURIComponent(author.name)}/`;
const bioFileName = `${author.name}.md`;
try {
const entries = await fetchJson(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 }));
} catch {
authorsFailed += 1;
return [];
}
}));
const allFiles = authorFileLists.flat();
// Every stub is parsed synchronously from data already in hand — no
// per-file fetch, so no need for mapWithConcurrency here (that's still
// used by hydrateArticles(), which does real network fetches).
const cacheSnapshot = await cacheSnapshotPromise;
let loaded = 0;
for (const file of allFiles) {
const stub = resolveStub(file.author, file.fileName, file.mtime, file.authorRoot, cacheSnapshot);
if (stub) {
loaded += 1;
partialResolved.push(stub);
}
}
await pruneCache(allFiles.map(file => `${file.authorRoot}${encodeURIComponent(file.fileName)}`));
crawlStats = { total: allFiles.length, loaded, authorsTotal: authors.length, authorsFailed, rootFailed: false };
const finalArticles = sortAndFilterPublished(partialResolved);
notifyProgress(true);
return finalArticles;
}
// Cached after the first successful crawl; concurrent callers during the
// crawl share the same in-flight promise rather than each starting their
// own duplicate crawl. See onArticlesProgress() for incremental results.
export function discoverArticles() {
if (articleCache) return Promise.resolve(articleCache);
if (!crawlPromise) {
crawlPromise = runCrawl().then(
result => { articleCache = result; crawlPromise = null; return result; },
error => { crawlPromise = null; throw error; },
);
}
return crawlPromise;
}
export async function findArticle(hash) {
const article = (await discoverArticles()).find(candidate => candidate.hash === hash);
if (!article) throw new Error(t('articleNotFound'));
return article;
}
export async function fetchAuthorProfile(author) {
const path = `${ARTICLES_URL}${encodeURIComponent(author)}/${encodeURIComponent(author)}.md`;
try {
const response = await fetch(path);
if (!response.ok) return null;
const { meta, content } = parseFrontmatter(await response.text());
return { author, ...meta, content, path };
} catch { return null; }
}
+51
View File
@@ -0,0 +1,51 @@
// Small YAML-frontmatter reader for the canonical blog schema. It supports
// quoted/unquoted scalars, booleans, numbers and scalar arrays in inline or
// block form. The editor remains the authoritative validator and writer.
function parseScalar(raw) {
const value = raw.trim();
if (!value) return '';
if (value === 'true') return true;
if (value === 'false') return false;
if (value === 'null' || value === '~') return null;
if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
if (value.startsWith('"') && value.endsWith('"')) {
try { return JSON.parse(value); } catch { return value.slice(1, -1); }
}
if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1).replace(/''/g, "'");
if (value.startsWith('[') && value.endsWith(']')) {
const inside = value.slice(1, -1).trim();
if (!inside) return [];
const values = [];
let token = '';
let quote = '';
for (const character of inside) {
if ((character === '"' || character === "'") && (!quote || quote === character)) quote = quote ? '' : character;
if (character === ',' && !quote) { values.push(parseScalar(token)); token = ''; }
else token += character;
}
values.push(parseScalar(token));
return values;
}
return value;
}
export function parseFrontmatter(raw) {
// The closing "---" doesn't require a trailing newline of its own — a
// file that's nothing but frontmatter (no body, no trailing newline)
// still parses instead of falling through to "no frontmatter at all".
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
if (!match) return { meta: {}, content: raw };
const meta = {};
let arrayKey = null;
for (const line of match[1].split(/\r?\n/)) {
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*(.*))?$/);
if (!field) { arrayKey = null; continue; }
const [, key, rawValue = ''] = field;
if (!rawValue.trim()) { meta[key] = []; arrayKey = key; }
else { meta[key] = parseScalar(rawValue); arrayKey = null; }
}
return { meta, content: match[2] ?? '' };
}
+53
View File
@@ -0,0 +1,53 @@
// UI string lookup, so every user-facing message lives in a translatable
// JSON file (strings/{locale}.json) instead of being hardcoded in the
// JS. English (strings/en.json) is always fetched first as the
// fallback set; if the page's declared language (<html lang>) names another
// locale, that file is fetched too and merged on top — a translation file
// only needs to cover the keys it actually translates, missing ones still
// render in English rather than breaking.
import { BASE_URL } from './config.js';
const FALLBACK_LOCALE = 'en';
let strings = null;
let locale = FALLBACK_LOCALE;
async function fetchStrings(code) {
try {
const response = await fetch(`${BASE_URL}strings/${code}.json`);
if (!response.ok) return null;
return await response.json();
} catch {
return null;
}
}
// Must be awaited before the first render — see router.js's `ready` chain —
// since t()/getLocale() below assume this has already resolved.
export async function loadStrings() {
const fallback = await fetchStrings(FALLBACK_LOCALE);
const requested = (document.documentElement.lang || FALLBACK_LOCALE).trim().toLowerCase().split('-')[0];
if (requested === FALLBACK_LOCALE || !requested) {
strings = fallback;
locale = FALLBACK_LOCALE;
return;
}
const translated = await fetchStrings(requested);
strings = translated ? { ...fallback, ...translated } : fallback;
locale = translated ? requested : FALLBACK_LOCALE;
}
// The resolved locale code (e.g. "en", "cs") — used for Intl.* calls
// elsewhere so dates/sorting/search match whatever language is displayed.
export function getLocale() {
return locale;
}
// Looks up `key` and substitutes any `{name}` placeholders from `params`.
// Falls back to the raw key if strings never loaded (e.g. en.json itself
// failed to fetch) — a visible-but-not-crashing degradation, same spirit as
// the rest of this codebase's silent-skip fallbacks.
export function t(key, params = {}) {
const template = strings?.[key] ?? key;
return template.replace(/\{(\w+)\}/g, (match, name) => (params[name] != null ? String(params[name]) : match));
}
+121
View File
@@ -0,0 +1,121 @@
// Entry point — loads templates then dispatches routes on hash changes.
//
// Hash routing scheme:
// 0 segments → all articles, page 1
// first = "search" → search results (#search/query)
// 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
// 3 segments, first = "post" → single article (#post/alice/my-article)
// 4 segments, first = "author", third = "page" → author filter, page N (#author/alice/page/2)
// 4 segments, first = "category", third = "page" → category filter, page N (#category/linux/page/2)
// 4 segments, first = "tag", third = "page" → tag filter, page N (#tag/guides/page/2)
import { MARKED_URL } from './config.js';
import { loadTemplates } from './templates.js';
import { loadStrings, t } from './i18n.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) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.onload = resolve;
s.onerror = () => reject(new Error(`Failed to load ${url}`));
document.head.appendChild(s);
});
}
// Parse a page-number segment — returns 1 for any non-positive-integer value.
const parsePage = s => { const n = +s; return Number.isInteger(n) && n >= 1 ? n : 1; };
function router() {
const hash = location.hash.slice(1); // strip leading #
const parts = hash.split('/').filter(Boolean);
// Search: decode query and guard against empty string (#search or #search/).
// A trailing "/page/N" is peeled off for pagination first — the query
// itself is always a single encodeURIComponent()'d segment when built
// by this app (see the search-form handler below), so this only
// matters for hand-typed/legacy multi-segment links, where joining the
// rest back with "/" keeps a literal slash in the query working too.
if (parts[0] === 'search') {
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('/'));
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 loadArticleList();
}
// All routing — initial and hashchange — waits for this before running.
let ready;
function init() {
// Wire up search form submission → navigate to #search/query
document.getElementById('search-form')?.addEventListener('submit', e => {
e.preventDefault();
const q = document.getElementById('search-input')?.value?.trim();
if (q) location.hash = `#search/${encodeURIComponent(q)}`;
});
// Strings load first — everything else (including the error handler
// right below) may call t(), so it needs to already have resolved.
ready = loadStrings().then(() => Promise.all([loadScript(MARKED_URL), loadTemplates()])).then(() => {
// Fail with a clear, catchable message here rather than a cryptic
// uncaught TypeError the first time showListView()/showArticleView()
// run — both mount points are a hard requirement, unlike
// search/tag/year, which are optional.
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 cmjs's own mount points when at least one
// exists, rather than replacing the whole page — this can run on a page
// 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 => {
const message = t('appStartError', { message: err.message });
const listView = document.getElementById('list-view');
const articleView = document.getElementById('article-view');
if (listView || articleView) {
if (listView) {
listView.hidden = false;
listView.innerHTML = '';
const p = document.createElement('p');
p.className = 'error';
p.textContent = message;
listView.appendChild(p);
}
if (articleView) articleView.hidden = true;
} else {
document.body.textContent = message;
}
});
ready.then(router).catch(() => {});
}
// readyState guard in case DOMContentLoaded already fired
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.addEventListener('hashchange', () => ready.then(router).catch(() => {}));
File renamed without changes.
+570
View File
@@ -0,0 +1,570 @@
import { templates } from './templates.js';
import { deriveContentFields, discoverArticles, fetchAuthorProfile, findArticle, getCrawlStats, hydrateArticles, onArticlesProgress, resolveMediaUrl } from './discovery.js';
import { parseFrontmatter } from './frontmatter.js';
import { t, getLocale } from './i18n.js';
const PAGE_SIZE = 20;
// Bumped at the start of every list-rendering view. A progress listener
// checks its own snapshot of this before touching the DOM, so a background
// crawl's late update can't render into a view the user has since
// navigated away from (e.g. clicked into a different filter/page).
let currentGeneration = 0;
// The hash of the last list view shown (all articles, a filter, a search,
// any page) — used by loadArticle()'s "back" link so it returns to wherever
// the user actually came from instead of always resetting to page 1 of
// everything.
let lastListHash = '#';
function formatDate(value) {
if (!value) return '';
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
return Number.isNaN(date.valueOf()) ? String(value) : new Intl.DateTimeFormat(getLocale(), { day: 'numeric', month: 'long', year: 'numeric' }).format(date);
}
export function showListView() {
document.getElementById('list-view').hidden = false;
document.getElementById('article-view').hidden = true;
document.title = t('siteName');
}
export function showArticleView() {
document.getElementById('list-view').hidden = true;
document.getElementById('article-view').hidden = false;
}
function setField(element, value, apply) {
if (element && value != null && value !== '') apply(element, value);
else element?.remove();
}
function clearSearchInput() {
const input = document.getElementById('search-input');
if (input) input.value = '';
}
function filterLabel(filter) {
if (!filter) return '';
if (filter.type === 'author') return t('filterAuthor', { value: filter.value });
if (filter.type === 'tag') return t('filterTag', { value: filter.value });
if (filter.type === 'category') return t('filterYear', { value: filter.value.replace(/^year-/i, '') });
return filter.value;
}
function pageHash(page, filter) {
if (!filter) return page === 1 ? '#' : `#page/${page}`;
const base = `${filter.type}/${encodeURIComponent(filter.value)}`;
return page === 1 ? `#${base}` : `#${base}/page/${page}`;
}
function buildPaginationNav(page, totalPages, filter) {
const nav = document.createElement('nav');
nav.className = 'pagination';
nav.setAttribute('aria-label', t('paginationLabel'));
const previous = document.createElement('a');
previous.textContent = t('paginationPrevious');
const next = document.createElement('a');
next.textContent = t('paginationNext');
if (page > 1) previous.href = pageHash(page - 1, filter);
else { previous.classList.add('disabled'); previous.setAttribute('aria-disabled', 'true'); }
if (page < totalPages) next.href = pageHash(page + 1, filter);
else { next.classList.add('disabled'); next.setAttribute('aria-disabled', 'true'); }
const info = document.createElement('span');
info.className = 'page-info';
info.textContent = t('paginationPageInfo', { page, totalPages });
nav.append(previous, info, next);
return nav;
}
function renderTags(container, tags) {
if (!container || !tags?.length) { container?.remove(); return; }
tags.forEach(identifier => {
const link = document.createElement('a');
link.className = 'tag';
link.href = `#tag/${encodeURIComponent(identifier)}`;
link.textContent = identifier;
container.appendChild(link);
});
}
function renderFilterLink(label, href, count, active = false) {
const link = document.createElement('a');
link.className = `filter-link${active ? ' is-active' : ''}`;
link.href = href;
if (active) link.setAttribute('aria-current', 'page');
link.textContent = label;
if (count != null) {
const countElement = document.createElement('span');
countElement.className = 'filter-count';
countElement.textContent = `(${count})`;
link.appendChild(countElement);
}
return link;
}
// Years and tags render independently — either `<nav>` can be absent from
// the page (e.g. a site that only wants tag filtering, like this project's
// own index.html) without the other silently stopping too. Whichever
// section renders first gets the "all articles" reset link, so it's never
// lost just because the years nav isn't on the page.
function renderFilterNavigation(articles, activeFilter = null) {
const years = document.getElementById('year-filters');
const tags = document.getElementById('tag-filters');
if (!years && !tags) return;
const allArticlesLink = () => renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter);
if (years) {
years.innerHTML = '';
const yearCounts = new Map();
articles.forEach(article => {
const year = Number(article.categoryYear);
if (year) yearCounts.set(year, (yearCounts.get(year) || 0) + 1);
});
const yearList = document.createElement('div');
yearList.className = 'filter-list';
yearList.appendChild(allArticlesLink());
for (let year = 2026; year >= 2009; year -= 1) {
const count = yearCounts.get(year) || 0;
const active = activeFilter?.type === 'category' && activeFilter.value.toLowerCase() === `year-${year}`;
yearList.appendChild(renderFilterLink(t('yearLabel', { year }), `#category/year-${year}`, count, active));
}
years.appendChild(yearList);
}
if (tags) {
tags.innerHTML = '';
const tagCounts = new Map();
articles.forEach(article => (article.tags || []).forEach(tag => tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)));
const tagList = document.createElement('div');
tagList.className = 'filter-list';
if (!years) tagList.appendChild(allArticlesLink());
// A `year-YYYY` id is also a normal entry in `article.tags` (that's how
// it's recognised as the category — see buildStub() in discovery.js),
// but it belongs in the years nav above, not duplicated here.
[...tagCounts.keys()]
.filter(id => !/^year-20\d{2}$/i.test(id))
.sort((a, b) => a.localeCompare(b, getLocale()))
.forEach(id => {
const active = activeFilter?.type === 'tag' && activeFilter.value === id;
tagList.appendChild(renderFilterLink(id, `#tag/${encodeURIComponent(id)}`, tagCounts.get(id), active));
});
tags.appendChild(tagList);
}
}
// Builds the #post/author/slug hash for an article, encoding each segment
// individually. article.hash itself (a raw, unencoded "author/slug") is only
// ever used for internal matching in findArticle() — router.js decodes the
// two segments of a #post/... hash separately when parsing, so the link
// needs to encode them separately too, rather than interpolating the
// combined hash as-is (which breaks — decodeURIComponent throws — for any
// author/slug containing a literal "%" or other reserved character).
function postHref(article) {
return `#post/${encodeURIComponent(article.author)}/${encodeURIComponent(article.slug)}`;
}
// Shared between renderCard() and loadArticle() — date, reading-time, and
// the category/year badge are populated identically in both places.
function applyArticleMetaFields(field, { date, readingTime, category, categoryYear }) {
setField(field('date'), date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); });
setField(field('reading-time'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
// A post only counts as a "category" when one of its directory-name
// tags happens to name a year — not every article has one.
setField(field('category'), category, (categoryEl, value) => {
categoryEl.textContent = t('yearLabel', { year: categoryYear });
categoryEl.href = `#category/${encodeURIComponent(value)}`;
});
}
function renderCard(article) {
const fragment = document.importNode(templates.card, true);
const element = fragment.firstElementChild;
if (!element) throw new Error(t('templateEmptyError'));
const field = name => element.querySelector(`[data-field="${name}"]`);
const imageLink = field('image-link');
setField(field('image'), article.coverUrl, (image, value) => {
image.src = value;
image.alt = article.title || article.slug;
if (imageLink) {
imageLink.href = postHref(article);
imageLink.removeAttribute('aria-hidden');
imageLink.removeAttribute('tabindex');
}
});
if (!article.coverUrl) {
imageLink?.remove();
element.classList.add('card--no-image');
}
field('link').href = postHref(article);
field('title').textContent = article.title || article.slug;
applyArticleMetaFields(field, article);
setField(field('description'), article.description, (description, value) => { description.textContent = value; });
const author = field('author');
author.textContent = article.authorName || article.author;
author.href = `#author/${encodeURIComponent(article.author)}`;
renderTags(field('tags'), article.tags);
return element;
}
// Near-total crawl failure (wrong origin after a migration, CORS broken,
// 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), 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), 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 { 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);
const articlesBroken = total >= 5 && loaded / total < 0.1;
if (!authorsBroken && !articlesBroken) return;
const warning = document.createElement('p');
warning.className = 'error';
warning.textContent = authorsBroken
? t('crawlHealthAuthorsFailed')
: t('crawlHealthArticlesFailed', { loaded, total });
view.appendChild(warning);
}
// Renders one snapshot of a paginated article page into `view` — shared by
// the article list (live progress updates and the final render, so a large
// site's first page shows up as soon as it's indexed instead of blocking on
// the whole tree — see onArticlesProgress() in discovery.js) and search
// (see loadSearch). `pageArticles` must already be hydrated — this function
// is purely synchronous DOM-building, no fetching of its own. `heading`,
// `emptyKey`/`loadingKey` let each caller supply its own copy; `filter`
// still drives pagination links (buildPaginationNav) even when it's not
// one of the sidebar's own filter types (search uses `{type: 'search',
// value: query}`, matched by pageHash() the same way as any other filter).
function renderArticleListBody(view, { total, pageArticles, page, pages, filter, done, heading, emptyKey = 'emptyArticleList', loadingKey = 'loadingArticles' }) {
view.innerHTML = '';
renderCrawlHealthWarning(view, done);
if (heading) {
const headingElement = document.createElement('h2');
headingElement.className = 'category-heading';
headingElement.textContent = heading;
view.appendChild(headingElement);
}
if (!total) {
const message = document.createElement('p');
message.className = done ? 'empty' : 'loading';
message.textContent = done ? t(emptyKey) : t(loadingKey);
view.appendChild(message);
return;
}
pageArticles.forEach(article => view.appendChild(renderCard(article)));
if (pages > 1) view.appendChild(buildPaginationNav(page, pages, filter));
if (!done) {
const indexing = document.createElement('p');
indexing.className = 'loading';
indexing.textContent = t('indexingBackground');
view.appendChild(indexing);
}
}
export async function loadArticleList(filter = null, page = 1) {
showListView();
window.scrollTo(0, 0);
clearSearchInput();
lastListHash = location.hash || '#';
const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('loadingArticles')}</p>`;
const generation = ++currentGeneration;
const applyFilter = allArticles => {
renderFilterNavigation(allArticles, filter);
if (!filter) return allArticles;
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;
return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase();
});
};
// 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.
// `renderToken` guards against two overlapping hydration+render cycles
// (e.g. two progress ticks arriving close together) applying out of
// order — only the most recently *started* one is allowed to render.
let renderToken = 0;
const render = async (rawArticles, done) => {
if (generation !== currentGeneration) return;
const myToken = ++renderToken;
const articles = applyFilter(rawArticles);
const pages = Math.max(1, Math.ceil(articles.length / PAGE_SIZE));
const clampedPage = Math.max(1, Math.min(page, pages));
const pageStubs = articles.slice((clampedPage - 1) * PAGE_SIZE, clampedPage * PAGE_SIZE);
const pageArticles = pageStubs.length ? await hydrateArticles(pageStubs) : [];
if (generation !== currentGeneration || myToken !== renderToken) return;
renderArticleListBody(view, { total: articles.length, pageArticles, page: clampedPage, pages, filter, done, heading: filter ? filterLabel(filter) : undefined });
// Once the list is fully indexed (not mid-background-crawl, where
// page boundaries could still shift), warm the cache for the next
// page so clicking "next" is instant. Fire-and-forget — it touches
// no DOM, just fetches and caches, so it's safe to keep running
// even if the user has since navigated elsewhere.
if (done && clampedPage < pages) {
hydrateArticles(articles.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE));
}
};
const unsubscribe = onArticlesProgress((partialArticles, done) => { render(partialArticles, done); });
try {
const allArticles = await discoverArticles();
await render(allArticles, true);
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
} finally {
unsubscribe();
}
}
// GitHub/Twitter/LinkedIn/Mastodon are brand names, not translatable text —
// only "Website" (below, via t()) is actual UI copy.
const SOCIAL_LABELS = { github: 'GitHub', twitter: 'Twitter', linkedin: 'LinkedIn', mastodon: 'Mastodon' };
function makeSocialLink(href, label) {
const link = document.createElement('a');
link.href = href;
link.textContent = label;
link.target = '_blank';
link.rel = 'noopener noreferrer';
return link;
}
function renderAuthorProfile(profile) {
const element = document.importNode(templates.authorProfile, true);
const field = name => element.querySelector(`[data-field="${name}"]`);
setField(field('avatar'), profile.avatar, (image, value) => { image.src = resolveMediaUrl(value, profile.path); image.alt = profile.title || profile.author; });
field('name').textContent = profile.title || profile.author;
const bio = field('bio');
// See the matching note in loadArticle() — same no-sanitization caveat
// applies here (an author bio's raw HTML, if any, renders as-is).
if (profile.content?.trim()) bio.innerHTML = marked.parse(profile.content); else bio.remove();
const social = field('social');
const links = Object.entries(SOCIAL_LABELS).filter(([key]) => profile[key]).map(([key, label]) => makeSocialLink(profile[key], label));
if (profile.website) links.push(makeSocialLink(profile.website, t('socialWebsite')));
if (links.length) links.forEach(link => social.appendChild(link)); else social.remove();
return element;
}
export async function loadAuthorView(author, page = 1) {
showListView();
const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
clearSearchInput();
let generation;
try {
// Independent of each other — fetching in parallel instead of
// sequentially shaves a round-trip. Safe to run together:
// fetchAuthorProfile() never touches the DOM, and the profile card
// below is only inserted once both have settled, so there's
// nothing for loadArticleList()'s own view updates to race with.
const profilePromise = fetchAuthorProfile(author);
const listPromise = loadArticleList({ type: 'author', value: author }, page);
// loadArticleList() bumps currentGeneration synchronously as one of
// its first steps (before any await), so reading it right after
// starting — not awaiting — that call captures the exact generation
// this navigation owns. Without this, a slower-resolving call here
// could insert a stale profile card into a list-view the user has
// since navigated away from (it stays in the DOM invisibly, since
// list-view would be hidden, only to reappear stale later).
generation = currentGeneration;
const [profile] = await Promise.all([profilePromise, listPromise]);
if (generation === currentGeneration && page === 1 && profile) {
view.insertBefore(renderAuthorProfile(profile), view.firstChild);
}
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
}
}
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
// below) — small enough that a search which only needs page 1 doesn't fire
// a big burst of simultaneous requests, large enough to keep the number of
// rounds (and their per-round render) reasonable on a big site.
const SEARCH_SCAN_BATCH = PAGE_SIZE;
// Scan progress for the current search query — lets paging forward resume
// exactly where the last call left off instead of re-checking articles
// already ruled in/out, and lets paging *backward* (or re-rendering the
// 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.
let searchState = null;
export async function loadSearch(query, page = 1) {
showListView();
window.scrollTo(0, 0);
const generation = ++currentGeneration;
lastListHash = location.hash || '#';
const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('searching')}</p>`;
const input = document.getElementById('search-input');
if (input) input.value = query;
// Drives pagination links the same way any other filter does (see
// pageHash()) — `#search/<query>` for page 1, `#search/<query>/page/N`
// after, which router.js recognises and parses back into (query, page).
const filter = { type: 'search', value: query };
try {
const needle = query.toLocaleLowerCase(getLocale());
const matchesQuery = article => [article.title, article.description, article.author, article.authorName, article.category, ...article.tags]
.some(value => String(value || '').toLocaleLowerCase(getLocale()).includes(needle));
const stubs = await discoverArticles();
if (generation !== currentGeneration) return;
renderFilterNavigation(stubs);
if (!searchState || searchState.query !== query || searchState.stubs !== stubs) {
searchState = { query, stubs, cursor: 0, matches: [] };
}
const state = searchState;
// Only scan far enough to fill the requested page, plus one extra
// match so pagination knows whether a next page exists — never the
// whole (possibly huge) stub set. Jumping straight to a deep page
// (typed/bookmarked link) just scans further up front, but still
// only as far as *that* page needs.
const target = page * PAGE_SIZE + 1;
const renderResults = () => {
if (generation !== currentGeneration) return;
const scanDone = state.cursor >= state.stubs.length;
const haveEnough = state.matches.length >= target;
// Once either is true, nothing left running will change what's
// shown for this page — that's what `done` gates downstream
// (crawl-health check, hiding the "still working" footer).
const done = scanDone || haveEnough;
// Exact once the whole set's been scanned; otherwise it's only a
// lower bound (what's already been found, or at least one more
// page than requested, whichever is bigger) — same "can still
// shift until done" contract loadArticleList already uses for
// its own page count.
const knownPages = Math.max(1, Math.ceil(state.matches.length / PAGE_SIZE));
const pages = scanDone ? knownPages : Math.max(knownPages, page + 1);
const clampedPage = Math.max(1, Math.min(page, pages));
const pageMatches = state.matches.slice((clampedPage - 1) * PAGE_SIZE, clampedPage * PAGE_SIZE);
renderArticleListBody(view, {
total: state.matches.length,
pageArticles: pageMatches,
page: clampedPage,
pages,
filter,
done,
heading: t('searchHeading', { query }),
emptyKey: 'searchNoResults',
loadingKey: 'searching',
});
};
renderResults();
// Scan forward in small batches, stopping as soon as `target` is
// met — resuming this same loop (via a later loadSearch call
// reusing `state`) picks up right after the last-scanned stub.
// The generation check guards against a superseded call's loop
// (user navigated away, or to a further page) mutating shared
// `state` after the fact — only the most recently started call is
// allowed to keep scanning or rendering.
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);
await hydrateArticles(batch);
if (generation !== currentGeneration) return;
state.cursor += batch.length;
batch.forEach(article => { if (matchesQuery(article)) state.matches.push(article); });
renderResults();
}
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('searchError', { message: error.message })}</p>`;
}
}
function buildTOC(content) {
const headings = [...content.querySelectorAll('h2, h3')];
if (headings.length < 3) return null;
const seen = new Map();
headings.forEach(heading => {
const base = heading.textContent.toLocaleLowerCase(getLocale()).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || t('tocFallbackHeading');
const count = (seen.get(base) || 0) + 1; seen.set(base, count); heading.id = count === 1 ? base : `${base}-${count}`;
});
const nav = document.createElement('nav'); nav.className = 'toc';
nav.innerHTML = `<p class="toc-title">${t('tocTitle')}</p><ol class="toc-list"></ol>`;
headings.forEach(heading => {
const item = document.createElement('li'); if (heading.tagName === 'H3') item.classList.add('toc-h3');
const link = document.createElement('a'); link.href = `#${heading.id}`; link.textContent = heading.textContent; item.appendChild(link); nav.lastChild.appendChild(item);
});
return nav;
}
export async function loadArticle(hash) {
showArticleView();
window.scrollTo(0, 0);
const generation = ++currentGeneration; // also invalidates any still-running loadArticleList progress listener
clearSearchInput();
const view = document.getElementById('article-view');
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
try {
const article = await findArticle(hash);
const [raw, profile] = await Promise.all([
fetch(article.path).then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); }),
fetchAuthorProfile(article.author),
]);
// Nothing below has any visible effect until this point, so this is
// the one place worth checking: without it, a slower-resolving
// (superseded) loadArticle() call could still render over whatever
// the user has since navigated to.
if (generation !== currentGeneration) return;
const { meta, content } = parseFrontmatter(raw);
// This view always fetches its own content directly (above) rather
// than relying on discoverArticles()'s stub — the list view defers
// that fetch until a page is actually rendered (see
// loadArticleList), and a single visited article is exactly such a
// case, so readingTime/coverUrl are derived fresh here. Unlike
// 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);
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, 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();
const author = field('author'); author.textContent = meta.authorName || article.author; author.href = `#author/${encodeURIComponent(article.author)}`;
renderTags(field('tags'), article.tags);
const contentElement = field('content');
// Rendered directly via innerHTML with no HTML-sanitization pass —
// anyone who can write a .md file here can include raw HTML,
// including <script> tags (same for renderAuthorProfile()'s bio; the
// description field above skips this — it's plain textContent).
// Only let people you trust author content.
contentElement.innerHTML = marked.parse(content);
contentElement.querySelectorAll('img[src]').forEach(imageElement => { imageElement.src = resolveMediaUrl(imageElement.getAttribute('src'), article.path); });
const toc = buildTOC(contentElement); if (toc) contentElement.insertBefore(toc, contentElement.firstChild);
const back = document.createElement('a'); back.href = lastListHash; back.className = 'back-link'; back.textContent = t('backToArticles');
view.innerHTML = ''; view.append(back, element);
if (profile) { const divider = document.createElement('hr'); divider.className = 'article-divider'; view.append(divider, renderAuthorProfile(profile)); }
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articleLoadError', { message: error.message })}</p>`;
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"siteName": "Ukázkový blog",
"appStartError": "Blog se nepodařilo spustit: {message}",
"articleNotFound": "Článek nebyl nalezen",
"filterAuthor": "Autor: {value}",
"filterTag": "Štítek: {value}",
"filterYear": "Ročník: {value}",
"filterAllArticles": "Všechny články",
"yearLabel": "Ročník {year}",
"paginationLabel": "Stránkování",
"paginationPrevious": "Předchozí",
"paginationNext": "Další",
"paginationPageInfo": "{page} z {totalPages}",
"templateEmptyError": "Šablona článku je prázdná.",
"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…",
"emptyArticleList": "Nebyly nalezeny žádné články.",
"articlesLoadError": "Články se nepodařilo načíst: {message}",
"indexingBackground": "Zbytek článků se stále indexuje na pozadí…",
"searching": "Hledám…",
"searchHeading": "Hledání: „{query}“",
"searchNoResults": "Hledání neodpovídá žádný článek.",
"searchError": "Hledání se nepodařilo: {message}",
"tocTitle": "Obsah",
"tocFallbackHeading": "sekce",
"backToArticles": "Zpět na články",
"articleLoadError": "Článek se nepodařilo načíst: {message}",
"socialWebsite": "Web"
}
+41
View File
@@ -0,0 +1,41 @@
{
"siteName": "Example Blog",
"appStartError": "Failed to start the blog: {message}",
"articleNotFound": "Article not found",
"filterAuthor": "Author: {value}",
"filterTag": "Tag: {value}",
"filterYear": "Year: {value}",
"filterAllArticles": "All articles",
"yearLabel": "Year {year}",
"paginationLabel": "Pagination",
"paginationPrevious": "Previous",
"paginationNext": "Next",
"paginationPageInfo": "{page} of {totalPages}",
"templateEmptyError": "Article template is empty.",
"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…",
"emptyArticleList": "No articles found.",
"articlesLoadError": "Failed to load articles: {message}",
"indexingBackground": "Still indexing the rest of the articles…",
"searching": "Searching…",
"searchHeading": "Search: \"{query}\"",
"searchNoResults": "No articles match your search.",
"searchError": "Search failed: {message}",
"tocTitle": "Contents",
"tocFallbackHeading": "section",
"backToArticles": "Back to articles",
"articleLoadError": "Failed to load article: {message}",
"socialWebsite": "Website"
}
+41
View File
@@ -0,0 +1,41 @@
{
"siteName": "Ukážkový blog",
"appStartError": "Blog sa nepodarilo spustiť: {message}",
"articleNotFound": "Článok nebol nájdený",
"filterAuthor": "Autor: {value}",
"filterTag": "Štítok: {value}",
"filterYear": "Ročník: {value}",
"filterAllArticles": "Všetky články",
"yearLabel": "Ročník {year}",
"paginationLabel": "Stránkovanie",
"paginationPrevious": "Predchádzajúca",
"paginationNext": "Ďalšia",
"paginationPageInfo": "{page} z {totalPages}",
"templateEmptyError": "Šablóna článku je prázdna.",
"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…",
"emptyArticleList": "Neboli nájdené žiadne články.",
"articlesLoadError": "Články sa nepodarilo načítať: {message}",
"indexingBackground": "Zvyšok článkov sa stále indexuje na pozadí…",
"searching": "Hľadám…",
"searchHeading": "Hľadanie: „{query}“",
"searchNoResults": "Hľadaniu nezodpovedá žiadny článok.",
"searchError": "Hľadanie sa nepodarilo: {message}",
"tocTitle": "Obsah",
"tocFallbackHeading": "sekcia",
"backToArticles": "Späť na články",
"articleLoadError": "Článok sa nepodarilo načítať: {message}",
"socialWebsite": "Web"
}
@@ -9,6 +9,7 @@
<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>
<figure class="hero-image">
File renamed without changes.
@@ -1,5 +1,7 @@
<article class="card">
<img class="card-image" data-field="image" alt="">
<a class="card-media" data-field="image-link" href="" aria-hidden="true" tabindex="-1">
<img class="card-image" data-field="image" alt="" loading="lazy">
</a>
<div class="card-body">
<h2><a data-field="link" href=""><span data-field="title"></span></a></h2>
<div class="card-byline">
@@ -10,6 +12,7 @@
<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>
</article>
View File
File renamed without changes.