Finalizing the code #1
No files matched your search
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
/articles/
|
||||
@@ -0,0 +1,2 @@
|
||||
# Git Overrides
|
||||
* Do not push commited changes in this project
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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(() => {}));
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
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.
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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(/&/g, '&').replace(/"/g, '"').replace(/'/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; }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
File renamed without changes.
Reference in new issue
Block a user