Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f1bb8acfe | ||
|
|
bef6538cb6 | ||
|
|
84c349b28d | ||
|
|
96cefba24d | ||
|
|
4e2229fed9 | ||
|
|
2b2cdb1d3c | ||
|
|
057f6673cb | ||
|
|
cf2733368f | ||
|
|
6d424ffbc9 | ||
|
|
c3d8f0171c | ||
|
|
ae9bcfec3a | ||
|
|
c8464398b9 | ||
|
|
730f16934d | ||
|
|
91a24574a8 | ||
|
|
16395fa7c5 | ||
|
|
62f909ead8 | ||
|
|
9e8af56069 | ||
|
|
c4d39ea1ad | ||
|
|
78765b95f5 | ||
|
|
243319aae3 | ||
|
|
eaf202ec83 | ||
|
|
24f9740d97 | ||
|
|
a0f1ed67d9 | ||
|
|
22f17a748b | ||
|
|
e739c7cf8c | ||
|
|
3c510ba92e | ||
|
|
1dc5e16d14 | ||
|
|
d3c6789cfe | ||
|
|
e7f8939ced | ||
|
|
997768e2ae | ||
|
|
5c716a3f9b | ||
|
|
e217d76695 | ||
|
|
885178d1e7 | ||
|
|
122593da24 | ||
|
|
58acdbff98 | ||
|
|
36297616a5 | ||
|
|
7c6e13a2fe | ||
|
|
202f88b206 | ||
|
|
81fb2a2414 | ||
|
|
60cf355352 | ||
|
|
e58a5e1336 | ||
|
|
c05a85b21b | ||
|
|
601f2902f5 | ||
|
|
84f21956fa | ||
|
|
99bb85e98a | ||
|
|
9d87cffeeb | ||
|
|
469a19388e | ||
|
|
b92bbc7129 |
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.
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
/cmjs/articles/
|
||||
/cmjs/pictures/
|
||||
/articles/
|
||||
/js/config.js
|
||||
@@ -0,0 +1,2 @@
|
||||
# Git Overrides
|
||||
* Do not push commited changes in this project
|
||||
@@ -1,83 +1,177 @@
|
||||
# cmjs — Minimal Client-Side CMS
|
||||
# cmkiss — Minimal Client-Side CMS
|
||||
|
||||
A minimal CMS where articles are Markdown files rendered in the browser. No build step, no database, no server-side code.
|
||||
|
||||
## How it works
|
||||
|
||||
1. The browser traverses `cmjs/articles/` via a server directory index — discovering authors, year categories, and article files. Articles may be direct Markdown files or use the nested `author/category/post/post.md` layout; nginx JSON autoindex is preferred, with HTML autoindexes supported as a fallback.
|
||||
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, 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 and tag filtering** — clicking an author name or tag pill shows a filtered list. Both 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* cmkiss — 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 cmkiss 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
|
||||
│ └── expa-YYYY/ # Main expedition-year category
|
||||
│ └── slug/ # One directory per post
|
||||
│ └── slug.md
|
||||
└── pictures/ # Article images (referenced via image: frontmatter field)
|
||||
│ ├── config-default.js # Shipped defaults for BASE_URL, MARKED_URL, ARTICLES_URL
|
||||
│ ├── config.js # Optional local overrides — gitignored; see "Local configuration" below
|
||||
│ ├── 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, loadTagView, 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"
|
||||
```
|
||||
|
||||
## Local configuration
|
||||
|
||||
`js/config-default.js` holds the shipped defaults (`BASE_URL`, `MARKED_URL`, `ARTICLES_URL`) and
|
||||
is tracked in git; every other module imports from it. It also loads an optional `js/config.js`
|
||||
— gitignored, not part of cmkiss's own git history — to override any of them. **Nothing needs to
|
||||
be created before first use**: if `js/config.js` doesn't exist, the shipped defaults apply as-is.
|
||||
|
||||
The split exists for using cmkiss as a git submodule (or otherwise pulling in a newer version some
|
||||
other way): since `config.js` is gitignored, updating cmkiss never touches it, so any override
|
||||
you've made survives. Only `config-default.js` gets updated.
|
||||
|
||||
To override a setting, create `js/config.js` and export just the constant(s) you want to change —
|
||||
anything you don't export falls back to `config-default.js`. For example, to move where articles
|
||||
live (see "Pointing articles/ somewhere else" below for the matching nginx-side change this
|
||||
needs):
|
||||
|
||||
```js
|
||||
export const ARTICLES_URL = new URL('/blog-content/articles/', location.origin).href;
|
||||
```
|
||||
|
||||
## Adding an article
|
||||
|
||||
1. Create the author, year, and post directories if they don't exist: `cmjs/articles/author/expa-YYYY/my-article/`
|
||||
2. Create the Markdown file inside: `cmjs/articles/author/expa-YYYY/my-article/my-article.md`
|
||||
3. Optional tag markers can sit beside the Markdown file as `tag-name.tag`.
|
||||
4. Add a frontmatter block at the top
|
||||
5. 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 and tags 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 `+`. Each tag id is a normal topic tag,
|
||||
shown as a pill linking to `#tag/tag-id`. Omit this whole segment (and its leading `~`) if the
|
||||
article has no tags.
|
||||
|
||||
The separator is `~` (tilde), not a hyphen — deliberately, since a slug is expected to be plain
|
||||
kebab-case (single hyphens only) and `~` isn't something you'd type by accident while writing
|
||||
one, unlike an extra `-`.
|
||||
|
||||
**Drafts** aren't a file-naming convention — they live in a separate `draft/` subdirectory inside
|
||||
the author's own directory: `articles/author/draft/my-article~16062026~guides.md`. The crawler
|
||||
only ever reads `.md` files directly inside an author's directory, so anything under `draft/` is
|
||||
never indexed, listed, searched, or paginated. nginx should also deny access to every author's
|
||||
`draft/` folder outright (see "nginx configuration" below), so a draft isn't reachable by a
|
||||
guessed direct URL either — not just hidden from the crawl. To publish an article, move its file
|
||||
out of `draft/` into the author directory itself.
|
||||
|
||||
Images (a cover photo, inline pictures) live in a `media/` folder shared by all of that author's
|
||||
articles, not next to each individual article file — so the same picture can be reused across
|
||||
several articles without keeping a duplicate copy per article. Reference one from frontmatter or
|
||||
markdown with a path relative to the author directory, e.g. `image: media/photo.jpg`.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Create the author directory if it doesn't exist: `articles/author/`
|
||||
2. Create the Markdown file directly inside it, using the naming convention above, e.g.
|
||||
`articles/author/my-article~16062026~guides+tutorials.md` — or inside `articles/author/draft/`
|
||||
instead, if it's not ready to publish yet.
|
||||
3. Optionally add a cover image to that author's shared `articles/author/media/` folder
|
||||
(creating it if needed), and reference it from frontmatter with `image: media/photo.jpg`.
|
||||
4. Add a frontmatter block at the top of the Markdown file
|
||||
5. Write your content — it will appear in the list automatically (once it's out of `draft/`)
|
||||
|
||||
To change an article's date or tags later, rename its file — its content doesn't need to change.
|
||||
`author` is derived from the containing directory — no frontmatter field needed
|
||||
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...
|
||||
@@ -86,19 +180,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` and `tags` frontmatter fields, if present, are ignored — superseded by the file
|
||||
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.
|
||||
@@ -122,7 +216,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.
|
||||
@@ -133,46 +227,78 @@ 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 legacy article at `cmjs/articles/alice/linux/my-article.md` |
|
||||
| `#alice/expa-2024/my-article/my-article` | Single nested article at `cmjs/articles/alice/expa-2024/my-article/my-article.md` |
|
||||
| `#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`.
|
||||
Author names and tags on each card and article are clickable links to their respective filter views. A search box in the header navigates to `#search/query`.
|
||||
|
||||
## 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.
|
||||
|
||||
cmkiss never names the page: the `<title>`, the site header, and any other branding (the header
|
||||
search placeholder, the sidebar section headings) belong to the embedding page and are plain
|
||||
static HTML — translate them by editing the page directly (`demo/index.html` for this repo's own
|
||||
demo). The only change cmkiss makes to the tab title is in the article view, where it shows
|
||||
`<article title> | <page's own title>`; list views restore the page's original `<title>` exactly.
|
||||
|
||||
## 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.
|
||||
To add cmkiss to any HTML page, three things are needed: the CSS, the two mount-point `<div>`s, and the script.
|
||||
|
||||
### 1. CSS — in `<head>`
|
||||
|
||||
```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>`
|
||||
|
||||
@@ -181,30 +307,54 @@ 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 filter list are optional and can go anywhere else in the page — see "Search, article list, and tag list as independent pieces" below. Unlike those, `#list-view`/`#article-view` are both required — router.js checks for them at startup and shows a clear error (scoped to whichever of the two is present, or the whole page if neither is) instead of silently failing if one is missing.
|
||||
|
||||
### 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 cmkiss isn't installed at the site root, a plain relative path like `css/...` will resolve against the *page's* directory, not wherever cmkiss actually lives. Use a path (root-relative or otherwise) that correctly points at wherever you placed it, e.g. if cmkiss lives at `/cmkiss/` on your site:
|
||||
> ```html
|
||||
> <link rel="stylesheet" href="/cmjs/css/components/card.css">
|
||||
> <script type="module" src="/cmjs/js/router.js"></script>
|
||||
> <link rel="stylesheet" href="/cmkiss/css/components/card.css">
|
||||
> <script type="module" src="/cmkiss/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>
|
||||
```
|
||||
|
||||
`#tag-filters` is optional — omit it and nothing breaks, the article list and search still work
|
||||
standalone.
|
||||
|
||||
### Minimal example
|
||||
|
||||
@@ -215,45 +365,185 @@ 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/` — **except** each author's own `draft/` folder, which must be
|
||||
denied outright (see "Adding an article" above: this is what keeps a draft from being indexed or
|
||||
fetched by a guessed direct URL).
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com;
|
||||
root /var/www/cmjs;
|
||||
root /var/www/cmkiss;
|
||||
|
||||
# Serve the SPA
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# Serve the demo page
|
||||
location /demo/ {
|
||||
try_files $uri $uri/ /demo/index.html;
|
||||
}
|
||||
|
||||
# Deny every author's draft/ folder outright — must come before the
|
||||
# /articles/ block below (a regex location overrides a prefix location
|
||||
# in nginx, so this still wins even though it's declared after it).
|
||||
location ~ ^/articles/[^/]+/draft/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# Enable JSON directory listing for article discovery (applies to all subdirs)
|
||||
location /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 cmkiss (`ARTICLES_URL`,
|
||||
computed as a sibling of the `js/` folder). Articles don't have to live there — override
|
||||
`ARTICLES_URL` in your local `js/config.js` (see "Local configuration" above) to point anywhere
|
||||
under the same origin (or a different one, CORS permitting).
|
||||
|
||||
Whatever it resolves to needs its own matching `location` block enabling the JSON (or HTML)
|
||||
autoindex, plus the matching `draft/` deny block (see above) — it doesn't have to be named
|
||||
`articles/` or nested under wherever cmkiss itself lives:
|
||||
|
||||
```nginx
|
||||
location ~ ^/blog-content/articles/[^/]+/draft/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
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 cmkiss 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, shared `media/` folder, and `draft/` folder together)
|
||||
along with each file's `mtime`. Only `.md` files listed directly inside an author's own directory
|
||||
are kept as candidate articles — the bio file is filtered out by name, and any subdirectory
|
||||
(`media/`, `draft/`) is skipped outright rather than being descended into, so nothing under
|
||||
`draft/` is ever indexed. That's it: because an article file's *name* already encodes its slug,
|
||||
date, and tags (see "Adding an article" above), that single listing is enough to sort, filter,
|
||||
paginate, and populate the sidebar's tag counts, without any further request per article and
|
||||
without ever fetching an article's actual Markdown.
|
||||
**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 —
|
||||
articles and cached searches alike. A change to the search-match record shape instead, or a new
|
||||
store entirely, only needs `DB_VERSION` bumped (IndexedDB's own upgrade counter): `onupgradeneeded`
|
||||
creates whatever store is missing without touching the other one's existing data.
|
||||
- **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `js/discovery.js` (default `10`) caps both how
|
||||
many hydration (content) fetches run at once and how many authors' directory listings are
|
||||
fetched at once during indexing; raise it for a fast/local nginx, lower it for a slow or
|
||||
shared/production instance.
|
||||
- **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.
|
||||
- **Persistent search-match cache** — alongside the resumable in-session scan above, each query's
|
||||
per-article match results (`{ mtime, matched }`, keyed by article path) are cached in IndexedDB
|
||||
too (`searchQueries` store in `js/cache.js`, keyed by the query text itself) and reused across
|
||||
page reloads and browser sessions, not just while paging through one visit. Searching the same
|
||||
term again only re-fetches and re-checks articles that are new or whose `mtime` has changed
|
||||
since — everything else is trusted from the cache without a content re-fetch at all. Capped at
|
||||
`MAX_CACHED_SEARCHES` (default `30`) distinct queries, evicting the least-recently-searched one
|
||||
first, so trying many different search terms over time doesn't grow the cache unbounded.
|
||||
- **Crawl health** — if the root author-listing fetch fails outright, or if every individual
|
||||
author's own directory listing fails (wrong origin, CORS misconfigured), a small warning banner
|
||||
is shown above the article list instead of silently rendering an empty page (see
|
||||
`getCrawlStats()` / `renderCrawlHealthWarning()` in `js/discovery.js` / `js/views.js`).
|
||||
Non-conforming `.md` file names aren't treated as a health problem — only as a per-file skip —
|
||||
since indexing does no per-article fetch that could actually fail.
|
||||
- **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
|
||||
|
||||
@@ -262,5 +552,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 +0,0 @@
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"tags": [
|
||||
{
|
||||
"id": "analogova-astrofotografie",
|
||||
"label": "Analogová astrofotografie",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "astromakers",
|
||||
"label": "Astromakers",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "ccd",
|
||||
"label": "CCD",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "deep-sky",
|
||||
"label": "Deep-sky",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "digitalni-astrofotografie",
|
||||
"label": "Digitální astrofotografie",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "existencialni-otazky",
|
||||
"label": "Existenciální otázky",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "expedicnici",
|
||||
"label": "Expedičníci",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "experimenty",
|
||||
"label": "Experimenty",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "katalog-og",
|
||||
"label": "Katalog OG",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "kometa",
|
||||
"label": "Kometa",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "meziplanetarni-hmota",
|
||||
"label": "Meziplanetární hmota",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "mesic",
|
||||
"label": "Měsíc",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "nezarazene",
|
||||
"label": "Nezařazené",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "nove-objevy",
|
||||
"label": "Nové objevy",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "po-expedici",
|
||||
"label": "Po expedici",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "projekty",
|
||||
"label": "Projekty",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "radioastronomie",
|
||||
"label": "Rádioastronomie",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "scigifoto",
|
||||
"label": "Scigifoto",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "skupiny",
|
||||
"label": "Skupiny",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "spolecenske-aktivity",
|
||||
"label": "Společenské aktivity",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "vedouci",
|
||||
"label": "Vedoucí",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "vysledky-pozorovani",
|
||||
"label": "Výsledky pozorování",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "zivot-na-expedici",
|
||||
"label": "Život na expedici",
|
||||
"description": "",
|
||||
"aliases": [],
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
Source SQL dump: /home/jakub/Documents/expa/web/wp/wordpress/db/migration_dump.sql
|
||||
Source uploads: /home/jakub/Documents/expa/web/wp/wordpress/html/wp-content/uploads
|
||||
Exported posts: 509
|
||||
Skipped records: 1025
|
||||
Copied media files: 322
|
||||
Unresolved upload URLs:
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/03/export-3-export-3-scaled.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/03/export-4-export-4-scaled.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/export-2-export-2.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/final-1.1-final-1.1-scaled.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/final-export-final-export.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/final-nova-uprava-final-nova-uprava.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/final-pc-final-pc.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/iris-export-6.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/jesle-finale-2-jesle-finale-2-1.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/jeslicky-novy-6-vance-jeslicky-novy-6-vance.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/koule-plna-hvezd-export-1.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/m42-orion-starless-scaled.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/m51-export-3-hdr-sharp-m51-export-3-hdr-sharp-scaled.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/mensi-mensi.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/mesic-jizni-okraj-mesic-export-2-1-rotated.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/orion-novy-pokus-final-4-orion-novy-pokus-final-4-scaled.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/06/triangulum-hdr-6-triangulum-hdr-6.tif
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/08/obloha-nad-expou-v-nedeli-pred-obedem-celooblohovka-expa-1024x767.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/08/pacman-4-pacman-4.jpg
|
||||
http://astronomickaexpedice.cz/wp-content/uploads/2025/08/triangulace-v-triangulu-aneb-jak-jsem-s-pomoci-triangulace-nasel-galaxii-triangulum-triangulace-1024x594.jpeg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/06/ngc-457-muzicek1-1024x907.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/expa2012_dusik-1024x681.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/expa2012_dusik_vejce-1024x681.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/img_11062-1024x682.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/p1240331-1024x768.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/p1240437-1024x768.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/pyromanie-300x199.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/smallimg_2165a-300x112.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/sun-300x199.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/v1010cas-300x202.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/07/v502oph-300x202.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/115-300x157.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/130-300x157.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/232-300x157.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/309-300x157.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/310-300x157.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/409-300x157.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/410-300x157.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/412-300x157.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/509-300x157.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/511-300x157.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/expa-300x199.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2012/08/m13-expa1-300x199.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/astrometry2.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/calib-cometa_00041.gif
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/cwcas_lightcurve.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/cwcas_oc.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/etd1.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/exo-chart1.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/exo2.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/formular.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/gif1.gif
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/ha-2-20-zmenseno.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/lrgb_m8_krivka-1024x1019.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/lrgb_m8_krivka.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/m31_single_done_red.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/mereni-komety.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/orlimlhovina-mini.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/referencni.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/screen21.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/sestava-na-spektra.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/vega-neupraveno.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/vega-s-prvky.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/vega-upraveno.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/vega_opraveno.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/wasp_2b_ja.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/wasp_2b_peter.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/wasp_74b_dienstbier.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/wasp_74b_plsek.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2019/08/wasp_74b_siegert_kohl.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2022/07/v-nektere-vede-se-muze-skryvat-astrofotografie-v-kazde-astrofotografii-se-muze-skryvat-veda-varzralok-1024x831.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2022/08/lbn-527-lbn527-1024x854.png
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2022/08/okno-do-nebe-aneb-porovnani-kvality-oblohy-windowfinal-1-1024x1024.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2022/08/okno-do-nebe-aneb-porovnani-kvality-oblohy-windowfinal-1024x1024.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2023/08/pozorovani-tranzitu-exoplanety-toi-4010-d-img-5285-225x300.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2024/01/ifn-okolo-polarky-pokus-6-1024x698.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/bode-modry-3-1320x928.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/duse-5-1320x742.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/i-se-starsim-fotakem-lze-vykouzlit-zazraky-krajinka-canon-30d-1024x683.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/i-se-starsim-fotakem-lze-vykouzlit-zazraky-rybi-oko-canon-30d-1024x683.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/m27-cinka-m27-1320x969.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/panorama-export-1320x903.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/vesmirna-zahradka-hotovo-1024x745.jpeg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/zakreslovani-slunce-img-4097-768x1024.jpeg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2024/08/zakreslovani-slunce-img-4105-768x1024.jpeg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2025/09/NGC40-1002x1024.jpg
|
||||
https://astronomickaexpedice.cz/wp-content/uploads/2025/10/final-1024x686.jpg
|
||||
https://www.astronomickaexpedice.cz/wp-content/uploads/2016/06/img_9395.jpg
|
||||
https://www.astronomickaexpedice.cz/wp-content/uploads/2016/07/img_4514blog.jpg
|
||||
https://www.astronomickaexpedice.cz/wp-content/uploads/2019/08/cinka-mini.jpg
|
||||
https://www.astronomickaexpedice.cz/wp-content/uploads/2019/08/final_ic59_63.jpg
|
||||
https://www.astronomickaexpedice.cz/wp-content/uploads/2019/08/heartfinal-mini.jpg
|
||||
https://www.astronomickaexpedice.cz/wp-content/uploads/2019/08/zamrzle1.jpg
|
||||
@@ -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,154 +0,0 @@
|
||||
import { parseFrontmatter } from './frontmatter.js';
|
||||
import { BASE_URL, PICTURES_URL } from './config.js';
|
||||
|
||||
function parseHtmlDirectoryListing(raw) {
|
||||
const entries = [];
|
||||
const seen = new Set();
|
||||
const anchors = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
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);
|
||||
entries.push({ name, type: clean.endsWith('/') ? 'directory' : 'file' });
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
|
||||
export function asList(value) {
|
||||
if (Array.isArray(value)) return value.map(String).filter(Boolean);
|
||||
if (value == null || value === '') return [];
|
||||
return String(value).split(',').map(item => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
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);
|
||||
if (url.pathname.includes('/wp-content/uploads/')) path = url.pathname.split('/wp-content/uploads/')[1];
|
||||
else return path;
|
||||
} catch { return path; }
|
||||
}
|
||||
if (path.startsWith('media/') && articlePath) return new URL(path, articlePath).href;
|
||||
path = path.replace(/^.*\/wp-content\/uploads\//i, '')
|
||||
.replace(/^\/?(?:blog\/media|cmjs\/pictures|pictures)\//i, '')
|
||||
.replace(/^\.\.\/pictures\//i, '');
|
||||
return new URL(path.replace(/^\//, ''), PICTURES_URL).href;
|
||||
}
|
||||
|
||||
let tagCache = null;
|
||||
export async function fetchTagRegistry() {
|
||||
if (tagCache) return tagCache;
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}blog-config/tags.json`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const registry = await response.json();
|
||||
tagCache = Array.isArray(registry.tags) ? registry.tags : [];
|
||||
} catch { tagCache = []; }
|
||||
return tagCache;
|
||||
}
|
||||
|
||||
let articleCache = null;
|
||||
function isPublished(meta) {
|
||||
if (meta.status != null) return ['published', 'publish'].includes(String(meta.status).toLowerCase());
|
||||
return !['true', 'yes', '1'].includes(String(meta.draft ?? '').toLowerCase().trim());
|
||||
}
|
||||
|
||||
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*$/, '')}…`;
|
||||
}
|
||||
|
||||
export async function discoverArticles() {
|
||||
if (articleCache) return articleCache;
|
||||
const authorEntries = await fetchJson(`${BASE_URL}articles/`);
|
||||
const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.'));
|
||||
const categoryLists = await Promise.all(authors.map(async author => {
|
||||
const entries = await fetchJson(`${BASE_URL}articles/${encodeURIComponent(author.name)}/`);
|
||||
const directories = entries.filter(entry => entry.type === 'directory' && entry.name !== '_history');
|
||||
const years = directories.filter(entry => /^expa-20\d{2}$/i.test(entry.name));
|
||||
return (years.length ? years : directories).map(category => ({ author: author.name, category: category.name }));
|
||||
}));
|
||||
|
||||
const sources = (await Promise.all(categoryLists.flat().map(async ({ author, category }) => {
|
||||
const root = `${BASE_URL}articles/${encodeURIComponent(author)}/${encodeURIComponent(category)}/`;
|
||||
const entries = await fetchJson(root);
|
||||
const direct = entries.filter(entry => entry.type === 'file' && entry.name.endsWith('.md'))
|
||||
.map(entry => ({ author, category, slug: entry.name.replace(/\.md$/, ''), name: entry.name, root }));
|
||||
const nested = await Promise.all(entries.filter(entry => entry.type === 'directory' && entry.name !== '_history').map(async post => {
|
||||
const postRoot = `${root}${encodeURIComponent(post.name)}/`;
|
||||
const files = (await fetchJson(postRoot)).filter(entry => entry.type === 'file' && entry.name.endsWith('.md') && !entry.name.startsWith('_'));
|
||||
const selected = files.find(entry => entry.name === 'index.md') || files.find(entry => entry.name === `${post.name}.md`) || (files.length === 1 ? files[0] : null);
|
||||
return selected ? { author, category, slug: post.name, name: selected.name, root: postRoot } : null;
|
||||
}));
|
||||
return [...direct, ...nested.filter(Boolean)];
|
||||
}))).flat();
|
||||
|
||||
const articles = await Promise.all(sources.map(async source => {
|
||||
const path = `${source.root}${encodeURIComponent(source.name)}`;
|
||||
try {
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const raw = await response.text();
|
||||
const { meta, content } = parseFrontmatter(raw);
|
||||
const words = content.trim().split(/\s+/).filter(Boolean).length;
|
||||
return {
|
||||
...meta,
|
||||
author: source.author,
|
||||
category: source.category,
|
||||
expa: Number(meta.expa || source.category.replace(/^expa-/i, '')) || null,
|
||||
slug: source.slug,
|
||||
path,
|
||||
hash: `${source.author}/${source.category}/${source.slug}`,
|
||||
readingTime: Math.max(1, Math.round(words / 200)),
|
||||
description: meta.description || excerpt(content),
|
||||
tags: asList(meta.tags),
|
||||
coverUrl: resolveMediaUrl(meta.cover || meta.image, path),
|
||||
published: isPublished(meta),
|
||||
};
|
||||
} catch { return null; }
|
||||
}));
|
||||
articleCache = articles.filter(article => article?.published).sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
||||
return articleCache;
|
||||
}
|
||||
|
||||
export async function findArticle(hash) {
|
||||
const article = (await discoverArticles()).find(candidate => candidate.hash === hash);
|
||||
if (!article) throw new Error('Článek nebyl nalezen');
|
||||
return article;
|
||||
}
|
||||
|
||||
export async function fetchAuthorProfile(author) {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}articles/${encodeURIComponent(author)}/${encodeURIComponent(author)}.md`);
|
||||
if (!response.ok) return null;
|
||||
const { meta, content } = parseFrontmatter(await response.text());
|
||||
return { author, ...meta, content };
|
||||
} catch { return null; }
|
||||
}
|
||||
@@ -1,81 +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" or "tag" → filtered list, page 1
|
||||
// 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, 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/)
|
||||
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 === 2 && parts[0] === 'tag') loadTagView(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 if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodeURIComponent(parts[1]), parsePage(parts[3]));
|
||||
else if (parts.length === 4) loadArticle(parts.map(decodeURIComponent).join('/'));
|
||||
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.textContent = `Blog se nepodařilo spustit: ${err.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(() => {}));
|
||||
@@ -1,308 +0,0 @@
|
||||
import { templates } from './templates.js';
|
||||
import { discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, resolveMediaUrl } from './discovery.js';
|
||||
import { parseFrontmatter } from './frontmatter.js';
|
||||
import { PICTURES_URL } from './config.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
let tagLabels = new Map();
|
||||
|
||||
async function ensureTags() {
|
||||
if (!tagLabels.size) tagLabels = new Map((await fetchTagRegistry()).map(tag => [tag.id, tag.label]));
|
||||
}
|
||||
|
||||
function czechDate(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('cs-CZ', { 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 = 'Expediční blogy';
|
||||
}
|
||||
|
||||
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 `Autor: ${filter.value}`;
|
||||
if (filter.type === 'tag') return `Štítek: ${tagLabels.get(filter.value) || filter.value}`;
|
||||
return `Ročník: ${filter.value.replace(/^expa-/i, 'Expa ')}`;
|
||||
}
|
||||
|
||||
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', 'Stránkování');
|
||||
const previous = document.createElement('a');
|
||||
previous.textContent = 'Předchozí';
|
||||
const next = document.createElement('a');
|
||||
next.textContent = 'Další';
|
||||
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 = `${page} z ${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 = tagLabels.get(identifier) || 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;
|
||||
}
|
||||
|
||||
function renderFilterNavigation(articles, activeFilter = null) {
|
||||
const years = document.getElementById('year-filters');
|
||||
const tags = document.getElementById('tag-filters');
|
||||
if (!years || !tags) return;
|
||||
years.innerHTML = '';
|
||||
tags.innerHTML = '';
|
||||
|
||||
const yearCounts = new Map();
|
||||
articles.forEach(article => {
|
||||
const year = Number(article.expa);
|
||||
if (year) yearCounts.set(year, (yearCounts.get(year) || 0) + 1);
|
||||
});
|
||||
const yearList = document.createElement('div');
|
||||
yearList.className = 'filter-list';
|
||||
yearList.appendChild(renderFilterLink('Všechny články', '#', articles.length, !activeFilter));
|
||||
for (let year = 2026; year >= 2009; year -= 1) {
|
||||
const count = yearCounts.get(year) || 0;
|
||||
const active = activeFilter?.type === 'category' && activeFilter.value.toLowerCase() === `expa-${year}`;
|
||||
yearList.appendChild(renderFilterLink(`Expa ${year}`, `#category/expa-${year}`, count, active));
|
||||
}
|
||||
years.appendChild(yearList);
|
||||
|
||||
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';
|
||||
[...tagLabels.entries()]
|
||||
.filter(([id]) => tagCounts.has(id))
|
||||
.sort((a, b) => (tagLabels.get(a[0]) || a[0]).localeCompare(tagLabels.get(b[0]) || b[0], 'cs'))
|
||||
.forEach(([id, label]) => {
|
||||
const active = activeFilter?.type === 'tag' && activeFilter.value === id;
|
||||
tagList.appendChild(renderFilterLink(label, `#tag/${encodeURIComponent(id)}`, tagCounts.get(id), active));
|
||||
});
|
||||
tags.appendChild(tagList);
|
||||
}
|
||||
|
||||
function renderCard(article) {
|
||||
const fragment = document.importNode(templates.card, true);
|
||||
const element = fragment.firstElementChild;
|
||||
if (!element) throw new Error('Šablona článku je prázdná.');
|
||||
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 = `#${article.hash}`;
|
||||
imageLink.removeAttribute('aria-hidden');
|
||||
imageLink.removeAttribute('tabindex');
|
||||
}
|
||||
});
|
||||
if (!article.coverUrl) {
|
||||
imageLink?.remove();
|
||||
element.classList.add('card--no-image');
|
||||
}
|
||||
field('link').href = `#${article.hash}`;
|
||||
field('title').textContent = article.title || article.slug;
|
||||
setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = czechDate(value); });
|
||||
setField(field('description'), article.description, (description, value) => { description.textContent = value; });
|
||||
setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = `${value} min čtení`; });
|
||||
const author = field('author');
|
||||
author.textContent = article.authorName || article.author;
|
||||
author.href = `#author/${encodeURIComponent(article.author)}`;
|
||||
const category = field('category');
|
||||
category.textContent = `Expa ${article.expa || article.category.replace(/^expa-/i, '')}`;
|
||||
category.href = `#category/${encodeURIComponent(article.category)}`;
|
||||
renderTags(field('tags'), article.tags);
|
||||
return element;
|
||||
}
|
||||
|
||||
export async function loadArticleList(filter = null, page = 1) {
|
||||
showListView();
|
||||
window.scrollTo(0, 0);
|
||||
clearSearchInput();
|
||||
const view = document.getElementById('list-view');
|
||||
view.innerHTML = '<p class="loading">Načítám články…</p>';
|
||||
try {
|
||||
await ensureTags();
|
||||
const allArticles = await discoverArticles();
|
||||
renderFilterNavigation(allArticles, filter);
|
||||
let articles = allArticles;
|
||||
if (filter) articles = articles.filter(article => {
|
||||
if (filter.type === 'tag') return article.tags.includes(filter.value);
|
||||
return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase();
|
||||
});
|
||||
view.innerHTML = '';
|
||||
if (filter) {
|
||||
const heading = document.createElement('h2');
|
||||
heading.className = 'category-heading';
|
||||
heading.textContent = filterLabel(filter);
|
||||
view.appendChild(heading);
|
||||
}
|
||||
if (!articles.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'empty';
|
||||
empty.textContent = 'Nebyly nalezeny žádné články.';
|
||||
view.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const pages = Math.ceil(articles.length / PAGE_SIZE);
|
||||
page = Math.max(1, Math.min(page, pages));
|
||||
articles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE).forEach(article => view.appendChild(renderCard(article)));
|
||||
if (pages > 1) view.appendChild(buildPaginationNav(page, pages, filter));
|
||||
} catch (error) {
|
||||
view.innerHTML = `<p class="error">Články se nepodařilo načíst: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
const SOCIAL_LABELS = { github: 'GitHub', twitter: 'Twitter', linkedin: 'LinkedIn', mastodon: 'Mastodon', website: 'Web' };
|
||||
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 = `${PICTURES_URL}${value}`; image.alt = profile.title || profile.author; });
|
||||
field('name').textContent = profile.title || profile.author;
|
||||
const bio = field('bio');
|
||||
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]) => {
|
||||
const link = document.createElement('a'); link.href = profile[key]; link.textContent = label; link.target = '_blank'; link.rel = 'noopener noreferrer'; return link;
|
||||
});
|
||||
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">Načítám…</p>';
|
||||
clearSearchInput();
|
||||
try {
|
||||
const profile = await fetchAuthorProfile(author);
|
||||
await loadArticleList({ type: 'author', value: author }, page);
|
||||
if (page === 1 && profile) view.insertBefore(renderAuthorProfile(profile), view.firstChild);
|
||||
} catch (error) { view.innerHTML = `<p class="error">Články se nepodařilo načíst: ${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);
|
||||
|
||||
export async function loadSearch(query) {
|
||||
showListView();
|
||||
window.scrollTo(0, 0);
|
||||
const view = document.getElementById('list-view');
|
||||
view.innerHTML = '<p class="loading">Hledám…</p>';
|
||||
const input = document.getElementById('search-input');
|
||||
if (input) input.value = query;
|
||||
try {
|
||||
await ensureTags();
|
||||
const needle = query.toLocaleLowerCase('cs');
|
||||
const allArticles = await discoverArticles();
|
||||
renderFilterNavigation(allArticles);
|
||||
const articles = allArticles.filter(article => [article.title, article.description, article.author, article.authorName, article.category, ...article.tags.map(tag => tagLabels.get(tag) || tag)]
|
||||
.some(value => String(value || '').toLocaleLowerCase('cs').includes(needle)));
|
||||
view.innerHTML = `<h2 class="category-heading">Hledání: „${query}“</h2>`;
|
||||
if (!articles.length) view.insertAdjacentHTML('beforeend', '<p class="empty">Hledání neodpovídá žádný článek.</p>');
|
||||
else articles.forEach(article => view.appendChild(renderCard(article)));
|
||||
} catch (error) { view.innerHTML = `<p class="error">Hledání se nepodařilo: ${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('cs').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'sekce';
|
||||
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">Obsah</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);
|
||||
clearSearchInput();
|
||||
const view = document.getElementById('article-view');
|
||||
view.innerHTML = '<p class="loading">Načítám…</p>';
|
||||
try {
|
||||
await ensureTags();
|
||||
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),
|
||||
]);
|
||||
const { meta, content } = parseFrontmatter(raw);
|
||||
document.title = `${meta.title || article.slug} | Expediční blogy`;
|
||||
const element = document.importNode(templates.article, true);
|
||||
const field = name => element.querySelector(`[data-field="${name}"]`);
|
||||
field('title').textContent = meta.title || article.slug;
|
||||
setField(field('date'), meta.date, (time, value) => { time.dateTime = String(value); time.textContent = czechDate(value); });
|
||||
setField(field('description'), meta.description, (description, value) => { description.textContent = value; });
|
||||
setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = `${value} min čtení`; });
|
||||
const image = field('image');
|
||||
if (article.coverUrl) { image.src = article.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)}`;
|
||||
const category = field('category'); category.textContent = `Expa ${article.expa}`; category.href = `#category/${encodeURIComponent(article.category)}`;
|
||||
renderTags(field('tags'), article.tags);
|
||||
const contentElement = field('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 = '#'; back.className = 'back-link'; back.textContent = 'Zpět na články';
|
||||
view.innerHTML = ''; view.append(back, element);
|
||||
if (profile) { const divider = document.createElement('hr'); divider.className = 'article-divider'; view.append(divider, renderAuthorProfile(profile)); }
|
||||
} catch (error) { view.innerHTML = `<p class="error">Článek se nepodařilo načíst: ${error.message}</p>`; }
|
||||
}
|
||||
Whitespace-only changes.
Binary file not shown.
|
Before Width: | Height: | Size: 223 KiB |
@@ -64,7 +64,7 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Author + category row in article header — .author-link and .tag defined in card.css */
|
||||
/* Author + tag row in article header — .author-link and .tag defined in card.css */
|
||||
.article-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
File renamed without changes.
@@ -90,7 +90,7 @@
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* Author + category row at the bottom of each card */
|
||||
/* Author + tag row at the bottom of each card */
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -115,7 +115,7 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Shared category badge — used in cards and article headers */
|
||||
/* Tag pill — also used in cards and article headers */
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: var(--color-tag-bg);
|
||||
@@ -134,8 +134,8 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Heading shown above a filtered list (author or category view) */
|
||||
.category-heading {
|
||||
/* Heading shown above a filtered list (author, tag, or search results) */
|
||||
.list-heading {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-soft);
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -50,8 +50,8 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* The expedition blog follows the same restrained night-sky palette as the
|
||||
main site, independently of the visitor's operating-system theme. */
|
||||
/* 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;
|
||||
@@ -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>
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1">
|
||||
<title>Expediční blogy</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 class="blog-page">
|
||||
<header>
|
||||
<!-- Site title always links back to the article list -->
|
||||
<a href="#" id="site-title">Expediční blogy</a>
|
||||
|
||||
<!-- Search form — submits to #search/query via router.js -->
|
||||
<form id="search-form" role="search">
|
||||
<input type="search" id="search-input" placeholder="Hledat v článcích" autocomplete="off" aria-label="Hledat v článcích">
|
||||
<button type="submit">Hledat</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<main class="blog-shell">
|
||||
<section class="blog-content" aria-label="Články">
|
||||
<!-- 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="Filtrování blogu">
|
||||
<section class="filter-section">
|
||||
<h2>Ročníky</h2>
|
||||
<nav id="year-filters" aria-label="Filtrovat podle ročníku"></nav>
|
||||
</section>
|
||||
<section class="filter-section">
|
||||
<h2>Rubriky</h2>
|
||||
<nav id="tag-filters" aria-label="Filtrovat podle rubriky"></nav>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script type="module" src="cmjs/js/router.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
// Persistent cache for discovered articles (keyed by each article file's own
|
||||
// path) and for per-search-query match results (keyed by the query text —
|
||||
// see setSearchCacheEntry() below). Raw IndexedDB (no wrapper library — see
|
||||
// .claude/nginx-sitemap-crawler-plan.md L4/L5 on the zero-dependency,
|
||||
// long-stable-API requirement). If IndexedDB is unavailable (private
|
||||
// browsing, old browser, blocked), every operation below silently becomes a
|
||||
// no-op so discovery.js/views.js get live-fetch/live-scan behaviour for free
|
||||
// without needing their own availability branching (plan doc R6).
|
||||
|
||||
// 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;
|
||||
// Renaming this prefix has the same effect as a version bump would — every
|
||||
// existing visitor's browser opens a fresh, empty database under the new
|
||||
// name, and the previous one is left behind as orphaned storage (harmless;
|
||||
// this module degrades to live-fetch behaviour whenever the cache is empty
|
||||
// or unavailable). Do this deliberately, not casually.
|
||||
const DB_NAME = `cmkiss-cache-v${CACHE_SCHEMA_VERSION}`;
|
||||
const ARTICLE_STORE = 'articleFiles';
|
||||
const SEARCH_STORE = 'searchQueries';
|
||||
// IndexedDB's own upgrade counter — separate from CACHE_SCHEMA_VERSION,
|
||||
// which only governs the *article* record shape (and busts the cache by
|
||||
// renaming the whole database). Bump this instead whenever a store or index
|
||||
// is added/changed; onupgradeneeded below creates only what's missing, so
|
||||
// existing article-cache data survives the upgrade.
|
||||
const DB_VERSION = 2;
|
||||
// Caps how many distinct queries' match results stay cached — otherwise
|
||||
// every unique term ever typed into search would keep its own per-article
|
||||
// map forever, growing unbounded on a long-lived site. Evicted
|
||||
// least-recently-searched-first — see setSearchCacheEntry().
|
||||
const MAX_CACHED_SEARCHES = 30;
|
||||
|
||||
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, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(ARTICLE_STORE)) db.createObjectStore(ARTICLE_STORE, { keyPath: 'path' });
|
||||
if (!db.objectStoreNames.contains(SEARCH_STORE)) {
|
||||
db.createObjectStore(SEARCH_STORE, { keyPath: 'query' }).createIndex('lastUsed', 'lastUsed');
|
||||
}
|
||||
};
|
||||
request.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(storeName, 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(storeName, mode).objectStore(storeName);
|
||||
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(ARTICLE_STORE, 'readonly', store => store.getAll());
|
||||
return new Map((records || []).map(record => [record.path, record]));
|
||||
}
|
||||
|
||||
export async function setCacheEntry(path, entry) {
|
||||
await runRequest(ARTICLE_STORE, 'readwrite', store => store.put({ ...entry, path }));
|
||||
}
|
||||
|
||||
// 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(ARTICLE_STORE, 'readwrite').objectStore(ARTICLE_STORE);
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
// Per-query search-match cache: `entries` is a { [articlePath]: { mtime,
|
||||
// matched } } map — see loadSearch() in views.js, which trusts an entry
|
||||
// (skipping a content re-fetch entirely) whenever its mtime still matches
|
||||
// the live article, and only re-checks what's new or changed. Keyed by the
|
||||
// exact (locale-lowercased) text matchesQuery() itself compares against, so
|
||||
// "Linux" and "linux" share one cached entry.
|
||||
export async function getSearchCacheEntry(query) {
|
||||
return runRequest(SEARCH_STORE, 'readonly', store => store.get(query));
|
||||
}
|
||||
|
||||
export async function setSearchCacheEntry(query, entries) {
|
||||
await runRequest(SEARCH_STORE, 'readwrite', store => store.put({ query, entries, lastUsed: Date.now() }));
|
||||
await pruneSearchCache();
|
||||
}
|
||||
|
||||
// Keeps the search-query cache bounded to MAX_CACHED_SEARCHES entries,
|
||||
// dropping the least-recently-searched ones first — the same "don't grow
|
||||
// unbounded" concern pruneCache() addresses for the article cache, just
|
||||
// against searches instead of renamed/deleted files.
|
||||
async function pruneSearchCache() {
|
||||
if (!cacheAvailable) return;
|
||||
let db;
|
||||
try {
|
||||
db = await openDb();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const store = db.transaction(SEARCH_STORE, 'readwrite').objectStore(SEARCH_STORE);
|
||||
let excess = await new Promise((resolve, reject) => {
|
||||
const request = store.count();
|
||||
request.onsuccess = () => resolve(request.result - MAX_CACHED_SEARCHES);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
if (excess <= 0) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const cursorRequest = store.index('lastUsed').openCursor();
|
||||
cursorRequest.onsuccess = event => {
|
||||
const cursor = event.target.result;
|
||||
if (!cursor || excess <= 0) { resolve(); return; }
|
||||
cursor.delete();
|
||||
excess -= 1;
|
||||
cursor.continue();
|
||||
};
|
||||
cursorRequest.onerror = () => reject(cursorRequest.error);
|
||||
});
|
||||
} catch {
|
||||
// Same reasoning as runRequest() above.
|
||||
}
|
||||
}
|
||||
@@ -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,42 @@
|
||||
// Shipped defaults for BASE_URL, MARKED_URL, ARTICLES_URL, plus the loader
|
||||
// that lets an optional local js/config.js (gitignored — not part of this
|
||||
// repo's history; see "Local configuration" in the README) override any of
|
||||
// them. Every other module imports from THIS file, not config.js directly —
|
||||
// config.js might not exist at all (nothing has created one yet, or it was
|
||||
// deleted), and a plain `import ... from './config.js'` would then break
|
||||
// the whole module graph with no way to catch it. A dynamic import() can be
|
||||
// caught, so that's what resolves it below; top-level await makes every
|
||||
// module that imports this one wait for that resolution before it runs,
|
||||
// same as if these were plain synchronous exports.
|
||||
//
|
||||
// config.js, if present, only needs to export whichever of these it wants
|
||||
// to change — anything it doesn't export falls back to the default here.
|
||||
// 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.
|
||||
|
||||
const DEFAULT_BASE_URL = new URL('../', import.meta.url).href;
|
||||
const DEFAULT_MARKED_URL = new URL('../vendor/marked.min.js', import.meta.url).href;
|
||||
const DEFAULT_ARTICLES_URL = new URL('articles/', DEFAULT_BASE_URL).href;
|
||||
|
||||
let overrides = {};
|
||||
try {
|
||||
overrides = await import('./config.js');
|
||||
} catch {
|
||||
// No local config.js (or it failed to load) — shipped defaults stand.
|
||||
}
|
||||
|
||||
// Base URL of the repo root — strings/ and templates/ live here, one level
|
||||
// up from this file (e.g. https://example.com/).
|
||||
export const BASE_URL = overrides.BASE_URL ?? DEFAULT_BASE_URL;
|
||||
|
||||
// Path to the marked.js vendor script — lives in vendor/, a sibling of this
|
||||
// file's own js/ directory, not alongside the app's own modules.
|
||||
export const MARKED_URL = overrides.MARKED_URL ?? DEFAULT_MARKED_URL;
|
||||
|
||||
// Base URL of the articles/ folder. Defaults to a sibling of this file's own
|
||||
// js/ directory (the repo root's own articles/), but articles don't have to
|
||||
// live anywhere near the rest of cmkiss — override it in config.js to point
|
||||
// anywhere under the same origin, or even a different one, CORS permitting.
|
||||
// Whatever it resolves to must still be a directory nginx serves a JSON (or
|
||||
// HTML) autoindex listing for — see "nginx configuration" in the README.
|
||||
export const ARTICLES_URL = overrides.ARTICLES_URL ?? DEFAULT_ARTICLES_URL;
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
import { parseFrontmatter } from './frontmatter.js';
|
||||
import { BASE_URL, ARTICLES_URL } from './config-default.js';
|
||||
import { mapWithConcurrency } from './concurrency.js';
|
||||
import { getAllCacheEntries, setCacheEntry, pruneCache } from './cache.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
// Caps simultaneous requests in the two places this module still fires one
|
||||
// per item: markdown content fetches during hydration (see
|
||||
// hydrateArticle()/hydrateArticles()) and each author's own directory
|
||||
// listing during wave 2 of indexing (see runCrawl()) — indexing no longer
|
||||
// needs a per-*article* fetch at all (every stub field is encoded in its
|
||||
// file's own name — see buildStub()), but still fetches one listing per
|
||||
// author. Raise it for a fast/local nginx, lower it for a slow or shared/
|
||||
// production instance — see .claude/nginx-sitemap-crawler-plan.md §2a for
|
||||
// the original rationale (written when this also governed per-article
|
||||
// indexing requests).
|
||||
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 sortArticles(rawArticles) {
|
||||
return [...rawArticles].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(sortArticles(partialResolved), false);
|
||||
return () => progressListeners.delete(listener);
|
||||
}
|
||||
|
||||
function notifyProgress(done) {
|
||||
const snapshot = sortArticles(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, and tags — see the README's "Adding an article" section.
|
||||
// Format: `<slug>~<DDMMYYYY>[~tag1+tag2+…].md`. `~` (rather than e.g. "--")
|
||||
// is deliberate: a slug is expected to be plain kebab-case, and "~" isn't a
|
||||
// character anyone would type by accident while writing one — unlike a
|
||||
// double-hyphen separator, which collides with an easy slug typo (an extra
|
||||
// "-") and silently drops the whole article instead of erroring. Returns
|
||||
// null for any file name that doesn't match — callers treat that as a
|
||||
// silent skip, e.g. the author's own bio file or a stray unrelated file.
|
||||
// Drafts aren't a naming convention at all — they live in an author's own
|
||||
// `draft/` subdirectory (see the README's "Adding an article" section),
|
||||
// which the crawler never descends into (wave 2 below only reads `type ===
|
||||
// 'file'` entries from an author's own listing) and which nginx should deny
|
||||
// outright, so a draft is never indexed or fetchable in the first place.
|
||||
function parseArticleFileName(fileName) {
|
||||
if (!fileName.endsWith('.md')) return null;
|
||||
const base = fileName.slice(0, -3);
|
||||
const [slug, dateSegment, tagsSegment] = base.split('~');
|
||||
if (!slug || !dateSegment || !/^\d{8}$/.test(dateSegment)) return null;
|
||||
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) : [],
|
||||
};
|
||||
}
|
||||
|
||||
// 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 } = parsed;
|
||||
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
|
||||
|
||||
return {
|
||||
author,
|
||||
slug,
|
||||
hash: `${author}/${slug}`,
|
||||
path,
|
||||
mtime,
|
||||
date,
|
||||
tags,
|
||||
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. `mtime` is a
|
||||
// change-detection signal, not a required key: `cached.mtime === mtime`
|
||||
// already matches when both are `null` (a server whose listing never
|
||||
// yields a parseable mtime — see parseHtmlDirectoryListing), which is the
|
||||
// right call — there's no evidence the file changed, as opposed to a
|
||||
// mismatch, which is evidence that it did. Treating "unknown" as "assume
|
||||
// changed" would defeat the cache on every single crawl instead of just
|
||||
// when a file actually needs re-hydrating.
|
||||
function resolveStub(author, fileName, mtime, authorRoot, cacheSnapshot) {
|
||||
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
|
||||
const cached = cacheSnapshot.get(path);
|
||||
if (cached && 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 (error) {
|
||||
// Leave stub unhydrated — retried next time it's rendered. Logged
|
||||
// (not surfaced in the UI, which just shows the title-less card)
|
||||
// so a persistent failure — a real bug in parseFrontmatter() or
|
||||
// deriveContentFields(), not just a transient fetch blip — is still
|
||||
// visible somewhere instead of silently retrying forever.
|
||||
console.error(`Failed to hydrate article ${stub.path}:`, error);
|
||||
}
|
||||
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 sortArticles(partialResolved);
|
||||
}
|
||||
const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.'));
|
||||
|
||||
// Wave 2 — always fetched live, in full, for every author (not just the
|
||||
// current page): each author's own directory listing, with per-file
|
||||
// mtimes. Capped by MAX_CONCURRENT_REQUESTS like hydrateArticles() below
|
||||
// — a site with many authors would otherwise fire one listing request
|
||||
// per author simultaneously, with no limit. This is what builds the
|
||||
// flat, cross-author list that drives global sorting and pagination —
|
||||
// see the plan doc's "keep the flat feed" decision. It's also the *only*
|
||||
// per-author network request indexing ever needs: every stub field
|
||||
// (slug, date, tags) is parsed straight out of each article file's own
|
||||
// name (see buildStub/parseArticleFileName) — there's no per-article
|
||||
// fetch at index time. The `type === 'file'` filter below also means any
|
||||
// subdirectory (the author's shared `media/` folder, a `draft/` folder —
|
||||
// see "Adding an article" in the README) is skipped without ever being
|
||||
// descended into. The author's own bio file (`<author>.md`) is filtered
|
||||
// out by name in the same listing — only `.md` files other than the bio
|
||||
// file are candidate articles. One author's listing failing (network
|
||||
// blip, deleted mid-crawl) is silently skipped rather than failing every other
|
||||
// author's list too, but is still counted, so a *total* breakage (wrong
|
||||
// origin, CORS) remains visible via getCrawlStats() instead of just
|
||||
// silently producing an empty article list.
|
||||
let authorsFailed = 0;
|
||||
const authorFileLists = await mapWithConcurrency(authors, MAX_CONCURRENT_REQUESTS, async author => {
|
||||
const 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 = sortArticles(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; }
|
||||
}
|
||||
@@ -31,11 +31,15 @@ function parseScalar(raw) {
|
||||
}
|
||||
|
||||
export function parseFrontmatter(raw) {
|
||||
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
// 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/)) {
|
||||
if (!line.trim()) continue; // blank line — doesn't close a block array
|
||||
const item = line.match(/^\s*-\s+(.*)$/);
|
||||
if (item && arrayKey) { meta[arrayKey].push(parseScalar(item[1])); continue; }
|
||||
const field = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(?:\s*(.*))?$/);
|
||||
@@ -44,5 +48,5 @@ export function parseFrontmatter(raw) {
|
||||
if (!rawValue.trim()) { meta[key] = []; arrayKey = key; }
|
||||
else { meta[key] = parseScalar(rawValue); arrayKey = null; }
|
||||
}
|
||||
return { meta, content: match[2] };
|
||||
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-default.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));
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
// 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 = "tag" → tag filter, page 1
|
||||
// 3 segments, first = "post" → single article (#post/alice/my-article)
|
||||
// 4 segments, first = "author", third = "page" → author filter, page N (#author/alice/page/2)
|
||||
// 4 segments, first = "tag", third = "page" → tag filter, page N (#tag/guides/page/2)
|
||||
|
||||
import { MARKED_URL } from './config-default.js';
|
||||
import { loadTemplates } from './templates.js';
|
||||
import { loadStrings, t } from './i18n.js';
|
||||
import { loadArticleList, loadAuthorView, loadTagView, loadArticle, loadSearch } from './views.js';
|
||||
|
||||
// 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] === 'tag') loadTagView(decodeURIComponent(parts[1]));
|
||||
else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodeURIComponent).join('/'));
|
||||
else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodeURIComponent(parts[1]), parsePage(parts[3]));
|
||||
else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodeURIComponent(parts[1]), parsePage(parts[3]));
|
||||
else 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, which are optional.
|
||||
if (!document.getElementById('list-view') || !document.getElementById('article-view')) {
|
||||
throw new Error('Missing required #list-view and/or #article-view element — see the "Embedding in an existing page" section of the README.');
|
||||
}
|
||||
});
|
||||
|
||||
// Show a top-level error if dependencies fail to load (or a mount point
|
||||
// is missing). Scoped to cmkiss's own mount points when at least one
|
||||
// exists, rather than replacing the whole page — this can run on a page
|
||||
// that embeds cmkiss 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(() => {}));
|
||||
@@ -2,7 +2,7 @@
|
||||
// Each template needs its own <template> element — reusing one would overwrite
|
||||
// the shared DocumentFragment, leaving both pointing to the last HTML assigned.
|
||||
|
||||
import { BASE_URL } from './config.js';
|
||||
import { BASE_URL } from './config-default.js';
|
||||
|
||||
export const templates = {};
|
||||
|
||||
+589
@@ -0,0 +1,589 @@
|
||||
import { templates } from './templates.js';
|
||||
import { deriveContentFields, discoverArticles, fetchAuthorProfile, findArticle, getCrawlStats, hydrateArticles, onArticlesProgress, resolveMediaUrl } from './discovery.js';
|
||||
import { getSearchCacheEntry, setSearchCacheEntry } from './cache.js';
|
||||
import { parseFrontmatter } from './frontmatter.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
|
||||
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 = '#';
|
||||
|
||||
// The embedding page's own <title>, captured once when this module first
|
||||
// runs (module scripts are deferred, so <title> is already parsed). cmkiss
|
||||
// never names the page itself — list views restore this exactly, and the
|
||||
// article view only prefixes it with the article's title.
|
||||
const pageTitle = document.title;
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
|
||||
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 = pageTitle;
|
||||
}
|
||||
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
|
||||
function renderFilterNavigation(articles, activeFilter = null) {
|
||||
const tags = document.getElementById('tag-filters');
|
||||
if (!tags) return;
|
||||
tags.innerHTML = '';
|
||||
const tagCounts = new Map();
|
||||
articles.forEach(article => (article.tags || []).forEach(tag => tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)));
|
||||
const tagList = document.createElement('div');
|
||||
tagList.className = 'filter-list';
|
||||
tagList.appendChild(renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter));
|
||||
[...tagCounts.keys()]
|
||||
.sort((a, b) => a.localeCompare(b, getLocale()))
|
||||
.forEach(id => {
|
||||
const active = activeFilter?.type === 'tag' && activeFilter.value === id;
|
||||
tagList.appendChild(renderFilterLink(id, `#tag/${encodeURIComponent(id)}`, tagCounts.get(id), active));
|
||||
});
|
||||
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 and reading-time are
|
||||
// populated identically in both places.
|
||||
function applyArticleMetaFields(field, { date, readingTime }) {
|
||||
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 }); });
|
||||
}
|
||||
|
||||
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), or every individual author's own listing
|
||||
// failing (checked regardless of site size, since discovery.js now silently
|
||||
// skips individual author failures — see the wave-2 comment in
|
||||
// discovery.js). Unlike an older version of this check, a low loaded/total
|
||||
// ratio (candidate .md files that didn't match the article naming
|
||||
// convention) is deliberately *not* treated as a health problem: indexing
|
||||
// does no per-article fetch at all, so a non-conforming file name is a
|
||||
// filename issue, not a network/CORS one — and legitimately non-conforming
|
||||
// .md files (notes, leftovers) coexisting with real articles shouldn't
|
||||
// trigger a false "something's broken" banner. Only meaningful once the
|
||||
// crawl has actually finished (`done`) — mid-crawl stats are incomplete and
|
||||
// would misreport a healthy crawl as broken.
|
||||
function renderCrawlHealthWarning(view, done) {
|
||||
if (!done) return;
|
||||
const { authorsTotal, authorsFailed, rootFailed } = getCrawlStats();
|
||||
// rootFailed means the very first listing fetch (the author directories
|
||||
// themselves) couldn't be read at all — a total breakage same in spirit
|
||||
// as every author's own listing failing, just one level higher up.
|
||||
const authorsBroken = rootFailed || (authorsTotal > 0 && authorsFailed === authorsTotal);
|
||||
if (!authorsBroken) return;
|
||||
const warning = document.createElement('p');
|
||||
warning.className = 'error';
|
||||
warning.textContent = t('crawlHealthAuthorsFailed');
|
||||
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 = 'list-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;
|
||||
if (filter.type === 'tag') return allArticles.filter(article => article.tags.includes(filter.value));
|
||||
// Author names are exact, case-sensitive directory names (used
|
||||
// as-is for paths and profile lookups elsewhere) — comparing them
|
||||
// case-insensitively here could merge two distinctly-named author
|
||||
// directories that differ only in case.
|
||||
return allArticles.filter(article => article.author === filter.value);
|
||||
};
|
||||
|
||||
// Sorting/filtering/pagination all work on stubs alone (tags, date —
|
||||
// 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));
|
||||
}
|
||||
};
|
||||
|
||||
// Same error handling as the final render below (not just the same
|
||||
// message — an error thrown here, e.g. a malformed template, would
|
||||
// otherwise be an unhandled rejection that leaves the list stuck on its
|
||||
// loading state instead of ever showing articlesLoadError).
|
||||
const showRenderError = error => {
|
||||
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
|
||||
};
|
||||
const unsubscribe = onArticlesProgress((partialArticles, done) => { render(partialArticles, done).catch(showRenderError); });
|
||||
try {
|
||||
const allArticles = await discoverArticles();
|
||||
await render(allArticles, true);
|
||||
} catch (error) {
|
||||
showRenderError(error);
|
||||
} 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 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.
|
||||
//
|
||||
// `entries` is this same session's in-memory mirror of the persistent
|
||||
// per-query cache (see cache.js's getSearchCacheEntry()/
|
||||
// setSearchCacheEntry()): a { [articlePath]: { mtime, matched } } map,
|
||||
// loaded once when the query first changes and written back whenever a scan
|
||||
// round adds something new to it (`dirty`). Reusing it — instead of only
|
||||
// `cursor`/`matches` — means even a *first* search after a page reload can
|
||||
// skip re-fetching and re-checking every article whose content hasn't
|
||||
// changed since it was last searched for, no matter how this query's own
|
||||
// scan happened to leave off last time.
|
||||
let searchState = null;
|
||||
|
||||
export async function loadSearch(query, page = 1) {
|
||||
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.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) {
|
||||
// Loaded once per new query. Pruned to the current stub set's
|
||||
// paths so a renamed/deleted article's old cached result can't
|
||||
// linger — cheap, since `stubs` (unlike hydrating them) is
|
||||
// already in hand with no further fetch.
|
||||
const cached = await getSearchCacheEntry(needle);
|
||||
if (generation !== currentGeneration) return;
|
||||
const validPaths = new Set(stubs.map(article => article.path));
|
||||
const entries = {};
|
||||
for (const [path, entry] of Object.entries(cached?.entries || {})) if (validPaths.has(path)) entries[path] = entry;
|
||||
searchState = { query, stubs, cursor: 0, matches: [], entries, dirty: false };
|
||||
}
|
||||
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);
|
||||
// Still hydrates the whole batch — a no-op per already-hydrated
|
||||
// stub (see hydrateArticle() in discovery.js), which a cache hit
|
||||
// below always is: it can only exist for a path this exact
|
||||
// query already matched content against, at this same mtime,
|
||||
// which means it's already sitting in the article-level cache
|
||||
// too. Rendering pageArticles further down never re-hydrates,
|
||||
// so this stays unconditional rather than trusting that chain
|
||||
// of reasoning to never drift out of sync.
|
||||
await hydrateArticles(batch);
|
||||
if (generation !== currentGeneration) return;
|
||||
state.cursor += batch.length;
|
||||
batch.forEach(article => {
|
||||
// A cached result only counts as known if its mtime still
|
||||
// matches the live stub — an edited-since-last-search
|
||||
// article is re-checked, same as one with no entry at all.
|
||||
const known = state.entries[article.path];
|
||||
const isKnown = known?.mtime === article.mtime;
|
||||
const matched = isKnown ? known.matched : matchesQuery(article);
|
||||
if (!isKnown) { state.entries[article.path] = { mtime: article.mtime, matched }; state.dirty = true; }
|
||||
if (matched) state.matches.push(article);
|
||||
});
|
||||
renderResults();
|
||||
}
|
||||
// Persist whatever's new this round — fire-and-forget, since a
|
||||
// failed/slow write should never hold up rendering (cache.js
|
||||
// already swallows its own storage errors). Next time this exact
|
||||
// query is searched, anything covered here needs no re-fetch at
|
||||
// all, only what's new or changed since.
|
||||
if (state.dirty) {
|
||||
state.dirty = false;
|
||||
setSearchCacheEntry(needle, state.entries);
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('searchError', { message: error.message })}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// Skip the separator when the embedding page has no <title> of its own.
|
||||
document.title = `${meta.title || article.slug}${pageTitle ? ` | ${pageTitle}` : ''}`;
|
||||
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 });
|
||||
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,37 @@
|
||||
{
|
||||
"appStartError": "Blog se nepodařilo spustit: {message}",
|
||||
"articleNotFound": "Článek nebyl nalezen",
|
||||
|
||||
"filterAuthor": "Autor: {value}",
|
||||
"filterTag": "Štítek: {value}",
|
||||
"filterAllArticles": "Všechny články",
|
||||
|
||||
"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.",
|
||||
|
||||
"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,37 @@
|
||||
{
|
||||
"appStartError": "Failed to start the blog: {message}",
|
||||
"articleNotFound": "Article not found",
|
||||
|
||||
"filterAuthor": "Author: {value}",
|
||||
"filterTag": "Tag: {value}",
|
||||
"filterAllArticles": "All articles",
|
||||
|
||||
"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.",
|
||||
|
||||
"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,37 @@
|
||||
{
|
||||
"appStartError": "Blog sa nepodarilo spustiť: {message}",
|
||||
"articleNotFound": "Článok nebol nájdený",
|
||||
|
||||
"filterAuthor": "Autor: {value}",
|
||||
"filterTag": "Štítok: {value}",
|
||||
"filterAllArticles": "Všetky články",
|
||||
|
||||
"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.",
|
||||
|
||||
"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"
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
<p class="description" data-field="description"></p>
|
||||
<div class="article-meta">
|
||||
<a class="author-link" data-field="author" href=""></a>
|
||||
<a class="tag" data-field="category" href=""></a>
|
||||
<span class="tag-list" data-field="tags"></span>
|
||||
</div>
|
||||
</header>
|
||||
File renamed without changes.
@@ -11,7 +11,6 @@
|
||||
<p data-field="description"></p>
|
||||
<div class="card-meta">
|
||||
<a class="author-link" data-field="author" href=""></a>
|
||||
<a class="tag" data-field="category" href=""></a>
|
||||
<span class="tag-list" data-field="tags"></span>
|
||||
</div>
|
||||
</div>
|
||||
File renamed without changes.
Reference in new issue
Block a user