48 Commits
Author SHA1 Message Date
biosias 2f1bb8acfe Merge pull request 'Stop overriding page title and #site-title with siteName (AI)' (#4) from claude into main
Reviewed-on: #4
2026-10-27 15:43:31 +01:00
biosiasandClaude Opus 5.5 bef6538cb6 Stop overriding page title and #site-title with siteName (AI)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:05:44 +01:00
biosias 84c349b28d Merge pull request 'Claude' (#3) from claude into main
Reviewed-on: #3
2026-10-26 23:32:56 +01:00
biosiasandClaude Sonnet 5 96cefba24d Cache per-query search matches so repeat searches skip unchanged articles (AI)
Adds a searchQueries IndexedDB store (js/cache.js) keyed by the exact
search text, holding a { articlePath: { mtime, matched } } map per
query. loadSearch() loads it once per new query, trusts a cached
result whenever an article's mtime still matches instead of running
matchesQuery() again, and writes back whatever's newly checked each
scan round. Repeat searches for the same term — even after a page
reload — now only need to actually check articles that are new or
have changed since, not the whole site again. Capped at
MAX_CACHED_SEARCHES (30) distinct queries with least-recently-used
eviction so trying many different terms over time doesn't grow the
cache unbounded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:18:03 +02:00
biosiasandClaude Sonnet 5 4e2229fed9 Log hydration failures instead of swallowing them silently (AI)
hydrateArticleNow()'s catch-all discarded network, HTTP, and parsing
errors alike with no diagnostic, using the same fallback for all of
them. A real bug in parseFrontmatter()/deriveContentFields() would
silently leave an article title-less forever with no way to tell it
apart from a transient fetch blip. Now logged via console.error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:08:17 +02:00
biosiasandClaude Sonnet 5 2b2cdb1d3c Translate the optional #site-title header link at startup (AI)
document.title was already set from the siteName string, but a
visible #site-title header link stayed hardcoded in whatever language
it was typed in — a translated site showed a translated tab title
next to an untranslated header. Set once in router.js's init() rather
than showListView(), so a page that loads straight into an article
route still gets it. Documented #site-title as optional embedding
markup and corrected README's claim that <title> isn't JS-templated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:07:45 +02:00
biosiasandClaude Sonnet 5 057f6673cb Cap concurrent per-author listing fetches during indexing (AI)
Wave 2 of the crawl fetched every author's directory listing via a
plain Promise.all, with no limit — a site with hundreds of authors
fired one simultaneous request per author on every cold-cache load.
Routed through mapWithConcurrency/MAX_CONCURRENT_REQUESTS, the same
cap hydrateArticles() already respects for content fetches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:05:54 +02:00
biosiasandClaude Sonnet 5 cf2733368f Catch render() errors from the progress-listener callback (AI)
The onArticlesProgress listener invoked async render() without
awaiting or catching it, unlike the final render() call, which is
inside a try/catch. A mid-crawl render failure was an unhandled
rejection that left the list stuck on its loading state forever
instead of showing articlesLoadError.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:04:49 +02:00
biosiasandClaude Sonnet 5 6d424ffbc9 Stop crawl-health warning false-positiving on non-article files (AI)
The loaded/total ratio check treated a low filename-match rate as a
sign of network/CORS breakage, but indexing does no per-article fetch
at all — a low ratio just means many non-conforming .md files coexist
with real articles, which is normal and not a health problem. Removed
that heuristic and its now-unused crawlHealthArticlesFailed string;
real breakage is still fully caught by the authors-failed check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:04:10 +02:00
biosiasandClaude Sonnet 5 c3d8f0171c Fix blank line silently dropping YAML block arrays (AI)
A blank line between a block-array key and its "- item" lines matched
neither the item nor the key regex, resetting arrayKey to null and
causing the following items to be silently skipped. Blank lines are
now ignored instead of ending the array.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:02:38 +02:00
biosiasandClaude Sonnet 5 ae9bcfec3a Fix IndexedDB cache being defeated when mtime is unparseable (AI)
resolveStub() required a non-null mtime to reuse a cached hydrated
entry, so any listing without a parseable mtime (HTML-autoindex
fallback edge cases) rebuilt every stub from scratch on every crawl.
Comparing mtime directly (including null === null) trusts the cache
when there's no evidence either way, same as before this changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:02:31 +02:00
biosiasandClaude Sonnet 5 c8464398b9 Replace underscore draft convention with author/draft/ folder (AI)
Drafts now live in an author's own draft/ subdirectory instead of a
leading-underscore filename prefix. The crawler already skips
subdirectories when listing an author's files, so nothing under
draft/ is ever indexed; nginx should also deny that path outright so
drafts aren't fetchable by a guessed direct URL either.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:49:00 +02:00
biosiasandClaude Sonnet 5 730f16934d Remove category/year filtering, keep tag-only filtering (AI)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:19:59 +02:00
biosiasandClaude Sonnet 5 91a24574a8 Remove last literal mentions of the old project name (AI)
cache.js's comment explaining the DB_NAME prefix change referenced
the old "cmjs-" prefix literally as a historical note. Reworded to
describe the effect generically instead, so no trace of the former
project name remains anywhere in the repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:58:17 +02:00
biosiasandClaude Sonnet 5 16395fa7c5 Rename project from cmjs to cmkiss (AI)
Updates every in-repo mention: README.md title/prose/example paths,
code comments in config-default.js and router.js, and the IndexedDB
DB_NAME prefix in cache.js (cmjs-cache-v3 -> cmkiss-cache-v3).

The DB_NAME change has the same effect as a schema-version bump --
every returning visitor's browser opens a fresh, empty cache under the
new name and the old cmjs-cache-v3 database is left as orphaned
storage. Harmless (cache.js degrades to live-fetch when the cache is
empty), noted in a comment there rather than left as an unexplained
side effect.

Two things this commit does NOT cover, both outside this checkout:
- The local directory itself (still /home/user/Projects/cmjs) --
  renamed separately right after this commit.
- The git hosting-side repo name/remote URL
  (git.astronomickaexpedice.cz, Astronomicka_Expedice/cmjs.git) --
  requires server-side access this session doesn't have; needs a
  manual rename on the host plus a local `git remote set-url` update
  to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:56:09 +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
41 changed files with 2382 additions and 1040 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
+389 -98
View File
@@ -1,83 +1,177 @@
# cmjs — Minimal Client-Side CMS
# cmkiss — Minimal Client-Side CMS
A minimal CMS where articles are Markdown files rendered in the browser. No build step, no database, no server-side code.
## How it works
1. The browser traverses `cmjs/articles/` via a server directory index — discovering authors, year categories, and article files. Articles may be direct Markdown files or use the nested `author/category/post/post.md` layout; nginx JSON autoindex is preferred, with HTML autoindexes supported as a fallback.
2. Each `.md` file's YAML frontmatter is parsed to build the article list.
1. The browser traverses `articles/` via a server directory index — discovering authors and
their article files; nginx JSON autoindex is preferred, with HTML autoindexes supported as a
fallback.
2. Sorting, filtering, and publish status all come from the article file's own *name*
(`slug~DDMMYYYY~tags.md`) — knowable straight from the listing that discovers it, with no
further request per article. A `.md` file's actual content (title, description, image) is
only fetched once that article is about to be shown — the current page's cards, a single
article, or a search — not for the whole site up front. See "Adding an article" and "Article
discovery & caching" below.
3. Clicking an article fetches the Markdown file and renders it with [marked.js](https://marked.js.org/).
4. Hash-based routing handles navigation — back/forward works without page reloads.
5. Once an article's content has been fetched, it's cached in IndexedDB, keyed by the article
file's own path and invalidated by that file's `mtime`, so repeat visits skip re-fetching
unchanged content — see "Article discovery & caching" below.
## Features
- **Dark mode** — automatically follows the OS `prefers-color-scheme` setting; no toggle needed.
- **Client-side search** — the search box in the header filters all articles by title, description, author, and category in-memory. Navigates to `#search/query` so results are bookmarkable.
- **Client-side search** — the search box in the header filters all articles by title, description, author, and tags in-memory. Navigates to `#search/query` so results are bookmarkable; results paginate the same way the article list does.
- **Reading time** — estimated from the article word count (200 wpm) and shown on list cards and in article headers. Computed automatically; no frontmatter field needed.
- **Table of contents** — auto-generated from `h2`/`h3` headings and inserted at the top of the article body. Only appears when the article has three or more headings.
- **Back link** — a "← Back to articles" link above every article for one-click navigation back to the list.
- **Back link** — a "← Back to articles" link above every article, returning to whatever list, filter, or search page (and page number) the reader actually came from rather than always resetting to page 1 of everything.
- **Pagination** — article list paginates at 20 articles per page; page state is encoded in the hash so browser history works.
- **Author and category filtering** — clicking an author name or category badge shows a filtered list. Both support pagination.
- **Author and tag filtering** — clicking an author name or tag pill shows a filtered list. Both support pagination.
- **Responsive layout** — layout, cards, images, and pagination all adapt to narrow screens (breakpoint at 640 px).
- **Keyboard navigation** — all interactive elements have visible `:focus-visible` outlines for keyboard users.
- **Author profiles** — a profile card with bio and social links is shown at the top of each author's article list. Created by adding `articles/{author}/{author}.md`.
- **Offline-friendly caching** — discovered articles are cached in IndexedDB, so repeat visits skip re-fetching content that hasn't changed.
- **Translatable UI** — every UI string is looked up from a locale JSON file (English, Czech, and Slovak ship by default); adding a language needs no code changes.
## Project structure
This whole repo *is* cmkiss — clone it as-is into an existing site (or copy `css/`, `js/`,
`strings/`, `templates/`, and your own `articles/` into one) and embed it into a page there; see
"Embedding in an existing page" below. `demo/` is a standalone demo/preview page, kept separate
from the library — not something an embedding page needs to bring along.
```
cmjs/
├── README.md
├── index.html # Single-page shell — served from the repo root
├── css/ # Site-level styles for index.html
│ ├── reset.css # CSS variables, dark mode, box-sizing reset, focus styles
│ ├── header.css # Site header, title link, search form
│ └── layout.css # Main content wrapper, state messages, responsive
├── demo/ # Standalone demo/preview page — not needed to embed cmkiss elsewhere
│ ├── index.html # Single-page shell
│ └── css/
│ ├── reset.css # CSS variables, dark mode, box-sizing reset, focus styles
│ ├── header.css # Site header, title link, search form
│ └── layout.css # Main content wrapper, state messages, responsive
├── css/
│ └── components/ # Component styles — needed by any embedding page
│ ├── card.css # Article list cards and filter heading
│ ├── article.css # Single article view, back link, hero image
│ ├── content.css # Rendered markdown styles (.content *)
│ ├── pagination.css # Prev/next page navigation
│ ├── toc.css # Auto-generated table of contents
│ └── author-profile.css # Author profile card (avatar, bio, social links)
├── js/
│ └── marked.min.js # marked.js (local copy, loaded as a global)
└── cmjs/ # App content — articles, templates, component styles
├── css/
│ └── components/
│ ├── card.css # Article list cards and filter heading
│ ├── article.css # Single article view, back link, hero image
│ ├── content.css # Rendered markdown styles (.content *)
│ ├── pagination.css # Prev/next page navigation
│ └── toc.css # Auto-generated table of contents
├── js/
│ ├── config.js # BASE_URL, MARKED_URL, PICTURES_URL (all via import.meta.url)
│ ├── frontmatter.js # parseFrontmatter() — pure YAML-front parser
│ ├── templates.js # loadTemplates(), templates cache
│ ├── discovery.js # discoverArticles(), fetchJson(), article cache
│ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadCategoryView, loadSearch
│ └── router.js # Hash router and app entry point
├── templates/
│ ├── card.html # Article list card layout
│ └── article.html # Full article view layout
├── articles/
│ └── author/ # One directory per author
│ └── expa-YYYY/ # Main expedition-year category
│ └── slug/ # One directory per post
│ └── slug.md
└── pictures/ # Article images (referenced via image: frontmatter field)
│ ├── config-default.js # Shipped defaults for BASE_URL, MARKED_URL, ARTICLES_URL
│ ├── config.js # Optional local overrides — gitignored; see "Local configuration" below
│ ├── frontmatter.js # parseFrontmatter() — pure YAML-front parser
│ ├── templates.js # loadTemplates(), templates cache
│ ├── discovery.js # discoverArticles(), fetchJson(), article cache
│ ├── cache.js # IndexedDB cache for discovered articles, keyed by article file path
│ ├── concurrency.js # mapWithConcurrency() — caps simultaneous per-article fetches
│ ├── i18n.js # t(), loadStrings(), getLocale() — UI string lookup
│ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadTagView, loadSearch
│ └── router.js # Hash router and app entry point
├── vendor/
│ └── marked.min.js # marked.js (local copy, loaded as a global) — the only 3rd-party code
├── strings/
│ ├── en.json # UI strings (default/fallback locale) — see "Translations" below
│ ├── cs.json # Czech translation
│ └── sk.json # Slovak translation
├── templates/
│ ├── card.html # Article list card layout
│ ├── article.html # Full article view layout
│ └── author-profile.html # Author profile card layout
└── articles/
└── author/ # One directory per author
├── author.md # Author profile (optional)
├── media/ # Shared images for this author — see "Adding an article"
│ ├── avatar.jpg # Referenced by author.md's `avatar:` field
│ └── cover.jpg # Referenced by one or more articles' `image:` field
└── slug~DDMMYYYY~tag1+tag2.md # One file per article — see "Adding an article"
```
## Local configuration
`js/config-default.js` holds the shipped defaults (`BASE_URL`, `MARKED_URL`, `ARTICLES_URL`) and
is tracked in git; every other module imports from it. It also loads an optional `js/config.js`
— gitignored, not part of cmkiss's own git history — to override any of them. **Nothing needs to
be created before first use**: if `js/config.js` doesn't exist, the shipped defaults apply as-is.
The split exists for using cmkiss as a git submodule (or otherwise pulling in a newer version some
other way): since `config.js` is gitignored, updating cmkiss never touches it, so any override
you've made survives. Only `config-default.js` gets updated.
To override a setting, create `js/config.js` and export just the constant(s) you want to change —
anything you don't export falls back to `config-default.js`. For example, to move where articles
live (see "Pointing articles/ somewhere else" below for the matching nginx-side change this
needs):
```js
export const ARTICLES_URL = new URL('/blog-content/articles/', location.origin).href;
```
## Adding an article
1. Create the author, year, and post directories if they don't exist: `cmjs/articles/author/expa-YYYY/my-article/`
2. Create the Markdown file inside: `cmjs/articles/author/expa-YYYY/my-article/my-article.md`
3. Optional tag markers can sit beside the Markdown file as `tag-name.tag`.
4. Add a frontmatter block at the top
5. Write your content — it will appear in the list automatically
An article is a single Markdown file placed directly in its author's directory. Its *file name*
is where its publish date and tags live — not in separate files, and not in frontmatter. The
format is:
`author` and `category` are derived from the directory path — no frontmatter fields needed for them.
```
<slug>~<DDMMYYYY>~<tag1>+<tag2>+….md
```
- `<slug>` — the stable identifier used in the article's URL (`#post/author/slug`). Treat this
as permanent: retagging or correcting the date later just means renaming the file (see below),
but changing the slug changes the article's URL.
- `<DDMMYYYY>` — the publish date, 8 digits, no separators (e.g. `16062026` for 16 June 2026).
- `<tag1>+<tag2>+…` — zero or more tag ids, joined with `+`. Each tag id is a normal topic tag,
shown as a pill linking to `#tag/tag-id`. Omit this whole segment (and its leading `~`) if the
article has no tags.
The separator is `~` (tilde), not a hyphen — deliberately, since a slug is expected to be plain
kebab-case (single hyphens only) and `~` isn't something you'd type by accident while writing
one, unlike an extra `-`.
**Drafts** aren't a file-naming convention — they live in a separate `draft/` subdirectory inside
the author's own directory: `articles/author/draft/my-article~16062026~guides.md`. The crawler
only ever reads `.md` files directly inside an author's directory, so anything under `draft/` is
never indexed, listed, searched, or paginated. nginx should also deny access to every author's
`draft/` folder outright (see "nginx configuration" below), so a draft isn't reachable by a
guessed direct URL either — not just hidden from the crawl. To publish an article, move its file
out of `draft/` into the author directory itself.
Images (a cover photo, inline pictures) live in a `media/` folder shared by all of that author's
articles, not next to each individual article file — so the same picture can be reused across
several articles without keeping a duplicate copy per article. Reference one from frontmatter or
markdown with a path relative to the author directory, e.g. `image: media/photo.jpg`.
Steps:
1. Create the author directory if it doesn't exist: `articles/author/`
2. Create the Markdown file directly inside it, using the naming convention above, e.g.
`articles/author/my-article~16062026~guides+tutorials.md` — or inside `articles/author/draft/`
instead, if it's not ready to publish yet.
3. Optionally add a cover image to that author's shared `articles/author/media/` folder
(creating it if needed), and reference it from frontmatter with `image: media/photo.jpg`.
4. Add a frontmatter block at the top of the Markdown file
5. Write your content — it will appear in the list automatically (once it's out of `draft/`)
To change an article's date or tags later, rename its file — its content doesn't need to change.
`author` is derived from the containing directory — no frontmatter field needed
for it either. Treat the directory name as case-sensitive and exact: it's matched exactly (not
case-insensitively) against `#author/...` URLs and author-profile lookups, so avoid two author
directories that differ only in case. Because every one of these is encoded in the file name,
discovering an article's metadata never requires fetching anything beyond the directory listing
that already reveals it — no further per-article request at all, which is what makes the "Article
discovery & caching" section below possible.
> **A note on trust:** article and author-bio Markdown is rendered directly via marked.js with no
> HTML-sanitization pass — anyone who can write a `.md` file here can include raw HTML, including
> `<script>` tags. Only let people you trust add or edit content.
### Frontmatter format
```yaml
---
title: My Article Title
date: 2026-06-16
description: A short summary shown in the article list.
draft: false
---
Your markdown content here...
@@ -86,19 +180,19 @@ Your markdown content here...
| Field | Required | Notes |
|-------|----------|-------|
| `title` | yes | Shown in the list and article header |
| `date` | yes | ISO format `YYYY-MM-DD`, used for sorting newest-first |
| `description` | no | Short summary shown under the title in the list |
| `image` | no | Filename of the preview image in the `pictures/` directory (e.g. `photo.jpg`); shown as card thumbnail and article hero |
| `draft` | no | Set to `true`, `yes`, or `1` to hide from the list |
| `image` | no | Path to an image in the author's shared `media/` folder (e.g. `media/photo.jpg`); shown as card thumbnail and article hero |
Reading time is computed automatically from the article word count — no frontmatter field needed.
Reading time is computed automatically from the article word count — no frontmatter field
needed. `date` and `tags` frontmatter fields, if present, are ignored — superseded by the file
name described above.
## Author profiles
To add a profile for an author, create a file named `{author}.md` directly inside the author's directory:
```
cmjs/articles/bob/bob.md
articles/bob/bob.md
```
The profile appears as a card above the article list when a visitor filters by that author. It is optional — authors without a profile file work normally.
@@ -122,7 +216,7 @@ Short bio rendered as markdown.
| Field | Notes |
|-------|-------|
| `title` | Display name shown in the profile card. Falls back to the author directory name. |
| `avatar` | Filename in `pictures/` — displayed as a circular thumbnail. |
| `avatar` | Path to an image, relative to the author directory — e.g. `media/avatar.jpg` if kept in the shared media folder alongside article images, or a bare filename if kept directly beside `{author}.md`. Displayed as a circular thumbnail. |
| `github` / `twitter` / `linkedin` / `mastodon` / `website` | Any subset of these renders as pill links below the bio. |
The body text below the frontmatter is rendered as markdown and shown as the bio.
@@ -133,46 +227,78 @@ The body text below the frontmatter is rendered as markdown and shown as the bio
|----------|------|
| *(empty)* | All articles, page 1 |
| `#page/2` | All articles, page 2 |
| `#search/linux` | Search results for "linux" |
| `#search/linux` | Search results for "linux", page 1 |
| `#search/linux/page/2` | Same, page 2 |
| `#author/alice` | All articles by alice, page 1 |
| `#author/alice/page/2` | All articles by alice, page 2 |
| `#category/linux` | All articles in "linux", page 1 |
| `#category/linux/page/2` | All articles in "linux", page 2 |
| `#alice/linux/my-article` | Single legacy article at `cmjs/articles/alice/linux/my-article.md` |
| `#alice/expa-2024/my-article/my-article` | Single nested article at `cmjs/articles/alice/expa-2024/my-article/my-article.md` |
| `#tag/guides` | All articles tagged "guides", page 1 |
| `#tag/guides/page/2` | Same, page 2 |
| `#post/alice/my-article` | Single article at `articles/alice/my-article~DDMMYYYY~tags.md` |
Author names and category tags on each card and article are clickable links to their respective filter views. A search box in the header navigates to `#search/query`.
Author names and tags on each card and article are clickable links to their respective filter views. A search box in the header navigates to `#search/query`.
## Customising the layout
The HTML structure for the article list card and the full article view live in `cmjs/templates/`:
The HTML structure for the article list card, the full article view, and the author profile
card live in `templates/`:
- `cmjs/templates/card.html` — the card shown in the article list
- `cmjs/templates/article.html` — the full article view (header, hero image, content)
- `templates/card.html` — the card shown in the article list
- `templates/article.html` — the full article view (header, hero image, content)
- `templates/author-profile.html` — the profile card (avatar, bio, social links)
Edit these files directly to change layout — no JS changes needed. Elements are identified by `data-field` attributes, which is how the JS modules find and populate them. Keep existing `data-field` values when rearranging; add new ones if you add new fields.
## Translations
Every UI string the JS renders (loading/error states, pagination, filter labels, the table of
contents heading, etc.) is looked up through `t(key, params)` in `js/i18n.js` rather than
hardcoded — `strings/en.json` is the default/fallback string table.
Available locales: `en` (default), `cs` (Czech), `sk` (Slovak).
To add another translation:
1. Set `<html lang="xx">` on the embedding page (or `demo/index.html` for this repo's own demo)
to the target language code (e.g. `cs`).
2. Add `strings/xx.json` with the same keys as `en.json` (copy it as a starting point) —
you only need to include the keys you're translating; anything missing quietly falls back to
English rather than breaking.
3. `{placeholder}` tokens in a string (e.g. `"{page} of {totalPages}"`) are substituted at
render time — keep them in your translated string, in whatever order/position reads
naturally in that language.
The resolved locale also drives date formatting (`Intl.DateTimeFormat`), search matching, and
tag-label sorting (`localeCompare`), via `getLocale()` in the same module — so a translation
file doesn't just relabel text, it affects how dates and sorting behave too.
cmkiss never names the page: the `<title>`, the site header, and any other branding (the header
search placeholder, the sidebar section headings) belong to the embedding page and are plain
static HTML — translate them by editing the page directly (`demo/index.html` for this repo's own
demo). The only change cmkiss makes to the tab title is in the article view, where it shows
`<article title> | <page's own title>`; list views restore the page's original `<title>` exactly.
## Embedding in an existing page
To add cmjs to any HTML page, three things are needed: the CSS, the two mount-point `<div>`s, and the script.
To add cmkiss to any HTML page, three things are needed: the CSS, the two mount-point `<div>`s, and the script.
### 1. CSS — in `<head>`
```html
<!-- Site-level styles (or replace with your own) -->
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/header.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="demo/css/reset.css">
<link rel="stylesheet" href="demo/css/header.css">
<link rel="stylesheet" href="demo/css/layout.css">
<!-- Component styles -->
<link rel="stylesheet" href="cmjs/css/components/card.css">
<link rel="stylesheet" href="cmjs/css/components/article.css">
<link rel="stylesheet" href="cmjs/css/components/content.css">
<link rel="stylesheet" href="cmjs/css/components/pagination.css">
<link rel="stylesheet" href="cmjs/css/components/toc.css">
<link rel="stylesheet" href="css/components/card.css">
<link rel="stylesheet" href="css/components/article.css">
<link rel="stylesheet" href="css/components/content.css">
<link rel="stylesheet" href="css/components/pagination.css">
<link rel="stylesheet" href="css/components/toc.css">
<link rel="stylesheet" href="css/components/author-profile.css">
```
The site-level CSS files (`reset.css`, `header.css`, `layout.css`) are optional — they style the shell around the CMS. If your page already has a layout and reset, you can skip them and only load the component stylesheets. Note that `reset.css` also defines the CSS custom properties (colours, radius) used throughout — if you skip it, define the variables yourself.
The site-level CSS files (`reset.css`, `header.css`, `layout.css`) are optional — they style the shell around the CMS. If your page already has a layout and reset, you can skip them and only load the component stylesheets. Note that `reset.css` also defines the CSS custom properties (colours, radius) used throughout — if you skip it, define the variables yourself. `author-profile.css` is only needed if any of your authors have a profile file (see "Author profiles" above) — safe to skip otherwise.
### 2. Mount points — in `<body>`
@@ -181,30 +307,54 @@ The site-level CSS files (`reset.css`, `header.css`, `layout.css`) are optional
<div id="article-view" hidden></div>
```
Place these wherever you want the CMS to appear. The router toggles `hidden` between them on navigation.
Optionally add a search form anywhere in the page:
```html
<form id="search-form" role="search">
<input type="search" id="search-input" placeholder="Search articles…" autocomplete="off">
</form>
```
Place these wherever you want the CMS to appear. The router toggles `hidden` between them on navigation. A search box and tag filter list are optional and can go anywhere else in the page — see "Search, article list, and tag list as independent pieces" below. Unlike those, `#list-view`/`#article-view` are both required — router.js checks for them at startup and shows a clear error (scoped to whichever of the two is present, or the whole page if neither is) instead of silently failing if one is missing.
### 3. Script — before `</body>`
```html
<script type="module" src="cmjs/js/router.js"></script>
<script type="module" src="js/router.js"></script>
```
`router.js` is the only script tag needed. It loads `marked.min.js` automatically at startup (path configured in `cmjs/js/config.js`).
`router.js` is the only script tag needed. It loads `marked.min.js` automatically at startup (path configured in `js/config.js`).
> **Subdirectory pages:** If the embedding page lives in a subdirectory (e.g. `/blog/index.html`), relative paths like `cmjs/...` will resolve incorrectly. Use root-relative paths with a leading `/` for the CSS links and script tag:
> **Subdirectory pages:** If the embedding page lives in a subdirectory (e.g. `/blog/index.html`) and cmkiss isn't installed at the site root, a plain relative path like `css/...` will resolve against the *page's* directory, not wherever cmkiss actually lives. Use a path (root-relative or otherwise) that correctly points at wherever you placed it, e.g. if cmkiss lives at `/cmkiss/` on your site:
> ```html
> <link rel="stylesheet" href="/cmjs/css/components/card.css">
> <script type="module" src="/cmjs/js/router.js"></script>
> <link rel="stylesheet" href="/cmkiss/css/components/card.css">
> <script type="module" src="/cmkiss/js/router.js"></script>
> ```
> All internal fetches (articles, templates, marked.js) resolve relative to the module files via `import.meta.url` and work automatically — only the HTML tags need the `/` prefix.
> All internal fetches (articles, templates, marked.js) resolve relative to the module files via `import.meta.url` and work automatically — only the HTML tags need a path pointing at the right place.
### Search, article list, and tag list as independent pieces
The mount points above don't have to sit next to each other, or even all be present — each is a
plain element the router looks up by `id`, wherever it happens to be in your page. This lets you
scatter search, the article list, and the tag list across a custom layout (search box in the site
header, tag list in a sidebar, articles in the main column) instead of stacking them the way the
demo does.
- **Article list + single article — `#list-view` / `#article-view`.** These two are the only
required pair; the router toggles `hidden` between them and always needs both present somewhere
on the page. This pair is also where **search results** render — there's no separate results
container, so any page using the search box below still needs `#list-view` on it somewhere to
actually show what was searched for.
- **Search box — `#search-form` / `#search-input`.** Fully optional and independent; place it
anywhere (e.g. a site-wide header, not necessarily near the list). Submitting it navigates to
`#search/query`; matches render into `#list-view`.
```html
<form id="search-form" role="search">
<input type="search" id="search-input" placeholder="Search articles…" autocomplete="off">
</form>
```
- **Tag list — `#tag-filters`.** Optional; put a container (a `<nav>` works well) anywhere, e.g. a
sidebar. It's automatically filled in with one link per tag in use (alphabetical, with an
article count) every time a list view renders — no manual updates needed. Clicking a tag
navigates to `#tag/tag-id`, rendering the filtered list into `#list-view`.
```html
<nav id="tag-filters" aria-label="Filter by tag"></nav>
```
`#tag-filters` is optional — omit it and nothing breaks, the article list and search still work
standalone.
### Minimal example
@@ -215,45 +365,185 @@ Optionally add a search form anywhere in the page:
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Site</title>
<link rel="stylesheet" href="cmjs/css/components/card.css">
<link rel="stylesheet" href="cmjs/css/components/article.css">
<link rel="stylesheet" href="cmjs/css/components/content.css">
<link rel="stylesheet" href="cmjs/css/components/pagination.css">
<link rel="stylesheet" href="cmjs/css/components/toc.css">
<link rel="stylesheet" href="css/components/card.css">
<link rel="stylesheet" href="css/components/article.css">
<link rel="stylesheet" href="css/components/content.css">
<link rel="stylesheet" href="css/components/pagination.css">
<link rel="stylesheet" href="css/components/toc.css">
</head>
<body>
<div id="list-view"></div>
<div id="article-view" hidden></div>
<script type="module" src="cmjs/js/router.js"></script>
<script type="module" src="js/router.js"></script>
</body>
</html>
```
## nginx configuration
Point nginx `root` at the repo root. The `autoindex` directive applies recursively to all subdirectories under `/cmjs/articles/`.
Point nginx `root` at the repo root. The `autoindex` directive applies recursively to all
subdirectories under `/articles/` — **except** each author's own `draft/` folder, which must be
denied outright (see "Adding an article" above: this is what keeps a draft from being indexed or
fetched by a guessed direct URL).
```nginx
server {
listen 80;
server_name example.com;
root /var/www/cmjs;
root /var/www/cmkiss;
# Serve the SPA
location / {
try_files $uri $uri/ /index.html;
# Serve the demo page
location /demo/ {
try_files $uri $uri/ /demo/index.html;
}
# Deny every author's draft/ folder outright — must come before the
# /articles/ block below (a regex location overrides a prefix location
# in nginx, so this still wins even though it's declared after it).
location ~ ^/articles/[^/]+/draft/ {
deny all;
return 404;
}
# Enable JSON directory listing for article discovery (applies to all subdirs)
location /cmjs/articles/ {
location /articles/ {
autoindex on;
autoindex_format json;
autoindex_localtime off; # keep mtimes in GMT — the crawler's cache relies on consistent, unambiguous timestamps
}
}
```
> **Note:** `autoindex_format json` requires nginx 1.7.9+. Check with `nginx -v`.
> **Note:** `autoindex_format json` requires nginx 1.7.9+. Check with `nginx -v`. If
> `autoindex_format json` is ever lost (config drift, an older nginx), the crawler falls back
> to parsing nginx's default HTML directory listing instead of breaking outright.
### Pointing articles/ somewhere else
By default the crawler looks for articles at `articles/` next to the rest of cmkiss (`ARTICLES_URL`,
computed as a sibling of the `js/` folder). Articles don't have to live there — override
`ARTICLES_URL` in your local `js/config.js` (see "Local configuration" above) to point anywhere
under the same origin (or a different one, CORS permitting).
Whatever it resolves to needs its own matching `location` block enabling the JSON (or HTML)
autoindex, plus the matching `draft/` deny block (see above) — it doesn't have to be named
`articles/` or nested under wherever cmkiss itself lives:
```nginx
location ~ ^/blog-content/articles/[^/]+/draft/ {
deny all;
return 404;
}
location /blog-content/articles/ {
autoindex on;
autoindex_format json;
autoindex_localtime off;
}
```
### Compression
Markdown and directory-listing JSON are both plain text, so gzip shrinks them well — and since
the browser decompresses transparently, this needs no changes on the cmkiss side. nginx doesn't
know `.md` is text by default (it falls back to `application/octet-stream`, which `gzip_types`
won't match), so map it explicitly and enable gzip for both content types:
```nginx
# in http {} or a shared mime block
types {
text/markdown md;
}
# in http {} or the server block above
gzip on;
gzip_types text/markdown text/plain application/json; # json covers the autoindex_format listings
gzip_min_length 256;
gzip_vary on;
```
Pre-compressing with `gzip_static` is possible but needs a `.md.gz` alongside every article, which
is a build step this project deliberately has none of — plain runtime `gzip on` is the better fit
here. Brotli usually compresses text better still, but needs the separate `ngx_brotli` module,
not built into stock nginx — only worth it if you're already building custom nginx modules.
## Article discovery & caching
Discovery happens in two phases. **Indexing** crawls `articles/` client-side to build a
lightweight "stub" for every article — the author directories, then each author's own directory
listing (their article files, bio file, shared `media/` folder, and `draft/` folder together)
along with each file's `mtime`. Only `.md` files listed directly inside an author's own directory
are kept as candidate articles — the bio file is filtered out by name, and any subdirectory
(`media/`, `draft/`) is skipped outright rather than being descended into, so nothing under
`draft/` is ever indexed. That's it: because an article file's *name* already encodes its slug,
date, and tags (see "Adding an article" above), that single listing is enough to sort, filter,
paginate, and populate the sidebar's tag counts, without any further request per article and
without ever fetching an article's actual Markdown.
**Hydration** is the separate, deferred step that fetches a specific article's content (title,
description, cover image, reading time) — it only ever runs for articles about to be rendered:
the current page's cards (`hydrateArticles()` in `js/discovery.js`), a single visited
article, or (since full-text search needs real content) as many search results as the page being
viewed actually needs. A large site's list is therefore fast to show regardless of how many
articles it has — indexing costs one request per author, full stop, and only ~20 articles' worth
of content is ever fetched per page view.
- **Cache** — hydrated articles are cached per article file in IndexedDB (`js/cache.js`),
keyed by the file's own path and invalidated by comparing its live `mtime` against the cached
one — once *any* visit renders an article, every later visit skips its content fetch too, until
the file is renamed (a rename changes the cache key, so a retagged/redated article is treated as
new — see "Adding an article" above) or its own mtime changes. Indexing reads the entire cache
once up front (`getAllCacheEntries()`, kicked off in parallel with the author/directory-listing
requests) so a warm cache doesn't cost an IndexedDB read per article. If IndexedDB is unavailable
(private browsing, disabled storage), discovery transparently falls back to fetching everything
live on every visit.
- **Cache schema version** — the cached records store the full derived article shape, not raw
content, so a code change to how articles are derived (in `js/discovery.js`) can make old
cached entries stale in *structure*, not just content — and a file's `mtime` won't catch that.
Bump `CACHE_SCHEMA_VERSION` in `js/cache.js` whenever that shape changes; it's baked into
the IndexedDB database name, so a bump invalidates every cached entry on the next deploy —
articles and cached searches alike. A change to the search-match record shape instead, or a new
store entirely, only needs `DB_VERSION` bumped (IndexedDB's own upgrade counter): `onupgradeneeded`
creates whatever store is missing without touching the other one's existing data.
- **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `js/discovery.js` (default `10`) caps both how
many hydration (content) fetches run at once and how many authors' directory listings are
fetched at once during indexing; raise it for a fast/local nginx, lower it for a slow or
shared/production instance.
- **Prefetching** — once the current page has fully rendered, the next page's articles are
hydrated in the background (fire-and-forget), so clicking "next" is usually an instant cache
hit rather than a fresh round-trip.
- **Paused/resumable search** — search needs real text (title, description), unlike the list
view, so it hydrates articles rather than just reading stubs — but only as many as the page
being viewed needs, in the same newest-first order as everything else (`loadSearch()` in
`js/views.js`). It scans stubs in small batches, stops as soon as it's found enough matches
to fill the current page (plus one, just to know whether a next page exists), and resumes
exactly where it left off — no re-checking — if the user pages further into the *same* query.
Paging back needs no scanning at all, since matches only ever accumulate. This keeps a search on
a large site to a handful of requests instead of hydrating the entire article set up front. The
page count is exact once the whole set's been scanned, and a lower bound (at least one more
page) until then.
- **Persistent search-match cache** — alongside the resumable in-session scan above, each query's
per-article match results (`{ mtime, matched }`, keyed by article path) are cached in IndexedDB
too (`searchQueries` store in `js/cache.js`, keyed by the query text itself) and reused across
page reloads and browser sessions, not just while paging through one visit. Searching the same
term again only re-fetches and re-checks articles that are new or whose `mtime` has changed
since — everything else is trusted from the cache without a content re-fetch at all. Capped at
`MAX_CACHED_SEARCHES` (default `30`) distinct queries, evicting the least-recently-searched one
first, so trying many different search terms over time doesn't grow the cache unbounded.
- **Crawl health** — if the root author-listing fetch fails outright, or if every individual
author's own directory listing fails (wrong origin, CORS misconfigured), a small warning banner
is shown above the article list instead of silently rendering an empty page (see
`getCrawlStats()` / `renderCrawlHealthWarning()` in `js/discovery.js` / `js/views.js`).
Non-conforming `.md` file names aren't treated as a health problem — only as a per-file skip —
since indexing does no per-article fetch that could actually fail.
- **Design rationale** — the original crawler design (wave-based crawling with a per-article
listing fetch, recency prioritisation, HTML-autoindex fallback, long-term "deploy and forget"
constraints) is documented in
[`.claude/nginx-sitemap-crawler-plan.md`](.claude/nginx-sitemap-crawler-plan.md), which ships
alongside the code. The per-article listing fetch it describes no longer exists (metadata moved
into the file name instead, and articles no longer live in a directory of their own), but the
rest of that reasoning — the caching model, the longevity constraints, the HTML fallback — still
applies.
## Running locally
@@ -262,5 +552,6 @@ Using nginx locally (example for Linux):
# Symlink or copy the repo to your nginx webroot, then reload:
sudo nginx -s reload
```
Then open `/demo/` in a browser to preview.
> Python's `http.server` returns HTML directory listings, not JSON, so article discovery won't work with it. Use nginx.
@@ -1,56 +0,0 @@
---
title: Hello World
date: 2026-06-16
description: A sample article to demonstrate the CMS.
image: hello-world-preview.webp
draft: false
---
Welcome to **cmjs** — a minimal client-side CMS powered by Markdown.
## How it works
Articles are plain Markdown files stored in the `articles/` directory. Each file carries its own metadata via YAML frontmatter, similar to [Hugo](https://gohugo.io/):
```yaml
---
title: My Article Title
date: 2026-06-16
description: A short summary shown in the article list.
draft: false
---
```
The app fetches the directory listing from the server, reads frontmatter from each `.md` file, and renders the list. Clicking an article fetches the file and renders the Markdown body client-side using [marked.js](https://marked.js.org/).
## Adding an article
1. Create a new `.md` file in `articles/`
2. Add a frontmatter block at the top
3. Write your content below the closing `---`
No rebuild, no CMS dashboard — the article appears in the list automatically.
## Frontmatter fields
| Field | Required | Description |
|-------|----------|-------------|
| `title` | yes | Display name shown in the list and article header |
| `date` | yes | ISO date (`YYYY-MM-DD`), used for sorting |
| `description` | no | Short summary shown under the title in the list |
| `draft` | no | Set to `true` to hide from the article list |
## Markdown features
Markdown renders fully — headings, lists, **bold**, _italic_, `inline code`, and fenced code blocks:
```javascript
// Frontmatter is parsed with a small built-in regex parser
function parseFrontmatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { meta: {}, content: raw };
// ...
}
```
> Articles are just files. No database, no server-side code, no login.
-1
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

@@ -64,7 +64,7 @@
font-style: italic;
}
/* Author + category row in article header — .author-link and .tag defined in card.css */
/* Author + tag row in article header — .author-link and .tag defined in card.css */
.article-meta {
display: flex;
align-items: center;
File renamed without changes.
@@ -90,7 +90,7 @@
line-height: 1.65;
}
/* Author + category row at the bottom of each card */
/* Author + tag row at the bottom of each card */
.card-meta {
display: flex;
align-items: center;
@@ -115,7 +115,7 @@
text-decoration: underline;
}
/* Shared category badge — used in cards and article headers */
/* Tag pill — also used in cards and article headers */
.tag {
display: inline-block;
background: var(--color-tag-bg);
@@ -134,8 +134,8 @@
color: #fff;
}
/* Heading shown above a filtered list (author or category view) */
.category-heading {
/* Heading shown above a filtered list (author, tag, or search results) */
.list-heading {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text-soft);
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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>
-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>
+178
View File
@@ -0,0 +1,178 @@
// Persistent cache for discovered articles (keyed by each article file's own
// path) and for per-search-query match results (keyed by the query text —
// see setSearchCacheEntry() below). Raw IndexedDB (no wrapper library — see
// .claude/nginx-sitemap-crawler-plan.md L4/L5 on the zero-dependency,
// long-stable-API requirement). If IndexedDB is unavailable (private
// browsing, old browser, blocked), every operation below silently becomes a
// no-op so discovery.js/views.js get live-fetch/live-scan behaviour for free
// without needing their own availability branching (plan doc R6).
// Bump this whenever discovery.js's derived article record shape changes.
// The version is baked into the DB name so a code deploy invalidates every
// cached entry immediately, instead of stale-shaped records lingering until
// each file's mtime happens to change — which, on a "deploy and forget"
// site, could be years.
const CACHE_SCHEMA_VERSION = 3;
// Renaming this prefix has the same effect as a version bump would — every
// existing visitor's browser opens a fresh, empty database under the new
// name, and the previous one is left behind as orphaned storage (harmless;
// this module degrades to live-fetch behaviour whenever the cache is empty
// or unavailable). Do this deliberately, not casually.
const DB_NAME = `cmkiss-cache-v${CACHE_SCHEMA_VERSION}`;
const ARTICLE_STORE = 'articleFiles';
const SEARCH_STORE = 'searchQueries';
// IndexedDB's own upgrade counter — separate from CACHE_SCHEMA_VERSION,
// which only governs the *article* record shape (and busts the cache by
// renaming the whole database). Bump this instead whenever a store or index
// is added/changed; onupgradeneeded below creates only what's missing, so
// existing article-cache data survives the upgrade.
const DB_VERSION = 2;
// Caps how many distinct queries' match results stay cached — otherwise
// every unique term ever typed into search would keep its own per-article
// map forever, growing unbounded on a long-lived site. Evicted
// least-recently-searched-first — see setSearchCacheEntry().
const MAX_CACHED_SEARCHES = 30;
let cacheAvailable = true;
let dbPromise = null;
function openDb() {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB unavailable')); return; }
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(ARTICLE_STORE)) db.createObjectStore(ARTICLE_STORE, { keyPath: 'path' });
if (!db.objectStoreNames.contains(SEARCH_STORE)) {
db.createObjectStore(SEARCH_STORE, { keyPath: 'query' }).createIndex('lastUsed', 'lastUsed');
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () => reject(new Error('IndexedDB open blocked'));
}).catch(error => { cacheAvailable = false; throw error; });
}
return dbPromise;
}
// Opens a store and resolves/rejects with the given IDBRequest's own outcome
// — the standard pattern, so result timing never depends on transaction
// lifecycle details.
async function runRequest(storeName, mode, makeRequest) {
if (!cacheAvailable) return undefined;
let db;
try {
db = await openDb();
} catch {
return undefined; // openDb() itself already flipped cacheAvailable
}
try {
const store = db.transaction(storeName, mode).objectStore(storeName);
return await new Promise((resolve, reject) => {
const request = makeRequest(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
} catch {
// A single request/transaction failing (e.g. one write hitting a
// storage quota) doesn't mean IndexedDB itself is gone — only
// openDb() failing disables the cache wholesale. Leave
// cacheAvailable as-is so later calls can still try.
return undefined;
}
}
// Returns every cached { path, mtime, article } record as a Map keyed by
// path — one bulk read instead of a separate IndexedDB round-trip per
// article file, which matters once there are hundreds of them: discovery.js
// reads this once up front rather than doing a point lookup per file during
// indexing. Empty Map on a miss or if the cache is unavailable.
export async function getAllCacheEntries() {
const records = await runRequest(ARTICLE_STORE, 'readonly', store => store.getAll());
return new Map((records || []).map(record => [record.path, record]));
}
export async function setCacheEntry(path, entry) {
await runRequest(ARTICLE_STORE, 'readwrite', store => store.put({ ...entry, path }));
}
// Removes any cached entry whose path is no longer in the live directory
// listing (renamed/deleted slugs), so the cache doesn't grow unbounded.
export async function pruneCache(validPaths) {
if (!cacheAvailable) return;
const valid = new Set(validPaths);
let db;
try {
db = await openDb();
} catch {
return; // openDb() itself already flipped cacheAvailable
}
try {
const store = db.transaction(ARTICLE_STORE, 'readwrite').objectStore(ARTICLE_STORE);
await new Promise((resolve, reject) => {
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = event => {
const cursor = event.target.result;
if (!cursor) { resolve(); return; }
if (!valid.has(cursor.value.path)) cursor.delete();
cursor.continue();
};
cursorRequest.onerror = () => reject(cursorRequest.error);
});
} catch {
// Same reasoning as runRequest() above — a failed prune pass doesn't
// mean IndexedDB itself is unavailable.
}
}
// Per-query search-match cache: `entries` is a { [articlePath]: { mtime,
// matched } } map — see loadSearch() in views.js, which trusts an entry
// (skipping a content re-fetch entirely) whenever its mtime still matches
// the live article, and only re-checks what's new or changed. Keyed by the
// exact (locale-lowercased) text matchesQuery() itself compares against, so
// "Linux" and "linux" share one cached entry.
export async function getSearchCacheEntry(query) {
return runRequest(SEARCH_STORE, 'readonly', store => store.get(query));
}
export async function setSearchCacheEntry(query, entries) {
await runRequest(SEARCH_STORE, 'readwrite', store => store.put({ query, entries, lastUsed: Date.now() }));
await pruneSearchCache();
}
// Keeps the search-query cache bounded to MAX_CACHED_SEARCHES entries,
// dropping the least-recently-searched ones first — the same "don't grow
// unbounded" concern pruneCache() addresses for the article cache, just
// against searches instead of renamed/deleted files.
async function pruneSearchCache() {
if (!cacheAvailable) return;
let db;
try {
db = await openDb();
} catch {
return;
}
try {
const store = db.transaction(SEARCH_STORE, 'readwrite').objectStore(SEARCH_STORE);
let excess = await new Promise((resolve, reject) => {
const request = store.count();
request.onsuccess = () => resolve(request.result - MAX_CACHED_SEARCHES);
request.onerror = () => reject(request.error);
});
if (excess <= 0) return;
await new Promise((resolve, reject) => {
const cursorRequest = store.index('lastUsed').openCursor();
cursorRequest.onsuccess = event => {
const cursor = event.target.result;
if (!cursor || excess <= 0) { resolve(); return; }
cursor.delete();
excess -= 1;
cursor.continue();
};
cursorRequest.onerror = () => reject(cursorRequest.error);
});
} catch {
// Same reasoning as runRequest() above.
}
}
+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;
}
+42
View File
@@ -0,0 +1,42 @@
// Shipped defaults for BASE_URL, MARKED_URL, ARTICLES_URL, plus the loader
// that lets an optional local js/config.js (gitignored — not part of this
// repo's history; see "Local configuration" in the README) override any of
// them. Every other module imports from THIS file, not config.js directly —
// config.js might not exist at all (nothing has created one yet, or it was
// deleted), and a plain `import ... from './config.js'` would then break
// the whole module graph with no way to catch it. A dynamic import() can be
// caught, so that's what resolves it below; top-level await makes every
// module that imports this one wait for that resolution before it runs,
// same as if these were plain synchronous exports.
//
// config.js, if present, only needs to export whichever of these it wants
// to change — anything it doesn't export falls back to the default here.
// All paths are derived from this module's own location via import.meta.url,
// so they resolve correctly regardless of where the embedding HTML page lives.
const DEFAULT_BASE_URL = new URL('../', import.meta.url).href;
const DEFAULT_MARKED_URL = new URL('../vendor/marked.min.js', import.meta.url).href;
const DEFAULT_ARTICLES_URL = new URL('articles/', DEFAULT_BASE_URL).href;
let overrides = {};
try {
overrides = await import('./config.js');
} catch {
// No local config.js (or it failed to load) — shipped defaults stand.
}
// Base URL of the repo root — strings/ and templates/ live here, one level
// up from this file (e.g. https://example.com/).
export const BASE_URL = overrides.BASE_URL ?? DEFAULT_BASE_URL;
// Path to the marked.js vendor script — lives in vendor/, a sibling of this
// file's own js/ directory, not alongside the app's own modules.
export const MARKED_URL = overrides.MARKED_URL ?? DEFAULT_MARKED_URL;
// Base URL of the articles/ folder. Defaults to a sibling of this file's own
// js/ directory (the repo root's own articles/), but articles don't have to
// live anywhere near the rest of cmkiss — override it in config.js to point
// anywhere under the same origin, or even a different one, CORS permitting.
// Whatever it resolves to must still be a directory nginx serves a JSON (or
// HTML) autoindex listing for — see "nginx configuration" in the README.
export const ARTICLES_URL = overrides.ARTICLES_URL ?? DEFAULT_ARTICLES_URL;
+429
View File
@@ -0,0 +1,429 @@
import { parseFrontmatter } from './frontmatter.js';
import { BASE_URL, ARTICLES_URL } from './config-default.js';
import { mapWithConcurrency } from './concurrency.js';
import { getAllCacheEntries, setCacheEntry, pruneCache } from './cache.js';
import { t } from './i18n.js';
// Caps simultaneous requests in the two places this module still fires one
// per item: markdown content fetches during hydration (see
// hydrateArticle()/hydrateArticles()) and each author's own directory
// listing during wave 2 of indexing (see runCrawl()) — indexing no longer
// needs a per-*article* fetch at all (every stub field is encoded in its
// file's own name — see buildStub()), but still fetches one listing per
// author. Raise it for a fast/local nginx, lower it for a slow or shared/
// production instance — see .claude/nginx-sitemap-crawler-plan.md §2a for
// the original rationale (written when this also governed per-article
// indexing requests).
const MAX_CONCURRENT_REQUESTS = 10;
const MONTH_ABBREVIATIONS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function parseHtmlDirectoryListing(raw) {
const entries = [];
const seen = new Set();
const anchors = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>[\s\S]*?<\/a>/gi;
// nginx's default autoindex puts "DD-Mon-YYYY HH:MM" right after the </a>
// on the same line — used as a best-effort mtime when JSON autoindex
// isn't available (see the "mtime" note in the README).
const datePattern = /(\d{2})-([A-Za-z]{3})-(\d{4})\s+(\d{2}):(\d{2})/;
let match;
while ((match = anchors.exec(raw))) {
const href = match[1].replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
if (!href || href.startsWith('../') || href.startsWith('/') || href.startsWith('#') || /^[a-z][a-z\d+.-]*:/i.test(href)) continue;
const clean = href.split(/[?#]/, 1)[0];
const name = decodeURIComponent(clean.replace(/\/$/, '')).split('/').pop();
if (!name || name === '.' || name === '..' || seen.has(name)) continue;
seen.add(name);
const lineEnd = raw.indexOf('\n', anchors.lastIndex);
const tail = raw.slice(anchors.lastIndex, lineEnd === -1 ? raw.length : lineEnd);
const dateMatch = datePattern.exec(tail);
const monthIndex = dateMatch ? MONTH_ABBREVIATIONS.indexOf(dateMatch[2]) : -1;
const mtime = monthIndex >= 0
? new Date(Date.UTC(+dateMatch[3], monthIndex, +dateMatch[1], +dateMatch[4], +dateMatch[5])).toISOString()
: null;
entries.push({ name, type: clean.endsWith('/') ? 'directory' : 'file', mtime });
}
return entries;
}
export async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`);
const raw = await response.text();
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
} catch { /* HTML autoindex fallback */ }
const entries = parseHtmlDirectoryListing(raw);
if (entries.length || /<(?:title|h1)[^>]*>\s*(?:index of|directory listing)/i.test(raw)) return entries;
throw new Error(`Expected a directory listing at ${url}`);
}
// Images live alongside whatever references them: every article file sits
// directly in its author's own directory (see buildStub()), next to that
// author's shared `media/` folder — so `articlePath` (the referencing .md
// file's own URL) is the resolution base. A relative path in frontmatter
// like `media/cover.jpg` resolves against the article file's own directory,
// which is the author directory itself, landing it in that shared folder —
// letting multiple articles reference the same image without duplicating it
// per article.
export function resolveMediaUrl(value, articlePath = '') {
if (!value) return '';
let path = String(value).trim();
if (/^(data:|blob:)/i.test(path)) return path;
if (/^https?:\/\//i.test(path)) {
try {
const url = new URL(path);
// Legacy compatibility, not a general-purpose feature: content
// migrated from a WordPress export sometimes still carries an
// absolute upload URL. Reduce it to a bare filename so it
// resolves against the article's own directory like any other
// relative image path, assuming the file itself was migrated
// alongside the article. A no-op for anything that isn't
// WordPress-derived content — most deployments never hit this.
if (url.pathname.includes('/wp-content/uploads/')) path = url.pathname.split('/wp-content/uploads/')[1];
else return path;
} catch { return path; }
}
return new URL(path.replace(/^\//, ''), articlePath || BASE_URL).href;
}
let articleCache = null;
let crawlPromise = null;
let crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: false };
// Raw (unfiltered/unsorted) article records resolved so far by the crawl
// currently or most recently in flight — lets a late subscriber (a view
// that mounts mid-crawl) immediately see what's already available instead
// of waiting for the next progress tick.
let partialResolved = [];
const progressListeners = new Set();
function sortArticles(rawArticles) {
return [...rawArticles].sort((a, b) => String(b.date).localeCompare(String(a.date)) || a.hash.localeCompare(b.hash));
}
// Subscribes to incremental crawl progress so a large site's article list
// can render as soon as the first articles are indexed instead of blocking
// on the whole tree. `listener(articles, done)` is called immediately with
// whatever has resolved so far, again as more of the crawl resolves, and a
// final time with `done: true`. Returns an unsubscribe function — always
// call it once the caller stops rendering (e.g. the user navigated away),
// otherwise a background crawl keeps calling a listener for a view that's
// no longer showing.
export function onArticlesProgress(listener) {
progressListeners.add(listener);
if (partialResolved.length) listener(sortArticles(partialResolved), false);
return () => progressListeners.delete(listener);
}
function notifyProgress(done) {
const snapshot = sortArticles(partialResolved);
progressListeners.forEach(listener => listener(snapshot, done));
}
// How many of the last crawl's candidate files actually produced an article
// vs. how many were listed, plus how many authors' listings failed
// outright — lets the UI show a "something's badly wrong" indicator instead
// of silently rendering an empty/near-empty list forever.
export function getCrawlStats() {
return { ...crawlStats };
}
function excerpt(content) {
const text = String(content || '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/`{1,3}[^`]*`{1,3}/g, '')
.replace(/[>#*_~\\]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (text.length <= 280) return text;
return `${text.slice(0, 277).replace(/\s+\S*$/, '')}…`;
}
// An article file's own name is the sole authoritative source of its slug,
// publish date, and tags — see the README's "Adding an article" section.
// Format: `<slug>~<DDMMYYYY>[~tag1+tag2+…].md`. `~` (rather than e.g. "--")
// is deliberate: a slug is expected to be plain kebab-case, and "~" isn't a
// character anyone would type by accident while writing one — unlike a
// double-hyphen separator, which collides with an easy slug typo (an extra
// "-") and silently drops the whole article instead of erroring. Returns
// null for any file name that doesn't match — callers treat that as a
// silent skip, e.g. the author's own bio file or a stray unrelated file.
// Drafts aren't a naming convention at all — they live in an author's own
// `draft/` subdirectory (see the README's "Adding an article" section),
// which the crawler never descends into (wave 2 below only reads `type ===
// 'file'` entries from an author's own listing) and which nginx should deny
// outright, so a draft is never indexed or fetchable in the first place.
function parseArticleFileName(fileName) {
if (!fileName.endsWith('.md')) return null;
const base = fileName.slice(0, -3);
const [slug, dateSegment, tagsSegment] = base.split('~');
if (!slug || !dateSegment || !/^\d{8}$/.test(dateSegment)) return null;
const day = dateSegment.slice(0, 2);
const month = dateSegment.slice(2, 4);
const year = dateSegment.slice(4, 8);
return {
slug,
date: `${year}-${month}-${day}`,
tags: tagsSegment ? tagsSegment.split('+').filter(Boolean) : [],
};
}
// Everything an article card/filter/sort needs, parsed entirely from its
// file's own name and its listing entry's mtime — no fetch of any kind.
// This is what runs for every article at index time; the (expensive)
// content fetch is deferred to hydrateArticle(), which only ever runs for
// articles actually being rendered. Returns null if the file name doesn't
// match the naming convention (see parseArticleFileName) — the caller
// treats that as a silent skip, matching the rest of this module's
// approach. `path` (the article file's own URL) doubles as its cache key —
// see cache.js — since every article now lives directly in its author's
// directory, alongside that author's bio file and shared `media/` folder,
// rather than in a directory of its own.
function buildStub(author, fileName, mtime, authorRoot) {
const parsed = parseArticleFileName(fileName);
if (!parsed) return null;
const { slug, date, tags } = parsed;
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
return {
author,
slug,
hash: `${author}/${slug}`,
path,
mtime,
date,
tags,
hydrated: false,
};
}
// nginx's JSON autoindex reports mtimes with second (or finer) precision,
// but the HTML-autoindex fallback (see parseHtmlDirectoryListing) only ever
// has minute precision. Truncating both to the same minute-level grain here
// — the one place a raw listing mtime enters the system — means the cache
// comparison in resolveStub() below still matches consistently even if a
// server ever flips between the two formats, instead of every file looking
// "changed" the moment JSON autoindex is lost.
function normalizeMtime(mtime) {
return mtime ? mtime.slice(0, 16) : null; // "YYYY-MM-DDTHH:MM" prefix
}
// Cache-aware: a file whose mtime matches its cached entry's mtime reuses
// that entry directly — worthwhile only when it's already hydrated
// (skipping a real content re-fetch); a merely-parsed stub is free to
// rebuild, so there's no point caching one just to skip re-parsing a
// string. `cacheSnapshot` is the whole cache read once up front (see
// runCrawl) — an in-memory lookup here instead of a separate IndexedDB read
// per file, which mattered when this also had to guard a listing fetch;
// kept anyway since it's still the same one cheap lookup. `mtime` is a
// change-detection signal, not a required key: `cached.mtime === mtime`
// already matches when both are `null` (a server whose listing never
// yields a parseable mtime — see parseHtmlDirectoryListing), which is the
// right call — there's no evidence the file changed, as opposed to a
// mismatch, which is evidence that it did. Treating "unknown" as "assume
// changed" would defeat the cache on every single crawl instead of just
// when a file actually needs re-hydrating.
function resolveStub(author, fileName, mtime, authorRoot, cacheSnapshot) {
const path = `${authorRoot}${encodeURIComponent(fileName)}`;
const cached = cacheSnapshot.get(path);
if (cached && cached.mtime === mtime && cached.article?.hydrated) return cached.article;
return buildStub(author, fileName, mtime, authorRoot);
}
// Fields only derivable from an article's actual markdown content (not its
// directory listing) — shared by hydrateArticle() (page-scoped list cards)
// and loadArticle() in views.js (which always fetches its own content
// directly, regardless of what's been hydrated for list rendering).
export function deriveContentFields(meta, content, path) {
const words = content.trim().split(/\s+/).filter(Boolean).length;
return {
readingTime: Math.max(1, Math.round(words / 200)),
description: meta.description || excerpt(content),
coverUrl: resolveMediaUrl(meta.cover || meta.image, path),
};
}
// Fetches and parses a stub's markdown content (title, description, cover
// image, reading time) — the one fetch this module defers until an article
// is actually about to be rendered (a visible list card, or the article
// itself), rather than for every article at index time. Mutates `stub` in
// place (rather than returning a new object) so every other reference to
// this same article — the shared discoverArticles() list, other pending
// hydrateArticle() calls for it — sees the update too, and a later
// re-render of the same page doesn't re-fetch it. No-ops if already
// hydrated. Leaves the stub as-is (title-less but still linkable/sortable)
// on any failure, matching this module's silent-skip approach elsewhere.
//
// De-duplicates concurrent calls for the *same* stub: without this, two
// callers racing on a not-yet-hydrated stub (e.g. a page's own hydration
// racing the next page's background prefetch) would each fire their own
// fetch for the same article file. `inFlightHydrations` tracks the shared
// promise for as long as one is in flight, keyed by the stub object itself
// (each article has exactly one stub instance for its whole lifetime — see
// buildStub()/resolveStub()) — removed again once settled, success or
// failure, so a failed hydration is still retried on the next call rather
// than permanently returning the same rejected attempt.
const inFlightHydrations = new WeakMap();
export function hydrateArticle(stub) {
if (stub.hydrated) return Promise.resolve(stub);
const existing = inFlightHydrations.get(stub);
if (existing) return existing;
const promise = hydrateArticleNow(stub).finally(() => inFlightHydrations.delete(stub));
inFlightHydrations.set(stub, promise);
return promise;
}
async function hydrateArticleNow(stub) {
try {
const response = await fetch(stub.path);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const raw = await response.text();
const { meta, content } = parseFrontmatter(raw);
// Snapshot stub's own fields *before* mutating, then reapply them
// after merging in meta — so a stray same-named frontmatter field
// (a leftover `tags:`/`date:`) can never override the authoritative
// marker-derived values. (stub can't be used as its own "source of
// truth" mid-Object.assign — by the time that argument is read it'd
// already reflect this same mutation.)
const authoritative = { ...stub };
Object.assign(stub, meta, authoritative, deriveContentFields(meta, content, stub.path), { hydrated: true });
await setCacheEntry(stub.path, { mtime: stub.mtime, article: stub });
} catch (error) {
// Leave stub unhydrated — retried next time it's rendered. Logged
// (not surfaced in the UI, which just shows the title-less card)
// so a persistent failure — a real bug in parseFrontmatter() or
// deriveContentFields(), not just a transient fetch blip — is still
// visible somewhere instead of silently retrying forever.
console.error(`Failed to hydrate article ${stub.path}:`, error);
}
return stub;
}
// Concurrency-limited batch hydration. Processing order follows `stubs`'
// own array order (each worker below just claims the next unclaimed index)
// — since discoverArticles() already returns stubs sorted newest-first by
// the authoritative `date` field, that means hydration (and thus which
// search results fill in first) is already newest-first too, for free.
export function hydrateArticles(stubs) {
return mapWithConcurrency(stubs, MAX_CONCURRENT_REQUESTS, hydrateArticle);
}
async function runCrawl() {
partialResolved = [];
// Independent of waves 1/2 below — kicked off in parallel with them and
// only awaited once it's actually needed, so its latency is hidden
// behind the (already-required) directory fetches rather than adding
// to the total.
const cacheSnapshotPromise = getAllCacheEntries();
// Wave 1 (root) — always fetched live: the author directories. Unlike
// wave 2 below, a failure here means discovery can't proceed at all
// (wrong origin, CORS, autoindex disabled) — rather than letting that
// reject all the way out to a generic "failed to load" message, treat it
// the same as "every author's listing failed": resolve to an empty,
// fully-indexed (done) result so the list view renders normally and
// renderCrawlHealthWarning() (via rootFailed below) shows the specific
// crawl-health message instead.
let authorEntries;
try {
authorEntries = await fetchJson(ARTICLES_URL);
} catch {
crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: true };
notifyProgress(true);
return sortArticles(partialResolved);
}
const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.'));
// Wave 2 — always fetched live, in full, for every author (not just the
// current page): each author's own directory listing, with per-file
// mtimes. Capped by MAX_CONCURRENT_REQUESTS like hydrateArticles() below
// — a site with many authors would otherwise fire one listing request
// per author simultaneously, with no limit. This is what builds the
// flat, cross-author list that drives global sorting and pagination —
// see the plan doc's "keep the flat feed" decision. It's also the *only*
// per-author network request indexing ever needs: every stub field
// (slug, date, tags) is parsed straight out of each article file's own
// name (see buildStub/parseArticleFileName) — there's no per-article
// fetch at index time. The `type === 'file'` filter below also means any
// subdirectory (the author's shared `media/` folder, a `draft/` folder —
// see "Adding an article" in the README) is skipped without ever being
// descended into. The author's own bio file (`<author>.md`) is filtered
// out by name in the same listing — only `.md` files other than the bio
// file are candidate articles. One author's listing failing (network
// blip, deleted mid-crawl) is silently skipped rather than failing every other
// author's list too, but is still counted, so a *total* breakage (wrong
// origin, CORS) remains visible via getCrawlStats() instead of just
// silently producing an empty article list.
let authorsFailed = 0;
const authorFileLists = await mapWithConcurrency(authors, MAX_CONCURRENT_REQUESTS, async author => {
const authorRoot = `${ARTICLES_URL}${encodeURIComponent(author.name)}/`;
const bioFileName = `${author.name}.md`;
try {
const entries = await fetchJson(authorRoot);
return entries
.filter(entry => entry.type === 'file' && entry.name.endsWith('.md') && entry.name !== bioFileName)
.map(entry => ({ author: author.name, fileName: entry.name, mtime: normalizeMtime(entry.mtime), authorRoot }));
} catch {
authorsFailed += 1;
return [];
}
});
const allFiles = authorFileLists.flat();
// Every stub is parsed synchronously from data already in hand — no
// per-file fetch, so no need for mapWithConcurrency here (that's still
// used by hydrateArticles(), which does real network fetches).
const cacheSnapshot = await cacheSnapshotPromise;
let loaded = 0;
for (const file of allFiles) {
const stub = resolveStub(file.author, file.fileName, file.mtime, file.authorRoot, cacheSnapshot);
if (stub) {
loaded += 1;
partialResolved.push(stub);
}
}
await pruneCache(allFiles.map(file => `${file.authorRoot}${encodeURIComponent(file.fileName)}`));
crawlStats = { total: allFiles.length, loaded, authorsTotal: authors.length, authorsFailed, rootFailed: false };
const finalArticles = sortArticles(partialResolved);
notifyProgress(true);
return finalArticles;
}
// Cached after the first successful crawl; concurrent callers during the
// crawl share the same in-flight promise rather than each starting their
// own duplicate crawl. See onArticlesProgress() for incremental results.
export function discoverArticles() {
if (articleCache) return Promise.resolve(articleCache);
if (!crawlPromise) {
crawlPromise = runCrawl().then(
result => { articleCache = result; crawlPromise = null; return result; },
error => { crawlPromise = null; throw error; },
);
}
return crawlPromise;
}
export async function findArticle(hash) {
const article = (await discoverArticles()).find(candidate => candidate.hash === hash);
if (!article) throw new Error(t('articleNotFound'));
return article;
}
export async function fetchAuthorProfile(author) {
const path = `${ARTICLES_URL}${encodeURIComponent(author)}/${encodeURIComponent(author)}.md`;
try {
const response = await fetch(path);
if (!response.ok) return null;
const { meta, content } = parseFrontmatter(await response.text());
return { author, ...meta, content, path };
} catch { return null; }
}
+6 -2
View File
@@ -31,11 +31,15 @@ function parseScalar(raw) {
}
export function parseFrontmatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
// The closing "---" doesn't require a trailing newline of its own — a
// file that's nothing but frontmatter (no body, no trailing newline)
// still parses instead of falling through to "no frontmatter at all".
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
if (!match) return { meta: {}, content: raw };
const meta = {};
let arrayKey = null;
for (const line of match[1].split(/\r?\n/)) {
if (!line.trim()) continue; // blank line — doesn't close a block array
const item = line.match(/^\s*-\s+(.*)$/);
if (item && arrayKey) { meta[arrayKey].push(parseScalar(item[1])); continue; }
const field = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(?:\s*(.*))?$/);
@@ -44,5 +48,5 @@ export function parseFrontmatter(raw) {
if (!rawValue.trim()) { meta[key] = []; arrayKey = key; }
else { meta[key] = parseScalar(rawValue); arrayKey = null; }
}
return { meta, content: match[2] };
return { meta, content: match[2] ?? '' };
}
+53
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));
}
+118
View File
@@ -0,0 +1,118 @@
// Entry point — loads templates then dispatches routes on hash changes.
//
// Hash routing scheme:
// 0 segments → all articles, page 1
// first = "search" → search results (#search/query)
// first = "search", ends "/page/N" → search results, page N (#search/query/page/2)
// 2 segments, first = "page" → all articles, page N (#page/2)
// 2 segments, first = "author" → author filter, page 1 (#author/alice)
// 2 segments, first = "tag" → tag filter, page 1
// 3 segments, first = "post" → single article (#post/alice/my-article)
// 4 segments, first = "author", third = "page" → author filter, page N (#author/alice/page/2)
// 4 segments, first = "tag", third = "page" → tag filter, page N (#tag/guides/page/2)
import { MARKED_URL } from './config-default.js';
import { loadTemplates } from './templates.js';
import { loadStrings, t } from './i18n.js';
import { loadArticleList, loadAuthorView, loadTagView, loadArticle, loadSearch } from './views.js';
// Dynamically inject marked.js as a plain global script before the app starts.
function loadScript(url) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.onload = resolve;
s.onerror = () => reject(new Error(`Failed to load ${url}`));
document.head.appendChild(s);
});
}
// Parse a page-number segment — returns 1 for any non-positive-integer value.
const parsePage = s => { const n = +s; return Number.isInteger(n) && n >= 1 ? n : 1; };
function router() {
const hash = location.hash.slice(1); // strip leading #
const parts = hash.split('/').filter(Boolean);
// Search: decode query and guard against empty string (#search or #search/).
// A trailing "/page/N" is peeled off for pagination first — the query
// itself is always a single encodeURIComponent()'d segment when built
// by this app (see the search-form handler below), so this only
// matters for hand-typed/legacy multi-segment links, where joining the
// rest back with "/" keeps a literal slash in the query working too.
if (parts[0] === 'search') {
const rest = parts.slice(1);
const hasPage = rest.length >= 3 && rest[rest.length - 2] === 'page' && /^\d+$/.test(rest[rest.length - 1]);
const queryParts = hasPage ? rest.slice(0, -2) : rest;
const q = decodeURIComponent(queryParts.join('/'));
if (q) loadSearch(q, hasPage ? parsePage(rest[rest.length - 1]) : 1); else loadArticleList();
}
else if (parts.length === 0) loadArticleList();
else if (parts.length === 2 && parts[0] === 'page') loadArticleList(null, parsePage(parts[1]));
else if (parts.length === 2 && parts[0] === 'author') loadAuthorView(decodeURIComponent(parts[1]));
else if (parts.length === 2 && parts[0] === 'tag') loadTagView(decodeURIComponent(parts[1]));
else if (parts.length === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodeURIComponent).join('/'));
else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodeURIComponent(parts[1]), parsePage(parts[3]));
else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodeURIComponent(parts[1]), parsePage(parts[3]));
else loadArticleList();
}
// All routing — initial and hashchange — waits for this before running.
let ready;
function init() {
// Wire up search form submission → navigate to #search/query
document.getElementById('search-form')?.addEventListener('submit', e => {
e.preventDefault();
const q = document.getElementById('search-input')?.value?.trim();
if (q) location.hash = `#search/${encodeURIComponent(q)}`;
});
// Strings load first — everything else (including the error handler
// right below) may call t(), so it needs to already have resolved.
ready = loadStrings().then(() => Promise.all([loadScript(MARKED_URL), loadTemplates()])).then(() => {
// Fail with a clear, catchable message here rather than a cryptic
// uncaught TypeError the first time showListView()/showArticleView()
// run — both mount points are a hard requirement, unlike
// search/tag, which are optional.
if (!document.getElementById('list-view') || !document.getElementById('article-view')) {
throw new Error('Missing required #list-view and/or #article-view element — see the "Embedding in an existing page" section of the README.');
}
});
// Show a top-level error if dependencies fail to load (or a mount point
// is missing). Scoped to cmkiss's own mount points when at least one
// exists, rather than replacing the whole page — this can run on a page
// that embeds cmkiss alongside plenty of its own content, which wiping
// document.body would destroy too. Falls back to the full-page replacement
// only when neither mount point is present to show the message in.
ready.catch(err => {
const message = t('appStartError', { message: err.message });
const listView = document.getElementById('list-view');
const articleView = document.getElementById('article-view');
if (listView || articleView) {
if (listView) {
listView.hidden = false;
listView.innerHTML = '';
const p = document.createElement('p');
p.className = 'error';
p.textContent = message;
listView.appendChild(p);
}
if (articleView) articleView.hidden = true;
} else {
document.body.textContent = message;
}
});
ready.then(router).catch(() => {});
}
// readyState guard in case DOMContentLoaded already fired
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.addEventListener('hashchange', () => ready.then(router).catch(() => {}));
+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 = {};
+589
View File
@@ -0,0 +1,589 @@
import { templates } from './templates.js';
import { deriveContentFields, discoverArticles, fetchAuthorProfile, findArticle, getCrawlStats, hydrateArticles, onArticlesProgress, resolveMediaUrl } from './discovery.js';
import { getSearchCacheEntry, setSearchCacheEntry } from './cache.js';
import { parseFrontmatter } from './frontmatter.js';
import { t, getLocale } from './i18n.js';
const PAGE_SIZE = 20;
// Bumped at the start of every list-rendering view. A progress listener
// checks its own snapshot of this before touching the DOM, so a background
// crawl's late update can't render into a view the user has since
// navigated away from (e.g. clicked into a different filter/page).
let currentGeneration = 0;
// The hash of the last list view shown (all articles, a filter, a search,
// any page) — used by loadArticle()'s "back" link so it returns to wherever
// the user actually came from instead of always resetting to page 1 of
// everything.
let lastListHash = '#';
// The embedding page's own <title>, captured once when this module first
// runs (module scripts are deferred, so <title> is already parsed). cmkiss
// never names the page itself — list views restore this exactly, and the
// article view only prefixes it with the article's title.
const pageTitle = document.title;
function formatDate(value) {
if (!value) return '';
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
return Number.isNaN(date.valueOf()) ? String(value) : new Intl.DateTimeFormat(getLocale(), { day: 'numeric', month: 'long', year: 'numeric' }).format(date);
}
export function showListView() {
document.getElementById('list-view').hidden = false;
document.getElementById('article-view').hidden = true;
document.title = pageTitle;
}
export function showArticleView() {
document.getElementById('list-view').hidden = true;
document.getElementById('article-view').hidden = false;
}
function setField(element, value, apply) {
if (element && value != null && value !== '') apply(element, value);
else element?.remove();
}
function clearSearchInput() {
const input = document.getElementById('search-input');
if (input) input.value = '';
}
function filterLabel(filter) {
if (!filter) return '';
if (filter.type === 'author') return t('filterAuthor', { value: filter.value });
if (filter.type === 'tag') return t('filterTag', { value: filter.value });
return filter.value;
}
function pageHash(page, filter) {
if (!filter) return page === 1 ? '#' : `#page/${page}`;
const base = `${filter.type}/${encodeURIComponent(filter.value)}`;
return page === 1 ? `#${base}` : `#${base}/page/${page}`;
}
function buildPaginationNav(page, totalPages, filter) {
const nav = document.createElement('nav');
nav.className = 'pagination';
nav.setAttribute('aria-label', t('paginationLabel'));
const previous = document.createElement('a');
previous.textContent = t('paginationPrevious');
const next = document.createElement('a');
next.textContent = t('paginationNext');
if (page > 1) previous.href = pageHash(page - 1, filter);
else { previous.classList.add('disabled'); previous.setAttribute('aria-disabled', 'true'); }
if (page < totalPages) next.href = pageHash(page + 1, filter);
else { next.classList.add('disabled'); next.setAttribute('aria-disabled', 'true'); }
const info = document.createElement('span');
info.className = 'page-info';
info.textContent = t('paginationPageInfo', { page, totalPages });
nav.append(previous, info, next);
return nav;
}
function renderTags(container, tags) {
if (!container || !tags?.length) { container?.remove(); return; }
tags.forEach(identifier => {
const link = document.createElement('a');
link.className = 'tag';
link.href = `#tag/${encodeURIComponent(identifier)}`;
link.textContent = identifier;
container.appendChild(link);
});
}
function renderFilterLink(label, href, count, active = false) {
const link = document.createElement('a');
link.className = `filter-link${active ? ' is-active' : ''}`;
link.href = href;
if (active) link.setAttribute('aria-current', 'page');
link.textContent = label;
if (count != null) {
const countElement = document.createElement('span');
countElement.className = 'filter-count';
countElement.textContent = `(${count})`;
link.appendChild(countElement);
}
return link;
}
function renderFilterNavigation(articles, activeFilter = null) {
const tags = document.getElementById('tag-filters');
if (!tags) return;
tags.innerHTML = '';
const tagCounts = new Map();
articles.forEach(article => (article.tags || []).forEach(tag => tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)));
const tagList = document.createElement('div');
tagList.className = 'filter-list';
tagList.appendChild(renderFilterLink(t('filterAllArticles'), '#', articles.length, !activeFilter));
[...tagCounts.keys()]
.sort((a, b) => a.localeCompare(b, getLocale()))
.forEach(id => {
const active = activeFilter?.type === 'tag' && activeFilter.value === id;
tagList.appendChild(renderFilterLink(id, `#tag/${encodeURIComponent(id)}`, tagCounts.get(id), active));
});
tags.appendChild(tagList);
}
// Builds the #post/author/slug hash for an article, encoding each segment
// individually. article.hash itself (a raw, unencoded "author/slug") is only
// ever used for internal matching in findArticle() — router.js decodes the
// two segments of a #post/... hash separately when parsing, so the link
// needs to encode them separately too, rather than interpolating the
// combined hash as-is (which breaks — decodeURIComponent throws — for any
// author/slug containing a literal "%" or other reserved character).
function postHref(article) {
return `#post/${encodeURIComponent(article.author)}/${encodeURIComponent(article.slug)}`;
}
// Shared between renderCard() and loadArticle() — date and reading-time are
// populated identically in both places.
function applyArticleMetaFields(field, { date, readingTime }) {
setField(field('date'), date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); });
setField(field('reading-time'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); });
}
function renderCard(article) {
const fragment = document.importNode(templates.card, true);
const element = fragment.firstElementChild;
if (!element) throw new Error(t('templateEmptyError'));
const field = name => element.querySelector(`[data-field="${name}"]`);
const imageLink = field('image-link');
setField(field('image'), article.coverUrl, (image, value) => {
image.src = value;
image.alt = article.title || article.slug;
if (imageLink) {
imageLink.href = postHref(article);
imageLink.removeAttribute('aria-hidden');
imageLink.removeAttribute('tabindex');
}
});
if (!article.coverUrl) {
imageLink?.remove();
element.classList.add('card--no-image');
}
field('link').href = postHref(article);
field('title').textContent = article.title || article.slug;
applyArticleMetaFields(field, article);
setField(field('description'), article.description, (description, value) => { description.textContent = value; });
const author = field('author');
author.textContent = article.authorName || article.author;
author.href = `#author/${encodeURIComponent(article.author)}`;
renderTags(field('tags'), article.tags);
return element;
}
// Near-total crawl failure (wrong origin after a migration, CORS broken,
// autoindex disabled) should be visible rather than silently rendering an
// empty/near-empty list forever — see .claude/nginx-sitemap-crawler-plan.md L3.
// Three independent signals feed this: the root author-listing fetch itself
// failing outright (rootFailed), or every individual author's own listing
// failing (checked regardless of site size, since discovery.js now silently
// skips individual author failures — see the wave-2 comment in
// discovery.js). Unlike an older version of this check, a low loaded/total
// ratio (candidate .md files that didn't match the article naming
// convention) is deliberately *not* treated as a health problem: indexing
// does no per-article fetch at all, so a non-conforming file name is a
// filename issue, not a network/CORS one — and legitimately non-conforming
// .md files (notes, leftovers) coexisting with real articles shouldn't
// trigger a false "something's broken" banner. Only meaningful once the
// crawl has actually finished (`done`) — mid-crawl stats are incomplete and
// would misreport a healthy crawl as broken.
function renderCrawlHealthWarning(view, done) {
if (!done) return;
const { authorsTotal, authorsFailed, rootFailed } = getCrawlStats();
// rootFailed means the very first listing fetch (the author directories
// themselves) couldn't be read at all — a total breakage same in spirit
// as every author's own listing failing, just one level higher up.
const authorsBroken = rootFailed || (authorsTotal > 0 && authorsFailed === authorsTotal);
if (!authorsBroken) return;
const warning = document.createElement('p');
warning.className = 'error';
warning.textContent = t('crawlHealthAuthorsFailed');
view.appendChild(warning);
}
// Renders one snapshot of a paginated article page into `view` — shared by
// the article list (live progress updates and the final render, so a large
// site's first page shows up as soon as it's indexed instead of blocking on
// the whole tree — see onArticlesProgress() in discovery.js) and search
// (see loadSearch). `pageArticles` must already be hydrated — this function
// is purely synchronous DOM-building, no fetching of its own. `heading`,
// `emptyKey`/`loadingKey` let each caller supply its own copy; `filter`
// still drives pagination links (buildPaginationNav) even when it's not
// one of the sidebar's own filter types (search uses `{type: 'search',
// value: query}`, matched by pageHash() the same way as any other filter).
function renderArticleListBody(view, { total, pageArticles, page, pages, filter, done, heading, emptyKey = 'emptyArticleList', loadingKey = 'loadingArticles' }) {
view.innerHTML = '';
renderCrawlHealthWarning(view, done);
if (heading) {
const headingElement = document.createElement('h2');
headingElement.className = 'list-heading';
headingElement.textContent = heading;
view.appendChild(headingElement);
}
if (!total) {
const message = document.createElement('p');
message.className = done ? 'empty' : 'loading';
message.textContent = done ? t(emptyKey) : t(loadingKey);
view.appendChild(message);
return;
}
pageArticles.forEach(article => view.appendChild(renderCard(article)));
if (pages > 1) view.appendChild(buildPaginationNav(page, pages, filter));
if (!done) {
const indexing = document.createElement('p');
indexing.className = 'loading';
indexing.textContent = t('indexingBackground');
view.appendChild(indexing);
}
}
export async function loadArticleList(filter = null, page = 1) {
showListView();
window.scrollTo(0, 0);
clearSearchInput();
lastListHash = location.hash || '#';
const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('loadingArticles')}</p>`;
const generation = ++currentGeneration;
const applyFilter = allArticles => {
renderFilterNavigation(allArticles, filter);
if (!filter) return allArticles;
if (filter.type === 'tag') return allArticles.filter(article => article.tags.includes(filter.value));
// Author names are exact, case-sensitive directory names (used
// as-is for paths and profile lookups elsewhere) — comparing them
// case-insensitively here could merge two distinctly-named author
// directories that differ only in case.
return allArticles.filter(article => article.author === filter.value);
};
// Sorting/filtering/pagination all work on stubs alone (tags, date —
// none of that needs a content fetch). Only the current
// page's cards need real content, so only that slice gets hydrated —
// this is what keeps a large site's list fast: the rest of the tree's
// markdown is never fetched until the user actually pages to it.
// `renderToken` guards against two overlapping hydration+render cycles
// (e.g. two progress ticks arriving close together) applying out of
// order — only the most recently *started* one is allowed to render.
let renderToken = 0;
const render = async (rawArticles, done) => {
if (generation !== currentGeneration) return;
const myToken = ++renderToken;
const articles = applyFilter(rawArticles);
const pages = Math.max(1, Math.ceil(articles.length / PAGE_SIZE));
const clampedPage = Math.max(1, Math.min(page, pages));
const pageStubs = articles.slice((clampedPage - 1) * PAGE_SIZE, clampedPage * PAGE_SIZE);
const pageArticles = pageStubs.length ? await hydrateArticles(pageStubs) : [];
if (generation !== currentGeneration || myToken !== renderToken) return;
renderArticleListBody(view, { total: articles.length, pageArticles, page: clampedPage, pages, filter, done, heading: filter ? filterLabel(filter) : undefined });
// Once the list is fully indexed (not mid-background-crawl, where
// page boundaries could still shift), warm the cache for the next
// page so clicking "next" is instant. Fire-and-forget — it touches
// no DOM, just fetches and caches, so it's safe to keep running
// even if the user has since navigated elsewhere.
if (done && clampedPage < pages) {
hydrateArticles(articles.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE));
}
};
// Same error handling as the final render below (not just the same
// message — an error thrown here, e.g. a malformed template, would
// otherwise be an unhandled rejection that leaves the list stuck on its
// loading state instead of ever showing articlesLoadError).
const showRenderError = error => {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
};
const unsubscribe = onArticlesProgress((partialArticles, done) => { render(partialArticles, done).catch(showRenderError); });
try {
const allArticles = await discoverArticles();
await render(allArticles, true);
} catch (error) {
showRenderError(error);
} finally {
unsubscribe();
}
}
// GitHub/Twitter/LinkedIn/Mastodon are brand names, not translatable text —
// only "Website" (below, via t()) is actual UI copy.
const SOCIAL_LABELS = { github: 'GitHub', twitter: 'Twitter', linkedin: 'LinkedIn', mastodon: 'Mastodon' };
function makeSocialLink(href, label) {
const link = document.createElement('a');
link.href = href;
link.textContent = label;
link.target = '_blank';
link.rel = 'noopener noreferrer';
return link;
}
function renderAuthorProfile(profile) {
const element = document.importNode(templates.authorProfile, true);
const field = name => element.querySelector(`[data-field="${name}"]`);
setField(field('avatar'), profile.avatar, (image, value) => { image.src = resolveMediaUrl(value, profile.path); image.alt = profile.title || profile.author; });
field('name').textContent = profile.title || profile.author;
const bio = field('bio');
// See the matching note in loadArticle() — same no-sanitization caveat
// applies here (an author bio's raw HTML, if any, renders as-is).
if (profile.content?.trim()) bio.innerHTML = marked.parse(profile.content); else bio.remove();
const social = field('social');
const links = Object.entries(SOCIAL_LABELS).filter(([key]) => profile[key]).map(([key, label]) => makeSocialLink(profile[key], label));
if (profile.website) links.push(makeSocialLink(profile.website, t('socialWebsite')));
if (links.length) links.forEach(link => social.appendChild(link)); else social.remove();
return element;
}
export async function loadAuthorView(author, page = 1) {
showListView();
const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
clearSearchInput();
let generation;
try {
// Independent of each other — fetching in parallel instead of
// sequentially shaves a round-trip. Safe to run together:
// fetchAuthorProfile() never touches the DOM, and the profile card
// below is only inserted once both have settled, so there's
// nothing for loadArticleList()'s own view updates to race with.
const profilePromise = fetchAuthorProfile(author);
const listPromise = loadArticleList({ type: 'author', value: author }, page);
// loadArticleList() bumps currentGeneration synchronously as one of
// its first steps (before any await), so reading it right after
// starting — not awaiting — that call captures the exact generation
// this navigation owns. Without this, a slower-resolving call here
// could insert a stale profile card into a list-view the user has
// since navigated away from (it stays in the DOM invisibly, since
// list-view would be hidden, only to reappear stale later).
generation = currentGeneration;
const [profile] = await Promise.all([profilePromise, listPromise]);
if (generation === currentGeneration && page === 1 && profile) {
view.insertBefore(renderAuthorProfile(profile), view.firstChild);
}
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`;
}
}
export const loadTagView = (tag, page = 1) => loadArticleList({ type: 'tag', value: tag }, page);
// How many stubs to hydrate-and-check per scanning round (see loadSearch
// below) — small enough that a search which only needs page 1 doesn't fire
// a big burst of simultaneous requests, large enough to keep the number of
// rounds (and their per-round render) reasonable on a big site.
const SEARCH_SCAN_BATCH = PAGE_SIZE;
// Scan progress for the current search query — lets paging forward resume
// exactly where the last call left off instead of re-checking articles
// already ruled in/out, and lets paging *backward* (or re-rendering the
// same page) skip scanning entirely, since `matches` only ever grows and
// stub order never changes. Reset whenever the query text changes; holds
// only the most recent query, so a new search simply replaces it.
//
// `entries` is this same session's in-memory mirror of the persistent
// per-query cache (see cache.js's getSearchCacheEntry()/
// setSearchCacheEntry()): a { [articlePath]: { mtime, matched } } map,
// loaded once when the query first changes and written back whenever a scan
// round adds something new to it (`dirty`). Reusing it — instead of only
// `cursor`/`matches` — means even a *first* search after a page reload can
// skip re-fetching and re-checking every article whose content hasn't
// changed since it was last searched for, no matter how this query's own
// scan happened to leave off last time.
let searchState = null;
export async function loadSearch(query, page = 1) {
showListView();
window.scrollTo(0, 0);
const generation = ++currentGeneration;
lastListHash = location.hash || '#';
const view = document.getElementById('list-view');
view.innerHTML = `<p class="loading">${t('searching')}</p>`;
const input = document.getElementById('search-input');
if (input) input.value = query;
// Drives pagination links the same way any other filter does (see
// pageHash()) — `#search/<query>` for page 1, `#search/<query>/page/N`
// after, which router.js recognises and parses back into (query, page).
const filter = { type: 'search', value: query };
try {
const needle = query.toLocaleLowerCase(getLocale());
const matchesQuery = article => [article.title, article.description, article.author, article.authorName, ...article.tags]
.some(value => String(value || '').toLocaleLowerCase(getLocale()).includes(needle));
const stubs = await discoverArticles();
if (generation !== currentGeneration) return;
renderFilterNavigation(stubs);
if (!searchState || searchState.query !== query || searchState.stubs !== stubs) {
// Loaded once per new query. Pruned to the current stub set's
// paths so a renamed/deleted article's old cached result can't
// linger — cheap, since `stubs` (unlike hydrating them) is
// already in hand with no further fetch.
const cached = await getSearchCacheEntry(needle);
if (generation !== currentGeneration) return;
const validPaths = new Set(stubs.map(article => article.path));
const entries = {};
for (const [path, entry] of Object.entries(cached?.entries || {})) if (validPaths.has(path)) entries[path] = entry;
searchState = { query, stubs, cursor: 0, matches: [], entries, dirty: false };
}
const state = searchState;
// Only scan far enough to fill the requested page, plus one extra
// match so pagination knows whether a next page exists — never the
// whole (possibly huge) stub set. Jumping straight to a deep page
// (typed/bookmarked link) just scans further up front, but still
// only as far as *that* page needs.
const target = page * PAGE_SIZE + 1;
const renderResults = () => {
if (generation !== currentGeneration) return;
const scanDone = state.cursor >= state.stubs.length;
const haveEnough = state.matches.length >= target;
// Once either is true, nothing left running will change what's
// shown for this page — that's what `done` gates downstream
// (crawl-health check, hiding the "still working" footer).
const done = scanDone || haveEnough;
// Exact once the whole set's been scanned; otherwise it's only a
// lower bound (what's already been found, or at least one more
// page than requested, whichever is bigger) — same "can still
// shift until done" contract loadArticleList already uses for
// its own page count.
const knownPages = Math.max(1, Math.ceil(state.matches.length / PAGE_SIZE));
const pages = scanDone ? knownPages : Math.max(knownPages, page + 1);
const clampedPage = Math.max(1, Math.min(page, pages));
const pageMatches = state.matches.slice((clampedPage - 1) * PAGE_SIZE, clampedPage * PAGE_SIZE);
renderArticleListBody(view, {
total: state.matches.length,
pageArticles: pageMatches,
page: clampedPage,
pages,
filter,
done,
heading: t('searchHeading', { query }),
emptyKey: 'searchNoResults',
loadingKey: 'searching',
});
};
renderResults();
// Scan forward in small batches, stopping as soon as `target` is
// met — resuming this same loop (via a later loadSearch call
// reusing `state`) picks up right after the last-scanned stub.
// The generation check guards against a superseded call's loop
// (user navigated away, or to a further page) mutating shared
// `state` after the fact — only the most recently started call is
// allowed to keep scanning or rendering.
while (state.cursor < state.stubs.length && state.matches.length < target) {
if (generation !== currentGeneration) return;
const batch = state.stubs.slice(state.cursor, state.cursor + SEARCH_SCAN_BATCH);
// Still hydrates the whole batch — a no-op per already-hydrated
// stub (see hydrateArticle() in discovery.js), which a cache hit
// below always is: it can only exist for a path this exact
// query already matched content against, at this same mtime,
// which means it's already sitting in the article-level cache
// too. Rendering pageArticles further down never re-hydrates,
// so this stays unconditional rather than trusting that chain
// of reasoning to never drift out of sync.
await hydrateArticles(batch);
if (generation !== currentGeneration) return;
state.cursor += batch.length;
batch.forEach(article => {
// A cached result only counts as known if its mtime still
// matches the live stub — an edited-since-last-search
// article is re-checked, same as one with no entry at all.
const known = state.entries[article.path];
const isKnown = known?.mtime === article.mtime;
const matched = isKnown ? known.matched : matchesQuery(article);
if (!isKnown) { state.entries[article.path] = { mtime: article.mtime, matched }; state.dirty = true; }
if (matched) state.matches.push(article);
});
renderResults();
}
// Persist whatever's new this round — fire-and-forget, since a
// failed/slow write should never hold up rendering (cache.js
// already swallows its own storage errors). Next time this exact
// query is searched, anything covered here needs no re-fetch at
// all, only what's new or changed since.
if (state.dirty) {
state.dirty = false;
setSearchCacheEntry(needle, state.entries);
}
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('searchError', { message: error.message })}</p>`;
}
}
function buildTOC(content) {
const headings = [...content.querySelectorAll('h2, h3')];
if (headings.length < 3) return null;
const seen = new Map();
headings.forEach(heading => {
const base = heading.textContent.toLocaleLowerCase(getLocale()).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || t('tocFallbackHeading');
const count = (seen.get(base) || 0) + 1; seen.set(base, count); heading.id = count === 1 ? base : `${base}-${count}`;
});
const nav = document.createElement('nav'); nav.className = 'toc';
nav.innerHTML = `<p class="toc-title">${t('tocTitle')}</p><ol class="toc-list"></ol>`;
headings.forEach(heading => {
const item = document.createElement('li'); if (heading.tagName === 'H3') item.classList.add('toc-h3');
const link = document.createElement('a'); link.href = `#${heading.id}`; link.textContent = heading.textContent; item.appendChild(link); nav.lastChild.appendChild(item);
});
return nav;
}
export async function loadArticle(hash) {
showArticleView();
window.scrollTo(0, 0);
const generation = ++currentGeneration; // also invalidates any still-running loadArticleList progress listener
clearSearchInput();
const view = document.getElementById('article-view');
view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`;
try {
const article = await findArticle(hash);
const [raw, profile] = await Promise.all([
fetch(article.path).then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); }),
fetchAuthorProfile(article.author),
]);
// Nothing below has any visible effect until this point, so this is
// the one place worth checking: without it, a slower-resolving
// (superseded) loadArticle() call could still render over whatever
// the user has since navigated to.
if (generation !== currentGeneration) return;
const { meta, content } = parseFrontmatter(raw);
// This view always fetches its own content directly (above) rather
// than relying on discoverArticles()'s stub — the list view defers
// that fetch until a page is actually rendered (see
// loadArticleList), and a single visited article is exactly such a
// case, so readingTime/coverUrl are derived fresh here. Unlike
// cards, the description field here stays frontmatter-only (no
// auto-excerpt) — the full content is right below it either way.
const { readingTime, coverUrl } = deriveContentFields(meta, content, article.path);
// Skip the separator when the embedding page has no <title> of its own.
document.title = `${meta.title || article.slug}${pageTitle ? ` | ${pageTitle}` : ''}`;
const element = document.importNode(templates.article, true);
const field = name => element.querySelector(`[data-field="${name}"]`);
field('title').textContent = meta.title || article.slug;
// Publish date comes from the article file's own name (already
// resolved onto `article.date`), not the raw frontmatter.
applyArticleMetaFields(field, { date: article.date, readingTime });
setField(field('description'), meta.description, (description, value) => { description.textContent = value; });
const image = field('image');
if (coverUrl) { image.src = coverUrl; image.alt = meta.title || article.slug; } else image.closest('figure')?.remove();
const author = field('author'); author.textContent = meta.authorName || article.author; author.href = `#author/${encodeURIComponent(article.author)}`;
renderTags(field('tags'), article.tags);
const contentElement = field('content');
// Rendered directly via innerHTML with no HTML-sanitization pass —
// anyone who can write a .md file here can include raw HTML,
// including <script> tags (same for renderAuthorProfile()'s bio; the
// description field above skips this — it's plain textContent).
// Only let people you trust author content.
contentElement.innerHTML = marked.parse(content);
contentElement.querySelectorAll('img[src]').forEach(imageElement => { imageElement.src = resolveMediaUrl(imageElement.getAttribute('src'), article.path); });
const toc = buildTOC(contentElement); if (toc) contentElement.insertBefore(toc, contentElement.firstChild);
const back = document.createElement('a'); back.href = lastListHash; back.className = 'back-link'; back.textContent = t('backToArticles');
view.innerHTML = ''; view.append(back, element);
if (profile) { const divider = document.createElement('hr'); divider.className = 'article-divider'; view.append(divider, renderAuthorProfile(profile)); }
} catch (error) {
if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articleLoadError', { message: error.message })}</p>`;
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"appStartError": "Blog se nepodařilo spustit: {message}",
"articleNotFound": "Článek nebyl nalezen",
"filterAuthor": "Autor: {value}",
"filterTag": "Štítek: {value}",
"filterAllArticles": "Všechny články",
"paginationLabel": "Stránkování",
"paginationPrevious": "Předchozí",
"paginationNext": "Další",
"paginationPageInfo": "{page} z {totalPages}",
"templateEmptyError": "Šablona článku je prázdná.",
"readingTime": "{minutes} min čtení",
"crawlHealthAuthorsFailed": "Pozor: nepodařilo se načíst žádného autora — zkontrolujte nastavení serveru.",
"loadingArticles": "Načítám články…",
"loadingGeneric": "Načítám…",
"emptyArticleList": "Nebyly nalezeny žádné články.",
"articlesLoadError": "Články se nepodařilo načíst: {message}",
"indexingBackground": "Zbytek článků se stále indexuje na pozadí…",
"searching": "Hledám…",
"searchHeading": "Hledání: „{query}“",
"searchNoResults": "Hledání neodpovídá žádný článek.",
"searchError": "Hledání se nepodařilo: {message}",
"tocTitle": "Obsah",
"tocFallbackHeading": "sekce",
"backToArticles": "Zpět na články",
"articleLoadError": "Článek se nepodařilo načíst: {message}",
"socialWebsite": "Web"
}
+37
View File
@@ -0,0 +1,37 @@
{
"appStartError": "Failed to start the blog: {message}",
"articleNotFound": "Article not found",
"filterAuthor": "Author: {value}",
"filterTag": "Tag: {value}",
"filterAllArticles": "All articles",
"paginationLabel": "Pagination",
"paginationPrevious": "Previous",
"paginationNext": "Next",
"paginationPageInfo": "{page} of {totalPages}",
"templateEmptyError": "Article template is empty.",
"readingTime": "{minutes} min read",
"crawlHealthAuthorsFailed": "Warning: couldn't load any authors — check your server configuration.",
"loadingArticles": "Loading articles…",
"loadingGeneric": "Loading…",
"emptyArticleList": "No articles found.",
"articlesLoadError": "Failed to load articles: {message}",
"indexingBackground": "Still indexing the rest of the articles…",
"searching": "Searching…",
"searchHeading": "Search: \"{query}\"",
"searchNoResults": "No articles match your search.",
"searchError": "Search failed: {message}",
"tocTitle": "Contents",
"tocFallbackHeading": "section",
"backToArticles": "Back to articles",
"articleLoadError": "Failed to load article: {message}",
"socialWebsite": "Website"
}
+37
View File
@@ -0,0 +1,37 @@
{
"appStartError": "Blog sa nepodarilo spustiť: {message}",
"articleNotFound": "Článok nebol nájdený",
"filterAuthor": "Autor: {value}",
"filterTag": "Štítok: {value}",
"filterAllArticles": "Všetky články",
"paginationLabel": "Stránkovanie",
"paginationPrevious": "Predchádzajúca",
"paginationNext": "Ďalšia",
"paginationPageInfo": "{page} z {totalPages}",
"templateEmptyError": "Šablóna článku je prázdna.",
"readingTime": "{minutes} min čítania",
"crawlHealthAuthorsFailed": "Pozor: nepodarilo sa načítať žiadneho autora — skontrolujte nastavenie servera.",
"loadingArticles": "Načítavam články…",
"loadingGeneric": "Načítavam…",
"emptyArticleList": "Neboli nájdené žiadne články.",
"articlesLoadError": "Články sa nepodarilo načítať: {message}",
"indexingBackground": "Zvyšok článkov sa stále indexuje na pozadí…",
"searching": "Hľadám…",
"searchHeading": "Hľadanie: „{query}“",
"searchNoResults": "Hľadaniu nezodpovedá žiadny článok.",
"searchError": "Hľadanie sa nepodarilo: {message}",
"tocTitle": "Obsah",
"tocFallbackHeading": "sekcia",
"backToArticles": "Späť na články",
"articleLoadError": "Článok sa nepodarilo načítať: {message}",
"socialWebsite": "Web"
}
@@ -8,7 +8,6 @@
<p class="description" data-field="description"></p>
<div class="article-meta">
<a class="author-link" data-field="author" href=""></a>
<a class="tag" data-field="category" href=""></a>
<span class="tag-list" data-field="tags"></span>
</div>
</header>
File renamed without changes.
@@ -11,7 +11,6 @@
<p data-field="description"></p>
<div class="card-meta">
<a class="author-link" data-field="author" href=""></a>
<a class="tag" data-field="category" href=""></a>
<span class="tag-list" data-field="tags"></span>
</div>
</div>
View File
File renamed without changes.