Author SHA1 Message Date
jpelc 5b9eff8dff update readme 2026-10-26 18:40:36 +01:00
jpelc 4c384a9517 prepare for webdav integration 2026-09-13 01:23:42 +02:00
biosias 62f909ead8 Merge pull request 'Fix configuration management' (#2) from claude into main
Reviewed-on: Astronomicka_Expedice/cmjs#2
2026-09-12 16:53:42 +02:00
biosiasandClaude Sonnet 5 9e8af56069 Make config.js truly optional, falling back to config-default.js (AI)
A plain static `import ... from './config.js'` can't gracefully
degrade if that file doesn't exist -- a missing module breaks the
whole graph before any application code can catch it. So the four
consumers (discovery.js, i18n.js, templates.js, router.js) now import
BASE_URL/MARKED_URL/ARTICLES_URL from config-default.js instead, which
tries a dynamic import() of the optional config.js (catchable, unlike
a static import) and merges in whatever it exports, falling back to
its own shipped defaults for anything config.js doesn't override --
or doesn't exist at all. Top-level await makes every importing module
wait for that resolution first, same as a plain synchronous export.

config.js itself is now just an optional, empty-by-default file: only
export the specific constant(s) you want to change. Updated the
README's "Local configuration" section (and its three cross-references
in Running locally / Embedding in an existing page / Pointing
articles/ somewhere else) to drop the now-unnecessary "copy this file
before first use" step entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 16:51:18 +02:00
biosiasandClaude Sonnet 5 c4d39ea1ad Split config.js into config-default.js (tracked) + config.js (gitignored)
Lets cmjs be used as a git submodule (or updated by any other means)
without a local config override -- e.g. a custom ARTICLES_URL -- being
clobbered by the update. js/config-default.js now holds the shipped
defaults and is tracked; js/config.js is gitignored and re-exports
those defaults unless overridden, and is what every other module
actually imports from.

Since js/config.js no longer ships with the repo, added a required
one-time setup step (cp js/config-default.js js/config.js) -- new
"Local configuration" section in the README, cross-referenced from
"Running locally", "Embedding in an existing page", and "Pointing
articles/ somewhere else".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 16:47:02 +02:00
biosias 78765b95f5 Merge pull request 'Finalizing the code' (#1) from claude into main
Reviewed-on: Astronomicka_Expedice/cmjs#1
2026-09-12 16:35:28 +02:00
biosiasandClaude Sonnet 5 243319aae3 Remove unused isCacheAvailable() export (AI)
Dead code -- nothing in the codebase imported or called it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 16:30:01 +02:00
biosiasandClaude Sonnet 5 eaf202ec83 Document recent behavior changes in README (AI)
Found while reviewing README completeness against the last bug-fix
pass:
- "Crawl health" bullet didn't mention the new rootFailed case (the
  root author-listing fetch failing outright, distinct from every
  individual author's listing failing).
- "Back link" feature bullet still described the old fixed-to-page-1
  behavior instead of the fixed return-to-referring-page behavior.
- No mention that #list-view/#article-view are checked for at startup
  with a friendly error, unlike the truly-optional mount points.
- No mention that author directory names are case-sensitive and
  matched exactly against #author/... URLs and profile lookups.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 16:27:17 +02:00
biosiasandClaude Sonnet 5 24f9740d97 Fix bugs and robustness gaps found in a full code review (AI)
Real bugs:
- loadArticle()/loadAuthorView() had no stale-render guard (unlike
  loadArticleList()/loadSearch()) -- a slower, superseded request could
  overwrite a newer one's render. Both now capture and check a
  generation snapshot.
- #post/... links interpolated article.hash (raw, unencoded
  "author/slug") directly, but router.js unconditionally
  decodeURIComponent()s each segment when parsing -- would throw
  (silently swallowed) for any author/slug containing a literal "%" or
  other reserved character. Links now go through a new postHref()
  helper that encodes each segment individually.
- A startup failure replaced the entire document.body, which would
  wipe out a host site's own page content when cmjs is embedded
  alongside it. Now scoped to cmjs's own mount points when at least
  one exists.
- hydrateArticle() had no in-flight de-duplication -- two concurrent
  callers for the same not-yet-hydrated stub (e.g. a page's hydration
  racing the next page's prefetch) could each fire a duplicate fetch.
  Now tracked via a WeakMap keyed by the stub.
- Leftover "expedition blog"/"expedition year" wording missed by the
  earlier astronomy-club genericization pass (that pass's grep didn't
  cover the English word "expedition").

Robustness gaps:
- cache.js no longer permanently disables the whole IndexedDB cache
  after a single failed transaction -- only openDb() itself failing
  does that now.
- Listing mtimes are normalized to minute precision at the one point
  they enter the system, so cache comparisons stay consistent even if
  a server flips between JSON and HTML-fallback autoindex.
- The root author-listing fetch failing outright is now treated like
  "every author's listing failed" (a new rootFailed crawl stat) instead
  of surfacing a generic, less-specific error.
- router.js now checks both required mount points exist right after
  startup and fails with a clear, catchable message instead of a bare
  TypeError the first time showListView()/showArticleView() runs.
- The article view's "back" link now returns to whatever list/search/
  filter page the user actually came from (tracked via lastListHash)
  instead of always resetting to page 1 of everything.
- filterLabel() now checks filter.type === 'category' explicitly
  instead of falling through to it as the default case.
- Author-name filtering is now case-sensitive, matching how author
  paths/profile lookups are looked up everywhere else.
- Documented (in code comments and a new README note) that article/bio
  Markdown renders via marked.js with no HTML-sanitization pass --
  only trusted authors should be adding content.

Minor:
- Extracted applyArticleMetaFields() to share the date/reading-time/
  category-badge logic between renderCard() and loadArticle() instead
  of duplicating it.
- Clarified resolveMediaUrl()'s WordPress-migration special case as
  legacy compatibility, not a general-purpose feature.
- frontmatter.js no longer requires a trailing newline after the
  closing "---" -- a file that's nothing but frontmatter now parses
  instead of falling through to "no frontmatter at all".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 16:23:26 +02:00
biosiasandClaude Sonnet 5 a0f1ed67d9 Document embedding search, article list, and tag list independently (AI)
New README subsection under "Embedding in an existing page" explaining
that #list-view/#article-view (the only required pair, also where
search results render), #search-form/#search-input, #tag-filters, and
#year-filters are all independent mount points that can be placed
anywhere in a custom layout rather than stacked the way the demo does
-- includes a #year-filters example, which wasn't documented anywhere
before despite still being fully supported by renderFilterNavigation().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 16:00:20 +02:00
biosiasandClaude Sonnet 5 22f17a748b Make the articles/ folder location independently configurable (AI)
Added ARTICLES_URL to js/config.js, defaulting to the current
behavior (a sibling of js/ at the repo root) but editable to point
anywhere under the same origin (or a different one, CORS permitting)
-- articles no longer have to live alongside the rest of cmjs.

discovery.js's three hardcoded `${BASE_URL}articles/...` usages
(author listing, per-author directory, author bio fetch) now all go
through ARTICLES_URL instead.

Documented in README's nginx configuration section, including that
whatever ARTICLES_URL points at needs its own matching autoindex
location block.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 15:53:35 +02:00
biosiasandClaude Sonnet 5 e739c7cf8c Move marked.min.js into its own vendor/ folder (AI)
Third-party code is now kept separate from the app's own js/ modules.
Updated js/config.js's MARKED_URL to the new sibling-directory path
(../vendor/marked.min.js) and the README's project structure diagram
accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 15:28:35 +02:00
biosiasandClaude Sonnet 5 3c510ba92e Move the demo page into its own demo/ folder (AI)
index.html and its site-shell CSS (reset.css, header.css, layout.css)
now live under demo/, separate from the library's own css/, js/,
strings/, templates/, and articles/ at the repo root. Root css/ now
holds only css/components/ — the styles any embedding page actually
needs.

Fixed demo/index.html's own relative paths to the component CSS and
router.js (one level up now); its own site-shell CSS links needed no
change since they moved along with it. No BASE_URL/MARKED_URL changes
in js/config.js — those resolve from the script's own location via
import.meta.url, independent of wherever the HTML page that loaded it
lives.

Updated README.md throughout: project structure diagram, Translations'
index.html mentions, the Embedding section's site-level CSS example
paths, the nginx config's SPA location block (now /demo/ instead of
root), and Running locally's preview instructions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 15:24:47 +02:00
biosiasandClaude Sonnet 5 1dc5e16d14 Flatten cmjs/ into the repo root (AI)
The repo is already named cmjs, so the nested cmjs/ subfolder was
redundant. Moved css/, js/, strings/, templates/, and the gitignored
articles/ up to the repo root and removed the empty cmjs/ directory,
so the whole repo (or just css/js/strings/templates/articles) can be
dropped into an existing site and embedded as-is — no wrapper folder
required.

This also fixes a real bug: js/config.js's MARKED_URL previously
reached outside cmjs/ (../../js/marked.min.js) to find the vendored
marked.min.js, which already lived at the repo root as a sibling of
cmjs/ rather than inside it. Now that both files sit in the same js/
folder, MARKED_URL is a same-directory reference and no longer depends
on anything outside this one folder tree. BASE_URL's own formula is
unchanged (config.js is still exactly one level below whatever holds
articles/, strings/, and templates/ — previously that was cmjs/, now
it's the repo root).

Updated index.html, .gitignore (/cmjs/articles/ -> /articles/), and
every path reference throughout README.md (project structure diagram,
embedding instructions, nginx config, discovery/caching docs) to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 15:20:19 +02:00
biosiasandClaude Sonnet 5 d3c6789cfe Remove the tag label registry entirely (AI)
Tag ids are now displayed as-is everywhere a label was previously
looked up (card/article pills, the tag filter sidebar, the tag filter
heading, and search matching) — no more cmjs/blog-config/tags.json,
fetchTagRegistry(), or the tagLabels/ensureTags() machinery in
views.js that read it.

The tag filter sidebar now excludes year-YYYY ids by an explicit
regex instead of relying on the registry never listing them (that was
an accidental side effect of iterating tagLabels' own entries).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 13:40:33 +02:00
biosiasandClaude Sonnet 5 e7f8939ced Replace astronomy/expedition-club content with generic placeholders (AI)
The demo tag registry, site name, and the year-category tag convention
(expa-YYYY) were all modeled on a specific Czech astronomy club. Swapped
in generic, English placeholders instead:

- blog-config/tags.json: 12 generic topic tags (guides, news,
  tutorials, ...) replacing the Czech astronomy-themed ones.
- The year/category tag convention is now `year-YYYY` instead of
  `expa-YYYY` (discovery.js's category regex, the derived field renamed
  expa -> categoryYear, and every views.js/README usage).
- siteName and yearLabel strings (en/cs/sk) and index.html's hardcoded
  title/header text no longer say "Expedition Blog(s)" / "Expa {year}".
- README examples updated to match (kometa -> guides, expa-2026 ->
  year-2026).

Also regenerated all test content (cmjs/articles/, gitignored) with the
new generic tags and year- prefix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 13:35:23 +02:00
biosiasandClaude Sonnet 5 997768e2ae Flatten article storage: one file per article, shared author media folder (AI)
Articles no longer live in their own per-post directory — each is now a
single `<slug>~<DDMMYYYY>~<tag1>+<tag2>.md` file directly inside its
author's directory, alongside that author's bio file and a shared
`media/` folder for images. This lets multiple articles reference the
same picture without duplicating it per article.

discovery.js: buildStub/resolveStub parse the article's own file name
instead of a wrapping directory's; indexing's per-author listing now
filters to `.md` files (excluding the bio file) rather than
subdirectories, and the author's own media/ folder is simply not a
`.md` file, so it's excluded for free. The article file's path is now
also its own cache key (the `root` concept is gone).

cache.js: bumped CACHE_SCHEMA_VERSION (cache key shape changed) and
renamed the object store from the now-inaccurate "slugDirs".

Migrated all existing test content (10,200 articles) to the new flat
layout; no code changes needed to the articles' own content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 13:25:04 +02:00
biosiasandClaude Sonnet 5 5c716a3f9b Document nginx compression for markdown/JSON transfers (AI)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 13:14:13 +02:00
biosiasandClaude Sonnet 5 e217d76695 Pause search hydration once a page's worth of matches is found (AI)
loadSearch now scans stubs in small batches and stops as soon as it
has enough matches for the requested page (plus one, to know whether
a next page exists), instead of hydrating every article up front.
Scan progress is kept per-query so paging forward resumes without
re-checking, and paging backward needs no scanning at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 13:07:37 +02:00
biosiasandClaude Sonnet 5 885178d1e7 Switch the post-directory field separator from -- to ~ (AI)
A double-hyphen separator collides with an easy authoring typo: an
accidental extra "-" in a kebab-case slug (e.g. "hello--world")
silently misparses as slug "hello" + an invalid date, dropping the
article from the index with no visible error. "~" isn't something
anyone would type by accident while writing a normal slug, and
(like "-") is in the URI "unreserved" set, so it never needs
percent-encoding either.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 12:32:13 +02:00
biosiasandClaude Sonnet 5 122593da24 Encode article metadata into the post directory name (AI)
Replaces .tag/.date/draft marker files with a single naming
convention: <slug>--<DDMMYYYY>--<tag1>+<tag2>+... (optionally
prefixed with "_" for draft). This eliminates the per-article
listing fetch entirely — every stub field is now parsed straight
out of the directory name the author-level listing already returns,
so indexing costs one request per author, full stop, regardless of
how many articles exist.

discovery.js: buildStub()/resolveStub() become synchronous (pure
string parsing, no fetch); runCrawl() drops the wave-3
crawl/mapWithConcurrency/mtime-priority-ordering entirely, since
there's no more per-item async work to stage. hydrateArticle() and
everything downstream in views.js is unchanged, since the derived
article shape is identical either way.

Removes the example admin/hello-world article (old format, git
was tracking it) and regenerates 10 authors x 20 articles in the new
format for testing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-12 00:05:48 +02:00
biosiasandClaude Sonnet 5 58acdbff98 Document that hydration already prioritizes newest articles (AI)
No functional change — hydrateArticles() already processes stubs in
their existing array order, and discoverArticles() already sorts
that array newest-first by the authoritative date field, so search
results (and page hydration) already fill in newest-first. Adds a
comment making this explicit, since it's a non-obvious "free"
property of the sort order rather than something the code visibly
does on purpose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 21:02:30 +02:00
biosiasandClaude Sonnet 5 36297616a5 Paginate search results (AI)
router.js: recognises a trailing /page/N on a #search/... hash,
falling back to treating the whole remainder as the query for
hand-typed/legacy links (the app itself always encodes the query as
one segment, so this is unambiguous for real usage).

views.js: renderArticleListBody() is generalized to take an explicit
heading/emptyKey/loadingKey instead of assuming the article-list's
own copy, so loadSearch() can reuse it directly with a
{type:'search', value:query} pseudo-filter driving the same
pageHash()-based pagination links as author/category/tag views.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 13:51:52 +02:00
biosiasandClaude Sonnet 5 7c6e13a2fe Batch cache reads and stream search results progressively (AI)
cache.js: replaces the per-directory getCacheEntry() point lookup
with getAllCacheEntries(), a single bulk read via IDBObjectStore
.getAll(). discovery.js's runCrawl() kicks this off in parallel with
the author/post-directory requests and passes the resulting Map into
resolveStub() as a synchronous in-memory lookup, cutting hundreds of
individual IndexedDB round-trips down to one.

hydrateArticles() now takes an optional onProgress callback;
loadSearch() uses it to render matches as hydration resolves instead
of blocking on the whole result set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 13:43:48 +02:00
biosiasandClaude Sonnet 5 202f88b206 Cache stubs, prefetch next page, parallelize author profile fetch (AI)
resolveStub() now caches every resolved stub (not just ones that get
hydrated), so a repeat visit skips the listing fetch for articles
nothing has ever rendered too, not only already-viewed ones. Once a
page's cards render, the next page's content hydrates in the
background so "next" is usually an instant cache hit. loadAuthorView
fetches the profile and article list in parallel instead of
sequentially.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 13:30:27 +02:00
biosiasandClaude Sonnet 5 81fb2a2414 Defer article content fetches to page-scoped hydration (AI)
Splits discovery into a cheap indexing phase (buildStub — directory
listing only: .tag/.date markers and a new `draft` marker file, no
markdown fetch) and a separate hydrateArticle() step that only runs
for articles actually being rendered: the current page's cards, a
visited article, or a search (which needs real text, so it hydrates
everything it's searching). Sorting, filtering, pagination, and the
sidebar counts all work from stubs alone.

`draft` (an empty marker file, same pattern as .tag/.date) replaces
the frontmatter draft:/status: check entirely, since that also needs
to be knowable without a content fetch. Bumps CACHE_SCHEMA_VERSION
since the cached article shape changed (added root/hydrated fields).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 13:22:17 +02:00
biosiasandClaude Sonnet 5 60cf355352 Stream the article list as it's indexed instead of blocking on the whole tree (AI)
discoverArticles() now dedupes concurrent callers into one shared
crawl and exposes onArticlesProgress() for incremental results;
loadArticleList() (and everything built on it: pagination, author/
category/tag filters, the sidebar counts) renders as soon as the
first articles resolve and keeps updating while the rest crawl in
the background, with a "still indexing…" note until done. The crawl
also visits the most-recently-touched directories first (mtime,
best-effort) so early results tend to already be close to correct;
final display order is unchanged (always the authoritative `date`).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 13:09:55 +02:00
biosiasandClaude Sonnet 5 e58a5e1336 Co-locate images with their articles/authors instead of a shared folder (AI)
resolveMediaUrl() now resolves frontmatter image/avatar filenames
relative to the referencing .md file's own directory instead of a
shared cmjs/pictures/ folder, which is removed along with
PICTURES_URL. fetchAuthorProfile() now returns its own file path so
avatars resolve the same way. Migrates the existing hello-world
example's preview image into its article directory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 12:53:16 +02:00
biosiasandClaude Sonnet 5 c05a85b21b Remove the years sidebar from index.html (AI)
Also fixes renderFilterNavigation() to render years/tags
independently instead of bailing out entirely when #year-filters
is missing, which would have silently broken tag filtering too.
The "all articles" reset link now moves into whichever section
renders first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 12:44:01 +02:00
biosiasandClaude Sonnet 5 601f2902f5 Fix README gaps: author-profile files, tag registry, search accuracy (AI)
author-profile.css/html and cmjs/blog-config/tags.json existed and
were used but were undocumented. Also fixes the search feature
description (it already matched tags, not just title/description/
author) and adds a tag-filtering bullet to match the routing table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 12:41:41 +02:00
biosias 84f21956fa Removed export file 2026-09-11 12:37:41 +02:00
biosiasandClaude Sonnet 5 99bb85e98a Add Czech and Slovak translation files (AI)
cmjs/strings/cs.json restores the site's original Czech UI text
(now sourced as a translation file rather than hardcoded); sk.json
is a new Slovak translation. Both cover every key in en.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 12:35:03 +02:00
BiosiasandClaude Sonnet 5 9d87cffeeb Translate UI to English and extract all messages into strings files (AI)
Adds cmjs/js/i18n.js (t()/loadStrings()/getLocale()) and
cmjs/strings/en.json as the default string table; every hardcoded
Czech UI string in views.js/router.js/discovery.js and the static
index.html shell now goes through it, so a translation just means
adding cmjs/strings/<locale>.json and setting <html lang>.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 12:16:19 +02:00
AIandClaude Sonnet 5 469a19388e Add cache schema versioning and isolate per-author crawl failures (AI)
Bumps the IndexedDB DB name on schema changes so a code deploy can't
serve stale-shaped cached articles. One author's directory listing
failing no longer takes the whole site down; a health banner now
covers that all-authors-failed case too, not just the low-ratio one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 12:06:39 +02:00
AIandClaude Sonnet 5 b92bbc7129 Rewrite article discovery for author/slug layout with IndexedDB cache (AI)
Drops the expa-YYYY category directory in favor of author/slug/slug.md
with .tag and .date marker files; adds mtime-based IndexedDB caching,
concurrency-limited crawling, an HTML-autoindex mtime fallback, and the
#post/<author>/<slug> route this layout change requires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd
2026-09-11 11:18:17 +02:00
42 changed files with 2389 additions and 1027 deletions

No files matched your search

+392
View File
@@ -0,0 +1,392 @@
# Nginx Sitemap Crawler — Design Plan
_Last updated: 2026-09-10_
## Tech stack
- **Language:** pure JavaScript, running **client-side inside a
webpage** (no Node, no server-side execution anywhere in this
project — nginx serves static listings only).
## Goal
Maintain a browsable, near-real-time sitemap of a two-level nginx-served
directory tree, using only stock nginx config (no server-side scripts on
the nginx host), minimizing redundant requests over time, and surfacing
the most recently changed content first.
## Constraints
- No server-side execution on the nginx host — plain `nginx.conf` only.
- Client-side script/app is where all logic (crawling, sorting,
pagination, caching) lives.
- Tree is two levels deep (root → level-1 dirs → level-2 dirs).
---
## 1. Nginx setup
Enable machine-readable directory listings on the relevant locations:
```nginx
location /files/ {
autoindex on;
autoindex_format json;
autoindex_localtime off;
}
```
- `autoindex_format json;` — makes listings parseable instead of scraping HTML.
- `autoindex_localtime off;` — keeps timestamps in GMT, consistent and
unambiguous for comparison logic (avoids DST/timezone drift issues).
Each listing entry includes `name`, `type` (`file`/`directory`), `size`,
and `mtime`.
---
## 2. Wave-based crawling
Directory names at level 2 aren't known until level 1 is fetched, so the
crawl naturally happens in sequential **waves**, each internally
**concurrent**:
- **Wave 1 (root)** — always fetched live on every run. Single request,
cheapest possible cost, and the sole source of truth for what
currently exists and what's changed.
- **Wave 2 (level-1 → level-2)** — fetched only as needed (see §5
caching) and only for directories relevant to the current page (see
§4 pagination). Requests within a wave are fired concurrently, not
sequentially, to keep latency low regardless of directory count.
---
### 2a. Concurrency limits — **decision: configurable, not hardcoded**
Rather than a fixed worker count baked into the logic, concurrency is
exposed as a **single tunable setting** (e.g. `maxConcurrentRequests`)
that governs how many wave-2 requests are in flight at once within a
wave. Rationale and shape:
- **Why configurable:** the right concurrency value depends on factors
that vary by deployment and can't be known in advance — network
distance to the nginx host (same-region vs. cross-region), how many
directories are typically in play per page (tied to §4a page size),
how many browser tabs/users might be hitting the same nginx instance
at once, and how conservative the operator wants to be about log
noise or perceived "scanning" behavior (as noted in earlier
discussion of request bursts).
- **Where it lives:** a single config value/constant near the top of
the client script (or exposed as a UI setting/advanced option),
rather than scattered magic numbers through the crawl logic.
- **Sensible default:** start conservative (e.g. 10) — safe for both
same-region and cross-region cases, and already shown earlier in
this plan to keep even a full ~500-directory cold-start crawl under
a few seconds. Users/operators who know their network is fast and
local, or who page through smaller slices (page size 10, see §4a),
can raise it; those on slower/cross-region links or shared/production
nginx instances can lower it.
- **Interaction with pagination:** since wave-2 is now scoped to just
the current page's directories (§4a), the concurrency setting mostly
matters relative to page size — e.g. at page size 10 with
concurrency 10, an entire page's wave-2 fetches resolve in a single
batch (~1 RTT); at page size 30 with concurrency 10, the same page
takes ~3 batches. Setting concurrency ≥ page size effectively
guarantees single-batch page loads, which is a reasonable rule of
thumb for operators tuning this value.
- **No hard ceiling imposed by nginx** — as established earlier,
nginx's default `worker_connections` comfortably absorbs dozens to
low hundreds of concurrent client connections, so the practical
upper bound is really about being a good citizen (log noise, shared
hosting) rather than anything nginx itself will choke on.
---
## 3. Recency-based prioritization
- A directory's `mtime` reflects changes to its **direct** children only
(additions, removals, renames) — not deeper nested edits. This is
sufficient for this use case, since the concern is new content
appearing, not deep nested edits (see "Known limitation" below).
- After wave 1 resolves, sort level-1 entries by `mtime` descending,
with directory name ascending as a stable tie-breaker (see R5).
This ordering drives both crawl priority and display order.
---
## 4. Pagination
- Slice the sorted level-1 list into fixed-size pages (default 10,
user-selectable — see §4a).
- Only fire wave-2 requests for the directories in the **currently
viewed page** — not the whole tree.
- Page navigation reuses the already-fetched, already-sorted wave-1
list in memory; no repeated root requests needed when paging.
- Optional: prefetch the next page's wave-2 requests in the background
while the current page is being viewed, so navigation feels instant.
---
### 4a. Page size — **decision: user-selectable, default 10**
- Default page size: **10** items.
- User-selectable alternatives: **15, 20, 30**.
- Changing page size resets pagination to page 1 (avoids ambiguous
partial pages / confusing item overlap when the slice boundaries
shift).
- Since wave 1 is already fully fetched and sorted in memory (see §2,
§3), changing page size is a **pure client-side re-slice** of the
existing sorted list — no new wave-1 request needed.
- Wave-2 fetch behavior (§5, cache-aware) is unaffected by page size
itself — only which/how many level-1 directories fall into the
current slice changes, which in turn determines which cache
entries are read or fetched.
---
## 5. Sitemap cache
Persist a cache entry per directory:
```json
{
"path": "/files/project-a/",
"mtime": "Wed, 10 Sep 2026 14:32:01 GMT",
"children": [ "...level-2 entries..." ],
"children_fetched_at": "2026-09-10T15:00:00Z"
}
```
### 5a. Storage mechanism — **decision: IndexedDB**
This runs purely client-side in a webpage — no filesystem access, so
the real choice is between browser storage APIs: **localStorage** vs.
**IndexedDB**. IndexedDB was chosen:
- **Storage ceiling** — localStorage is capped around 5-10MB
(browser-dependent) and shared across the whole origin; IndexedDB's
ceiling is far higher (typically hundreds of MB+), removing a
constraint that could otherwise bite as the tree grows.
- **Per-record updates** — IndexedDB is an object store keyed by
record (naturally: path → cache entry), so updating one directory's
entry doesn't require re-serializing/rewriting the entire cache, the
way localStorage (full blob, `JSON.stringify` every write) would.
- **Non-blocking** — IndexedDB is async by nature, which fits a cache
that's written to progressively as crawl waves resolve, without
freezing the UI thread. localStorage is synchronous and would block
rendering on every read/write as the cache grows.
- **No manual serialization** — IndexedDB stores structured JS objects
directly; no need to `JSON.stringify`/`parse` on every access.
- **Still zero external dependencies** — native browser API, no
library required (though a thin promise-wrapper like `idb` is a
reasonable, optional ergonomics improvement later if the raw API
proves too verbose).
**Practical shape:**
- One object store, e.g. `directoryCache`, keyed by directory `path`.
- Each record holds `{ path, mtime, children, children_fetched_at }`
as described below.
- Reads/writes are async (`Promise`-based or wrapped in one), which
aligns naturally with the already-async wave-2 fetches.
On each run, compare each level-1 directory's **live** `mtime` (from
wave 1) against the **cached** `mtime`:
- **Match** → skip wave 2 for that directory; serve `children` from
cache.
- **Newer or missing** → fetch wave 2 live, then update the cache entry
with the new `mtime` and `children`.
Additional cache maintenance:
- **Pruning** — after wave 1, any cached level-1 entries no longer
present in the live listing should be removed (handles deletions).
- **Wave 1 is never cached** — it has no parent to diff against, and
it's already the cheapest possible request (1 per run, regardless of
tree size).
- **Cold start** — first run has no cache, so it pays the full crawl
cost once. Every subsequent run should be dramatically cheaper,
bounded roughly by how much actually changed since the last run.
---
## 6. Why this combination works well together
- Sorting by `mtime` descending means the directories most likely to
have a **stale cache** (recent changes) are exactly the ones
prioritized to the front — so page 1 concentrates the real crawl
cost, while later pages are almost pure cache hits.
- Over repeated runs, total request cost trends toward being
proportional to **how much changed**, not the size of the tree.
- Progressive/skeleton rendering (page renders immediately with cached
or partial data, updates in place as fresher wave-2 results arrive)
keeps the experience responsive even while cache misses are being
resolved in the background.
---
## Known accepted limitation
Directory `mtime` only reflects direct-child changes, not deeper nested
edits (e.g. a file modified inside an existing level-2 directory won't
bump the level-1 directory's `mtime`). Accepted as a non-issue here,
since the priority signal needed is "new subdirectories appearing,"
which this correctly captures.
---
## Longevity Considerations
_This project is intended as "deploy and forget" for a ~30 year
horizon. That's a materially different constraint than "get this
working now," and shapes several decisions beyond what pure
functionality would require._
### L1. IndexedDB eviction — not a concern
Some browsers (e.g. Safari ITP) may evict IndexedDB data for
infrequently-visited origins. **Explicitly accepted as a non-issue**:
R6's uncached fallback mode already handles any IndexedDB
unavailability gracefully (falls back to live fetching), so eviction
simply means the cache benefit doesn't materialize on that visit — not
a failure. No action needed beyond what's already in R6.
### L2. HTML fallback if JSON listing breaks — **decision: included**
`autoindex_format json` depends on nginx config staying intact over
the project's lifetime. Configs can drift — a server migration,
reconfiguration, or someone disabling `autoindex_format json` (while
leaving `autoindex on;` itself in place) is realistic over 30 years.
To survive this, the crawler includes an **HTML fallback parser**:
- On each request, if the response isn't valid JSON (parse fails, or
`Content-Type` isn't JSON), fall back to treating the response as
nginx's **default HTML autoindex output** instead of failing the
request outright.
- Parse the HTML using the browser's native `DOMParser` (built-in, no
dependency) — extract entries from the `<a href="...">` listing,
using the link `href` for name/type (trailing `/` = directory) and
the surrounding text for `mtime`/size where present.
- This is strictly a **fallback path**, not the primary path — JSON
remains preferred when available, since it's more reliable to parse
and already carries structured `mtime`. The HTML fallback exists
purely so the tool degrades to "still works, just via a different
parse path" rather than "breaks entirely" if `autoindex_format json`
is ever lost from the config.
- This directly follows the same silent-skip philosophy as R3: prefer
degraded function over hard failure, wherever degrading is possible
without violating the zero-dependency principle.
### L3. Total-failure visibility
Silent-skip (R3) is correct for individual transient request failures,
but should not extend to masking a *total* breakage (e.g. CORS
misconfigured after a migration, autoindex disabled entirely, wrong
origin). The crawler tracks a simple "N of M directories loaded"
count; if that ratio is very low (e.g. near-zero) after a crawl
completes, a minimal, dependency-free visible indicator (a status line
in the UI, no alerting infrastructure) should communicate that
something is broken, so the project doesn't fail silently for years
before a human notices.
### L4. Zero dependencies, deliberately
No npm packages, no build step, no framework — stated explicitly as a
guiding principle, not just an incidental outcome of earlier
decisions. Any dependency introduces a 30-year liability: registries
can vanish, packages can go unmaintained, and even a "small" library
can break under a future browser change with nobody left to patch it.
Future contributors should treat this as a hard constraint, not a
preference to be relaxed for convenience.
### L5. Prefer long-stable JS/browser APIs over new syntax
Favor well-established, broadly-supported features (`fetch`,
`Promise`, `async`/`await`, basic IndexedDB, `DOMParser`) over newer or
experimental JS/browser APIs. The newer something is today, the more
likely it is to be deprecated, changed, or need a polyfill by the time
this project is 15-20 years old. Nothing currently behind a flag or
"experimental" label should be used, even if convenient now.
### L6. Keep this plan attached to the code
For a deploy-and-forget project, the largest real risk isn't
technical — it's a future maintainer with zero context. This document
should ship alongside the implementation (same repository, or
inlined as a comment block) rather than live separately, so the
reasoning behind each decision (why JSON over HTML, why IndexedDB,
why silent-skip, etc.) survives together with the code itself.
### L7. nginx version/config drift
`autoindex_format json` requires a reasonably modern nginx (long
available, but not guaranteed present on every install). Document a
minimum nginx version alongside the deployment instructions, and treat
"response isn't valid JSON" as an expected, handled case (see L2)
rather than an exceptional one — since over 30 years, drift in the
server config is a "when," not an "if."
---
## Risks & Edge Cases
### R1. CORS
If the webpage is served from a different origin than the nginx
directory listing, browser `fetch()` calls will be blocked without
proper CORS headers. Requires an `add_header Access-Control-Allow-Origin
...;` (or equivalent) on the relevant nginx `location` block, unless
the page and the file server share an origin. Needs to be decided/set
up alongside the nginx config in §1.
### R2. Trailing slashes on directory requests
nginx's autoindex only serves a listing cleanly when the request URL
ends in `/`. Requesting a directory path without the trailing slash
typically triggers a redirect (e.g. 301) instead of returning the
listing directly, adding an extra round-trip. The crawler must
consistently append `/` when building wave-2 request URLs from the
`name` field returned in wave-1's JSON.
### R3. Error handling for failed requests — **decision: silent skip**
If a wave-2 request fails (timeout, 404, host briefly unreachable),
it is silently skipped: no retry, no visible error marker. The
directory in question falls back to whatever is already in the
IndexedDB cache (stale-but-present data), or is simply omitted from
that page's render if nothing is cached yet. Accepted trade-off for
simplicity — a failed fetch does not block or degrade the rest of the
page.
### R4. Race conditions from rapid navigation
Changing page size or flipping pages quickly can cause in-flight
requests from a previous page/size to resolve *after* newer ones,
overwriting current state with stale data. Mitigated with a
generation/token counter: each navigation action increments a
"current generation" value, and any response that arrives tagged with
an older generation than the current one is discarded rather than
rendered.
### R5. `mtime` tie-breaking
autoindex JSON timestamps are second-precision, so directories touched
within the same second (e.g. a batch of subdirectories created
together by a script) can tie on `mtime`. The sort in §3 uses `mtime`
descending as the primary key with **directory name ascending** as a
stable secondary key, so ordering — and therefore pagination — stays
consistent across runs instead of jittering on ties.
### R6. IndexedDB availability — **decision: uncached fallback mode**
IndexedDB may be unavailable or restricted (private/incognito modes in
some browsers) or subject to storage eviction under pressure. The
crawler treats any IndexedDB access failure (open error, blocked
transaction, etc.) as a signal to fall back to an **uncached mode**:
every wave-2 request is fetched live for the duration of that session,
with no persistence attempted. This keeps the sitemap fully functional
— just without the performance benefit of cache hits on repeat visits
— rather than erroring out or blocking the page. Detection happens
once at startup (attempt to open the DB; on failure, set an in-memory
`cacheAvailable = false` flag that the rest of the logic checks before
any cache read/write).
---
## Open items / future considerations
_All resolved — no open items remain._
Ruled out: WebDAV / `PROPFIND` (`ngx_http_dav_ext_module`) as a
single-request full-tree alternative — requires a non-stock nginx
build/module, which conflicts with the "ordinary nginx only" and
simplicity constraints. Wave-based crawling with caching (§2, §5)
remains the approach.
+2 -2
View File
@@ -1,2 +1,2 @@
/cmjs/articles/
/cmjs/pictures/
/articles/
/js/config.js
+2
View File
@@ -0,0 +1,2 @@
# Git Overrides
* Do not push commited changes in this project
+395 -92
View File
@@ -4,80 +4,188 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil
## 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
nested post directories; nginx JSON/HTML indexes and Nextcloud WebDAV PROPFIND are supported.
2. Sorting and filtering come from each post directory's *name*
(`slug--DDMMYYYY--expa-YYYY+tags/`) — knowable straight from the listing, with no
further request per article. A `.md` file's actual content (title, description, image) is
only fetched once that article is about to be shown — the current page's cards, a single
article, or a search — not for the whole site up front. See "Adding an article" and "Article
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 its ETag (or mtime fallback), so repeat visits skip re-fetching
unchanged content — see "Article discovery & caching" below.
## Features
- **Dark mode** — automatically follows the OS `prefers-color-scheme` setting; no toggle needed.
- **Client-side search** — the search box in the header filters all articles by title, description, author, and category in-memory. Navigates to `#search/query` so results are bookmarkable.
- **Client-side search** — the search box in the header filters all articles by title, description, author, category, and tags in-memory. Navigates to `#search/query` so results are bookmarkable; results paginate the same way the article list does.
- **Reading time** — estimated from the article word count (200 wpm) and shown on list cards and in article headers. Computed automatically; no frontmatter field needed.
- **Table of contents** — auto-generated from `h2`/`h3` headings and inserted at the top of the article body. Only appears when the article has three or more headings.
- **Back link** — a "← Back to articles" link above every article for one-click navigation back to the list.
- **Back link** — a "← Back to articles" link above every article, returning to whatever list, filter, or search page (and page number) the reader actually came from rather than always resetting to page 1 of everything.
- **Pagination** — article list paginates at 20 articles per page; page state is encoded in the hash so browser history works.
- **Author and category filtering** — clicking an author name or category badge shows a filtered list. Both support pagination.
- **Author, category, and tag filtering** — clicking an author name, category badge, or tag pill shows a filtered list. All three support pagination.
- **Responsive layout** — layout, cards, images, and pagination all adapt to narrow screens (breakpoint at 640 px).
- **Keyboard navigation** — all interactive elements have visible `:focus-visible` outlines for keyboard users.
- **Author profiles** — a profile card with bio and social links is shown at the top of each author's article list. Created by adding `articles/{author}/{author}.md`.
- **Offline-friendly caching** — discovered articles are cached in IndexedDB, so repeat visits skip re-fetching content that hasn't changed.
- **Translatable UI** — every UI string is looked up from a locale JSON file (English, Czech, and Slovak ship by default); adding a language needs no code changes.
## Project structure
This whole repo *is* cmjs — clone it as-is into an existing site (or copy `css/`, `js/`,
`strings/`, `templates/`, and your own `articles/` into one) and embed it into a page there; see
"Embedding in an existing page" below. `demo/` is a standalone demo/preview page, kept separate
from the library — not something an embedding page needs to bring along.
```
cmjs/
├── README.md
├── index.html # Single-page shell — served from the repo root
├── css/ # Site-level styles for index.html
│ ├── reset.css # CSS variables, dark mode, box-sizing reset, focus styles
│ ├── header.css # Site header, title link, search form
│ └── layout.css # Main content wrapper, state messages, responsive
├── demo/ # Standalone demo/preview page — not needed to embed cmjs elsewhere
│ ├── index.html # Single-page shell
│ └── css/
│ ├── reset.css # CSS variables, dark mode, box-sizing reset, focus styles
│ ├── header.css # Site header, title link, search form
│ └── layout.css # Main content wrapper, state messages, responsive
├── css/
│ └── components/ # Component styles — needed by any embedding page
│ ├── card.css # Article list cards and filter heading
│ ├── article.css # Single article view, back link, hero image
│ ├── content.css # Rendered markdown styles (.content *)
│ ├── pagination.css # Prev/next page navigation
│ ├── toc.css # Auto-generated table of contents
│ └── author-profile.css # Author profile card (avatar, bio, social links)
├── js/
│ └── marked.min.js # marked.js (local copy, loaded as a global)
└── cmjs/ # App content — articles, templates, component styles
├── css/
│ └── components/
│ ├── card.css # Article list cards and filter heading
│ ├── article.css # Single article view, back link, hero image
│ ├── content.css # Rendered markdown styles (.content *)
│ ├── pagination.css # Prev/next page navigation
│ └── toc.css # Auto-generated table of contents
├── js/
│ ├── config.js # BASE_URL, MARKED_URL, PICTURES_URL (all via import.meta.url)
│ ├── frontmatter.js # parseFrontmatter() — pure YAML-front parser
│ ├── templates.js # loadTemplates(), templates cache
│ ├── discovery.js # discoverArticles(), fetchJson(), article cache
│ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadCategoryView, loadSearch
│ └── router.js # Hash router and app entry point
├── templates/
│ ├── card.html # Article list card layout
│ └── article.html # Full article view layout
├── articles/
│ └── author/ # One directory per author
│ └── 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, loadCategoryView, loadSearch
│ └── router.js # Hash router and app entry point
├── vendor/
│ └── marked.min.js # marked.js (local copy, loaded as a global) — the only 3rd-party code
├── strings/
│ ├── en.json # UI strings (default/fallback locale) — see "Translations" below
│ ├── cs.json # Czech translation
│ └── sk.json # Slovak translation
├── templates/
│ ├── card.html # Article list card layout
│ ├── article.html # Full article view layout
│ └── author-profile.html # Author profile card layout
└── articles/ # The only folder cmjs reads
├── _tags.json # Tag registry (optional, see "Tag registry")
└── author/ # One directory per author
├── author.md # Author profile (optional)
├── avatar.jpg # Optional author avatar, referenced by author.md
└── slug--DDMMYYYY--expa-YYYY+tag1/ # One directory per article
├── slug.md
└── cover.jpg # Optional, referenced by image:
```
Drafts and archived posts live in sibling `drafts/` and `archive/` folders with the same
structure (see "Adding an article"). They sit outside `articles/`, so they are never served or
listed.
## Local configuration
`js/config-default.js` holds the shipped defaults (`BASE_URL`, `MARKED_URL`, `ARTICLES_URL`) and
is tracked in git; every other module imports from it. It also loads an optional `js/config.js`
— gitignored, not part of cmjs's own git history — to override any of them. **Nothing needs to
be created before first use**: if `js/config.js` doesn't exist, the shipped defaults apply as-is.
The split exists for using cmjs as a git submodule (or otherwise pulling in a newer version some
other way): since `config.js` is gitignored, updating cmjs never touches it, so any override
you've made survives. Only `config-default.js` gets updated.
To override a setting, create `js/config.js` and export just the constant(s) you want to change —
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;
```
An embedding page may configure a public DAV proxy without a local file:
```html
<script>window.CMJS_CONFIG = { articlesUrl: '/blog-content/', listingMode: 'webdav', tagsUrl: '/blog-content/_tags.json' };</script>
```
## Adding an article
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
An article is a Markdown file inside a post directory. Its *directory name* contains the stable
slug, date, expedition year, and topic tags. Publication status comes from the root folder the
post directory sits in:
| Root | Status | Shown by cmjs |
|------|--------|---------------|
| `articles/<author>/` | published | yes |
| `drafts/<author>/` | draft | no |
| `archive/<author>/` | archived | no |
Publishing, unpublishing, or archiving a post means moving its whole directory (Markdown and
images together) between these roots. cmjs only ever reads `articles/`.
The directory name format is:
```
<slug>--<DDMMYYYY>--expa-YYYY+<tag1>+<tag2>+…/<slug>.md
```
- `<slug>` — the stable identifier used in the article's URL (`#post/author/slug`), matching
`[a-z0-9][a-z0-9-]{0,95}`. The Markdown file inside the directory must be named `<slug>.md`.
Treat the slug as permanent: retagging or correcting the date later just means renaming the
directory (see below), but changing the slug changes the article's URL.
- `<DDMMYYYY>` — the publish date, 8 digits, no separators (e.g. `16062026` for 16 June 2026).
An invalid date (e.g. `31022026`) makes the directory be skipped.
- `expa-YYYY+<tag1>+<tag2>+…` — normalized tag ids (lowercase `[a-z0-9][a-z0-9.-]*`) joined
with `+`. `expa-YYYY` is the article's expedition/year marker, shown as a badge linking to
`#tag/expa-YYYY`; every other tag id is a normal topic tag, shown as a pill linking to
`#tag/tag-id`. Posts written by the blog editor always have exactly one `expa-YYYY` tag,
listed first. The whole `--…` tag suffix is optional for hand-made posts.
Images (cover and inline) live in the post directory, next to the Markdown file, and are
referenced by paths relative to it — `image: cover.jpg`, `![](photo.jpg)`, or a subfolder such
as `media/photo.jpg`. Absolute `https://` URLs are used as-is.
Steps:
1. Create the author directory if it doesn't exist: `articles/author/`
2. Create the post directory using the naming convention above, e.g.
`articles/author/my-article--16062026--expa-2026+guides/`.
3. Add `my-article.md` and optional media files inside that directory.
4. Add a frontmatter block at the top of the Markdown file
5. Write your content — it will appear in the list automatically
`author` and `category` are derived from the directory path — no frontmatter fields needed for them.
To change an article's date, tags, or publication state later, move/rename its directory — its
content doesn't need to change. `author` is derived from the containing directory — no frontmatter field needed
for it either. Treat the directory name as case-sensitive and exact: it's matched exactly (not
case-insensitively) against `#author/...` URLs and author-profile lookups, so avoid two author
directories that differ only in case. Because every one of these is encoded in the directory name,
discovering an article's metadata never requires fetching anything beyond the directory listing
that already reveals it — no further per-article request at all, which is what makes the "Article
discovery & caching" section below possible.
> **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
---
schema: 2
title: My Article Title
date: 2026-06-16
description: A short summary shown in the article list.
draft: false
authorName: Jane Doe
image: cover.jpg
updatedAt: '2026-08-27T21:14:03+02:00'
---
Your markdown content here...
@@ -86,19 +194,21 @@ 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 |
| `description` | no | Short summary shown under the title in the list; an excerpt of the body is used when missing |
| `authorName` | no | Display name of the author; falls back to the author directory name |
| `image` | no | Cover image, relative to the Markdown file (e.g. `cover.jpg`); shown as card thumbnail and article hero. `cover` is accepted as an alias |
| `schema`, `updatedAt` | no | Written by the blog editor; ignored by cmjs |
Reading time is computed automatically from the article word count — no frontmatter field needed.
Reading time is computed automatically from the article word count — no frontmatter field
needed. `date`, `tags`, `status`, `author`, `expa`, and `draft` frontmatter fields, if present,
are ignored — superseded by the directory name and root described above.
## Author profiles
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,36 +232,78 @@ 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 — usually a bare filename such as `avatar.jpg` kept directly beside `{author}.md`. Displayed as a circular thumbnail. |
| `github` / `twitter` / `linkedin` / `mastodon` / `website` | Any subset of these renders as pill links below the bio. |
The body text below the frontmatter is rendered as markdown and shown as the bio.
## Tag registry
`articles/_tags.json` (or whatever `TAGS_URL` points to) holds human-readable tag labels:
```json
{"schemaVersion": 1, "tags": [{"id": "guides", "label": "Guides", "description": "", "aliases": [], "active": true}]}
```
The blog editor maintains this file. cmjs reserves the `TAGS_URL` setting for it but doesn't read
it yet, so tag pills currently show the raw tag ids.
## Routing
| URL hash | View |
|----------|------|
| *(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/expa-2026` | All articles carrying the `expa-2026` expedition tag, page 1 |
| `#tag/expa-2026/page/2` | Same, page 2 |
| `#tag/guides` | All articles tagged "guides", page 1 |
| `#tag/guides/page/2` | Same, page 2 |
| `#post/alice/my-article` | Single article at `articles/alice/my-article--DDMMYYYY--expa-YYYY+tags/my-article.md` |
Author names and category tags on each card and article are clickable links to their respective filter views. A search box in the header navigates to `#search/query`.
## Customising the layout
The HTML structure for the article list card and the full article view live in `cmjs/templates/`:
The HTML structure for the article list card, the full article view, and the author profile
card live in `templates/`:
- `cmjs/templates/card.html` — the card shown in the article list
- `cmjs/templates/article.html` — the full article view (header, hero image, content)
- `templates/card.html` — the card shown in the article list
- `templates/article.html` — the full article view (header, hero image, content)
- `templates/author-profile.html` — the profile card (avatar, bio, social links)
Edit these files directly to change layout — no JS changes needed. Elements are identified by `data-field` attributes, which is how the JS modules find and populate them. Keep existing `data-field` values when rearranging; add new ones if you add new fields.
## Translations
Every UI string the JS renders (loading/error states, pagination, filter labels, the table of
contents heading, etc.) is looked up through `t(key, params)` in `js/i18n.js` rather than
hardcoded — `strings/en.json` is the default/fallback string table.
Available locales: `en` (default), `cs` (Czech), `sk` (Slovak).
To add another translation:
1. Set `<html lang="xx">` on the embedding page (or `demo/index.html` for this repo's own demo)
to the target language code (e.g. `cs`).
2. Add `strings/xx.json` with the same keys as `en.json` (copy it as a starting point) —
you only need to include the keys you're translating; anything missing quietly falls back to
English rather than breaking.
3. `{placeholder}` tokens in a string (e.g. `"{page} of {totalPages}"`) are substituted at
render time — keep them in your translated string, in whatever order/position reads
naturally in that language.
The resolved locale also drives date formatting (`Intl.DateTimeFormat`), search matching, and
tag-label sorting (`localeCompare`), via `getLocale()` in the same module — so a translation
file doesn't just relabel text, it affects how dates and sorting behave too.
Site-specific branding that lives in the embedding page itself (the page `<title>`, the header
search placeholder, the sidebar section headings) is plain static HTML, not JS-templated —
translate it by editing that page directly (`demo/index.html` for this repo's own demo).
## Embedding in an existing page
To add cmjs to any HTML page, three things are needed: the CSS, the two mount-point `<div>`s, and the script.
@@ -160,19 +312,20 @@ To add cmjs to any HTML page, three things are needed: the CSS, the two mount-po
```html
<!-- Site-level styles (or replace with your own) -->
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/header.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="demo/css/reset.css">
<link rel="stylesheet" href="demo/css/header.css">
<link rel="stylesheet" href="demo/css/layout.css">
<!-- Component styles -->
<link rel="stylesheet" href="cmjs/css/components/card.css">
<link rel="stylesheet" href="cmjs/css/components/article.css">
<link rel="stylesheet" href="cmjs/css/components/content.css">
<link rel="stylesheet" href="cmjs/css/components/pagination.css">
<link rel="stylesheet" href="cmjs/css/components/toc.css">
<link rel="stylesheet" href="css/components/card.css">
<link rel="stylesheet" href="css/components/article.css">
<link rel="stylesheet" href="css/components/content.css">
<link rel="stylesheet" href="css/components/pagination.css">
<link rel="stylesheet" href="css/components/toc.css">
<link rel="stylesheet" href="css/components/author-profile.css">
```
The site-level CSS files (`reset.css`, `header.css`, `layout.css`) are optional — they style the shell around the CMS. If your page already has a layout and reset, you can skip them and only load the component stylesheets. Note that `reset.css` also defines the CSS custom properties (colours, radius) used throughout — if you skip it, define the variables yourself.
The site-level CSS files (`reset.css`, `header.css`, `layout.css`) are optional — they style the shell around the CMS. If your page already has a layout and reset, you can skip them and only load the component stylesheets. Note that `reset.css` also defines the CSS custom properties (colours, radius) used throughout — if you skip it, define the variables yourself. `author-profile.css` is only needed if any of your authors have a profile file (see "Author profiles" above) — safe to skip otherwise.
### 2. Mount points — in `<body>`
@@ -181,30 +334,62 @@ The site-level CSS files (`reset.css`, `header.css`, `layout.css`) are optional
<div id="article-view" hidden></div>
```
Place these wherever you want the CMS to appear. The router toggles `hidden` between them on navigation.
Optionally add a search form anywhere in the page:
```html
<form id="search-form" role="search">
<input type="search" id="search-input" placeholder="Search articles…" autocomplete="off">
</form>
```
Place these wherever you want the CMS to appear. The router toggles `hidden` between them on navigation. A search box and tag/year filter lists are optional and can go anywhere else in the page — see "Search, article list, and tag list as independent pieces" below. Unlike those, `#list-view`/`#article-view` are both required — router.js checks for them at startup and shows a clear error (scoped to whichever of the two is present, or the whole page if neither is) instead of silently failing if one is missing.
### 3. Script — before `</body>`
```html
<script type="module" src="cmjs/js/router.js"></script>
<script type="module" src="js/router.js"></script>
```
`router.js` is the only script tag needed. It loads `marked.min.js` automatically at startup (path configured in `cmjs/js/config.js`).
`router.js` is the only script tag needed. It loads `marked.min.js` automatically at startup (path configured in `js/config.js`).
> **Subdirectory pages:** If the embedding page lives in a subdirectory (e.g. `/blog/index.html`), relative paths like `cmjs/...` will resolve incorrectly. Use root-relative paths with a leading `/` for the CSS links and script tag:
> **Subdirectory pages:** If the embedding page lives in a subdirectory (e.g. `/blog/index.html`) and cmjs isn't installed at the site root, a plain relative path like `css/...` will resolve against the *page's* directory, not wherever cmjs actually lives. Use a path (root-relative or otherwise) that correctly points at wherever you placed it, e.g. if cmjs lives at `/cmjs/` on your site:
> ```html
> <link rel="stylesheet" href="/cmjs/css/components/card.css">
> <script type="module" src="/cmjs/js/router.js"></script>
> ```
> All internal fetches (articles, templates, marked.js) resolve relative to the module files via `import.meta.url` and work automatically — only the HTML tags need the `/` prefix.
> All internal fetches (articles, templates, marked.js) resolve relative to the module files via `import.meta.url` and work automatically — only the HTML tags need a path pointing at the right place.
### Search, article list, and tag list as independent pieces
The mount points above don't have to sit next to each other, or even all be present — each is a
plain element the router looks up by `id`, wherever it happens to be in your page. This lets you
scatter search, the article list, and the tag list across a custom layout (search box in the site
header, tag list in a sidebar, articles in the main column) instead of stacking them the way the
demo does.
- **Article list + single article — `#list-view` / `#article-view`.** These two are the only
required pair; the router toggles `hidden` between them and always needs both present somewhere
on the page. This pair is also where **search results** render — there's no separate results
container, so any page using the search box below still needs `#list-view` on it somewhere to
actually show what was searched for.
- **Search box — `#search-form` / `#search-input`.** Fully optional and independent; place it
anywhere (e.g. a site-wide header, not necessarily near the list). Submitting it navigates to
`#search/query`; matches render into `#list-view`.
```html
<form id="search-form" role="search">
<input type="search" id="search-input" placeholder="Search articles…" autocomplete="off">
</form>
```
- **Tag list — `#tag-filters`.** Optional; put a container (a `<nav>` works well) anywhere, e.g. a
sidebar. It's automatically filled in with one link per tag in use (alphabetical, with an
article count) every time a list view renders — no manual updates needed. Clicking a tag
navigates to `#tag/tag-id`, rendering the filtered list into `#list-view`.
```html
<nav id="tag-filters" aria-label="Filter by tag"></nav>
```
- **Year list — `#year-filters`** (optional, not used by this repo's own demo but supported the
same way): a container that gets filled in with one link per year 2026 down to 2009 that has at
least one article, linking to `#tag/expa-YYYY`. Can appear alongside `#tag-filters` or on
its own.
```html
<nav id="year-filters" aria-label="Filter by year"></nav>
```
Both filter lists are populated by the same function on every list render, so whichever of
`#tag-filters`/`#year-filters` is present just gets filled in — omit either one (or both) and
nothing breaks, the article list and search still work standalone.
### Minimal example
@@ -215,24 +400,30 @@ 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/`.
For a Nextcloud public share, proxy only the read-only `articles` share and
allow `GET`, `HEAD`, and `PROPFIND`; set `window.CMJS_CONFIG.listingMode` to
`webdav`. Nextcloud requires `X-Requested-With: XMLHttpRequest` on DAV
requests. A site-specific template is included in
`astronomickaexpedice.cz/deploy/blog-content.nginx.conf.template`.
Point nginx `root` at the repo root. The `autoindex` directive applies recursively to all subdirectories under `/articles/`.
```nginx
server {
@@ -240,20 +431,131 @@ server {
server_name example.com;
root /var/www/cmjs;
# Serve the SPA
location / {
try_files $uri $uri/ /index.html;
# Serve the demo page
location /demo/ {
try_files $uri $uri/ /demo/index.html;
}
# Enable JSON directory listing for article discovery (applies to all subdirs)
location /cmjs/articles/ {
location /articles/ {
autoindex on;
autoindex_format json;
autoindex_localtime off; # keep mtimes in GMT — the crawler's cache relies on consistent, unambiguous timestamps
}
}
```
> **Note:** `autoindex_format json` requires nginx 1.7.9+. Check with `nginx -v`.
> **Note:** `autoindex_format json` requires nginx 1.7.9+. Check with `nginx -v`. If
> `autoindex_format json` is ever lost (config drift, an older nginx), the crawler falls back
> to parsing nginx's default HTML directory listing instead of breaking outright.
### Pointing articles/ somewhere else
By default the crawler looks for articles at `articles/` next to the rest of cmjs (`ARTICLES_URL`,
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 — it doesn't have to be named `articles/` or nested under wherever cmjs itself lives:
```nginx
location /blog-content/articles/ {
autoindex on;
autoindex_format json;
autoindex_localtime off;
}
```
### Compression
Markdown and directory-listing JSON are both plain text, so gzip shrinks them well — and since
the browser decompresses transparently, this needs no changes on the cmjs side. nginx doesn't
know `.md` is text by default (it falls back to `application/octet-stream`, which `gzip_types`
won't match), so map it explicitly and enable gzip for both content types:
```nginx
# in http {} or a shared mime block
types {
text/markdown md;
}
# in http {} or the server block above
gzip on;
gzip_types text/markdown text/plain application/json; # json covers the autoindex_format listings
gzip_min_length 256;
gzip_vary on;
```
Pre-compressing with `gzip_static` is possible but needs a `.md.gz` alongside every article, which
is a build step this project deliberately has none of — plain runtime `gzip on` is the better fit
here. Brotli usually compresses text better still, but needs the separate `ngx_brotli` module,
not built into stock nginx — only worth it if you're already building custom nginx modules.
## Article discovery & caching
Discovery happens in two phases. **Indexing** crawls `articles/` client-side to build a
lightweight "stub" for every article — the author directories, then each author's own directory
listing (their post directories, bio file, and avatar together) along with each entry's ETag
(WebDAV) or `mtime` (nginx). Anything that isn't a directory with a valid post name (the
author's own `{author}.md`, the avatar, stray files) is simply filtered out of that same
listing. That's it: because a post directory's *name* already encodes its slug, date, and tags
(see "Adding an article" above), that single listing is enough to sort, filter, paginate, and populate the sidebar's tag/year counts, without
any further request per article and without ever fetching an article's actual Markdown.
**Hydration** is the separate, deferred step that fetches a specific article's content (title,
description, cover image, reading time) — it only ever runs for articles about to be rendered:
the current page's cards (`hydrateArticles()` in `js/discovery.js`), a single visited
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 Markdown file's own path and invalidated by comparing the post directory's live
ETag (or `mtime` when the listing has no ETags) against the cached one — once *any* visit
renders an article, every later visit skips its content fetch too, until the directory is
renamed (a rename changes the cache key, so a retagged/redated article is treated as new — see
"Adding an article" above) or its contents change. Indexing reads the entire cache
once up front (`getAllCacheEntries()`, kicked off in parallel with the author/directory-listing
requests) so a warm cache doesn't cost an IndexedDB read per article. If IndexedDB is unavailable
(private browsing, disabled storage), discovery transparently falls back to fetching everything
live on every visit.
- **Cache schema version** — the cached records store the full derived article shape, not raw
content, so a code change to how articles are derived (in `js/discovery.js`) can make old
cached entries stale in *structure*, not just content — and an ETag or `mtime` won't catch that.
Bump `CACHE_SCHEMA_VERSION` in `js/cache.js` whenever that shape changes; it's baked into
the IndexedDB database name, so a bump invalidates every cached entry on the next deploy.
- **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `js/discovery.js` (default `10`) caps how
many hydration (content) fetches run at once — the only per-article requests left, since
indexing no longer needs one; raise it for a fast/local nginx, lower it for a slow or shared/
production instance.
- **Prefetching** — once the current page has fully rendered, the next page's articles are
hydrated in the background (fire-and-forget), so clicking "next" is usually an instant cache
hit rather than a fresh round-trip.
- **Paused/resumable search** — search needs real text (title, description), unlike the list
view, so it hydrates articles rather than just reading stubs — but only as many as the page
being viewed needs, in the same newest-first order as everything else (`loadSearch()` in
`js/views.js`). It scans stubs in small batches, stops as soon as it's found enough matches
to fill the current page (plus one, just to know whether a next page exists), and resumes
exactly where it left off — no re-checking — if the user pages further into the *same* query.
Paging back needs no scanning at all, since matches only ever accumulate. This keeps a search on
a large site to a handful of requests instead of hydrating the entire article set up front. The
page count is exact once the whole set's been scanned, and a lower bound (at least one more
page) until then.
- **Crawl health** — if the root author-listing fetch fails outright, if every individual
author's own directory listing fails, or if very few of the known candidate files parsed
successfully (a malformed directory name, or a total breakage like the wrong origin or CORS
misconfigured), a small warning banner is shown above the article list instead of silently
rendering an empty page (see `getCrawlStats()` / `renderCrawlHealthWarning()` in
`js/discovery.js` / `js/views.js`).
- **Design rationale** — the original crawler design (wave-based crawling with a per-article
listing fetch, recency prioritisation, HTML-autoindex fallback, long-term "deploy and forget"
constraints) is documented in
[`.claude/nginx-sitemap-crawler-plan.md`](.claude/nginx-sitemap-crawler-plan.md), which ships
alongside the code. The per-article listing fetch it describes no longer exists (all metadata
needed for indexing is in the post directory name, so the author listing is enough), but the
rest of that reasoning — the caching model, the longevity constraints, the HTML fallback — still
applies.
## Running locally
@@ -262,5 +564,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
View File
@@ -1 +0,0 @@
-166
View File
@@ -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
}
]
}
-99
View File
@@ -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
-11
View File
@@ -1,11 +0,0 @@
// All paths are derived from this module's own location via import.meta.url,
// so they resolve correctly regardless of where the embedding HTML page lives.
// Base URL of the cmjs/ directory (e.g. https://example.com/cmjs/)
export const BASE_URL = new URL('../', import.meta.url).href;
// Path to the marked.js vendor script (lives one level above cmjs/)
export const MARKED_URL = new URL('../../js/marked.min.js', import.meta.url).href;
// Base URL of the pictures directory — prefix article image filenames with this
export const PICTURES_URL = new URL('../pictures/', import.meta.url).href;
-154
View File
@@ -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(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
if (!href || href.startsWith('../') || href.startsWith('/') || href.startsWith('#') || /^[a-z][a-z\d+.-]*:/i.test(href)) continue;
const clean = href.split(/[?#]/, 1)[0];
const name = decodeURIComponent(clean.replace(/\/$/, '')).split('/').pop();
if (!name || name === '.' || name === '..' || seen.has(name)) continue;
seen.add(name);
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; }
}
-81
View File
@@ -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(() => {}));
-308
View File
@@ -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>`; }
}
View File
Whitespace-only changes.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

File renamed without changes.
File renamed without changes.
@@ -15,6 +15,13 @@
border-color: var(--color-accent);
}
.card--clickable { cursor: pointer; }
.card--clickable:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 4px;
}
.card--no-image { grid-template-columns: 1fr; }
.card-media {
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2 -2
View File
@@ -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;
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1">
<title>Example Blog</title>
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/header.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="../css/components/card.css">
<link rel="stylesheet" href="../css/components/article.css">
<link rel="stylesheet" href="../css/components/content.css">
<link rel="stylesheet" href="../css/components/pagination.css">
<link rel="stylesheet" href="../css/components/toc.css">
<link rel="stylesheet" href="../css/components/author-profile.css">
</head>
<body class="blog-page">
<header>
<!-- Site title always links back to the article list -->
<a href="#" id="site-title">Example Blog</a>
<!-- Search form — submits to #search/query via router.js -->
<form id="search-form" role="search">
<input type="search" id="search-input" placeholder="Search articles…" autocomplete="off" aria-label="Search articles">
<button type="submit">Search</button>
</form>
</header>
<main class="blog-shell">
<section class="blog-content" aria-label="Articles">
<!-- Article list: shown when no hash is present -->
<div id="list-view"></div>
<!-- Article view: shown when a slug hash is present -->
<div id="article-view" hidden></div>
</section>
<aside class="blog-sidebar" aria-label="Blog filters">
<section class="filter-section">
<h2>Tags</h2>
<nav id="tag-filters" aria-label="Filter by tag"></nav>
</section>
</aside>
</main>
<script type="module" src="../js/router.js"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
server {
listen 80;
root /usr/share/nginx/html;
autoindex on;
autoindex_format json;
autoindex_localtime off;
location / { try_files $uri $uri/ =404; }
}
-52
View File
@@ -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>
+105
View File
@@ -0,0 +1,105 @@
// Persistent cache for discovered articles, keyed by each article file's own
// path. Raw IndexedDB (no wrapper library — see
// .claude/nginx-sitemap-crawler-plan.md L4/L5 on the zero-dependency,
// long-stable-API requirement). If IndexedDB is unavailable (private
// browsing, old browser, blocked), every operation below silently becomes a
// no-op so discovery.js gets live-fetch behaviour for free without needing
// its own availability branching (plan doc R6).
// Bump this whenever discovery.js's derived article record shape changes.
// The version is baked into the DB name so a code deploy invalidates every
// cached entry immediately, instead of stale-shaped records lingering until
// each file's mtime happens to change — which, on a "deploy and forget"
// site, could be years.
const CACHE_SCHEMA_VERSION = 3;
const DB_NAME = `cmjs-cache-v${CACHE_SCHEMA_VERSION}`;
const STORE_NAME = 'articleFiles';
let cacheAvailable = true;
let dbPromise = null;
function openDb() {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB unavailable')); return; }
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE_NAME, { keyPath: 'path' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () => reject(new Error('IndexedDB open blocked'));
}).catch(error => { cacheAvailable = false; throw error; });
}
return dbPromise;
}
// Opens a store and resolves/rejects with the given IDBRequest's own outcome
// — the standard pattern, so result timing never depends on transaction
// lifecycle details.
async function runRequest(mode, makeRequest) {
if (!cacheAvailable) return undefined;
let db;
try {
db = await openDb();
} catch {
return undefined; // openDb() itself already flipped cacheAvailable
}
try {
const store = db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
return await new Promise((resolve, reject) => {
const request = makeRequest(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
} catch {
// A single request/transaction failing (e.g. one write hitting a
// storage quota) doesn't mean IndexedDB itself is gone — only
// openDb() failing disables the cache wholesale. Leave
// cacheAvailable as-is so later calls can still try.
return undefined;
}
}
// Returns every cached { path, mtime, article } record as a Map keyed by
// path — one bulk read instead of a separate IndexedDB round-trip per
// article file, which matters once there are hundreds of them: discovery.js
// reads this once up front rather than doing a point lookup per file during
// indexing. Empty Map on a miss or if the cache is unavailable.
export async function getAllCacheEntries() {
const records = await runRequest('readonly', store => store.getAll());
return new Map((records || []).map(record => [record.path, record]));
}
export async function setCacheEntry(path, entry) {
await runRequest('readwrite', store => store.put({ ...entry, path }));
}
// Removes any cached entry whose path is no longer in the live directory
// listing (renamed/deleted slugs), so the cache doesn't grow unbounded.
export async function pruneCache(validPaths) {
if (!cacheAvailable) return;
const valid = new Set(validPaths);
let db;
try {
db = await openDb();
} catch {
return; // openDb() itself already flipped cacheAvailable
}
try {
const store = db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME);
await new Promise((resolve, reject) => {
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = event => {
const cursor = event.target.result;
if (!cursor) { resolve(); return; }
if (!valid.has(cursor.value.path)) cursor.delete();
cursor.continue();
};
cursorRequest.onerror = () => reject(cursorRequest.error);
});
} catch {
// Same reasoning as runRequest() above — a failed prune pass doesn't
// mean IndexedDB itself is unavailable.
}
}
+15
View File
@@ -0,0 +1,15 @@
// Runs `fn` over `items` with at most `limit` calls in flight at once.
// A worker-pool: each worker pulls the next index off a shared cursor until
// the list is exhausted, so slow items don't stall workers behind them.
export async function mapWithConcurrency(items, limit, fn) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await fn(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
+49
View File
@@ -0,0 +1,49 @@
// 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;
const pageConfig = (typeof window !== 'undefined' && window.CMJS_CONFIG) || {};
const resolveConfiguredUrl = value => {
try { return new URL(value, typeof document !== 'undefined' ? document.baseURI : DEFAULT_BASE_URL).href; }
catch { return value; }
};
let overrides = {};
try {
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 = resolveConfiguredUrl(pageConfig.baseUrl ?? pageConfig.BASE_URL ?? overrides.BASE_URL ?? DEFAULT_BASE_URL);
// Path to the marked.js vendor script — lives in vendor/, a sibling of this
// file's own js/ directory, not alongside the app's own modules.
export const MARKED_URL = resolveConfiguredUrl(pageConfig.markedUrl ?? pageConfig.MARKED_URL ?? overrides.MARKED_URL ?? DEFAULT_MARKED_URL);
// Base URL of the articles/ folder. Defaults to a sibling of this file's own
// js/ directory (the repo root's own articles/), but articles don't have to
// live anywhere near the rest of cmjs — override it in config.js to point
// anywhere under the same origin, or even a different one, CORS permitting.
// Whatever it resolves to must still be a directory nginx serves a JSON (or
// HTML) autoindex listing for — see "nginx configuration" in the README.
export const ARTICLES_URL = resolveConfiguredUrl(pageConfig.articlesUrl ?? pageConfig.ARTICLES_URL ?? overrides.ARTICLES_URL ?? DEFAULT_ARTICLES_URL);
export const LISTING_MODE = pageConfig.listingMode ?? pageConfig.LISTING_MODE ?? overrides.LISTING_MODE ?? 'auto';
export const TAGS_URL = resolveConfiguredUrl(pageConfig.tagsUrl ?? pageConfig.TAGS_URL ?? overrides.TAGS_URL ?? new URL('_tags.json', ARTICLES_URL).href);
+459
View File
@@ -0,0 +1,459 @@
import { parseFrontmatter } from './frontmatter.js';
import { BASE_URL, ARTICLES_URL, LISTING_MODE } from './config-default.js';
import { mapWithConcurrency } from './concurrency.js';
import { getAllCacheEntries, setCacheEntry, pruneCache } from './cache.js';
import { t } from './i18n.js';
// Caps simultaneous markdown content fetches during hydration (see
// hydrateArticle()/hydrateArticles()) — the only per-article requests left,
// since indexing no longer needs a per-article listing fetch at all (every
// stub field is encoded in its file's own name — see buildStub()). Raise it
// for a fast/local nginx, lower it for a slow or shared/production instance
// — see .claude/nginx-sitemap-crawler-plan.md §2a for the original rationale
// (written when this also governed indexing requests).
const MAX_CONCURRENT_REQUESTS = 10;
const MONTH_ABBREVIATIONS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function parseHtmlDirectoryListing(raw) {
const entries = [];
const seen = new Set();
const anchors = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>[\s\S]*?<\/a>/gi;
// nginx's default autoindex puts "DD-Mon-YYYY HH:MM" right after the </a>
// on the same line — used as a best-effort mtime when JSON autoindex
// isn't available (see the "mtime" note in the README).
const datePattern = /(\d{2})-([A-Za-z]{3})-(\d{4})\s+(\d{2}):(\d{2})/;
let match;
while ((match = anchors.exec(raw))) {
const href = match[1].replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
if (!href || href.startsWith('../') || href.startsWith('/') || href.startsWith('#') || /^[a-z][a-z\d+.-]*:/i.test(href)) continue;
const clean = href.split(/[?#]/, 1)[0];
const name = decodeURIComponent(clean.replace(/\/$/, '')).split('/').pop();
if (!name || name === '.' || name === '..' || seen.has(name)) continue;
seen.add(name);
const lineEnd = raw.indexOf('\n', anchors.lastIndex);
const tail = raw.slice(anchors.lastIndex, lineEnd === -1 ? raw.length : lineEnd);
const dateMatch = datePattern.exec(tail);
const monthIndex = dateMatch ? MONTH_ABBREVIATIONS.indexOf(dateMatch[2]) : -1;
const mtime = monthIndex >= 0
? new Date(Date.UTC(+dateMatch[3], monthIndex, +dateMatch[1], +dateMatch[4], +dateMatch[5])).toISOString()
: null;
entries.push({ name, type: clean.endsWith('/') ? 'directory' : 'file', mtime });
}
return entries;
}
export async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`);
const raw = await response.text();
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
} catch { /* HTML autoindex fallback */ }
const entries = parseHtmlDirectoryListing(raw);
if (entries.length || /<(?:title|h1)[^>]*>\s*(?:index of|directory listing)/i.test(raw)) return entries;
throw new Error(`Expected a directory listing at ${url}`);
}
export async function fetchWebDav(url) {
const response = await fetch(url, { method: 'PROPFIND', headers: { Depth: '1', 'Content-Type': 'application/xml; charset=utf-8', 'X-Requested-With': 'XMLHttpRequest' } });
if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`);
const xml = new DOMParser().parseFromString(await response.text(), 'application/xml');
if (xml.querySelector('parsererror')) throw new Error(`Invalid WebDAV listing at ${url}`);
const requested = new URL(url, typeof document !== 'undefined' ? document.baseURI : url).pathname.replace(/\/$/, '');
const seen = new Set(), entries = [];
const children = (node, name) => [...node.getElementsByTagNameNS('*', name)];
[...xml.getElementsByTagNameNS('*', 'response')].forEach((node, index) => {
const href = children(node, 'href')[0]?.textContent;
if (!href) return;
const parsed = new URL(href, url);
let path;
try { path = decodeURIComponent(parsed.pathname).replace(/\/$/, ''); } catch { return; }
// A reverse proxy may leave Nextcloud's internal href untouched
// (e.g. /remote.php/dav/...) while the request URL is /blog-content/.
// In that case the first collection response is still the requested
// root, just under a different public path. Skip it just as we do
// when the paths match.
if (path === requested || (index === 0 && children(node, 'collection').length)) return;
const name = path.split('/').pop();
if (!name || seen.has(name)) return;
seen.add(name);
entries.push({ name, type: children(node, 'collection').length ? 'directory' : 'file', mtime: children(node, 'getlastmodified')[0]?.textContent || null, etag: children(node, 'getetag')[0]?.textContent || null });
});
return entries;
}
export function fetchDirectory(url) {
return LISTING_MODE === 'webdav' ? fetchWebDav(url) : fetchJson(url);
}
// Images live alongside whatever references them: every article file sits
// directly in its author's own directory (see buildStub()), next to that
// author's shared `media/` folder — so `articlePath` (the referencing .md
// file's own URL) is the resolution base. A relative path in frontmatter
// like `media/cover.jpg` resolves against the article file's own directory,
// which is the author directory itself, landing it in that shared folder —
// letting multiple articles reference the same image without duplicating it
// per article.
export function resolveMediaUrl(value, articlePath = '') {
if (!value) return '';
let path = String(value).trim();
if (/^(data:|blob:)/i.test(path)) return path;
if (/^https?:\/\//i.test(path)) {
try {
const url = new URL(path);
// Legacy compatibility, not a general-purpose feature: content
// migrated from a WordPress export sometimes still carries an
// absolute upload URL. Reduce it to a bare filename so it
// resolves against the article's own directory like any other
// relative image path, assuming the file itself was migrated
// alongside the article. A no-op for anything that isn't
// WordPress-derived content — most deployments never hit this.
if (url.pathname.includes('/wp-content/uploads/')) path = url.pathname.split('/wp-content/uploads/')[1];
else return path;
} catch { return path; }
}
return new URL(path.replace(/^\//, ''), articlePath || BASE_URL).href;
}
let articleCache = null;
let crawlPromise = null;
let crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: false };
// Raw (unfiltered/unsorted) article records resolved so far by the crawl
// currently or most recently in flight — lets a late subscriber (a view
// that mounts mid-crawl) immediately see what's already available instead
// of waiting for the next progress tick.
let partialResolved = [];
const progressListeners = new Set();
function sortAndFilterPublished(rawArticles) {
return rawArticles
.filter(article => article?.published && article.date)
.sort((a, b) => String(b.date).localeCompare(String(a.date)) || a.hash.localeCompare(b.hash));
}
// Subscribes to incremental crawl progress so a large site's article list
// can render as soon as the first articles are indexed instead of blocking
// on the whole tree. `listener(articles, done)` is called immediately with
// whatever has resolved so far, again as more of the crawl resolves, and a
// final time with `done: true`. Returns an unsubscribe function — always
// call it once the caller stops rendering (e.g. the user navigated away),
// otherwise a background crawl keeps calling a listener for a view that's
// no longer showing.
export function onArticlesProgress(listener) {
progressListeners.add(listener);
if (partialResolved.length) listener(sortAndFilterPublished(partialResolved), false);
return () => progressListeners.delete(listener);
}
function notifyProgress(done) {
const snapshot = sortAndFilterPublished(partialResolved);
progressListeners.forEach(listener => listener(snapshot, done));
}
// How many of the last crawl's candidate files actually produced an article
// vs. how many were listed, plus how many authors' listings failed
// outright — lets the UI show a "something's badly wrong" indicator instead
// of silently rendering an empty/near-empty list forever.
export function getCrawlStats() {
return { ...crawlStats };
}
function excerpt(content) {
const text = String(content || '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/`{1,3}[^`]*`{1,3}/g, '')
.replace(/[>#*_~\\]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (text.length <= 280) return text;
return `${text.slice(0, 277).replace(/\s+\S*$/, '')}…`;
}
// An article file's own name is the sole authoritative source of its slug,
// publish date, tags/category, and draft status — see the README's "Adding
// an article" section. Format: `<slug>~<DDMMYYYY>[~tag1+tag2+…].md`,
// optionally prefixed with `_` for draft. `~` (rather than e.g. "--") is
// deliberate: a slug is expected to be plain kebab-case, and "~" isn't a
// character anyone would type by accident while writing one — unlike a
// double-hyphen separator, which collides with an easy slug typo (an extra
// "-") and silently drops the whole article instead of erroring. Returns
// null for any file name that doesn't match — callers treat that as a
// silent skip, e.g. the author's own bio file or a stray unrelated file.
function parsePostDirectoryName(dirName) {
const match = String(dirName).match(/^(.+?)--(\d{8})(?:--(.+))?$/);
if (!match) return null;
const [, slug, dateSegment, tagsSegment] = match;
if (!/^[a-z0-9][a-z0-9-]{0,95}$/.test(slug)) return null;
const day = dateSegment.slice(0, 2);
const month = dateSegment.slice(2, 4);
const year = dateSegment.slice(4, 8);
const parsedDate = new Date(Date.UTC(+year, +month - 1, +day));
if (parsedDate.getUTCFullYear() !== +year || parsedDate.getUTCMonth() !== +month - 1 || parsedDate.getUTCDate() !== +day) return null;
const tags = tagsSegment ? tagsSegment.split('+').filter(Boolean) : [];
if (tags.some(tag => !/^[a-z0-9][a-z0-9+.-]{0,95}$/.test(tag))) return null;
return { slug, date: `${year}-${month}-${day}`, tags };
}
// Everything an article card/filter/sort needs, parsed entirely from its
// 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, dirName, mtime, authorRoot, etag = null) {
const parsed = parsePostDirectoryName(dirName);
if (!parsed) return null;
const { slug, date, tags } = parsed;
const root = `${authorRoot}${encodeURIComponent(dirName)}/`;
const path = `${root}${encodeURIComponent(slug)}.md`;
// "Category" is represented by the reserved expedition tag itself.
// Keeping the real id (for example `expa-2027`) lets year links target
// the canonical tag route without inventing a second `year-YYYY` id.
const expaTag = tags.find(id => /^expa-20\d{2}$/i.test(id)) || null;
const category = expaTag;
return {
author,
slug,
hash: `${author}/${slug}`,
path,
root,
directory: dirName,
mtime,
etag,
category,
categoryYear: category ? Number(category.replace(/^expa-/i, '')) : null,
date,
tags,
published: true,
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) {
if (!mtime) return null;
const parsed = new Date(mtime);
return Number.isNaN(parsed.getTime()) ? String(mtime).slice(0, 16) : parsed.toISOString().slice(0, 16);
}
// Cache-aware: a file whose mtime matches its cached entry's mtime reuses
// that entry directly — worthwhile only when it's already hydrated
// (skipping a real content re-fetch); a merely-parsed stub is free to
// rebuild, so there's no point caching one just to skip re-parsing a
// string. `cacheSnapshot` is the whole cache read once up front (see
// runCrawl) — an in-memory lookup here instead of a separate IndexedDB read
// per file, which mattered when this also had to guard a listing fetch;
// kept anyway since it's still the same one cheap lookup.
function resolveStub(author, dirName, mtime, etag, authorRoot, cacheSnapshot) {
const parsed = parsePostDirectoryName(dirName);
if (!parsed) return null;
const path = `${authorRoot}${encodeURIComponent(dirName)}/${encodeURIComponent(parsed.slug)}.md`;
const cached = cacheSnapshot.get(path);
if (cached && ((etag && cached.article?.etag === etag) || (!etag && mtime != null && cached.mtime === mtime)) && cached.article?.hydrated) return cached.article;
return buildStub(author, dirName, mtime, authorRoot, etag);
}
// Fields only derivable from an article's actual markdown content (not its
// 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 headers = stub.etag ? { 'If-None-Match': stub.etag } : {};
let response = await fetch(stub.path, { headers });
if (response.status === 304) response = await fetch(stub.path);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const raw = await response.text();
const { meta, content } = parseFrontmatter(raw);
// Snapshot stub's own fields *before* mutating, then reapply them
// after merging in meta — so a stray same-named frontmatter field
// (a leftover `tags:`/`date:`) can never override the authoritative
// marker-derived values. (stub can't be used as its own "source of
// truth" mid-Object.assign — by the time that argument is read it'd
// already reflect this same mutation.)
const authoritative = { ...stub };
Object.assign(stub, meta, authoritative, deriveContentFields(meta, content, stub.path), { hydrated: true });
await setCacheEntry(stub.path, { mtime: stub.mtime, article: stub });
} catch { /* leave stub unhydrated — retried next time it's rendered */ }
return stub;
}
// Concurrency-limited batch hydration. Processing order follows `stubs`'
// own array order (each worker below just claims the next unclaimed index)
// — since discoverArticles() already returns stubs sorted newest-first by
// the authoritative `date` field, that means hydration (and thus which
// search results fill in first) is already newest-first too, for free.
export function hydrateArticles(stubs) {
return mapWithConcurrency(stubs, MAX_CONCURRENT_REQUESTS, hydrateArticle);
}
async function runCrawl() {
partialResolved = [];
// Independent of waves 1/2 below — kicked off in parallel with them and
// only awaited once it's actually needed, so its latency is hidden
// behind the (already-required) directory fetches rather than adding
// to the total.
const cacheSnapshotPromise = getAllCacheEntries();
// Wave 1 (root) — always fetched live: the author directories. Unlike
// wave 2 below, a failure here means discovery can't proceed at all
// (wrong origin, CORS, autoindex disabled) — rather than letting that
// reject all the way out to a generic "failed to load" message, treat it
// the same as "every author's listing failed": resolve to an empty,
// fully-indexed (done) result so the list view renders normally and
// renderCrawlHealthWarning() (via rootFailed below) shows the specific
// crawl-health message instead.
let authorEntries;
try {
authorEntries = await fetchDirectory(ARTICLES_URL);
} catch {
crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: true };
notifyProgress(true);
return sortAndFilterPublished(partialResolved);
}
const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.'));
// Wave 2 — always fetched live, in full, for every author (not just the
// current page): each author's own directory listing, with per-file
// mtimes. This is what builds the flat, cross-author list that drives
// global sorting and pagination — see the plan doc's "keep the flat
// feed" decision. It's also the *only* per-author network request
// indexing ever needs: every stub field (slug, date, tags, category,
// draft status) is parsed straight out of each article file's own name
// (see buildStub/parseArticleFileName) — there's no per-article fetch
// at index time. The author's own bio file (`<author>.md`) and shared
// `media/` folder live in the same listing and are simply filtered out
// here — only `.md` files other than the bio file are candidate
// articles. One author's listing failing (network blip, deleted
// mid-crawl) is silently skipped rather than failing every other
// author's list too, but is still counted, so a *total* breakage (wrong
// origin, CORS) remains visible via getCrawlStats() instead of just
// silently producing an empty article list.
let authorsFailed = 0;
const authorFileLists = await Promise.all(authors.map(async author => {
const authorRoot = `${ARTICLES_URL}${encodeURIComponent(author.name)}/`;
const bioFileName = `${author.name}.md`;
try {
const entries = await fetchDirectory(authorRoot);
return entries
.filter(entry => entry.type === 'directory' && parsePostDirectoryName(entry.name))
.map(entry => ({ author: author.name, dirName: entry.name, mtime: normalizeMtime(entry.mtime), etag: entry.etag || null, authorRoot }));
} catch {
authorsFailed += 1;
return [];
}
}));
const allFiles = authorFileLists.flat();
// Every stub is parsed synchronously from data already in hand — no
// 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.dirName, file.mtime, file.etag, file.authorRoot, cacheSnapshot);
if (stub) {
loaded += 1;
partialResolved.push(stub);
}
}
await pruneCache(allFiles.map(file => {
const parsed = parsePostDirectoryName(file.dirName);
return `${file.authorRoot}${encodeURIComponent(file.dirName)}/${encodeURIComponent(parsed.slug)}.md`;
}));
crawlStats = { total: allFiles.length, loaded, authorsTotal: authors.length, authorsFailed, rootFailed: false };
const finalArticles = sortAndFilterPublished(partialResolved);
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; }
}
+5 -2
View File
@@ -31,7 +31,10 @@ 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;
@@ -44,5 +47,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
View File
@@ -0,0 +1,53 @@
// UI string lookup, so every user-facing message lives in a translatable
// JSON file (strings/{locale}.json) instead of being hardcoded in the
// JS. English (strings/en.json) is always fetched first as the
// fallback set; if the page's declared language (<html lang>) names another
// locale, that file is fetched too and merged on top — a translation file
// only needs to cover the keys it actually translates, missing ones still
// render in English rather than breaking.
import { BASE_URL } from './config-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));
}
+127
View File
@@ -0,0 +1,127 @@
// Entry point — loads templates then dispatches routes on hash changes.
//
// Hash routing scheme:
// 0 segments → all articles, page 1
// first = "search" → search results (#search/query)
// first = "search", ends "/page/N" → search results, page N (#search/query/page/2)
// 2 segments, first = "page" → all articles, page N (#page/2)
// 2 segments, first = "author" → author filter, page 1 (#author/alice)
// 2 segments, first = "category" (legacy) or "tag" → filtered list, page 1
// 3 segments, first = "post" → single article (#post/alice/my-article)
// 4 segments, first = "author", third = "page" → author filter, page N (#author/alice/page/2)
// 4 segments, first = "category", third = "page" → category filter, page N (#category/linux/page/2)
// 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, 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);
// A hand-edited/legacy hash can contain a malformed percent escape. Do
// not let one bad segment abort routing (and make an otherwise valid
// article appear unclickable); keep the literal segment in that case.
const decodePart = value => {
try { return decodeURIComponent(value); } catch { return value; }
};
// Search: decode query and guard against empty string (#search or #search/).
// A trailing "/page/N" is peeled off for pagination first — the query
// 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 = decodePart(queryParts.join('/'));
if (q) loadSearch(q, hasPage ? parsePage(rest[rest.length - 1]) : 1); else loadArticleList();
}
else if (parts.length === 0) loadArticleList();
else if (parts.length === 2 && parts[0] === 'page') loadArticleList(null, parsePage(parts[1]));
else if (parts.length === 2 && parts[0] === 'author') loadAuthorView(decodePart(parts[1]));
else if (parts.length === 2 && parts[0] === 'category') loadCategoryView(decodePart(parts[1]));
else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodePart(parts[1]));
else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodePart).join('/'));
else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodePart(parts[1]), parsePage(parts[3]));
else if (parts.length === 4 && parts[0] === 'category' && parts[2] === 'page') loadCategoryView(decodePart(parts[1]), parsePage(parts[3]));
else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodePart(parts[1]), parsePage(parts[3]));
else loadArticleList();
}
// All routing — initial and hashchange — waits for this before running.
let ready;
function init() {
// Wire up search form submission → navigate to #search/query
document.getElementById('search-form')?.addEventListener('submit', e => {
e.preventDefault();
const q = document.getElementById('search-input')?.value?.trim();
if (q) location.hash = `#search/${encodeURIComponent(q)}`;
});
// Strings load first — everything else (including the error handler
// right below) may call t(), so it needs to already have resolved.
ready = loadStrings().then(() => Promise.all([loadScript(MARKED_URL), loadTemplates()])).then(() => {
// Fail with a clear, catchable message here rather than a cryptic
// uncaught TypeError the first time showListView()/showArticleView()
// run — both mount points are a hard requirement, unlike
// search/tag/year, which are optional.
if (!document.getElementById('list-view') || !document.getElementById('article-view')) {
throw new Error('Missing required #list-view and/or #article-view element — see the "Embedding in an existing page" section of the README.');
}
});
// Show a top-level error if dependencies fail to load (or a mount point
// is missing). Scoped to cmjs's own mount points when at least one
// exists, rather than replacing the whole page — this can run on a page
// that embeds cmjs alongside plenty of its own content, which wiping
// document.body would destroy too. Falls back to the full-page replacement
// only when neither mount point is present to show the message in.
ready.catch(err => {
const message = t('appStartError', { message: err.message });
const listView = document.getElementById('list-view');
const articleView = document.getElementById('article-view');
if (listView || articleView) {
if (listView) {
listView.hidden = false;
listView.innerHTML = '';
const p = document.createElement('p');
p.className = 'error';
p.textContent = message;
listView.appendChild(p);
}
if (articleView) articleView.hidden = true;
} else {
document.body.textContent = message;
}
});
ready.then(router).catch(() => {});
}
// readyState guard in case DOMContentLoaded already fired
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.addEventListener('hashchange', () => ready.then(router).catch(() => {}));
+1 -1
View File
@@ -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 = {};
+596
View File
@@ -0,0 +1,596 @@
import { templates } from './templates.js';
import { deriveContentFields, discoverArticles, fetchAuthorProfile, findArticle, getCrawlStats, hydrateArticles, onArticlesProgress, resolveMediaUrl } from './discovery.js';
import { parseFrontmatter } from './frontmatter.js';
import { t, getLocale } from './i18n.js';
const PAGE_SIZE = 20;
// Bumped at the start of every list-rendering view. A progress listener
// checks its own snapshot of this before touching the DOM, so a background
// crawl's late update can't render into a view the user has since
// navigated away from (e.g. clicked into a different filter/page).
let currentGeneration = 0;
// The hash of the last list view shown (all articles, a filter, a search,
// any page) — used by loadArticle()'s "back" link so it returns to wherever
// the user actually came from instead of always resetting to page 1 of
// everything.
let lastListHash = '#';
function formatDate(value) {
if (!value) return '';
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
return Number.isNaN(date.valueOf()) ? String(value) : new Intl.DateTimeFormat(getLocale(), { day: 'numeric', month: 'long', year: 'numeric' }).format(date);
}
export function showListView() {
document.getElementById('list-view').hidden = false;
document.getElementById('article-view').hidden = true;
document.title = t('siteName');
}
export function showArticleView() {
document.getElementById('list-view').hidden = true;
document.getElementById('article-view').hidden = false;
}
function setField(element, value, apply) {
if (element && value != null && value !== '') apply(element, value);
else element?.remove();
}
function clearSearchInput() {
const input = document.getElementById('search-input');
if (input) input.value = '';
}
function filterLabel(filter) {
if (!filter) return '';
if (filter.type === 'author') return t('filterAuthor', { value: filter.value });
if (filter.type === 'tag' && /^expa-20\d{2}$/i.test(filter.value)) return t('filterYear', { value: filter.value.slice(5) });
if (filter.type === 'tag') return t('filterTag', { value: filter.value });
if (filter.type === 'category') return t('filterYear', { value: filter.value.replace(/^year-/i, '').replace(/^expa-/i, '') });
return filter.value;
}
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) {
const visibleTags = (tags || []).filter(identifier => !/^expa-20\d{2}$/i.test(identifier));
if (!container || !visibleTags.length) { container?.remove(); return; }
visibleTags.forEach(identifier => {
const link = document.createElement('a');
link.className = 'tag';
link.href = `#tag/${encodeURIComponent(identifier)}`;
link.textContent = identifier;
container.appendChild(link);
});
}
function renderFilterLink(label, href, count, active = false) {
const link = document.createElement('a');
link.className = `filter-link${active ? ' is-active' : ''}`;
link.href = href;
if (active) link.setAttribute('aria-current', 'page');
link.textContent = label;
if (count != null) {
const countElement = document.createElement('span');
countElement.className = 'filter-count';
countElement.textContent = `(${count})`;
link.appendChild(countElement);
}
return link;
}
// Years and tags render independently — either `<nav>` can be absent from
// the page (e.g. a site that only wants tag filtering, like this project's
// own index.html) without the other silently stopping too. Whichever
// section renders first gets the "all articles" reset link, so it's never
// lost just because the years nav isn't on the page.
function renderFilterNavigation(articles, activeFilter = null) {
const years = document.getElementById('year-filters');
const tags = document.getElementById('tag-filters');
if (!years && !tags) return;
const allArticlesLink = () => renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter);
if (years) {
years.innerHTML = '';
const yearCounts = new Map();
articles.forEach(article => {
const year = Number(article.categoryYear);
if (year) yearCounts.set(year, (yearCounts.get(year) || 0) + 1);
});
const yearList = document.createElement('div');
yearList.className = 'filter-list';
yearList.appendChild(allArticlesLink());
for (let year = 2026; year >= 2009; year -= 1) {
const count = yearCounts.get(year) || 0;
const expeditionTag = `expa-${year}`;
const active = activeFilter?.type === 'tag' && activeFilter.value.toLowerCase() === expeditionTag;
yearList.appendChild(renderFilterLink(t('yearLabel', { year }), `#tag/${expeditionTag}`, count, active));
}
years.appendChild(yearList);
}
if (tags) {
tags.innerHTML = '';
const tagCounts = new Map();
articles.forEach(article => (article.tags || []).forEach(tag => tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)));
const tagList = document.createElement('div');
tagList.className = 'filter-list';
if (!years) tagList.appendChild(allArticlesLink());
// An `expa-YYYY` id is also a normal entry in `article.tags` (that's how
// it's recognised as the category — see buildStub() in discovery.js),
// but it belongs in the years nav above, not duplicated here.
[...tagCounts.keys()]
.filter(id => !/^expa-20\d{2}$/i.test(id))
.sort((a, b) => a.localeCompare(b, getLocale()))
.forEach(id => {
const active = activeFilter?.type === 'tag' && activeFilter.value === id;
tagList.appendChild(renderFilterLink(id, `#tag/${encodeURIComponent(id)}`, tagCounts.get(id), active));
});
tags.appendChild(tagList);
}
}
// Builds the #post/author/slug hash for an article, encoding each segment
// individually. article.hash itself (a raw, unencoded "author/slug") is only
// ever used for internal matching in findArticle() — router.js decodes the
// two segments of a #post/... hash separately when parsing, so the link
// needs to encode them separately too, rather than interpolating the
// combined hash as-is (which breaks — decodeURIComponent throws — for any
// author/slug containing a literal "%" or other reserved character).
function postHref(article) {
return `#post/${encodeURIComponent(article.author)}/${encodeURIComponent(article.slug)}`;
}
// Shared between renderCard() and loadArticle() — date, reading-time, and
// the category/year badge are populated identically in both places.
function applyArticleMetaFields(field, { date, readingTime, category, categoryYear }) {
setField(field('date'), date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); });
setField(field('reading-time'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
// A post only counts as a "category" when one of its directory-name
// tags happens to name a year — not every article has one.
setField(field('category'), category, (categoryEl, value) => {
categoryEl.textContent = t('yearLabel', { year: categoryYear });
categoryEl.href = `#tag/${encodeURIComponent(value)}`;
});
}
function renderCard(article) {
const fragment = document.importNode(templates.card, true);
const element = fragment.firstElementChild;
if (!element) throw new Error(t('templateEmptyError'));
const field = name => element.querySelector(`[data-field="${name}"]`);
const imageLink = field('image-link');
setField(field('image'), article.coverUrl, (image, value) => {
image.src = value;
image.alt = article.title || article.slug;
if (imageLink) {
imageLink.href = postHref(article);
imageLink.removeAttribute('aria-hidden');
imageLink.removeAttribute('tabindex');
}
});
if (!article.coverUrl) {
imageLink?.remove();
element.classList.add('card--no-image');
}
field('link').href = postHref(article);
field('title').textContent = article.title || article.slug;
applyArticleMetaFields(field, article);
setField(field('description'), article.description, (description, value) => { description.textContent = value; });
const author = field('author');
author.textContent = article.authorName || article.author;
author.href = `#author/${encodeURIComponent(article.author)}`;
renderTags(field('tags'), article.tags);
// The card itself is the hit target, not only the optional cover image or
// title link. Keep the metadata links (author/tags/year) independent, but
// let clicks in the body/description open the same stable post route.
element.classList.add('card--clickable');
element.tabIndex = 0;
element.setAttribute('role', 'link');
const openPost = () => { location.hash = postHref(article).slice(1); };
element.addEventListener('click', event => {
if (event.defaultPrevented || event.button !== 0 || event.target.closest('a')) return;
openPost();
});
element.addEventListener('keydown', event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
if (event.target !== element) return;
event.preventDefault();
openPost();
});
return element;
}
// Near-total crawl failure (wrong origin after a migration, CORS broken,
// autoindex disabled) should be visible rather than silently rendering an
// empty/near-empty list forever — see .claude/nginx-sitemap-crawler-plan.md L3.
// Three independent signals feed this: the root author-listing fetch itself
// failing outright (rootFailed), every individual author's own listing
// failing (checked regardless of site size, since discovery.js now silently
// skips individual author failures — see the wave-2 comment in
// discovery.js), and, for larger sites, a near-zero loaded/total ratio at
// the article level. Only meaningful once the crawl has actually finished
// (`done`) — mid-crawl stats are incomplete and would misreport a healthy
// crawl as broken.
function renderCrawlHealthWarning(view, done) {
if (!done) return;
const { total, loaded, authorsTotal, authorsFailed, rootFailed } = getCrawlStats();
// rootFailed means the very first listing fetch (the author directories
// themselves) couldn't be read at all — a total breakage same in spirit
// as every author's own listing failing, just one level higher up.
const authorsBroken = rootFailed || (authorsTotal > 0 && authorsFailed === authorsTotal);
const articlesBroken = total >= 5 && loaded / total < 0.1;
if (!authorsBroken && !articlesBroken) return;
const warning = document.createElement('p');
warning.className = 'error';
warning.textContent = authorsBroken
? t('crawlHealthAuthorsFailed')
: t('crawlHealthArticlesFailed', { loaded, total });
view.appendChild(warning);
}
// Renders one snapshot of a paginated article page into `view` — shared by
// the article list (live progress updates and the final render, so a large
// site's first page shows up as soon as it's indexed instead of blocking on
// the whole tree — see onArticlesProgress() in discovery.js) and search
// (see loadSearch). `pageArticles` must already be hydrated — this function
// is purely synchronous DOM-building, no fetching of its own. `heading`,
// `emptyKey`/`loadingKey` let each caller supply its own copy; `filter`
// still drives pagination links (buildPaginationNav) even when it's not
// one of the sidebar's own filter types (search uses `{type: 'search',
// value: query}`, matched by pageHash() the same way as any other filter).
function renderArticleListBody(view, { total, pageArticles, page, pages, filter, done, heading, emptyKey = 'emptyArticleList', loadingKey = 'loadingArticles' }) {
view.innerHTML = '';
renderCrawlHealthWarning(view, done);
if (heading) {
const headingElement = document.createElement('h2');
headingElement.className = 'category-heading';
headingElement.textContent = heading;
view.appendChild(headingElement);
}
if (!total) {
const message = document.createElement('p');
message.className = done ? 'empty' : 'loading';
message.textContent = done ? t(emptyKey) : t(loadingKey);
view.appendChild(message);
return;
}
pageArticles.forEach(article => view.appendChild(renderCard(article)));
if (pages > 1) view.appendChild(buildPaginationNav(page, pages, filter));
if (!done) {
const indexing = document.createElement('p');
indexing.className = 'loading';
indexing.textContent = t('indexingBackground');
view.appendChild(indexing);
}
}
export async function loadArticleList(filter = null, page = 1) {
showListView();
window.scrollTo(0, 0);
clearSearchInput();
lastListHash = location.hash || '#';
const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('loadingArticles')}</p>`;
const generation = ++currentGeneration;
const applyFilter = allArticles => {
renderFilterNavigation(allArticles, filter);
if (!filter) return allArticles;
return allArticles.filter(article => {
if (filter.type === 'tag') return article.tags.includes(filter.value);
// Author names are exact, case-sensitive directory names (used
// as-is for paths and profile lookups elsewhere) — comparing
// them case-insensitively here could merge two distinctly-named
// author directories that differ only in case.
if (filter.type === 'author') return article.author === filter.value;
if (filter.type === 'category') {
const value = filter.value.toLowerCase();
return value === String(article.category || '').toLowerCase()
|| value === `year-${article.categoryYear}`;
}
return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase();
});
};
// Sorting/filtering/pagination all work on stubs alone (tags, date,
// category — none of that needs a content fetch). Only the current
// page's cards need real content, so only that slice gets hydrated —
// this is what keeps a large site's list fast: the rest of the tree's
// markdown is never fetched until the user actually pages to it.
// `renderToken` guards against two overlapping hydration+render cycles
// (e.g. two progress ticks arriving close together) applying out of
// order — only the most recently *started* one is allowed to render.
let renderToken = 0;
const render = async (rawArticles, done) => {
if (generation !== currentGeneration) return;
const myToken = ++renderToken;
const articles = applyFilter(rawArticles);
const pages = Math.max(1, Math.ceil(articles.length / PAGE_SIZE));
const clampedPage = Math.max(1, Math.min(page, pages));
const pageStubs = articles.slice((clampedPage - 1) * PAGE_SIZE, clampedPage * PAGE_SIZE);
const pageArticles = pageStubs.length ? await hydrateArticles(pageStubs) : [];
if (generation !== currentGeneration || myToken !== renderToken) return;
renderArticleListBody(view, { total: articles.length, pageArticles, page: clampedPage, pages, filter, done, heading: filter ? filterLabel(filter) : undefined });
// Once the list is fully indexed (not mid-background-crawl, where
// page boundaries could still shift), warm the cache for the next
// page so clicking "next" is instant. Fire-and-forget — it touches
// no DOM, just fetches and caches, so it's safe to keep running
// even if the user has since navigated elsewhere.
if (done && clampedPage < pages) {
hydrateArticles(articles.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE));
}
};
const unsubscribe = onArticlesProgress((partialArticles, done) => { render(partialArticles, done); });
try {
const allArticles = await discoverArticles();
await render(allArticles, true);
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
} finally {
unsubscribe();
}
}
// GitHub/Twitter/LinkedIn/Mastodon are brand names, not translatable text —
// only "Website" (below, via t()) is actual UI copy.
const SOCIAL_LABELS = { github: 'GitHub', twitter: 'Twitter', linkedin: 'LinkedIn', mastodon: 'Mastodon' };
function makeSocialLink(href, label) {
const link = document.createElement('a');
link.href = href;
link.textContent = label;
link.target = '_blank';
link.rel = 'noopener noreferrer';
return link;
}
function renderAuthorProfile(profile) {
const element = document.importNode(templates.authorProfile, true);
const field = name => element.querySelector(`[data-field="${name}"]`);
setField(field('avatar'), profile.avatar, (image, value) => { image.src = resolveMediaUrl(value, profile.path); image.alt = profile.title || profile.author; });
field('name').textContent = profile.title || profile.author;
const bio = field('bio');
// See the matching note in loadArticle() — same no-sanitization caveat
// applies here (an author bio's raw HTML, if any, renders as-is).
if (profile.content?.trim()) bio.innerHTML = marked.parse(profile.content); else bio.remove();
const social = field('social');
const links = Object.entries(SOCIAL_LABELS).filter(([key]) => profile[key]).map(([key, label]) => makeSocialLink(profile[key], label));
if (profile.website) links.push(makeSocialLink(profile.website, t('socialWebsite')));
if (links.length) links.forEach(link => social.appendChild(link)); else social.remove();
return element;
}
export async function loadAuthorView(author, page = 1) {
showListView();
const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
clearSearchInput();
let generation;
try {
// Independent of each other — fetching in parallel instead of
// sequentially shaves a round-trip. Safe to run together:
// fetchAuthorProfile() never touches the DOM, and the profile card
// below is only inserted once both have settled, so there's
// nothing for loadArticleList()'s own view updates to race with.
const profilePromise = fetchAuthorProfile(author);
const listPromise = loadArticleList({ type: 'author', value: author }, page);
// loadArticleList() bumps currentGeneration synchronously as one of
// its first steps (before any await), so reading it right after
// starting — not awaiting — that call captures the exact generation
// this navigation owns. Without this, a slower-resolving call here
// could insert a stale profile card into a list-view the user has
// since navigated away from (it stays in the DOM invisibly, since
// list-view would be hidden, only to reappear stale later).
generation = currentGeneration;
const [profile] = await Promise.all([profilePromise, listPromise]);
if (generation === currentGeneration && page === 1 && profile) {
view.insertBefore(renderAuthorProfile(profile), view.firstChild);
}
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
}
}
export const loadCategoryView = (category, page = 1) => loadArticleList({ type: 'category', value: category }, page);
export const loadTagView = (tag, page = 1) => loadArticleList({ type: 'tag', value: tag }, page);
// How many stubs to hydrate-and-check per scanning round (see loadSearch
// below) — small enough that a search which only needs page 1 doesn't fire
// a big burst of simultaneous requests, large enough to keep the number of
// rounds (and their per-round render) reasonable on a big site.
const SEARCH_SCAN_BATCH = PAGE_SIZE;
// Scan progress for the current search query — lets paging forward resume
// exactly where the last call left off instead of re-checking articles
// already ruled in/out, and lets paging *backward* (or re-rendering the
// same page) skip scanning entirely, since `matches` only ever grows and
// stub order never changes. Reset whenever the query text changes; holds
// only the most recent query, so a new search simply replaces it.
let searchState = null;
export async function loadSearch(query, page = 1) {
showListView();
window.scrollTo(0, 0);
const generation = ++currentGeneration;
lastListHash = location.hash || '#';
const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('searching')}</p>`;
const input = document.getElementById('search-input');
if (input) input.value = query;
// Drives pagination links the same way any other filter does (see
// pageHash()) — `#search/<query>` for page 1, `#search/<query>/page/N`
// after, which router.js recognises and parses back into (query, page).
const filter = { type: 'search', value: query };
try {
const needle = query.toLocaleLowerCase(getLocale());
const matchesQuery = article => [article.title, article.description, article.author, article.authorName, article.category, ...article.tags]
.some(value => String(value || '').toLocaleLowerCase(getLocale()).includes(needle));
const stubs = await discoverArticles();
if (generation !== currentGeneration) return;
renderFilterNavigation(stubs);
if (!searchState || searchState.query !== query || searchState.stubs !== stubs) {
searchState = { query, stubs, cursor: 0, matches: [] };
}
const state = searchState;
// Only scan far enough to fill the requested page, plus one extra
// match so pagination knows whether a next page exists — never the
// whole (possibly huge) stub set. Jumping straight to a deep page
// (typed/bookmarked link) just scans further up front, but still
// only as far as *that* page needs.
const target = page * PAGE_SIZE + 1;
const renderResults = () => {
if (generation !== currentGeneration) return;
const scanDone = state.cursor >= state.stubs.length;
const haveEnough = state.matches.length >= target;
// Once either is true, nothing left running will change what's
// shown for this page — that's what `done` gates downstream
// (crawl-health check, hiding the "still working" footer).
const done = scanDone || haveEnough;
// Exact once the whole set's been scanned; otherwise it's only a
// lower bound (what's already been found, or at least one more
// page than requested, whichever is bigger) — same "can still
// shift until done" contract loadArticleList already uses for
// its own page count.
const knownPages = Math.max(1, Math.ceil(state.matches.length / PAGE_SIZE));
const pages = scanDone ? knownPages : Math.max(knownPages, page + 1);
const clampedPage = Math.max(1, Math.min(page, pages));
const pageMatches = state.matches.slice((clampedPage - 1) * PAGE_SIZE, clampedPage * PAGE_SIZE);
renderArticleListBody(view, {
total: state.matches.length,
pageArticles: pageMatches,
page: clampedPage,
pages,
filter,
done,
heading: t('searchHeading', { query }),
emptyKey: 'searchNoResults',
loadingKey: 'searching',
});
};
renderResults();
// Scan forward in small batches, stopping as soon as `target` is
// met — resuming this same loop (via a later loadSearch call
// reusing `state`) picks up right after the last-scanned stub.
// The generation check guards against a superseded call's loop
// (user navigated away, or to a further page) mutating shared
// `state` after the fact — only the most recently started call is
// allowed to keep scanning or rendering.
while (state.cursor < state.stubs.length && state.matches.length < target) {
if (generation !== currentGeneration) return;
const batch = state.stubs.slice(state.cursor, state.cursor + SEARCH_SCAN_BATCH);
await hydrateArticles(batch);
if (generation !== currentGeneration) return;
state.cursor += batch.length;
batch.forEach(article => { if (matchesQuery(article)) state.matches.push(article); });
renderResults();
}
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('searchError', { message: error.message })}</p>`;
}
}
function buildTOC(content) {
const headings = [...content.querySelectorAll('h2, h3')];
if (headings.length < 3) return null;
const seen = new Map();
headings.forEach(heading => {
const base = heading.textContent.toLocaleLowerCase(getLocale()).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || t('tocFallbackHeading');
const count = (seen.get(base) || 0) + 1; seen.set(base, count); heading.id = count === 1 ? base : `${base}-${count}`;
});
const nav = document.createElement('nav'); nav.className = 'toc';
nav.innerHTML = `<p class="toc-title">${t('tocTitle')}</p><ol class="toc-list"></ol>`;
headings.forEach(heading => {
const item = document.createElement('li'); if (heading.tagName === 'H3') item.classList.add('toc-h3');
const link = document.createElement('a'); link.href = `#${heading.id}`; link.textContent = heading.textContent; item.appendChild(link); nav.lastChild.appendChild(item);
});
return nav;
}
export async function loadArticle(hash) {
showArticleView();
window.scrollTo(0, 0);
const generation = ++currentGeneration; // also invalidates any still-running loadArticleList progress listener
clearSearchInput();
const view = document.getElementById('article-view');
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
try {
const article = await findArticle(hash);
const [raw, profile] = await Promise.all([
fetch(article.path).then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); }),
fetchAuthorProfile(article.author),
]);
// Nothing below has any visible effect until this point, so this is
// the one place worth checking: without it, a slower-resolving
// (superseded) loadArticle() call could still render over whatever
// the user has since navigated to.
if (generation !== currentGeneration) return;
const { meta, content } = parseFrontmatter(raw);
// This view always fetches its own content directly (above) rather
// than relying on discoverArticles()'s stub — the list view defers
// that fetch until a page is actually rendered (see
// loadArticleList), and a single visited article is exactly such a
// case, so readingTime/coverUrl are derived fresh here. Unlike
// cards, the description field here stays frontmatter-only (no
// auto-excerpt) — the full content is right below it either way.
const { readingTime, coverUrl } = deriveContentFields(meta, content, article.path);
document.title = `${meta.title || article.slug} | ${t('siteName')}`;
const element = document.importNode(templates.article, true);
const field = name => element.querySelector(`[data-field="${name}"]`);
field('title').textContent = meta.title || article.slug;
// Publish date comes from the article file's own name (already
// resolved onto `article.date`), not the raw frontmatter.
applyArticleMetaFields(field, { date: article.date, readingTime, category: article.category, categoryYear: article.categoryYear });
setField(field('description'), meta.description, (description, value) => { description.textContent = value; });
const image = field('image');
if (coverUrl) { image.src = coverUrl; image.alt = meta.title || article.slug; } else image.closest('figure')?.remove();
const author = field('author'); author.textContent = meta.authorName || article.author; author.href = `#author/${encodeURIComponent(article.author)}`;
renderTags(field('tags'), article.tags);
const contentElement = field('content');
// Rendered directly via innerHTML with no HTML-sanitization pass —
// anyone who can write a .md file here can include raw HTML,
// including <script> tags (same for renderAuthorProfile()'s bio; the
// description field above skips this — it's plain textContent).
// Only let people you trust author content.
contentElement.innerHTML = marked.parse(content);
contentElement.querySelectorAll('img[src]').forEach(imageElement => { imageElement.src = resolveMediaUrl(imageElement.getAttribute('src'), article.path); });
const toc = buildTOC(contentElement); if (toc) contentElement.insertBefore(toc, contentElement.firstChild);
const back = document.createElement('a'); back.href = lastListHash; back.className = 'back-link'; back.textContent = t('backToArticles');
view.innerHTML = ''; view.append(back, element);
if (profile) { const divider = document.createElement('hr'); divider.className = 'article-divider'; view.append(divider, renderAuthorProfile(profile)); }
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articleLoadError', { message: error.message })}</p>`;
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"siteName": "Ukázkový blog",
"appStartError": "Blog se nepodařilo spustit: {message}",
"articleNotFound": "Článek nebyl nalezen",
"filterAuthor": "Autor: {value}",
"filterTag": "Štítek: {value}",
"filterYear": "Expedice {value}",
"filterAllArticles": "Všechny články",
"yearLabel": "Expedice {year}",
"paginationLabel": "Stránkování",
"paginationPrevious": "Předchozí",
"paginationNext": "Další",
"paginationPageInfo": "{page} z {totalPages}",
"templateEmptyError": "Šablona článku je prázdná.",
"readingTime": "{minutes} min čtení",
"crawlHealthAuthorsFailed": "Pozor: nepodařilo se načíst žádného autora — zkontrolujte nastavení serveru.",
"crawlHealthArticlesFailed": "Pozor: načtena jen {loaded} z {total} položek — zkontrolujte nastavení serveru.",
"loadingArticles": "Načítám články…",
"loadingGeneric": "Načítám…",
"emptyArticleList": "Nebyly nalezeny žádné články.",
"articlesLoadError": "Články se nepodařilo načíst: {message}",
"indexingBackground": "Zbytek článků se stále indexuje na pozadí…",
"searching": "Hledám…",
"searchHeading": "Hledání: „{query}“",
"searchNoResults": "Hledání neodpovídá žádný článek.",
"searchError": "Hledání se nepodařilo: {message}",
"tocTitle": "Obsah",
"tocFallbackHeading": "sekce",
"backToArticles": "Zpět na články",
"articleLoadError": "Článek se nepodařilo načíst: {message}",
"socialWebsite": "Web"
}
+41
View File
@@ -0,0 +1,41 @@
{
"siteName": "Example Blog",
"appStartError": "Failed to start the blog: {message}",
"articleNotFound": "Article not found",
"filterAuthor": "Author: {value}",
"filterTag": "Tag: {value}",
"filterYear": "Expedition {value}",
"filterAllArticles": "All articles",
"yearLabel": "Expedition {year}",
"paginationLabel": "Pagination",
"paginationPrevious": "Previous",
"paginationNext": "Next",
"paginationPageInfo": "{page} of {totalPages}",
"templateEmptyError": "Article template is empty.",
"readingTime": "{minutes} min read",
"crawlHealthAuthorsFailed": "Warning: couldn't load any authors — check your server configuration.",
"crawlHealthArticlesFailed": "Warning: only loaded {loaded} of {total} items — check your server configuration.",
"loadingArticles": "Loading articles…",
"loadingGeneric": "Loading…",
"emptyArticleList": "No articles found.",
"articlesLoadError": "Failed to load articles: {message}",
"indexingBackground": "Still indexing the rest of the articles…",
"searching": "Searching…",
"searchHeading": "Search: \"{query}\"",
"searchNoResults": "No articles match your search.",
"searchError": "Search failed: {message}",
"tocTitle": "Contents",
"tocFallbackHeading": "section",
"backToArticles": "Back to articles",
"articleLoadError": "Failed to load article: {message}",
"socialWebsite": "Website"
}
+41
View File
@@ -0,0 +1,41 @@
{
"siteName": "Ukážkový blog",
"appStartError": "Blog sa nepodarilo spustiť: {message}",
"articleNotFound": "Článok nebol nájdený",
"filterAuthor": "Autor: {value}",
"filterTag": "Štítok: {value}",
"filterYear": "Expedícia {value}",
"filterAllArticles": "Všetky články",
"yearLabel": "Expedícia {year}",
"paginationLabel": "Stránkovanie",
"paginationPrevious": "Predchádzajúca",
"paginationNext": "Ďalšia",
"paginationPageInfo": "{page} z {totalPages}",
"templateEmptyError": "Šablóna článku je prázdna.",
"readingTime": "{minutes} min čítania",
"crawlHealthAuthorsFailed": "Pozor: nepodarilo sa načítať žiadneho autora — skontrolujte nastavenie servera.",
"crawlHealthArticlesFailed": "Pozor: načítaných len {loaded} z {total} položiek — skontrolujte nastavenie servera.",
"loadingArticles": "Načítavam články…",
"loadingGeneric": "Načítavam…",
"emptyArticleList": "Neboli nájdené žiadne články.",
"articlesLoadError": "Články sa nepodarilo načítať: {message}",
"indexingBackground": "Zvyšok článkov sa stále indexuje na pozadí…",
"searching": "Hľadám…",
"searchHeading": "Hľadanie: „{query}“",
"searchNoResults": "Hľadaniu nezodpovedá žiadny článok.",
"searchError": "Hľadanie sa nepodarilo: {message}",
"tocTitle": "Obsah",
"tocFallbackHeading": "sekcia",
"backToArticles": "Späť na články",
"articleLoadError": "Článok sa nepodarilo načítať: {message}",
"socialWebsite": "Web"
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.