From b1017f29c2da4dcb26d836b6224bd18e6e4222ba Mon Sep 17 00:00:00 2001 From: Jakub Pelc Date: Thu, 10 Sep 2026 16:42:21 +0200 Subject: [PATCH 01/30] prepare for blog posts --- .gitignore | 2 + README.md | 19 +- cmjs/articles/alice/alice.md | 7 - .../alice/linux/getting-started-with-linux.md | 59 -- .../alice/linux/understanding-permissions.md | 69 -- .../alice/programming/python-for-beginners.md | 79 --- cmjs/articles/bob/bob.md | 7 - .../bob/devops/introduction-to-docker.md | 92 --- .../bob/devops/nginx-reverse-proxy.md | 94 --- .../bob/linux/bash-scripting-basics.md | 85 --- cmjs/blog-config/.gitkeep | 1 + cmjs/blog-config/tags.json | 166 +++++ cmjs/css/components/article.css | 1 + cmjs/css/components/card.css | 65 +- cmjs/export-report.txt | 99 +++ cmjs/js/discovery.js | 214 +++--- cmjs/js/frontmatter.js | 58 +- cmjs/js/router.js | 9 +- cmjs/js/views.js | 639 +++++++----------- cmjs/templates/article.html | 1 + cmjs/templates/card.html | 5 +- css/header.css | 26 +- css/layout.css | 106 ++- css/reset.css | 17 + index.html | 36 +- 25 files changed, 917 insertions(+), 1039 deletions(-) create mode 100644 .gitignore delete mode 100644 cmjs/articles/alice/alice.md delete mode 100644 cmjs/articles/alice/linux/getting-started-with-linux.md delete mode 100644 cmjs/articles/alice/linux/understanding-permissions.md delete mode 100644 cmjs/articles/alice/programming/python-for-beginners.md delete mode 100644 cmjs/articles/bob/bob.md delete mode 100644 cmjs/articles/bob/devops/introduction-to-docker.md delete mode 100644 cmjs/articles/bob/devops/nginx-reverse-proxy.md delete mode 100644 cmjs/articles/bob/linux/bash-scripting-basics.md create mode 100644 cmjs/blog-config/.gitkeep create mode 100644 cmjs/blog-config/tags.json create mode 100644 cmjs/export-report.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e290289 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/cmjs/articles/ +/cmjs/pictures/ \ No newline at end of file diff --git a/README.md b/README.md index 65d446e..f9825d2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil ## How it works -1. The browser traverses `cmjs/articles/` via nginx JSON autoindex — discovering authors, categories, and article files. +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. 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. @@ -54,17 +54,19 @@ cmjs/ │ └── article.html # Full article view layout ├── articles/ │ └── author/ # One directory per author - │ └── category/ # One directory per category - │ └── slug.md # Article file + │ └── expa-YYYY/ # Main expedition-year category + │ └── slug/ # One directory per post + │ └── slug.md └── pictures/ # Article images (referenced via image: frontmatter field) ``` ## Adding an article -1. Create the author and category directories if they don't exist: `cmjs/articles/author/category/` -2. Create a `.md` file inside: `cmjs/articles/author/category/my-article.md` -3. Add a frontmatter block at the top -4. Write your content — it will appear in the list automatically +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 `author` and `category` are derived from the directory path — no frontmatter fields needed for them. @@ -136,7 +138,8 @@ The body text below the frontmatter is rendered as markdown and shown as the bio | `#author/alice/page/2` | All articles by alice, page 2 | | `#category/linux` | All articles in "linux", page 1 | | `#category/linux/page/2` | All articles in "linux", page 2 | -| `#alice/linux/my-article` | Single article at `cmjs/articles/alice/linux/my-article.md` | +| `#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` | 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`. diff --git a/cmjs/articles/alice/alice.md b/cmjs/articles/alice/alice.md deleted file mode 100644 index ae9037d..0000000 --- a/cmjs/articles/alice/alice.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: Alice Smith -github: https://github.com/alice -twitter: https://twitter.com/alice ---- - -Linux enthusiast and Python developer. I cover everything from shell scripting to beginner programming guides. diff --git a/cmjs/articles/alice/linux/getting-started-with-linux.md b/cmjs/articles/alice/linux/getting-started-with-linux.md deleted file mode 100644 index 034aa38..0000000 --- a/cmjs/articles/alice/linux/getting-started-with-linux.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Getting Started with Linux -date: 2026-06-10 -description: A beginner's guide to the Linux operating system and its core concepts. -draft: false ---- - -Linux is a free and open-source operating system kernel first released by Linus Torvalds in 1991. Today it powers everything from smartphones to supercomputers. - -## What is a distribution? - -A Linux distribution (distro) bundles the kernel with software, a package manager, and a desktop environment. Popular choices include: - -- **Ubuntu** — beginner-friendly, large community -- **Fedora** — cutting-edge packages, sponsored by Red Hat -- **Arch Linux** — minimal base, user-assembled -- **Debian** — stability-focused, the base for many other distros - -## The terminal - -The terminal is your most powerful tool. A few commands to get started: - -```bash -# Print current directory -pwd - -# List files -ls -lah - -# Change directory -cd /home/user - -# Copy a file -cp source.txt destination.txt -``` - -## File system layout - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. The Linux file system follows the Filesystem Hierarchy Standard (FHS): - -| Directory | Purpose | -|-----------|---------| -| `/` | Root of the entire file system | -| `/home` | User home directories | -| `/etc` | System configuration files | -| `/var` | Variable data (logs, caches) | -| `/usr` | User-installed programs and libraries | - -## Package management - -Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. On Debian/Ubuntu systems, packages are managed with `apt`: - -```bash -sudo apt update # refresh package index -sudo apt install vim # install a package -sudo apt upgrade # upgrade all packages -``` - -> The best way to learn Linux is to use it daily. Break things, fix them, and you'll learn more than any book can teach. diff --git a/cmjs/articles/alice/linux/understanding-permissions.md b/cmjs/articles/alice/linux/understanding-permissions.md deleted file mode 100644 index 67d33e2..0000000 --- a/cmjs/articles/alice/linux/understanding-permissions.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -title: Understanding Linux File Permissions -date: 2026-05-22 -description: Learn how Linux controls access to files and directories with users, groups, and permission bits. -draft: false ---- - -Every file and directory in Linux has an owner, a group, and a set of permission bits. Understanding these is essential for keeping a system secure. - -## Reading permission strings - -Run `ls -l` and you'll see output like this: - -``` --rwxr-xr-- 1 alice developers 4096 May 22 09:14 deploy.sh -``` - -The first column breaks down as: - -- `-` — file type (`d` for directory, `l` for symlink) -- `rwx` — owner permissions (read, write, execute) -- `r-x` — group permissions -- `r--` — other (world) permissions - -## Changing permissions with chmod - -Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. - -```bash -# Give owner execute permission -chmod u+x script.sh - -# Remove write from group and others -chmod go-w file.txt - -# Set exact permissions with octal notation -chmod 644 config.ini # rw-r--r-- -chmod 755 bin/ # rwxr-xr-x -``` - -## Changing ownership with chown - -```bash -# Change owner -chown alice file.txt - -# Change owner and group -chown alice:developers project/ - -# Recursive change -chown -R www-data:www-data /var/www/html -``` - -## Special permission bits - -Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris: - -| Bit | Name | Effect on files | Effect on directories | -|-----|------|-----------------|-----------------------| -| `4` | setuid | Run as file owner | — | -| `2` | setgid | Run as file group | New files inherit group | -| `1` | sticky | — | Only owner can delete | - -```bash -# Set sticky bit on shared directory -chmod +t /tmp/shared -``` - -> When in doubt, use the least privilege necessary. A file that only needs to be read should not be executable. diff --git a/cmjs/articles/alice/programming/python-for-beginners.md b/cmjs/articles/alice/programming/python-for-beginners.md deleted file mode 100644 index c451eb7..0000000 --- a/cmjs/articles/alice/programming/python-for-beginners.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -title: Python for Beginners -date: 2026-06-01 -description: An introduction to Python programming with practical examples and clear explanations. -draft: false ---- - -Python is a high-level, general-purpose programming language known for its readable syntax and large standard library. It's a great first language. - -## Variables and types - -Python is dynamically typed — you don't declare types explicitly: - -```python -name = "Alice" -age = 30 -height = 1.72 -is_admin = True -``` - -## Control flow - -Lorem ipsum dolor sit amet, consectetur adipiscing elit: - -```python -# Conditionals -if age >= 18: - print("Adult") -elif age >= 13: - print("Teenager") -else: - print("Child") - -# Loops -for i in range(5): - print(i) - -fruits = ["apple", "banana", "cherry"] -for fruit in fruits: - print(fruit) -``` - -## Functions - -Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua: - -```python -def greet(name, greeting="Hello"): - return f"{greeting}, {name}!" - -print(greet("Alice")) # Hello, Alice! -print(greet("Bob", "Hi")) # Hi, Bob! -``` - -## Lists and dictionaries - -```python -# List — ordered, mutable -numbers = [3, 1, 4, 1, 5, 9] -numbers.append(2) -numbers.sort() - -# Dictionary — key-value pairs -user = { - "name": "Alice", - "age": 30, - "roles": ["admin", "editor"] -} -print(user["name"]) # Alice -``` - -## Why Python? - -- Clean, readable syntax -- Huge standard library -- Active ecosystem (pip, PyPI) -- Used in web, data science, automation, AI - -> Write code that is easy to read. You'll spend far more time reading code than writing it. diff --git a/cmjs/articles/bob/bob.md b/cmjs/articles/bob/bob.md deleted file mode 100644 index 8022003..0000000 --- a/cmjs/articles/bob/bob.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: Bob Johnson -github: https://github.com/bob -website: https://bob.example.com ---- - -DevOps engineer with a passion for containers, automation, and clean infrastructure. I write about Docker, nginx, and everything in between. diff --git a/cmjs/articles/bob/devops/introduction-to-docker.md b/cmjs/articles/bob/devops/introduction-to-docker.md deleted file mode 100644 index a66e843..0000000 --- a/cmjs/articles/bob/devops/introduction-to-docker.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Introduction to Docker -date: 2026-06-14 -description: Understand containers, images, and how Docker simplifies application deployment. -draft: false ---- - -Docker is a platform for building, shipping, and running applications in containers. A container packages your code together with its dependencies, so it runs the same everywhere. - -## Containers vs virtual machines - -| | Container | Virtual Machine | -|---|-----------|-----------------| -| Start time | Milliseconds | Minutes | -| Size | Megabytes | Gigabytes | -| Isolation | Process-level | Hardware-level | -| Overhead | Very low | Higher | - -## Core concepts - -- **Image** — a read-only template (like a class) -- **Container** — a running instance of an image (like an object) -- **Registry** — a storage and distribution system for images (Docker Hub) -- **Dockerfile** — instructions for building an image - -## Basic commands - -```bash -# Pull an image -docker pull nginx:alpine - -# Run a container -docker run -d -p 8080:80 --name my-nginx nginx:alpine - -# List running containers -docker ps - -# Stop and remove -docker stop my-nginx -docker rm my-nginx -``` - -## Writing a Dockerfile - -Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor: - -```dockerfile -FROM node:20-alpine - -WORKDIR /app - -COPY package*.json ./ -RUN npm ci --only=production - -COPY . . - -EXPOSE 3000 -CMD ["node", "server.js"] -``` - -Build and run it: - -```bash -docker build -t my-app:latest . -docker run -d -p 3000:3000 my-app:latest -``` - -## Docker Compose - -Ut enim ad minim veniam for multi-container applications: - -```yaml -services: - web: - build: . - ports: - - "3000:3000" - depends_on: - - db - db: - image: postgres:16 - environment: - POSTGRES_PASSWORD: secret -``` - -```bash -docker compose up -d -docker compose logs -f -docker compose down -``` - -> Containers solve "works on my machine" by making your machine part of the deliverable. diff --git a/cmjs/articles/bob/devops/nginx-reverse-proxy.md b/cmjs/articles/bob/devops/nginx-reverse-proxy.md deleted file mode 100644 index 9add0f6..0000000 --- a/cmjs/articles/bob/devops/nginx-reverse-proxy.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: Setting Up an nginx Reverse Proxy -date: 2026-05-30 -description: Use nginx as a reverse proxy to route traffic to multiple backend services on a single server. -draft: false ---- - -A reverse proxy sits in front of your backend services, forwarding client requests to the appropriate server. nginx excels at this role — it's fast, stable, and highly configurable. - -## Why use a reverse proxy? - -- Route multiple domains/paths to different backends -- Terminate TLS in one place -- Add rate limiting, caching, and access control -- Hide backend server details from clients - -## Basic reverse proxy configuration - -```nginx -server { - listen 80; - server_name app.example.com; - - location / { - proxy_pass http://localhost:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } -} -``` - -## Proxying multiple services - -Lorem ipsum dolor sit amet, consectetur adipiscing elit: - -```nginx -server { - listen 80; - server_name example.com; - - # Main app - location / { - proxy_pass http://localhost:3000; - } - - # API on a subpath - location /api/ { - proxy_pass http://localhost:4000/; - } - - # Static files served directly - location /static/ { - root /var/www; - expires 30d; - } -} -``` - -## Adding TLS with Certbot - -Sed do eiusmod tempor incididunt ut labore: - -```bash -# Install Certbot -sudo apt install certbot python3-certbot-nginx - -# Obtain and install certificate -sudo certbot --nginx -d example.com -d www.example.com - -# Certbot auto-renews via systemd timer — verify it -sudo systemctl status certbot.timer -``` - -## Upstream blocks for load balancing - -```nginx -upstream backend { - server 127.0.0.1:3001; - server 127.0.0.1:3002; - server 127.0.0.1:3003; - keepalive 32; -} - -server { - location / { - proxy_pass http://backend; - proxy_http_version 1.1; - proxy_set_header Connection ""; - } -} -``` - -> Always set `proxy_set_header X-Forwarded-For` so your application knows the real client IP, not the proxy address. diff --git a/cmjs/articles/bob/linux/bash-scripting-basics.md b/cmjs/articles/bob/linux/bash-scripting-basics.md deleted file mode 100644 index 7fc6968..0000000 --- a/cmjs/articles/bob/linux/bash-scripting-basics.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: Bash Scripting Basics -date: 2026-06-08 -description: Write your first shell scripts to automate repetitive tasks on Linux and macOS. -draft: false ---- - -A shell script is a plain text file containing a sequence of commands. The shell reads and executes them in order — a simple but powerful form of automation. - -## Your first script - -```bash -#!/usr/bin/env bash -# The shebang line above tells the OS which interpreter to use - -echo "Hello, world!" -``` - -Save it as `hello.sh`, make it executable, and run it: - -```bash -chmod +x hello.sh -./hello.sh -``` - -## Variables - -Lorem ipsum dolor sit amet, consectetur adipiscing elit: - -```bash -NAME="Bob" -echo "Hello, $NAME" - -# Command substitution -TODAY=$(date +%Y-%m-%d) -echo "Today is $TODAY" -``` - -## Conditionals and loops - -```bash -# If / else -if [ -f "/etc/hosts" ]; then - echo "hosts file exists" -else - echo "hosts file missing" -fi - -# For loop over files -for file in *.log; do - echo "Processing $file" - gzip "$file" -done - -# While loop -COUNT=0 -while [ $COUNT -lt 5 ]; do - echo "Count: $COUNT" - COUNT=$((COUNT + 1)) -done -``` - -## Functions - -Ut enim ad minim veniam, quis nostrud exercitation: - -```bash -backup() { - local src="$1" - local dst="$2" - cp -r "$src" "$dst/$(basename $src)-$(date +%Y%m%d)" - echo "Backed up $src to $dst" -} - -backup /etc/nginx /var/backups -``` - -## Practical tips - -- Always quote variables: `"$VAR"` not `$VAR` -- Use `set -e` to exit on error -- Use `set -u` to catch undefined variables -- Prefer `[[ ]]` over `[ ]` for conditionals in bash - -> Automate anything you do more than twice. Your future self will thank you. diff --git a/cmjs/blog-config/.gitkeep b/cmjs/blog-config/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/cmjs/blog-config/.gitkeep @@ -0,0 +1 @@ + diff --git a/cmjs/blog-config/tags.json b/cmjs/blog-config/tags.json new file mode 100644 index 0000000..957acdd --- /dev/null +++ b/cmjs/blog-config/tags.json @@ -0,0 +1,166 @@ +{ + "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 + } + ] +} diff --git a/cmjs/css/components/article.css b/cmjs/css/components/article.css index bdf2b92..4128d07 100644 --- a/cmjs/css/components/article.css +++ b/cmjs/css/components/article.css @@ -68,6 +68,7 @@ .article-meta { display: flex; align-items: center; + flex-wrap: wrap; gap: 0.5rem; font-size: 0.85rem; } diff --git a/cmjs/css/components/card.css b/cmjs/css/components/card.css index ffd4208..9b7ec78 100644 --- a/cmjs/css/components/card.css +++ b/cmjs/css/components/card.css @@ -2,36 +2,53 @@ Article list cards ------------------------------------------------------------------------- */ .card { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - overflow: hidden; /* clip card-image to rounded corners */ - margin-bottom: 1rem; - transition: border-color 0.15s, box-shadow 0.15s; + display: grid; + grid-template-columns: minmax(220px, 34%) minmax(0, 1fr); + gap: 1.75rem; + padding: 1.1rem 0 1.75rem; + border-bottom: 1px solid var(--color-border); + margin-bottom: 1.75rem; + transition: border-color 0.15s; } .card:hover { - border-color: var(--color-text-muted); - box-shadow: var(--shadow-card); + border-color: var(--color-accent); +} + +.card--no-image { grid-template-columns: 1fr; } + +.card-media { + display: block; + align-self: start; + overflow: hidden; + aspect-ratio: 16 / 10; + background: var(--color-code-bg); } -/* Preview thumbnail — shown only when article has an image: field */ .card-image { display: block; width: 100%; - height: 200px; + height: 100%; object-fit: cover; + transition: transform 0.25s ease; +} + +.card:hover .card-image { transform: scale(1.025); } + +.card-media:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 3px; } /* Text content wrapper */ .card-body { - padding: 1.25rem 1.5rem; + padding: 0.1rem 0 0; } .card h2 { - margin: 0 0 0.2rem; - font-size: 1.15rem; - font-weight: 600; + margin: 0 0 0.35rem; + font-size: clamp(1.35rem, 2.2vw, 1.8rem); + font-weight: 500; } .card h2 a { @@ -67,20 +84,26 @@ } .card p { - margin: 0 0 0.6rem; + margin: 1rem 0 0.8rem; color: var(--color-text-soft); - font-size: 0.95rem; + font-size: 1rem; + line-height: 1.65; } /* Author + category row at the bottom of each card */ .card-meta { display: flex; align-items: center; + flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; font-size: 0.82rem; } +.tag-list { + display: contents; +} + /* Shared author link — used in cards and article headers */ .author-link { color: var(--color-text-muted); @@ -120,6 +143,12 @@ } @media (max-width: 640px) { - .card-image { height: 150px; } - .card-body { padding: 1rem; } + .card { + grid-template-columns: 1fr; + gap: 0.9rem; + padding-top: 0; + } + + .card-media { aspect-ratio: 16 / 9; } + .card-body { padding: 0; } } diff --git a/cmjs/export-report.txt b/cmjs/export-report.txt new file mode 100644 index 0000000..2763c51 --- /dev/null +++ b/cmjs/export-report.txt @@ -0,0 +1,99 @@ +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 diff --git a/cmjs/js/discovery.js b/cmjs/js/discovery.js index d72afa4..408ad2a 100644 --- a/cmjs/js/discovery.js +++ b/cmjs/js/discovery.js @@ -1,98 +1,154 @@ import { parseFrontmatter } from './frontmatter.js'; -import { BASE_URL } from './config.js'; +import { BASE_URL, PICTURES_URL } from './config.js'; -// Fetch a URL and parse the response as JSON, throwing on non-OK status. +function parseHtmlDirectoryListing(raw) { + const entries = []; + const seen = new Set(); + const anchors = /]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/gi; + let match; + while ((match = anchors.exec(raw))) { + const href = match[1].replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'"); + if (!href || href.startsWith('../') || href.startsWith('/') || href.startsWith('#') || /^[a-z][a-z\d+.-]*:/i.test(href)) continue; + const clean = href.split(/[?#]/, 1)[0]; + const name = decodeURIComponent(clean.replace(/\/$/, '')).split('/').pop(); + if (!name || name === '.' || name === '..' || seen.has(name)) continue; + seen.add(name); + entries.push({ name, type: clean.endsWith('/') ? 'directory' : 'file' }); + } + return entries; +} + export async function fetchJson(url) { - const res = await fetch(url); - if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); - return res.json(); + 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; } -// --------------------------------------------------------------------------- -// Article discovery -// Traverses the 3-level directory tree: author → category → article files. -// Results are cached after the first load. -// --------------------------------------------------------------------------- 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; - - // Level 1: author directories const authorEntries = await fetchJson(`${BASE_URL}articles/`); - const authorDirs = authorEntries.filter(e => e.type === 'directory'); + 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 })); + })); - // Level 2: category directories (parallel across all authors) - const categoryLists = await Promise.all( - authorDirs.map(async author => { - const entries = await fetchJson(`${BASE_URL}articles/${author.name}/`); - return entries - .filter(e => e.type === 'directory') - .map(cat => ({ author: author.name, category: cat.name })); - }) - ); - const allCategories = categoryLists.flat(); - - // Level 3: .md files (parallel across all categories) - const fileLists = await Promise.all( - allCategories.map(async ({ author, category }) => { - const entries = await fetchJson(`${BASE_URL}articles/${author}/${category}/`); - return entries - .filter(e => e.type === 'file' && e.name.endsWith('.md')) - .map(f => ({ author, category, name: f.name })); - }) - ); - const allFiles = fileLists.flat(); - - // Level 4: fetch frontmatter and compute reading time for each article (parallel) - const articles = await Promise.all( - allFiles.map(async ({ author, category, name }) => { - const slug = name.replace(/\.md$/, ''); - const path = `${BASE_URL}articles/${author}/${category}/${name}`; - const hash = `${author}/${category}/${slug}`; - try { - const res = await fetch(path); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const raw = await res.text(); - const { meta, content } = parseFrontmatter(raw); - const words = content.trim().split(/\s+/).filter(Boolean).length; - const readingTime = Math.max(1, Math.round(words / 200)); - return { slug, path, hash, author, category, readingTime, ...meta }; - } catch { - return { slug, path, hash, author, category, title: slug }; - } - }) - ); - - // Filter drafts — treat 'true', 'yes', '1' (case-insensitive) as draft - // Sort newest-first; articles without a date fall to the end - articleCache = articles - .filter(a => { - const d = String(a.draft ?? '').toLowerCase().trim(); - return d !== 'true' && d !== 'yes' && d !== '1'; - }) - .sort((a, b) => { - if (!a.date && !b.date) return 0; - if (!a.date) return 1; - if (!b.date) return -1; - return b.date.localeCompare(a.date); - }); + 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; } -// --------------------------------------------------------------------------- -// Author profile -// Tries to fetch articles/{author}/{author}.md — returns parsed profile or null. -// No cache; called at most once per author view navigation. -// --------------------------------------------------------------------------- +export async function 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 res = await fetch(`${BASE_URL}articles/${author}/${author}.md`); - if (!res.ok) return null; - const { meta, content } = parseFrontmatter(await res.text()); + 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; - } + } catch { return null; } } diff --git a/cmjs/js/frontmatter.js b/cmjs/js/frontmatter.js index 123873e..cf63992 100644 --- a/cmjs/js/frontmatter.js +++ b/cmjs/js/frontmatter.js @@ -1,22 +1,48 @@ -// Parses Hugo-style YAML frontmatter from a raw markdown string. -// Returns { meta: { key: value, ... }, content: '...' } +// Small YAML-frontmatter reader for the canonical blog schema. It supports +// quoted/unquoted scalars, booleans, numbers and scalar arrays in inline or +// block form. The editor remains the authoritative validator and writer. + +function parseScalar(raw) { + const value = raw.trim(); + if (!value) return ''; + if (value === 'true') return true; + if (value === 'false') return false; + if (value === 'null' || value === '~') return null; + if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value); + if (value.startsWith('"') && value.endsWith('"')) { + try { return JSON.parse(value); } catch { return value.slice(1, -1); } + } + if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1).replace(/''/g, "'"); + if (value.startsWith('[') && value.endsWith(']')) { + const inside = value.slice(1, -1).trim(); + if (!inside) return []; + const values = []; + let token = ''; + let quote = ''; + for (const character of inside) { + if ((character === '"' || character === "'") && (!quote || quote === character)) quote = quote ? '' : character; + if (character === ',' && !quote) { values.push(parseScalar(token)); token = ''; } + else token += character; + } + values.push(parseScalar(token)); + return values; + } + return value; +} + export function parseFrontmatter(raw) { const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); if (!match) return { meta: {}, content: raw }; - const meta = {}; - match[1].split('\n').forEach(line => { - const colon = line.indexOf(':'); - if (colon === -1) return; - const key = line.slice(0, colon).trim(); - const rawVal = line.slice(colon + 1).trim(); - // Strip surrounding quotes only when they form a matched pair - const val = (rawVal.startsWith('"') && rawVal.endsWith('"')) || - (rawVal.startsWith("'") && rawVal.endsWith("'")) - ? rawVal.slice(1, -1) - : rawVal; - meta[key] = val; - }); - + let arrayKey = null; + for (const line of match[1].split(/\r?\n/)) { + const item = line.match(/^\s*-\s+(.*)$/); + if (item && arrayKey) { meta[arrayKey].push(parseScalar(item[1])); continue; } + const field = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(?:\s*(.*))?$/); + if (!field) { arrayKey = null; continue; } + const [, key, rawValue = ''] = field; + if (!rawValue.trim()) { meta[key] = []; arrayKey = key; } + else { meta[key] = parseScalar(rawValue); arrayKey = null; } + } return { meta, content: match[2] }; } diff --git a/cmjs/js/router.js b/cmjs/js/router.js index fdb55c3..787b639 100644 --- a/cmjs/js/router.js +++ b/cmjs/js/router.js @@ -5,14 +5,14 @@ // first = "search" → search results (#search/query) // 2 segments, first = "page" → all articles, page N (#page/2) // 2 segments, first = "author" → author filter, page 1 (#author/alice) -// 2 segments, first = "category" → category filter, page 1 (#category/linux) +// 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, loadArticle, loadSearch } from './views.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) { @@ -41,9 +41,12 @@ function router() { 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(); } @@ -62,7 +65,7 @@ function init() { // Show a top-level error if dependencies fail to load ready.catch(err => { - document.body.innerHTML = `

Failed to initialise: ${err.message}

`; + document.body.textContent = `Blog se nepodařilo spustit: ${err.message}`; }); ready.then(router).catch(() => {}); diff --git a/cmjs/js/views.js b/cmjs/js/views.js index a366681..5f6c590 100644 --- a/cmjs/js/views.js +++ b/cmjs/js/views.js @@ -1,19 +1,25 @@ import { templates } from './templates.js'; -import { discoverArticles, fetchAuthorProfile } from './discovery.js'; +import { discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, resolveMediaUrl } from './discovery.js'; import { parseFrontmatter } from './frontmatter.js'; -import { BASE_URL, PICTURES_URL } from './config.js'; - -// marked is loaded as a plain global from marked.min.js +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); +} -// --------------------------------------------------------------------------- -// View helpers — toggle which panel is visible -// --------------------------------------------------------------------------- export function showListView() { document.getElementById('list-view').hidden = false; document.getElementById('article-view').hidden = true; - document.title = 'cmjs'; + document.title = 'Expediční blogy'; } export function showArticleView() { @@ -21,449 +27,282 @@ export function showArticleView() { document.getElementById('article-view').hidden = false; } -// Set a data-field element's value, or remove it if there is no value. -function setField(el, value, apply) { - if (value != null && value !== '') apply(el, value); - else el.remove(); +function setField(element, value, apply) { + if (element && value != null && value !== '') apply(element, value); + else element?.remove(); } -// Clear the search box when navigating away from a search view. -const clearSearchInput = () => { - const el = document.getElementById('search-input'); - if (el) el.value = ''; -}; +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 ')}`; +} -// --------------------------------------------------------------------------- -// Pagination nav builder -// Builds hash links that match the router's URL scheme: -// no filter → # (page 1) or #page/N -// author → #author/name (page 1) or #author/name/page/N -// category → #category/name (page 1) or #category/name/page/N -// --------------------------------------------------------------------------- function pageHash(page, filter) { if (!filter) return page === 1 ? '#' : `#page/${page}`; - const base = `${filter.type}/${filter.value}`; + 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'; - - const prev = document.createElement('a'); - prev.textContent = '← Previous'; - if (page > 1) { - prev.href = pageHash(page - 1, filter); - } else { - prev.classList.add('disabled'); - prev.setAttribute('aria-disabled', 'true'); - } - + 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 ${page} of ${totalPages}`; - - const next = document.createElement('a'); - next.textContent = 'Next →'; - if (page < totalPages) { - next.href = pageHash(page + 1, filter); - } else { - next.classList.add('disabled'); - next.setAttribute('aria-disabled', 'true'); - } - - nav.appendChild(prev); - nav.appendChild(info); - nav.appendChild(next); + info.textContent = `${page} z ${totalPages}`; + nav.append(previous, info, next); return nav; } -// --------------------------------------------------------------------------- -// Card rendering — shared by loadArticleList and loadSearch -// --------------------------------------------------------------------------- -function renderCard(a) { - const el = document.importNode(templates.card, true); - const f = name => el.querySelector(`[data-field="${name}"]`); - - setField(f('image'), a.image, (el, v) => { - el.src = `${PICTURES_URL}${v}`; - el.alt = a.title || a.slug; +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); }); - - f('link').href = `#${a.hash}`; - f('title').textContent = a.title || a.slug; - - setField(f('date'), a.date, (el, v) => { - el.dateTime = v; - el.textContent = v; - }); - - setField(f('description'), a.description, (el, v) => { el.textContent = v; }); - - setField(f('reading-time'), a.readingTime, (el, v) => { - el.textContent = `${v} min read`; - }); - - const authorLink = f('author'); - authorLink.textContent = a.author; - authorLink.href = `#author/${a.author}`; - - const catLink = f('category'); - catLink.textContent = a.category; - catLink.href = `#category/${a.category}`; - - return el; } -// --------------------------------------------------------------------------- -// Article list — with optional author or category filter and pagination -// filter: { type: 'author'|'category', value: string } or null for all -// --------------------------------------------------------------------------- -export async function loadArticleList(filter, page = 1) { +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); - const listView = document.getElementById('list-view'); - listView.innerHTML = '

Loading articles…

'; - clearSearchInput(); - - let articles; + const view = document.getElementById('list-view'); + view.innerHTML = '

Načítám články…

'; try { - articles = await discoverArticles(); - } catch (err) { - listView.innerHTML = `

Could not load articles: ${err.message}

`; - return; - } - - // Apply optional author or category filter (case-insensitive) - if (filter) { - articles = articles.filter( - a => a[filter.type] && a[filter.type].toLowerCase() === filter.value.toLowerCase() - ); - } - - if (articles.length === 0) { - listView.innerHTML = '

No articles found.

'; - return; - } - - // Pagination — clamp page to valid range - const totalPages = Math.ceil(articles.length / PAGE_SIZE); - page = Math.max(1, Math.min(page, totalPages)); - const visible = articles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - - listView.innerHTML = ''; - - // Filter heading - if (filter) { - const h = document.createElement('h2'); - h.className = 'category-heading'; - h.textContent = `${filter.type === 'author' ? 'Author' : 'Category'}: ${filter.value}`; - listView.appendChild(h); - } - - visible.forEach(a => listView.appendChild(renderCard(a))); - - if (totalPages > 1) { - listView.appendChild(buildPaginationNav(page, totalPages, filter)); - } -} - -// --------------------------------------------------------------------------- -// Author profile card renderer -// Known social fields rendered as labelled pill links. -// --------------------------------------------------------------------------- -const SOCIAL_LABELS = { - github: 'GitHub', - twitter: 'Twitter', - linkedin: 'LinkedIn', - mastodon: 'Mastodon', - website: 'Website', -}; - -function renderAuthorProfile(profile) { - const el = document.importNode(templates.authorProfile, true); - const f = name => el.querySelector(`[data-field="${name}"]`); - - setField(f('avatar'), profile.avatar, (el, v) => { - el.src = `${PICTURES_URL}${v}`; - el.alt = profile.title || profile.author; - }); - - f('name').textContent = profile.title || profile.author; - - const bioEl = f('bio'); - if (profile.content && profile.content.trim()) { - // marked output is trusted (author-controlled markdown), innerHTML is intentional - bioEl.innerHTML = marked.parse(profile.content); - } else { - bioEl.remove(); - } - - const socialEl = f('social'); - const links = Object.entries(SOCIAL_LABELS) - .filter(([key]) => profile[key]) - .map(([key, label]) => { - const a = document.createElement('a'); - a.href = profile[key]; - a.textContent = label; - a.target = '_blank'; - a.rel = 'noopener noreferrer'; - return a; + 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(); }); - - if (links.length > 0) { - links.forEach(a => socialEl.appendChild(a)); - } else { - socialEl.remove(); + 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 = `

Články se nepodařilo načíst: ${error.message}

`; } - - return el; } -// --------------------------------------------------------------------------- -// Author view — profile card (page 1 only) + filtered article list -// --------------------------------------------------------------------------- +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(); - window.scrollTo(0, 0); - const listView = document.getElementById('list-view'); - listView.innerHTML = '

Loading…

'; - + const view = document.getElementById('list-view'); + view.innerHTML = '

Načítám…

'; clearSearchInput(); - - let articles, profile; try { - [articles, profile] = await Promise.all([ - discoverArticles(), - fetchAuthorProfile(author), - ]); - } catch (err) { - listView.innerHTML = `

Could not load articles: ${err.message}

`; - return; - } - - articles = articles.filter( - a => a.author.toLowerCase() === author.toLowerCase() - ); - - listView.innerHTML = ''; - - // Profile card — page 1 only - if (page === 1 && profile) { - listView.appendChild(renderAuthorProfile(profile)); - } - - // Author heading - const h = document.createElement('h2'); - h.className = 'category-heading'; - h.textContent = `Author: ${author}`; - listView.appendChild(h); - - if (articles.length === 0) { - const empty = document.createElement('p'); - empty.className = 'empty'; - empty.textContent = 'No articles found.'; - listView.appendChild(empty); - return; - } - - const totalPages = Math.ceil(articles.length / PAGE_SIZE); - page = Math.max(1, Math.min(page, totalPages)); - const visible = articles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - - visible.forEach(a => listView.appendChild(renderCard(a))); - - if (totalPages > 1) { - listView.appendChild(buildPaginationNav(page, totalPages, { type: 'author', value: author })); - } + 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 = `

Články se nepodařilo načíst: ${error.message}

`; } } -export function loadCategoryView(category, page = 1) { - return loadArticleList({ type: 'category', value: category }, page); -} +export const loadCategoryView = (category, page = 1) => loadArticleList({ type: 'category', value: category }, page); +export const loadTagView = (tag, page = 1) => loadArticleList({ type: 'tag', value: tag }, page); -// --------------------------------------------------------------------------- -// Search — filters all articles in-memory across title, description, author, category -// --------------------------------------------------------------------------- export async function loadSearch(query) { showListView(); window.scrollTo(0, 0); - const listView = document.getElementById('list-view'); - listView.innerHTML = '

Searching…

'; - - // Populate the search box with the active query - const searchInput = document.getElementById('search-input'); - if (searchInput) searchInput.value = query; - - let articles; + const view = document.getElementById('list-view'); + view.innerHTML = '

Hledám…

'; + const input = document.getElementById('search-input'); + if (input) input.value = query; try { - articles = await discoverArticles(); - } catch (err) { - listView.innerHTML = `

Could not load articles: ${err.message}

`; - return; - } - - const q = query.toLowerCase(); - const results = articles.filter(a => - (a.title || '').toLowerCase().includes(q) || - (a.description || '').toLowerCase().includes(q) || - a.author.toLowerCase().includes(q) || - a.category.toLowerCase().includes(q) - ); - - listView.innerHTML = ''; - - const heading = document.createElement('h2'); - heading.className = 'category-heading'; - heading.textContent = `Search: "${query}"`; - listView.appendChild(heading); - - if (results.length === 0) { - const empty = document.createElement('p'); - empty.className = 'empty'; - empty.textContent = 'No articles match your search.'; - listView.appendChild(empty); - return; - } - - results.forEach(a => listView.appendChild(renderCard(a))); + 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 = `

Hledání: „${query}“

`; + if (!articles.length) view.insertAdjacentHTML('beforeend', '

Hledání neodpovídá žádný článek.

'); + else articles.forEach(article => view.appendChild(renderCard(article))); + } catch (error) { view.innerHTML = `

Hledání se nepodařilo: ${error.message}

`; } } -// --------------------------------------------------------------------------- -// Table of contents — built from h2/h3 headings in the rendered content. -// Only generated when there are 3 or more headings. -// --------------------------------------------------------------------------- -function buildTOC(contentEl) { - const headings = [...contentEl.querySelectorAll('h2, h3')]; +function buildTOC(content) { + const headings = [...content.querySelectorAll('h2, h3')]; if (headings.length < 3) return null; - - // Assign unique IDs so anchor links work; append -2/-3 on collisions const seen = new Map(); - headings.forEach(h => { - if (!h.id) { - const base = h.textContent.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, ''); - const count = (seen.get(base) ?? 0) + 1; - seen.set(base, count); - h.id = count === 1 ? base : `${base}-${count}`; - } + 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'; - - const title = document.createElement('p'); - title.className = 'toc-title'; - title.textContent = 'Contents'; - nav.appendChild(title); - - const ol = document.createElement('ol'); - ol.className = 'toc-list'; - headings.forEach(h => { - const li = document.createElement('li'); - if (h.tagName === 'H3') li.classList.add('toc-h3'); - const a = document.createElement('a'); - a.href = `#${h.id}`; - a.textContent = h.textContent; - li.appendChild(a); - ol.appendChild(li); + const nav = document.createElement('nav'); nav.className = 'toc'; + nav.innerHTML = '

Obsah

    '; + 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); }); - nav.appendChild(ol); return nav; } -// --------------------------------------------------------------------------- -// Single article view -// hash is "author/category/slug"; fetch path is "BASE_URL/articles/author/category/slug.md" -// --------------------------------------------------------------------------- export async function loadArticle(hash) { showArticleView(); window.scrollTo(0, 0); - const articleView = document.getElementById('article-view'); - articleView.innerHTML = '

    Loading…

    '; - clearSearchInput(); - - const [author, category] = hash.split('/'); - - // Fetch article and author profile in parallel (author is known from hash) - let raw, profile; + const view = document.getElementById('article-view'); + view.innerHTML = '

    Načítám…

    '; try { - [raw, profile] = await Promise.all([ - fetch(`${BASE_URL}articles/${hash}.md`).then(r => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.text(); - }), - fetchAuthorProfile(author), + 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), ]); - } catch (err) { - articleView.innerHTML = `

    Could not load article: ${err.message}

    `; - return; - } - - const { meta, content } = parseFrontmatter(raw); - - // Reading time computed from the raw content - const words = content.trim().split(/\s+/).filter(Boolean).length; - const readingTime = Math.max(1, Math.round(words / 200)); - - document.title = meta.title ? `${meta.title} — cmjs` : 'cmjs'; - - const el = document.importNode(templates.article, true); - const f = name => el.querySelector(`[data-field="${name}"]`); - - f('title').textContent = meta.title || hash; - - setField(f('date'), meta.date, (el, v) => { - el.dateTime = v; - el.textContent = v; - }); - - setField(f('description'), meta.description, (el, v) => { el.textContent = v; }); - - setField(f('reading-time'), readingTime, (el, v) => { - el.textContent = `${v} min read`; - }); - - // Hero image is inside
    — remove the whole figure when there's no image - const imgEl = f('image'); - if (meta.image) { - imgEl.src = `${PICTURES_URL}${meta.image}`; - imgEl.alt = meta.title || hash; - } else { - imgEl.closest('figure')?.remove(); - } - - const authorLink = f('author'); - authorLink.textContent = author; - authorLink.href = `#author/${author}`; - - const catLink = f('category'); - catLink.textContent = category; - catLink.href = `#category/${category}`; - - // Render markdown then build TOC from the resulting headings - const contentEl = f('content'); - // marked output is trusted (author-controlled markdown), innerHTML is intentional - contentEl.innerHTML = marked.parse(content); - - const toc = buildTOC(contentEl); - if (toc) contentEl.insertBefore(toc, contentEl.firstChild); - - // Back link above the article - const backLink = document.createElement('a'); - backLink.href = '#'; - backLink.className = 'back-link'; - backLink.textContent = '← Back to articles'; - - articleView.innerHTML = ''; - articleView.appendChild(backLink); - articleView.appendChild(el); - - if (profile) { - const divider = document.createElement('hr'); - divider.className = 'article-divider'; - articleView.appendChild(divider); - articleView.appendChild(renderAuthorProfile(profile)); - } + 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 = `

    Článek se nepodařilo načíst: ${error.message}

    `; } } diff --git a/cmjs/templates/article.html b/cmjs/templates/article.html index 192f4d3..8d3f308 100644 --- a/cmjs/templates/article.html +++ b/cmjs/templates/article.html @@ -9,6 +9,7 @@
    diff --git a/cmjs/templates/card.html b/cmjs/templates/card.html index 230eac7..57b88f6 100644 --- a/cmjs/templates/card.html +++ b/cmjs/templates/card.html @@ -1,5 +1,7 @@
    - +

    diff --git a/css/header.css b/css/header.css index 2d5466d..0ff4c5d 100644 --- a/css/header.css +++ b/css/header.css @@ -2,14 +2,14 @@ body > header { background: var(--color-surface); border-bottom: 1px solid var(--color-border); - padding: 0.8rem 1.5rem; + padding: 1rem 2rem; display: flex; align-items: center; gap: 1.5rem; } #site-title { - font-size: 1.2rem; + font-size: 1.35rem; font-weight: 700; color: var(--color-text); text-decoration: none; @@ -25,17 +25,35 @@ body > header { #search-form { flex: 1; max-width: 360px; + display: flex; + gap: 0.5rem; } #search-input { width: 100%; - padding: 0.35rem 0.75rem; + padding: 0.55rem 0.8rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-bg); color: var(--color-text); font-size: 0.9rem; font-family: inherit; + min-width: 0; +} + +#search-form button { + border: 1px solid var(--color-accent); + border-radius: var(--radius-sm); + padding: 0.55rem 0.9rem; + background: var(--color-accent); + color: #fff; + font: inherit; + cursor: pointer; +} + +#search-form button:hover, +#search-form button:focus-visible { + filter: brightness(1.12); } #search-input::placeholder { @@ -55,7 +73,7 @@ body > header { body > header { flex-wrap: wrap; gap: 0.75rem; - padding: 0.75rem 1rem; + padding: 0.8rem 1rem; } #search-form { diff --git a/css/layout.css b/css/layout.css index 97ceab5..ba0bef0 100644 --- a/css/layout.css +++ b/css/layout.css @@ -1,15 +1,107 @@ -/* Main content wrapper */ -main { - max-width: 720px; - margin: 2rem auto; - padding: 0 1.5rem; +/* Main content wrapper and blog filter rail */ +main.blog-shell { + max-width: 1180px; + margin: 3rem auto 5rem; + padding: 0 2rem; + display: grid; + grid-template-columns: minmax(0, 1fr) 270px; + gap: 4rem; + align-items: start; +} + +.blog-content { min-width: 0; } + +.blog-sidebar { + border: 1px solid var(--color-border); + padding: 1.5rem 1.35rem; + position: sticky; + top: 1.5rem; + background: color-mix(in srgb, var(--color-bg) 92%, white 8%); +} + +.filter-section + .filter-section { + border-top: 1px solid var(--color-border); + margin-top: 1.5rem; + padding-top: 1.5rem; +} + +.filter-section h2 { + color: var(--color-text-soft); + font-size: 1.05rem; + font-weight: 500; + letter-spacing: 0.08em; + margin: 0 0 1rem; +} + +.filter-list { + display: grid; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +.filter-link { + display: block; + padding: 0.35rem 0.5rem; + color: var(--color-text-muted); + text-decoration: none; + border-left: 2px solid transparent; +} + +.filter-link:hover, +.filter-link:focus-visible, +.filter-link.is-active { + color: var(--color-accent); + border-left-color: var(--color-accent); +} + +.filter-count { + color: var(--color-text-muted); + font-size: 0.8em; + margin-left: 0.3rem; +} + +@media (max-width: 900px) { + main.blog-shell { + margin-top: 2rem; + grid-template-columns: 1fr; + gap: 2rem; + } + + .blog-sidebar { + position: static; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.5rem; + } + + .filter-section + .filter-section { + border-top: 0; + border-left: 1px solid var(--color-border); + margin-top: 0; + padding-top: 0; + padding-left: 1.5rem; + } } @media (max-width: 640px) { - main { - margin: 1rem auto; + main.blog-shell { + margin: 1.5rem auto 3rem; padding: 0 1rem; } + + .blog-sidebar { + display: block; + } + + .filter-section + .filter-section { + border-left: 0; + border-top: 1px solid var(--color-border); + margin-top: 1.5rem; + padding-top: 1.5rem; + padding-left: 0; + } } /* State messages (loading / error / empty) */ diff --git a/css/reset.css b/css/reset.css index ab3e102..f559178 100644 --- a/css/reset.css +++ b/css/reset.css @@ -50,6 +50,23 @@ 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. */ +body.blog-page { + --color-bg: #050506; + --color-surface: #101114; + --color-border: #292b31; + --color-text: #f4f1ed; + --color-text-soft: #c6c4c2; + --color-text-muted: #92959d; + --color-accent: #e25555; + --color-tag-bg: #191b21; + --color-tag-border: #363941; + --color-code-bg: #131519; + --color-error: #ff7777; + --shadow-card: 0 12px 35px rgba(0, 0, 0, 0.28); +} + /* Global keyboard-navigation focus indicator */ :focus-visible { outline: 2px solid var(--color-accent); diff --git a/index.html b/index.html index 620585a..33cde5a 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,9 @@ - + - - cmjs + + Expediční blogy @@ -14,23 +14,37 @@ - +
    - cmjs + Expediční blogy
    -
    - -
    +
    +
    + +
    - - + + +
    + +
    -- 2.54.0 From b92bbc7129dfc4bab84163d00e882ab6e2475510 Mon Sep 17 00:00:00 2001 From: AI Date: Fri, 11 Sep 2026 11:18:17 +0200 Subject: [PATCH 02/30] 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// route this layout change requires. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd --- README.md | 75 +++++-- .../admin/hello-world/16-06-2026.date | 0 .../{general => hello-world}/hello-world.md | 2 - cmjs/js/cache.js | 84 ++++++++ cmjs/js/concurrency.js | 15 ++ cmjs/js/discovery.js | 188 +++++++++++++----- cmjs/js/router.js | 6 +- cmjs/js/views.js | 37 +++- 8 files changed, 322 insertions(+), 85 deletions(-) create mode 100644 cmjs/articles/admin/hello-world/16-06-2026.date rename cmjs/articles/admin/{general => hello-world}/hello-world.md (98%) create mode 100644 cmjs/js/cache.js create mode 100644 cmjs/js/concurrency.js diff --git a/README.md b/README.md index f9825d2..201433d 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil ## How it works -1. The browser traverses `cmjs/articles/` via a server directory index — discovering authors, year categories, and article files. Articles may be direct Markdown files or use the nested `author/category/post/post.md` layout; nginx JSON autoindex is preferred, with HTML autoindexes supported as a fallback. -2. Each `.md` file's YAML frontmatter is parsed to build the article list. +1. The browser traverses `cmjs/articles/` via a server directory index — discovering authors and their post directories (`author/slug/slug.md`); nginx JSON autoindex is preferred, with HTML autoindexes supported as a fallback. +2. Each `.md` file's YAML frontmatter is parsed for title/description/image; publish date and tags/category come from empty `.date`/`.tag` marker files beside it (see "Adding an article" 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. Discovered articles are cached in IndexedDB, keyed by post directory and invalidated by directory `mtime`, so repeat visits skip re-fetching unchanged content — see "Article discovery & caching" below. ## Features @@ -47,35 +48,45 @@ cmjs/ │ ├── 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 post directory + │ ├── concurrency.js # mapWithConcurrency() — caps simultaneous per-article fetches │ ├── 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 + │ └── author/ # One directory per author + │ ├── author.md # Author profile (optional) + │ └── slug/ # One directory per post + │ ├── slug.md # Article content + frontmatter + │ ├── tag-id.tag # Zero or more empty tag/category markers + │ └── DD-MM-YYYY.date # Publish date marker (exactly one) └── pictures/ # Article images (referenced via image: frontmatter field) ``` ## 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 +1. Create the author and post directories if they don't exist: `cmjs/articles/author/my-article/` +2. Create the Markdown file inside: `cmjs/articles/author/my-article/my-article.md` +3. Add a publish-date marker beside it: `cmjs/articles/author/my-article/DD-MM-YYYY.date` (e.g. + `16-06-2026.date`) — an empty file, exactly one per article. This is the sole source of the + publish date; a `date:` frontmatter field is no longer read. +4. Add tag/category markers as needed: `cmjs/articles/author/my-article/tag-id.tag` (e.g. + `kometa.tag`, `expa-2026.tag`) — empty files, any number. A tag id matching `expa-YYYY` is + treated as the article's "year"/category and shown in the Ročníky sidebar; every other tag + id is a normal topic tag. +5. Add a frontmatter block at the top of the Markdown file +6. Write your content — it will appear in the list automatically -`author` and `category` are derived from the directory path — no frontmatter fields needed for them. +`author` is derived from the directory path — no frontmatter field needed for it. Publish date, +tags, and category are derived from the `.date`/`.tag` marker files, not frontmatter. ### Frontmatter format ```yaml --- title: My Article Title -date: 2026-06-16 description: A short summary shown in the article list. draft: false --- @@ -86,12 +97,13 @@ 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 | -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 +`.date`/`.tag` marker files above. ## Author profiles @@ -136,10 +148,11 @@ The body text below the frontmatter is rendered as markdown and shown as the bio | `#search/linux` | Search results for "linux" | | `#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` | +| `#category/expa-2026` | All articles carrying the "expa-2026" year tag, page 1 | +| `#category/expa-2026/page/2` | Same, page 2 | +| `#tag/kometa` | All articles tagged "kometa", page 1 | +| `#tag/kometa/page/2` | Same, page 2 | +| `#post/alice/my-article` | Single article at `cmjs/articles/alice/my-article/my-article.md` | Author names and category tags on each card and article are clickable links to their respective filter views. A search box in the header navigates to `#search/query`. @@ -249,11 +262,33 @@ server { location /cmjs/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. + +## Article discovery & caching + +Articles are discovered by crawling `cmjs/articles/` client-side: fetch the author +directories, then each author's post directories along with their `mtime`, then — only for +directories whose `mtime` has changed since the last visit — that directory's own listing (to +read its `.tag`/`.date` markers) and its Markdown content. + +- **Cache** — results are cached per post directory in IndexedDB (`cmjs/js/cache.js`), keyed + by directory path and invalidated by comparing the live `mtime` against the cached one. If + IndexedDB is unavailable (private browsing, disabled storage), the crawler transparently + falls back to fetching everything live on every visit. +- **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `cmjs/js/discovery.js` (default `10`) caps how + many post directories are crawled at once; raise it for a fast/local nginx, lower it for a + slow or shared/production instance. +- **Design rationale** — the full reasoning (wave-based crawling, recency prioritisation, + HTML-autoindex fallback, and 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. ## Running locally diff --git a/cmjs/articles/admin/hello-world/16-06-2026.date b/cmjs/articles/admin/hello-world/16-06-2026.date new file mode 100644 index 0000000..e69de29 diff --git a/cmjs/articles/admin/general/hello-world.md b/cmjs/articles/admin/hello-world/hello-world.md similarity index 98% rename from cmjs/articles/admin/general/hello-world.md rename to cmjs/articles/admin/hello-world/hello-world.md index 72bf7e9..51c585f 100644 --- a/cmjs/articles/admin/general/hello-world.md +++ b/cmjs/articles/admin/hello-world/hello-world.md @@ -1,6 +1,5 @@ --- title: Hello World -date: 2026-06-16 description: A sample article to demonstrate the CMS. image: hello-world-preview.webp draft: false @@ -15,7 +14,6 @@ Articles are plain Markdown files stored in the `articles/` directory. Each file ```yaml --- title: My Article Title -date: 2026-06-16 description: A short summary shown in the article list. draft: false --- diff --git a/cmjs/js/cache.js b/cmjs/js/cache.js new file mode 100644 index 0000000..5a8dbe6 --- /dev/null +++ b/cmjs/js/cache.js @@ -0,0 +1,84 @@ +// Persistent cache for discovered articles, keyed by slug-directory path. +// Raw IndexedDB (no wrapper library — see .claude/nginx-sitemap-crawler-plan.md +// L4/L5 on the zero-dependency, long-stable-API requirement). If IndexedDB is +// unavailable (private browsing, old browser, blocked), every operation below +// silently becomes a no-op so discovery.js gets live-fetch behaviour for free +// without needing its own availability branching (plan doc R6). + +const DB_NAME = 'cmjs-cache'; +const STORE_NAME = 'slugDirs'; + +let cacheAvailable = true; +let dbPromise = null; + +function openDb() { + if (!dbPromise) { + dbPromise = new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB unavailable')); return; } + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE_NAME, { keyPath: 'path' }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error('IndexedDB open blocked')); + }).catch(error => { cacheAvailable = false; throw error; }); + } + return dbPromise; +} + +// Opens a store and resolves/rejects with the given IDBRequest's own outcome +// — the standard pattern, so result timing never depends on transaction +// lifecycle details. +async function runRequest(mode, makeRequest) { + if (!cacheAvailable) return undefined; + try { + const db = await openDb(); + const store = db.transaction(STORE_NAME, mode).objectStore(STORE_NAME); + return await new Promise((resolve, reject) => { + const request = makeRequest(store); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } catch { + cacheAvailable = false; + return undefined; + } +} + +export function isCacheAvailable() { + return cacheAvailable; +} + +// Returns the cached { path, mtime, article } record, or undefined on a miss +// (or if the cache is unavailable). +export async function getCacheEntry(path) { + return runRequest('readonly', store => store.get(path)); +} + +export async function setCacheEntry(path, entry) { + await runRequest('readwrite', store => store.put({ ...entry, path })); +} + +// Removes any cached entry whose path is no longer in the live directory +// listing (renamed/deleted slugs), so the cache doesn't grow unbounded. +export async function pruneCache(validPaths) { + if (!cacheAvailable) return; + const valid = new Set(validPaths); + try { + const db = await openDb(); + const store = db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME); + await new Promise((resolve, reject) => { + const cursorRequest = store.openCursor(); + cursorRequest.onsuccess = event => { + const cursor = event.target.result; + if (!cursor) { resolve(); return; } + if (!valid.has(cursor.value.path)) cursor.delete(); + cursor.continue(); + }; + cursorRequest.onerror = () => reject(cursorRequest.error); + }); + } catch { + cacheAvailable = false; + } +} diff --git a/cmjs/js/concurrency.js b/cmjs/js/concurrency.js new file mode 100644 index 0000000..6e37edb --- /dev/null +++ b/cmjs/js/concurrency.js @@ -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; +} diff --git a/cmjs/js/discovery.js b/cmjs/js/discovery.js index 408ad2a..21bfb18 100644 --- a/cmjs/js/discovery.js +++ b/cmjs/js/discovery.js @@ -1,10 +1,27 @@ import { parseFrontmatter } from './frontmatter.js'; import { BASE_URL, PICTURES_URL } from './config.js'; +import { mapWithConcurrency } from './concurrency.js'; +import { getCacheEntry, setCacheEntry, pruneCache } from './cache.js'; + +// Caps simultaneous per-article fetches (see the "Wave 3+4" step below) so a +// large site doesn't burst hundreds of requests at once. 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 full rationale. +const MAX_CONCURRENT_REQUESTS = 10; + +const MONTH_ABBREVIATIONS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +// A slug directory's own `.date` marker file (see README). +const DATE_FILE_PATTERN = /^(\d{2})-(\d{2})-(\d{4})\.date$/; function parseHtmlDirectoryListing(raw) { const entries = []; const seen = new Set(); - const anchors = /]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/gi; + const anchors = /]*\bhref\s*=\s*["']([^"']+)["'][^>]*>[\s\S]*?<\/a>/gi; + // nginx's default autoindex puts "DD-Mon-YYYY HH:MM" right after the + // on the same line — used as a best-effort mtime when JSON autoindex + // isn't available (see the "mtime" note in the README). + const datePattern = /(\d{2})-([A-Za-z]{3})-(\d{4})\s+(\d{2}):(\d{2})/; let match; while ((match = anchors.exec(raw))) { const href = match[1].replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'"); @@ -13,7 +30,16 @@ function parseHtmlDirectoryListing(raw) { 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' }); + + 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; } @@ -31,12 +57,6 @@ export async function fetchJson(url) { 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(); @@ -68,6 +88,15 @@ export async function fetchTagRegistry() { } let articleCache = null; +let crawlStats = { total: 0, loaded: 0 }; + +// How many of the last crawl's slug directories actually produced an article +// vs. how many were candidates — 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 isPublished(meta) { if (meta.status != null) return ['published', 'publish'].includes(String(meta.status).toLowerCase()); return !['true', 'yes', '1'].includes(String(meta.draft ?? '').toLowerCase().trim()); @@ -85,56 +114,111 @@ function excerpt(content) { return `${text.slice(0, 277).replace(/\s+\S*$/, '')}…`; } +// A single `.date` marker file is the sole authoritative source +// of an article's publish date; a missing/ambiguous one falls back to the +// (deprecated) frontmatter `date:` field so older, unmigrated content still +// works, resolved by the caller. +function parseDateFromFiles(files) { + const matches = files.map(file => file.name.match(DATE_FILE_PATTERN)).filter(Boolean); + if (matches.length !== 1) return null; + const [, day, month, year] = matches[0]; + return `${year}-${month}-${day}`; +} + +// Fetches a slug directory's own listing, resolves its markdown file and +// `.tag` marker files, and builds the full article record. Returns null on +// any failure (missing/unreadable markdown, network error) — the caller +// treats that as a silent skip, matching the rest of this module's approach. +async function buildArticle(dir) { + try { + const entries = await fetchJson(dir.root); + const files = entries.filter(entry => entry.type === 'file'); + const tagIds = files.filter(entry => entry.name.endsWith('.tag')).map(entry => entry.name.replace(/\.tag$/, '')); + const mdFiles = files.filter(entry => entry.name.endsWith('.md') && !entry.name.startsWith('_')); + const mdFile = mdFiles.find(entry => entry.name === `${dir.slug}.md`) + || mdFiles.find(entry => entry.name === 'index.md') + || (mdFiles.length === 1 ? mdFiles[0] : null); + if (!mdFile) return null; + + const path = `${dir.root}${encodeURIComponent(mdFile.name)}`; + 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; + // "Category" isn't a separate concept anymore — it's just whichever + // tag id (if any) looks like an expedition year. + const category = tagIds.find(id => /^expa-20\d{2}$/i.test(id)) || null; + + return { + ...meta, + author: dir.author, + category, + expa: category ? Number(category.replace(/^expa-/i, '')) : null, + slug: dir.slug, + path, + hash: `${dir.author}/${dir.slug}`, + date: parseDateFromFiles(files) || meta.date || null, + readingTime: Math.max(1, Math.round(words / 200)), + description: meta.description || excerpt(content), + tags: tagIds, + coverUrl: resolveMediaUrl(meta.cover || meta.image, path), + published: isPublished(meta), + }; + } catch { + return null; + } +} + export async function discoverArticles() { if (articleCache) return articleCache; + + // Wave 1 (root) — always fetched live: the author directories. 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; } + // Wave 2 — always fetched live, in full, for every author (not just the + // current page): each author's slug directories, with their mtimes. This + // is what builds the flat, cross-author list that drives global sorting + // and pagination — see the plan doc's "keep the flat feed" decision. + const slugDirLists = await Promise.all(authors.map(async author => { + const authorRoot = `${BASE_URL}articles/${encodeURIComponent(author.name)}/`; + const entries = await fetchJson(authorRoot); + return entries + .filter(entry => entry.type === 'directory' && entry.name !== '_history') + .map(entry => ({ + author: author.name, + slug: entry.name, + mtime: entry.mtime ?? null, + root: `${authorRoot}${encodeURIComponent(entry.name)}/`, + })); })); - articleCache = articles.filter(article => article?.published).sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))); + const slugDirs = slugDirLists.flat(); + + // Wave 3+4 — per slug directory, cache-aware and concurrency-limited: a + // directory whose mtime matches its cached entry skips both the listing + // fetch (for .tag/.date files) and the markdown content fetch entirely. + let loaded = 0; + const articles = await mapWithConcurrency(slugDirs, MAX_CONCURRENT_REQUESTS, async dir => { + const cached = await getCacheEntry(dir.root); + if (cached && dir.mtime != null && cached.mtime === dir.mtime) { + if (cached.article) loaded += 1; + return cached.article; + } + const article = await buildArticle(dir); + if (article) { + loaded += 1; + await setCacheEntry(dir.root, { mtime: dir.mtime, article }); + } + return article; + }); + + await pruneCache(slugDirs.map(dir => dir.root)); + crawlStats = { total: slugDirs.length, loaded }; + + articleCache = articles + .filter(article => article?.published && article.date) + .sort((a, b) => String(b.date).localeCompare(String(a.date)) || a.hash.localeCompare(b.hash)); return articleCache; } diff --git a/cmjs/js/router.js b/cmjs/js/router.js index 787b639..74d02d5 100644 --- a/cmjs/js/router.js +++ b/cmjs/js/router.js @@ -6,9 +6,10 @@ // 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) +// 3 segments, first = "post" → single article (#post/alice/my-article) // 4 segments, first = "author", third = "page" → author filter, page N (#author/alice/page/2) // 4 segments, first = "category", third = "page" → category filter, page N (#category/linux/page/2) +// 4 segments, first = "tag", third = "page" → tag filter, page N (#tag/kometa/page/2) import { MARKED_URL } from './config.js'; import { loadTemplates } from './templates.js'; @@ -42,11 +43,10 @@ function router() { 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 === 3 && parts[0] === 'post') loadArticle(parts.slice(1).map(decodeURIComponent).join('/')); else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodeURIComponent(parts[1]), parsePage(parts[3])); else if (parts.length === 4 && parts[0] === 'category' && parts[2] === 'page') loadCategoryView(decodeURIComponent(parts[1]), parsePage(parts[3])); else if (parts.length === 4 && parts[0] === 'tag' && parts[2] === 'page') loadTagView(decodeURIComponent(parts[1]), parsePage(parts[3])); - else if (parts.length === 4) loadArticle(parts.map(decodeURIComponent).join('/')); else loadArticleList(); } diff --git a/cmjs/js/views.js b/cmjs/js/views.js index 5f6c590..9dd9df3 100644 --- a/cmjs/js/views.js +++ b/cmjs/js/views.js @@ -1,5 +1,5 @@ import { templates } from './templates.js'; -import { discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, resolveMediaUrl } from './discovery.js'; +import { discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, getCrawlStats, resolveMediaUrl } from './discovery.js'; import { parseFrontmatter } from './frontmatter.js'; import { PICTURES_URL } from './config.js'; @@ -141,7 +141,7 @@ function renderCard(article) { image.src = value; image.alt = article.title || article.slug; if (imageLink) { - imageLink.href = `#${article.hash}`; + imageLink.href = `#post/${article.hash}`; imageLink.removeAttribute('aria-hidden'); imageLink.removeAttribute('tabindex'); } @@ -150,7 +150,7 @@ function renderCard(article) { imageLink?.remove(); element.classList.add('card--no-image'); } - field('link').href = `#${article.hash}`; + field('link').href = `#post/${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; }); @@ -158,13 +158,28 @@ function renderCard(article) { 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)}`; + // A slug directory only counts as a "category" when one of its .tag + // markers happens to name an expedition year — not every article has one. + setField(field('category'), article.category, (category, value) => { + category.textContent = `Expa ${article.expa}`; + category.href = `#category/${encodeURIComponent(value)}`; + }); 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. +function renderCrawlHealthWarning(view) { + const { total, loaded } = getCrawlStats(); + if (total < 5 || loaded / total >= 0.1) return; + const warning = document.createElement('p'); + warning.className = 'error'; + warning.textContent = `Pozor: načtena jen ${loaded} z ${total} položek — zkontrolujte nastavení serveru.`; + view.appendChild(warning); +} + export async function loadArticleList(filter = null, page = 1) { showListView(); window.scrollTo(0, 0); @@ -181,6 +196,7 @@ export async function loadArticleList(filter = null, page = 1) { return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase(); }); view.innerHTML = ''; + renderCrawlHealthWarning(view); if (filter) { const heading = document.createElement('h2'); heading.className = 'category-heading'; @@ -289,13 +305,18 @@ export async function loadArticle(hash) { 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); }); + // Publish date comes from the slug directory's `.date` marker file + // (already resolved onto `article.date`), not the raw frontmatter. + setField(field('date'), article.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)}`; + setField(field('category'), article.category, (category, value) => { + category.textContent = `Expa ${article.expa}`; + category.href = `#category/${encodeURIComponent(value)}`; + }); renderTags(field('tags'), article.tags); const contentElement = field('content'); contentElement.innerHTML = marked.parse(content); -- 2.54.0 From 469a19388efebb2fdc20ab4d504c581c757b3812 Mon Sep 17 00:00:00 2001 From: AI Date: Fri, 11 Sep 2026 12:06:39 +0200 Subject: [PATCH 03/30] 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 Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd --- README.md | 9 +++++++++ cmjs/js/cache.js | 8 +++++++- cmjs/js/discovery.js | 39 ++++++++++++++++++++++++++------------- cmjs/js/views.js | 14 +++++++++++--- 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 201433d..2f5b6bc 100644 --- a/README.md +++ b/README.md @@ -282,9 +282,18 @@ read its `.tag`/`.date` markers) and its Markdown content. by directory path and invalidated by comparing the live `mtime` against the cached one. If IndexedDB is unavailable (private browsing, disabled storage), the crawler 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 `cmjs/js/discovery.js`) can make old + cached entries stale in *structure*, not just content — and a directory's `mtime` won't catch + that. Bump `CACHE_SCHEMA_VERSION` in `cmjs/js/cache.js` whenever that shape changes; it's baked + into the IndexedDB database name, so a bump invalidates every cached entry on the next deploy. - **Concurrency** — `MAX_CONCURRENT_REQUESTS` in `cmjs/js/discovery.js` (default `10`) caps how many post directories are crawled at once; raise it for a fast/local nginx, lower it for a slow or shared/production instance. +- **Crawl health** — if every author's directory listing fails outright, or very few of the + known post directories loaded, a small warning banner is shown above the article list instead + of silently rendering an empty page (see `getCrawlStats()` / `renderCrawlHealthWarning()` in + `cmjs/js/discovery.js` / `cmjs/js/views.js`). - **Design rationale** — the full reasoning (wave-based crawling, recency prioritisation, HTML-autoindex fallback, and long-term "deploy and forget" constraints) is documented in [`.claude/nginx-sitemap-crawler-plan.md`](.claude/nginx-sitemap-crawler-plan.md), which ships diff --git a/cmjs/js/cache.js b/cmjs/js/cache.js index 5a8dbe6..705090a 100644 --- a/cmjs/js/cache.js +++ b/cmjs/js/cache.js @@ -5,7 +5,13 @@ // silently becomes a no-op so discovery.js gets live-fetch behaviour for free // without needing its own availability branching (plan doc R6). -const DB_NAME = 'cmjs-cache'; +// 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 directory's mtime happens to change — which, on a "deploy and +// forget" site, could be years. +const CACHE_SCHEMA_VERSION = 1; +const DB_NAME = `cmjs-cache-v${CACHE_SCHEMA_VERSION}`; const STORE_NAME = 'slugDirs'; let cacheAvailable = true; diff --git a/cmjs/js/discovery.js b/cmjs/js/discovery.js index 21bfb18..8364258 100644 --- a/cmjs/js/discovery.js +++ b/cmjs/js/discovery.js @@ -88,11 +88,12 @@ export async function fetchTagRegistry() { } let articleCache = null; -let crawlStats = { total: 0, loaded: 0 }; +let crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0 }; // How many of the last crawl's slug directories actually produced an article -// vs. how many were candidates — lets the UI show a "something's badly wrong" -// indicator instead of silently rendering an empty/near-empty list forever. +// vs. how many were candidates, 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 }; } @@ -181,17 +182,29 @@ export async function discoverArticles() { // current page): each author's slug directories, with their mtimes. This // is what builds the flat, cross-author list that drives global sorting // and pagination — see the plan doc's "keep the flat feed" decision. + // One author's listing failing (network blip, deleted mid-crawl) is + // silently skipped rather than failing every other author's list too — + // consistent with this module's usual per-item failure handling (see + // buildArticle) — 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 slugDirLists = await Promise.all(authors.map(async author => { const authorRoot = `${BASE_URL}articles/${encodeURIComponent(author.name)}/`; - const entries = await fetchJson(authorRoot); - return entries - .filter(entry => entry.type === 'directory' && entry.name !== '_history') - .map(entry => ({ - author: author.name, - slug: entry.name, - mtime: entry.mtime ?? null, - root: `${authorRoot}${encodeURIComponent(entry.name)}/`, - })); + try { + const entries = await fetchJson(authorRoot); + return entries + .filter(entry => entry.type === 'directory' && entry.name !== '_history') + .map(entry => ({ + author: author.name, + slug: entry.name, + mtime: entry.mtime ?? null, + root: `${authorRoot}${encodeURIComponent(entry.name)}/`, + })); + } catch { + authorsFailed += 1; + return []; + } })); const slugDirs = slugDirLists.flat(); @@ -214,7 +227,7 @@ export async function discoverArticles() { }); await pruneCache(slugDirs.map(dir => dir.root)); - crawlStats = { total: slugDirs.length, loaded }; + crawlStats = { total: slugDirs.length, loaded, authorsTotal: authors.length, authorsFailed }; articleCache = articles .filter(article => article?.published && article.date) diff --git a/cmjs/js/views.js b/cmjs/js/views.js index 9dd9df3..bfb1485 100644 --- a/cmjs/js/views.js +++ b/cmjs/js/views.js @@ -171,12 +171,20 @@ function renderCard(article) { // 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. +// Two independent signals feed this: every author's own listing failing +// (checked regardless of site size, since discovery.js now silently skips +// individual author failures — see the wave-2 comment in discovery.js) and, +// for larger sites, a near-zero loaded/total ratio at the article level. function renderCrawlHealthWarning(view) { - const { total, loaded } = getCrawlStats(); - if (total < 5 || loaded / total >= 0.1) return; + const { total, loaded, authorsTotal, authorsFailed } = getCrawlStats(); + const authorsBroken = authorsTotal > 0 && authorsFailed === authorsTotal; + const articlesBroken = total >= 5 && loaded / total < 0.1; + if (!authorsBroken && !articlesBroken) return; const warning = document.createElement('p'); warning.className = 'error'; - warning.textContent = `Pozor: načtena jen ${loaded} z ${total} položek — zkontrolujte nastavení serveru.`; + warning.textContent = authorsBroken + ? 'Pozor: nepodařilo se načíst žádného autora — zkontrolujte nastavení serveru.' + : `Pozor: načtena jen ${loaded} z ${total} položek — zkontrolujte nastavení serveru.`; view.appendChild(warning); } -- 2.54.0 From 9d87cffeebe5e5a3a5d712e8b1536cb545fb2370 Mon Sep 17 00:00:00 2001 From: Biosias Date: Fri, 11 Sep 2026 12:16:19 +0200 Subject: [PATCH 04/30] 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/.json and setting . Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd --- README.md | 29 ++++++++++++- cmjs/js/discovery.js | 3 +- cmjs/js/i18n.js | 53 ++++++++++++++++++++++++ cmjs/js/router.js | 7 +++- cmjs/js/views.js | 96 ++++++++++++++++++++++++-------------------- cmjs/strings/en.json | 40 ++++++++++++++++++ index.html | 22 +++++----- 7 files changed, 192 insertions(+), 58 deletions(-) create mode 100644 cmjs/js/i18n.js create mode 100644 cmjs/strings/en.json diff --git a/README.md b/README.md index 2f5b6bc..787c9eb 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,11 @@ cmjs/ │ ├── discovery.js # discoverArticles(), fetchJson(), article cache │ ├── cache.js # IndexedDB cache for discovered articles, keyed by post directory │ ├── concurrency.js # mapWithConcurrency() — caps simultaneous per-article fetches + │ ├── i18n.js # t(), loadStrings(), getLocale() — UI string lookup │ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadCategoryView, loadSearch │ └── router.js # Hash router and app entry point + ├── strings/ + │ └── en.json # UI strings (default/fallback locale) — see "Translations" below ├── templates/ │ ├── card.html # Article list card layout │ └── article.html # Full article view layout @@ -74,7 +77,7 @@ cmjs/ publish date; a `date:` frontmatter field is no longer read. 4. Add tag/category markers as needed: `cmjs/articles/author/my-article/tag-id.tag` (e.g. `kometa.tag`, `expa-2026.tag`) — empty files, any number. A tag id matching `expa-YYYY` is - treated as the article's "year"/category and shown in the Ročníky sidebar; every other tag + treated as the article's "year"/category and shown in the "Years" sidebar; every other tag id is a normal topic tag. 5. Add a frontmatter block at the top of the Markdown file 6. Write your content — it will appear in the list automatically @@ -165,6 +168,30 @@ The HTML structure for the article list card and the full article view live in ` 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 `cmjs/js/i18n.js` rather than +hardcoded — `cmjs/strings/en.json` is the default/fallback string table. + +To add a translation: + +1. Set `` in your `index.html` to the target language code (e.g. `cs`). +2. Add `cmjs/strings/xx.json` with the same keys as `en.json` (copy it as a starting point) — + you only need to include the keys you're translating; anything missing quietly falls back to + English rather than breaking. +3. `{placeholder}` tokens in a string (e.g. `"{page} of {totalPages}"`) are substituted at + render time — keep them in your translated string, in whatever order/position reads + naturally in that language. + +The resolved locale also drives date formatting (`Intl.DateTimeFormat`), search matching, and +tag-label sorting (`localeCompare`), via `getLocale()` in the same module — so a translation +file doesn't just relabel text, it affects how dates and sorting behave too. + +Site-specific branding that lives in `index.html` itself (the page ``, the header search +placeholder, the sidebar section headings) is plain static HTML, not JS-templated — translate it +by editing `index.html` directly, the same way you'd customise it for any other embedding site. + ## 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. diff --git a/cmjs/js/discovery.js b/cmjs/js/discovery.js index 8364258..a4d123d 100644 --- a/cmjs/js/discovery.js +++ b/cmjs/js/discovery.js @@ -2,6 +2,7 @@ import { parseFrontmatter } from './frontmatter.js'; import { BASE_URL, PICTURES_URL } from './config.js'; import { mapWithConcurrency } from './concurrency.js'; import { getCacheEntry, setCacheEntry, pruneCache } from './cache.js'; +import { t } from './i18n.js'; // Caps simultaneous per-article fetches (see the "Wave 3+4" step below) so a // large site doesn't burst hundreds of requests at once. Raise it for a @@ -237,7 +238,7 @@ export async function discoverArticles() { export async function findArticle(hash) { const article = (await discoverArticles()).find(candidate => candidate.hash === hash); - if (!article) throw new Error('Článek nebyl nalezen'); + if (!article) throw new Error(t('articleNotFound')); return article; } diff --git a/cmjs/js/i18n.js b/cmjs/js/i18n.js new file mode 100644 index 0000000..9885376 --- /dev/null +++ b/cmjs/js/i18n.js @@ -0,0 +1,53 @@ +// UI string lookup, so every user-facing message lives in a translatable +// JSON file (cmjs/strings/{locale}.json) instead of being hardcoded in the +// JS. English (cmjs/strings/en.json) is always fetched first as the +// fallback set; if the page's declared language (<html lang>) names another +// locale, that file is fetched too and merged on top — a translation file +// only needs to cover the keys it actually translates, missing ones still +// render in English rather than breaking. +import { BASE_URL } from './config.js'; + +const FALLBACK_LOCALE = 'en'; + +let strings = null; +let locale = FALLBACK_LOCALE; + +async function fetchStrings(code) { + try { + const response = await fetch(`${BASE_URL}strings/${code}.json`); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } +} + +// Must be awaited before the first render — see router.js's `ready` chain — +// since t()/getLocale() below assume this has already resolved. +export async function loadStrings() { + const fallback = await fetchStrings(FALLBACK_LOCALE); + const requested = (document.documentElement.lang || FALLBACK_LOCALE).trim().toLowerCase().split('-')[0]; + if (requested === FALLBACK_LOCALE || !requested) { + strings = fallback; + locale = FALLBACK_LOCALE; + return; + } + const translated = await fetchStrings(requested); + strings = translated ? { ...fallback, ...translated } : fallback; + locale = translated ? requested : FALLBACK_LOCALE; +} + +// The resolved locale code (e.g. "en", "cs") — used for Intl.* calls +// elsewhere so dates/sorting/search match whatever language is displayed. +export function getLocale() { + return locale; +} + +// Looks up `key` and substitutes any `{name}` placeholders from `params`. +// Falls back to the raw key if strings never loaded (e.g. en.json itself +// failed to fetch) — a visible-but-not-crashing degradation, same spirit as +// the rest of this codebase's silent-skip fallbacks. +export function t(key, params = {}) { + const template = strings?.[key] ?? key; + return template.replace(/\{(\w+)\}/g, (match, name) => (params[name] != null ? String(params[name]) : match)); +} diff --git a/cmjs/js/router.js b/cmjs/js/router.js index 74d02d5..7bd2584 100644 --- a/cmjs/js/router.js +++ b/cmjs/js/router.js @@ -13,6 +13,7 @@ import { MARKED_URL } from './config.js'; import { loadTemplates } from './templates.js'; +import { loadStrings, t } from './i18n.js'; import { loadArticleList, loadAuthorView, loadCategoryView, loadTagView, loadArticle, loadSearch } from './views.js'; // Dynamically inject marked.js as a plain global script before the app starts. @@ -61,11 +62,13 @@ function init() { if (q) location.hash = `#search/${encodeURIComponent(q)}`; }); - ready = loadScript(MARKED_URL).then(loadTemplates); + // 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()])); // Show a top-level error if dependencies fail to load ready.catch(err => { - document.body.textContent = `Blog se nepodařilo spustit: ${err.message}`; + document.body.textContent = t('appStartError', { message: err.message }); }); ready.then(router).catch(() => {}); diff --git a/cmjs/js/views.js b/cmjs/js/views.js index bfb1485..626d21e 100644 --- a/cmjs/js/views.js +++ b/cmjs/js/views.js @@ -2,6 +2,7 @@ import { templates } from './templates.js'; import { discoverArticles, fetchAuthorProfile, fetchTagRegistry, findArticle, getCrawlStats, resolveMediaUrl } from './discovery.js'; import { parseFrontmatter } from './frontmatter.js'; import { PICTURES_URL } from './config.js'; +import { t, getLocale } from './i18n.js'; const PAGE_SIZE = 20; let tagLabels = new Map(); @@ -10,16 +11,16 @@ async function ensureTags() { if (!tagLabels.size) tagLabels = new Map((await fetchTagRegistry()).map(tag => [tag.id, tag.label])); } -function czechDate(value) { +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('cs-CZ', { day: 'numeric', month: 'long', year: 'numeric' }).format(date); + 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 = 'Expediční blogy'; + document.title = t('siteName'); } export function showArticleView() { @@ -39,9 +40,9 @@ function clearSearchInput() { 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 ')}`; + if (filter.type === 'author') return t('filterAuthor', { value: filter.value }); + if (filter.type === 'tag') return t('filterTag', { value: tagLabels.get(filter.value) || filter.value }); + return t('filterYear', { value: filter.value.replace(/^expa-/i, 'Expa ') }); } function pageHash(page, filter) { @@ -53,18 +54,18 @@ function pageHash(page, filter) { function buildPaginationNav(page, totalPages, filter) { const nav = document.createElement('nav'); nav.className = 'pagination'; - nav.setAttribute('aria-label', 'Stránkování'); + nav.setAttribute('aria-label', t('paginationLabel')); const previous = document.createElement('a'); - previous.textContent = 'Předchozí'; + previous.textContent = t('paginationPrevious'); const next = document.createElement('a'); - next.textContent = 'Další'; + 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 = `${page} z ${totalPages}`; + info.textContent = t('paginationPageInfo', { page, totalPages }); nav.append(previous, info, next); return nav; } @@ -109,11 +110,11 @@ function renderFilterNavigation(articles, activeFilter = null) { }); const yearList = document.createElement('div'); yearList.className = 'filter-list'; - yearList.appendChild(renderFilterLink('Všechny články', '#', articles.length, !activeFilter)); + yearList.appendChild(renderFilterLink(t('filterAllArticles'), '#', 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)); + yearList.appendChild(renderFilterLink(t('yearLabel', { year }), `#category/expa-${year}`, count, active)); } years.appendChild(yearList); @@ -123,7 +124,7 @@ function renderFilterNavigation(articles, activeFilter = null) { 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')) + .sort((a, b) => (tagLabels.get(a[0]) || a[0]).localeCompare(tagLabels.get(b[0]) || b[0], getLocale())) .forEach(([id, label]) => { const active = activeFilter?.type === 'tag' && activeFilter.value === id; tagList.appendChild(renderFilterLink(label, `#tag/${encodeURIComponent(id)}`, tagCounts.get(id), active)); @@ -134,7 +135,7 @@ function renderFilterNavigation(articles, activeFilter = null) { 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á.'); + 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) => { @@ -152,16 +153,16 @@ function renderCard(article) { } field('link').href = `#post/${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('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); }); setField(field('description'), article.description, (description, value) => { description.textContent = value; }); - setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = `${value} min čtení`; }); + setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); }); const author = field('author'); author.textContent = article.authorName || article.author; author.href = `#author/${encodeURIComponent(article.author)}`; // A slug directory only counts as a "category" when one of its .tag // markers happens to name an expedition year — not every article has one. setField(field('category'), article.category, (category, value) => { - category.textContent = `Expa ${article.expa}`; + category.textContent = t('yearLabel', { year: article.expa }); category.href = `#category/${encodeURIComponent(value)}`; }); renderTags(field('tags'), article.tags); @@ -183,8 +184,8 @@ function renderCrawlHealthWarning(view) { const warning = document.createElement('p'); warning.className = 'error'; warning.textContent = authorsBroken - ? 'Pozor: nepodařilo se načíst žádného autora — zkontrolujte nastavení serveru.' - : `Pozor: načtena jen ${loaded} z ${total} položek — zkontrolujte nastavení serveru.`; + ? t('crawlHealthAuthorsFailed') + : t('crawlHealthArticlesFailed', { loaded, total }); view.appendChild(warning); } @@ -193,7 +194,7 @@ export async function loadArticleList(filter = null, page = 1) { window.scrollTo(0, 0); clearSearchInput(); const view = document.getElementById('list-view'); - view.innerHTML = '<p class="loading">Načítám články…</p>'; + view.innerHTML = `<p class="loading">${t('loadingArticles')}</p>`; try { await ensureTags(); const allArticles = await discoverArticles(); @@ -214,7 +215,7 @@ export async function loadArticleList(filter = null, page = 1) { if (!articles.length) { const empty = document.createElement('p'); empty.className = 'empty'; - empty.textContent = 'Nebyly nalezeny žádné články.'; + empty.textContent = t('emptyArticleList'); view.appendChild(empty); return; } @@ -223,11 +224,21 @@ export async function loadArticleList(filter = null, page = 1) { 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>`; + view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`; } } -const SOCIAL_LABELS = { github: 'GitHub', twitter: 'Twitter', linkedin: 'LinkedIn', mastodon: 'Mastodon', website: 'Web' }; +// 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}"]`); @@ -236,9 +247,8 @@ function renderAuthorProfile(profile) { 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; - }); + 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; } @@ -246,13 +256,13 @@ function renderAuthorProfile(profile) { export async function loadAuthorView(author, page = 1) { showListView(); const view = document.getElementById('list-view'); - view.innerHTML = '<p class="loading">Načítám…</p>'; + view.innerHTML = `<p class="loading">${t('loadingGeneric')}</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>`; } + } catch (error) { view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`; } } export const loadCategoryView = (category, page = 1) => loadArticleList({ type: 'category', value: category }, page); @@ -262,20 +272,20 @@ export async function loadSearch(query) { showListView(); window.scrollTo(0, 0); const view = document.getElementById('list-view'); - view.innerHTML = '<p class="loading">Hledám…</p>'; + view.innerHTML = `<p class="loading">${t('searching')}</p>`; const input = document.getElementById('search-input'); if (input) input.value = query; try { await ensureTags(); - const needle = query.toLocaleLowerCase('cs'); + const needle = query.toLocaleLowerCase(getLocale()); 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>'); + .some(value => String(value || '').toLocaleLowerCase(getLocale()).includes(needle))); + view.innerHTML = `<h2 class="category-heading">${t('searchHeading', { query })}</h2>`; + if (!articles.length) view.insertAdjacentHTML('beforeend', `<p class="empty">${t('searchNoResults')}</p>`); else articles.forEach(article => view.appendChild(renderCard(article))); - } catch (error) { view.innerHTML = `<p class="error">Hledání se nepodařilo: ${error.message}</p>`; } + } catch (error) { view.innerHTML = `<p class="error">${t('searchError', { message: error.message })}</p>`; } } function buildTOC(content) { @@ -283,11 +293,11 @@ function buildTOC(content) { 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 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">Obsah</p><ol class="toc-list"></ol>'; + 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); @@ -300,7 +310,7 @@ export async function loadArticle(hash) { window.scrollTo(0, 0); clearSearchInput(); const view = document.getElementById('article-view'); - view.innerHTML = '<p class="loading">Načítám…</p>'; + view.innerHTML = `<p class="loading">${t('loadingGeneric')}</p>`; try { await ensureTags(); const article = await findArticle(hash); @@ -309,20 +319,20 @@ export async function loadArticle(hash) { fetchAuthorProfile(article.author), ]); const { meta, content } = parseFrontmatter(raw); - document.title = `${meta.title || article.slug} | Expediční blogy`; + document.title = `${meta.title || article.slug} | ${t('siteName')}`; const element = document.importNode(templates.article, true); const field = name => element.querySelector(`[data-field="${name}"]`); field('title').textContent = meta.title || article.slug; // Publish date comes from the slug directory's `.date` marker file // (already resolved onto `article.date`), not the raw frontmatter. - setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = czechDate(value); }); + setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); }); setField(field('description'), meta.description, (description, value) => { description.textContent = value; }); - setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = `${value} min čtení`; }); + setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); }); 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)}`; setField(field('category'), article.category, (category, value) => { - category.textContent = `Expa ${article.expa}`; + category.textContent = t('yearLabel', { year: article.expa }); category.href = `#category/${encodeURIComponent(value)}`; }); renderTags(field('tags'), article.tags); @@ -330,8 +340,8 @@ export async function loadArticle(hash) { 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'; + const back = document.createElement('a'); back.href = '#'; 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) { view.innerHTML = `<p class="error">Článek se nepodařilo načíst: ${error.message}</p>`; } + } catch (error) { view.innerHTML = `<p class="error">${t('articleLoadError', { message: error.message })}</p>`; } } diff --git a/cmjs/strings/en.json b/cmjs/strings/en.json new file mode 100644 index 0000000..53cd895 --- /dev/null +++ b/cmjs/strings/en.json @@ -0,0 +1,40 @@ +{ + "siteName": "Expedition Blogs", + "appStartError": "Failed to start the blog: {message}", + "articleNotFound": "Article not found", + + "filterAuthor": "Author: {value}", + "filterTag": "Tag: {value}", + "filterYear": "Year: {value}", + "filterAllArticles": "All articles", + "yearLabel": "Expa {year}", + + "paginationLabel": "Pagination", + "paginationPrevious": "Previous", + "paginationNext": "Next", + "paginationPageInfo": "{page} of {totalPages}", + + "templateEmptyError": "Article template is empty.", + "readingTime": "{minutes} min read", + + "crawlHealthAuthorsFailed": "Warning: couldn't load any authors — check your server configuration.", + "crawlHealthArticlesFailed": "Warning: only loaded {loaded} of {total} items — check your server configuration.", + + "loadingArticles": "Loading articles…", + "loadingGeneric": "Loading…", + "emptyArticleList": "No articles found.", + "articlesLoadError": "Failed to load articles: {message}", + + "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" +} diff --git a/index.html b/index.html index 33cde5a..bf48fa4 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,9 @@ <!DOCTYPE html> -<html lang="cs"> +<html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1"> - <title>Expediční blogy + Expedition Blogs @@ -17,17 +17,17 @@
    - Expediční blogy + Expedition Blogs
    -
    +
    @@ -35,14 +35,14 @@
    -
    -- 2.54.0 From 99bb85e98a10eae1e13983954fa6bfe6e6b03fbb Mon Sep 17 00:00:00 2001 From: biosias Date: Fri, 11 Sep 2026 12:35:03 +0200 Subject: [PATCH 05/30] 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 Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd --- README.md | 8 ++++++-- cmjs/strings/cs.json | 40 ++++++++++++++++++++++++++++++++++++++++ cmjs/strings/sk.json | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 cmjs/strings/cs.json create mode 100644 cmjs/strings/sk.json diff --git a/README.md b/README.md index 787c9eb..7474a09 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,9 @@ cmjs/ │ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadCategoryView, loadSearch │ └── router.js # Hash router and app entry point ├── strings/ - │ └── en.json # UI strings (default/fallback locale) — see "Translations" below + │ ├── 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 @@ -174,7 +176,9 @@ Every UI string the JS renders (loading/error states, pagination, filter labels, contents heading, etc.) is looked up through `t(key, params)` in `cmjs/js/i18n.js` rather than hardcoded — `cmjs/strings/en.json` is the default/fallback string table. -To add a translation: +Available locales: `en` (default), `cs` (Czech), `sk` (Slovak). + +To add another translation: 1. Set `` in your `index.html` to the target language code (e.g. `cs`). 2. Add `cmjs/strings/xx.json` with the same keys as `en.json` (copy it as a starting point) — diff --git a/cmjs/strings/cs.json b/cmjs/strings/cs.json new file mode 100644 index 0000000..b3d6fc5 --- /dev/null +++ b/cmjs/strings/cs.json @@ -0,0 +1,40 @@ +{ + "siteName": "Expediční blogy", + "appStartError": "Blog se nepodařilo spustit: {message}", + "articleNotFound": "Článek nebyl nalezen", + + "filterAuthor": "Autor: {value}", + "filterTag": "Štítek: {value}", + "filterYear": "Ročník: {value}", + "filterAllArticles": "Všechny články", + "yearLabel": "Expa {year}", + + "paginationLabel": "Stránkování", + "paginationPrevious": "Předchozí", + "paginationNext": "Další", + "paginationPageInfo": "{page} z {totalPages}", + + "templateEmptyError": "Šablona článku je prázdná.", + "readingTime": "{minutes} min čtení", + + "crawlHealthAuthorsFailed": "Pozor: nepodařilo se načíst žádného autora — zkontrolujte nastavení serveru.", + "crawlHealthArticlesFailed": "Pozor: načtena jen {loaded} z {total} položek — zkontrolujte nastavení serveru.", + + "loadingArticles": "Načítám články…", + "loadingGeneric": "Načítám…", + "emptyArticleList": "Nebyly nalezeny žádné články.", + "articlesLoadError": "Články se nepodařilo načíst: {message}", + + "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" +} diff --git a/cmjs/strings/sk.json b/cmjs/strings/sk.json new file mode 100644 index 0000000..ae310a6 --- /dev/null +++ b/cmjs/strings/sk.json @@ -0,0 +1,40 @@ +{ + "siteName": "Expedičné blogy", + "appStartError": "Blog sa nepodarilo spustiť: {message}", + "articleNotFound": "Článok nebol nájdený", + + "filterAuthor": "Autor: {value}", + "filterTag": "Štítok: {value}", + "filterYear": "Ročník: {value}", + "filterAllArticles": "Všetky články", + "yearLabel": "Expa {year}", + + "paginationLabel": "Stránkovanie", + "paginationPrevious": "Predchádzajúca", + "paginationNext": "Ďalšia", + "paginationPageInfo": "{page} z {totalPages}", + + "templateEmptyError": "Šablóna článku je prázdna.", + "readingTime": "{minutes} min čítania", + + "crawlHealthAuthorsFailed": "Pozor: nepodarilo sa načítať žiadneho autora — skontrolujte nastavenie servera.", + "crawlHealthArticlesFailed": "Pozor: načítaných len {loaded} z {total} položiek — skontrolujte nastavenie servera.", + + "loadingArticles": "Načítavam články…", + "loadingGeneric": "Načítavam…", + "emptyArticleList": "Neboli nájdené žiadne články.", + "articlesLoadError": "Články sa nepodarilo načítať: {message}", + + "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" +} -- 2.54.0 From 84f21956fa186b6700e13391dc1d37137759a3b5 Mon Sep 17 00:00:00 2001 From: biosias Date: Fri, 11 Sep 2026 12:37:41 +0200 Subject: [PATCH 06/30] Removed export file --- .claude/nginx-sitemap-crawler-plan.md | 392 ++++++++++++++++++++++++++ CLAUDE.md | 2 + cmjs/export-report.txt | 99 ------- 3 files changed, 394 insertions(+), 99 deletions(-) create mode 100644 .claude/nginx-sitemap-crawler-plan.md create mode 100644 CLAUDE.md delete mode 100644 cmjs/export-report.txt diff --git a/.claude/nginx-sitemap-crawler-plan.md b/.claude/nginx-sitemap-crawler-plan.md new file mode 100644 index 0000000..7ff6cf8 --- /dev/null +++ b/.claude/nginx-sitemap-crawler-plan.md @@ -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 `` 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..369d154 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ +# Git Overrides +* Do not push commited changes in this project diff --git a/cmjs/export-report.txt b/cmjs/export-report.txt deleted file mode 100644 index 2763c51..0000000 --- a/cmjs/export-report.txt +++ /dev/null @@ -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 -- 2.54.0 From 601f2902f5f61237552b2ec29a988ab480f0141f Mon Sep 17 00:00:00 2001 From: biosias Date: Fri, 11 Sep 2026 12:41:41 +0200 Subject: [PATCH 07/30] 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 Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd --- README.md | 49 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7474a09..eee7646 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,17 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil ## Features - **Dark mode** — automatically follows the OS `prefers-color-scheme` setting; no toggle needed. -- **Client-side search** — the search box in the header filters all articles by title, description, author, and category in-memory. Navigates to `#search/query` so results are bookmarkable. +- **Client-side search** — the search box in the header filters all articles by title, description, author, category, and tags in-memory. Navigates to `#search/query` so results are bookmarkable. - **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. - **Pagination** — article list paginates at 20 articles per page; page state is encoded in the hash so browser history works. -- **Author and category filtering** — clicking an author name or category badge shows a filtered list. Both support pagination. +- **Author, category, and tag filtering** — clicking an author name, category badge, or tag pill shows a filtered list. All three support pagination. - **Responsive layout** — layout, cards, images, and pagination all adapt to narrow screens (breakpoint at 640 px). - **Keyboard navigation** — all interactive elements have visible `:focus-visible` outlines for keyboard users. - **Author profiles** — a profile card with bio and social links is shown at the top of each author's article list. Created by adding `articles/{author}/{author}.md`. +- **Offline-friendly caching** — discovered articles are cached in IndexedDB, so repeat visits skip re-fetching content that hasn't changed. +- **Translatable UI** — every UI string is looked up from a locale JSON file (English, Czech, and Slovak ship by default); adding a language needs no code changes. ## Project structure @@ -38,11 +40,12 @@ cmjs/ └── 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 + │ ├── 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/ │ ├── config.js # BASE_URL, MARKED_URL, PICTURES_URL (all via import.meta.url) │ ├── frontmatter.js # parseFrontmatter() — pure YAML-front parser @@ -58,8 +61,11 @@ cmjs/ │ ├── cs.json # Czech translation │ └── sk.json # Slovak translation ├── templates/ - │ ├── card.html # Article list card layout - │ └── article.html # Full article view layout + │ ├── card.html # Article list card layout + │ ├── article.html # Full article view layout + │ └── author-profile.html # Author profile card layout + ├── blog-config/ + │ └── tags.json # Tag id → human-readable label registry — see "Tag labels" below ├── articles/ │ └── author/ # One directory per author │ ├── author.md # Author profile (optional) @@ -87,6 +93,24 @@ cmjs/ `author` is derived from the directory path — no frontmatter field needed for it. Publish date, tags, and category are derived from the `.date`/`.tag` marker files, not frontmatter. +### Tag labels + +A `.tag` marker's filename is just an id (e.g. `kometa`) — the human-readable label shown in +the sidebar and on tag pills comes from `cmjs/blog-config/tags.json`: + +```json +{ + "schemaVersion": 1, + "tags": [ + { "id": "kometa", "label": "Comet", "description": "", "aliases": [], "active": true } + ] +} +``` + +Registering an id here is optional — an unregistered tag id still works, it just displays as +its raw id instead of a friendly label. `expa-YYYY` year ids don't need to be registered here; +they're recognised automatically by pattern and don't use this label registry. + ### Frontmatter format ```yaml @@ -163,10 +187,12 @@ Author names and category tags on each card and article are clickable links to t ## 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 `cmjs/templates/`: - `cmjs/templates/card.html` — the card shown in the article list - `cmjs/templates/article.html` — the full article view (header, hero image, content) +- `cmjs/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. @@ -214,9 +240,10 @@ To add cmjs to any HTML page, three things are needed: the CSS, the two mount-po + ``` -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 `` -- 2.54.0 From c05a85b21b7d7e09024c49aa370d954e3fddb100 Mon Sep 17 00:00:00 2001 From: biosias Date: Fri, 11 Sep 2026 12:44:01 +0200 Subject: [PATCH 08/30] 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 Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd --- README.md | 5 ++-- cmjs/js/views.js | 69 ++++++++++++++++++++++++++++-------------------- index.html | 4 --- 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index eee7646..a16097b 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,9 @@ cmjs/ publish date; a `date:` frontmatter field is no longer read. 4. Add tag/category markers as needed: `cmjs/articles/author/my-article/tag-id.tag` (e.g. `kometa.tag`, `expa-2026.tag`) — empty files, any number. A tag id matching `expa-YYYY` is - treated as the article's "year"/category and shown in the "Years" sidebar; every other tag - id is a normal topic tag. + treated as the article's "year"/category, shown as a badge on each card/article linking to + `#category/expa-YYYY`; every other tag id is a normal topic tag, shown as a pill linking to + `#tag/tag-id`. 5. Add a frontmatter block at the top of the Markdown file 6. Write your content — it will appear in the list automatically diff --git a/cmjs/js/views.js b/cmjs/js/views.js index 626d21e..f6e0491 100644 --- a/cmjs/js/views.js +++ b/cmjs/js/views.js @@ -96,40 +96,51 @@ function renderFilterLink(label, href, count, active = false) { return link; } +// Years and tags render independently — either `
    - + diff --git a/cmjs/js/cache.js b/js/cache.js similarity index 100% rename from cmjs/js/cache.js rename to js/cache.js diff --git a/cmjs/js/concurrency.js b/js/concurrency.js similarity index 100% rename from cmjs/js/concurrency.js rename to js/concurrency.js diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..00824f9 --- /dev/null +++ b/js/config.js @@ -0,0 +1,9 @@ +// All paths are derived from this module's own location via import.meta.url, +// so they resolve correctly regardless of where the embedding HTML page lives. + +// Base URL of the repo root — articles/, strings/, and templates/ all live +// here, one level up from this file (e.g. https://example.com/). +export const BASE_URL = new URL('../', import.meta.url).href; + +// Path to the marked.js vendor script (bundled alongside this file) +export const MARKED_URL = new URL('marked.min.js', import.meta.url).href; diff --git a/cmjs/js/discovery.js b/js/discovery.js similarity index 100% rename from cmjs/js/discovery.js rename to js/discovery.js diff --git a/cmjs/js/frontmatter.js b/js/frontmatter.js similarity index 100% rename from cmjs/js/frontmatter.js rename to js/frontmatter.js diff --git a/cmjs/js/i18n.js b/js/i18n.js similarity index 93% rename from cmjs/js/i18n.js rename to js/i18n.js index 9885376..e8285ef 100644 --- a/cmjs/js/i18n.js +++ b/js/i18n.js @@ -1,6 +1,6 @@ // UI string lookup, so every user-facing message lives in a translatable -// JSON file (cmjs/strings/{locale}.json) instead of being hardcoded in the -// JS. English (cmjs/strings/en.json) is always fetched first as the +// 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 () 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 diff --git a/cmjs/js/router.js b/js/router.js similarity index 100% rename from cmjs/js/router.js rename to js/router.js diff --git a/cmjs/js/templates.js b/js/templates.js similarity index 100% rename from cmjs/js/templates.js rename to js/templates.js diff --git a/cmjs/js/views.js b/js/views.js similarity index 100% rename from cmjs/js/views.js rename to js/views.js diff --git a/cmjs/strings/cs.json b/strings/cs.json similarity index 100% rename from cmjs/strings/cs.json rename to strings/cs.json diff --git a/cmjs/strings/en.json b/strings/en.json similarity index 100% rename from cmjs/strings/en.json rename to strings/en.json diff --git a/cmjs/strings/sk.json b/strings/sk.json similarity index 100% rename from cmjs/strings/sk.json rename to strings/sk.json diff --git a/cmjs/templates/article.html b/templates/article.html similarity index 100% rename from cmjs/templates/article.html rename to templates/article.html diff --git a/cmjs/templates/author-profile.html b/templates/author-profile.html similarity index 100% rename from cmjs/templates/author-profile.html rename to templates/author-profile.html diff --git a/cmjs/templates/card.html b/templates/card.html similarity index 100% rename from cmjs/templates/card.html rename to templates/card.html -- 2.54.0 From 3c510ba92e1a649e30ed8961dd0871ccdf85e7f1 Mon Sep 17 00:00:00 2001 From: biosias Date: Sat, 12 Sep 2026 15:24:47 +0200 Subject: [PATCH 24/30] Move the demo page into its own demo/ folder (AI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AXSVPS3UmmASiiprPfcnUd --- README.md | 36 +++++++++++++++++++---------------- {css => demo/css}/header.css | 0 {css => demo/css}/layout.css | 0 {css => demo/css}/reset.css | 0 index.html => demo/index.html | 14 +++++++------- 5 files changed, 27 insertions(+), 23 deletions(-) rename {css => demo/css}/header.css (100%) rename {css => demo/css}/layout.css (100%) rename {css => demo/css}/reset.css (100%) rename index.html => demo/index.html (76%) diff --git a/README.md b/README.md index d341720..69b760c 100644 --- a/README.md +++ b/README.md @@ -38,16 +38,18 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil This whole repo *is* cmjs — clone it as-is into an existing site (or copy `css/`, `js/`, `strings/`, `templates/`, and your own `articles/` into one) and embed it into a page there; see -"Embedding in an existing page" below. `index.html` plus the root `css/` files are just a -standalone demo/preview shell, not something an embedding page needs to bring along. +"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. ``` ├── README.md -├── index.html # Single-page demo shell — served from the repo root +├── demo/ # Standalone demo/preview page — not needed to embed cmjs elsewhere +│ ├── index.html # Single-page shell +│ └── css/ +│ ├── reset.css # CSS variables, dark mode, box-sizing reset, focus styles +│ ├── header.css # Site header, title link, search form +│ └── layout.css # Main content wrapper, state messages, responsive ├── css/ -│ ├── reset.css # Demo shell: CSS variables, dark mode, box-sizing reset, focus styles -│ ├── header.css # Demo shell: site header, title link, search form -│ ├── layout.css # Demo shell: main content wrapper, state messages, responsive │ └── 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 @@ -226,7 +228,8 @@ Available locales: `en` (default), `cs` (Czech), `sk` (Slovak). To add another translation: -1. Set `` in your `index.html` to the target language code (e.g. `cs`). +1. Set `` 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. @@ -238,9 +241,9 @@ The resolved locale also drives date formatting (`Intl.DateTimeFormat`), search tag-label sorting (`localeCompare`), via `getLocale()` in the same module — so a translation file doesn't just relabel text, it affects how dates and sorting behave too. -Site-specific branding that lives in `index.html` itself (the page ``, the header search -placeholder, the sidebar section headings) is plain static HTML, not JS-templated — translate it -by editing `index.html` directly, the same way you'd customise it for any other embedding site. +Site-specific branding that lives in the embedding page itself (the page `<title>`, the header +search placeholder, the sidebar section headings) is plain static HTML, not JS-templated — +translate it by editing that page directly (`demo/index.html` for this repo's own demo). ## Embedding in an existing page @@ -250,9 +253,9 @@ To add cmjs to any HTML page, three things are needed: the CSS, the two mount-po ```html <!-- Site-level styles (or replace with your own) --> -<link rel="stylesheet" href="css/reset.css"> -<link rel="stylesheet" href="css/header.css"> -<link rel="stylesheet" href="css/layout.css"> +<link rel="stylesheet" href="demo/css/reset.css"> +<link rel="stylesheet" href="demo/css/header.css"> +<link rel="stylesheet" href="demo/css/layout.css"> <!-- Component styles --> <link rel="stylesheet" href="css/components/card.css"> @@ -331,9 +334,9 @@ server { server_name example.com; root /var/www/cmjs; - # Serve the SPA - location / { - try_files $uri $uri/ /index.html; + # Serve the demo page + location /demo/ { + try_files $uri $uri/ /demo/index.html; } # Enable JSON directory listing for article discovery (applies to all subdirs) @@ -444,5 +447,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. diff --git a/css/header.css b/demo/css/header.css similarity index 100% rename from css/header.css rename to demo/css/header.css diff --git a/css/layout.css b/demo/css/layout.css similarity index 100% rename from css/layout.css rename to demo/css/layout.css diff --git a/css/reset.css b/demo/css/reset.css similarity index 100% rename from css/reset.css rename to demo/css/reset.css diff --git a/index.html b/demo/index.html similarity index 76% rename from index.html rename to demo/index.html index 84ec74c..cd12cdf 100644 --- a/index.html +++ b/demo/index.html @@ -7,12 +7,12 @@ <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"> + <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> @@ -43,6 +43,6 @@ </aside> </main> - <script type="module" src="js/router.js"></script> + <script type="module" src="../js/router.js"></script> </body> </html> -- 2.54.0 From e739c7cf8c5da5121f1a9d0302f168050cc6ad1c Mon Sep 17 00:00:00 2001 From: biosias <jakub@gdos.sk> Date: Sat, 12 Sep 2026 15:28:35 +0200 Subject: [PATCH 25/30] 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 --- README.md | 3 ++- js/config.js | 5 +++-- {js => vendor}/marked.min.js | 0 3 files changed, 5 insertions(+), 3 deletions(-) rename {js => vendor}/marked.min.js (100%) diff --git a/README.md b/README.md index 69b760c..ef56f52 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,6 @@ from the library — not something an embedding page needs to bring along. │ ├── 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) │ ├── config.js # BASE_URL, MARKED_URL (all via import.meta.url) │ ├── frontmatter.js # parseFrontmatter() — pure YAML-front parser │ ├── templates.js # loadTemplates(), templates cache @@ -68,6 +67,8 @@ from the library — not something an embedding page needs to bring along. │ ├── i18n.js # t(), loadStrings(), getLocale() — UI string lookup │ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadCategoryView, loadSearch │ └── router.js # Hash router and app entry point +├── vendor/ +│ └── marked.min.js # marked.js (local copy, loaded as a global) — the only 3rd-party code ├── strings/ │ ├── en.json # UI strings (default/fallback locale) — see "Translations" below │ ├── cs.json # Czech translation diff --git a/js/config.js b/js/config.js index 00824f9..7ba95bc 100644 --- a/js/config.js +++ b/js/config.js @@ -5,5 +5,6 @@ // here, one level up from this file (e.g. https://example.com/). export const BASE_URL = new URL('../', import.meta.url).href; -// Path to the marked.js vendor script (bundled alongside this file) -export const MARKED_URL = new URL('marked.min.js', import.meta.url).href; +// Path to the marked.js vendor script — lives in vendor/, a sibling of this +// file's own js/ directory, not alongside the app's own modules. +export const MARKED_URL = new URL('../vendor/marked.min.js', import.meta.url).href; diff --git a/js/marked.min.js b/vendor/marked.min.js similarity index 100% rename from js/marked.min.js rename to vendor/marked.min.js -- 2.54.0 From 22f17a748b5ff01355627ca1050a1152a37dec7c Mon Sep 17 00:00:00 2001 From: biosias <jakub@gdos.sk> Date: Sat, 12 Sep 2026 15:53:35 +0200 Subject: [PATCH 26/30] 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 --- README.md | 24 +++++++++++++++++++++++- js/config.js | 13 +++++++++++-- js/discovery.js | 8 ++++---- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ef56f52..7583cd0 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ from the library — not something an embedding page needs to bring along. │ ├── toc.css # Auto-generated table of contents │ └── author-profile.css # Author profile card (avatar, bio, social links) ├── js/ -│ ├── config.js # BASE_URL, MARKED_URL (all via import.meta.url) +│ ├── config.js # BASE_URL, MARKED_URL, ARTICLES_URL (all via import.meta.url) │ ├── frontmatter.js # parseFrontmatter() — pure YAML-front parser │ ├── templates.js # loadTemplates(), templates cache │ ├── discovery.js # discoverArticles(), fetchJson(), article cache @@ -353,6 +353,28 @@ server { > `autoindex_format json` is ever lost (config drift, an older nginx), the crawler falls back > to parsing nginx's default HTML directory listing instead of breaking outright. +### Pointing articles/ somewhere else + +By default the crawler looks for articles at `articles/` next to the rest of cmjs (`ARTICLES_URL` +in `js/config.js`, computed as a sibling of the `js/` folder). Articles don't have to live there — +edit `ARTICLES_URL` in `js/config.js` to point anywhere under the same origin (or a different one, +CORS permitting), e.g.: + +```js +export const ARTICLES_URL = new URL('/blog-content/articles/', location.origin).href; +``` + +Whatever it resolves to needs its own matching `location` block enabling the JSON (or HTML) +autoindex — it doesn't have to be named `articles/` or nested under wherever cmjs itself lives: + +```nginx +location /blog-content/articles/ { + autoindex on; + autoindex_format json; + autoindex_localtime off; +} +``` + ### Compression Markdown and directory-listing JSON are both plain text, so gzip shrinks them well — and since diff --git a/js/config.js b/js/config.js index 7ba95bc..ff3405b 100644 --- a/js/config.js +++ b/js/config.js @@ -1,10 +1,19 @@ // All paths are derived from this module's own location via import.meta.url, // so they resolve correctly regardless of where the embedding HTML page lives. -// Base URL of the repo root — articles/, strings/, and templates/ all live -// here, one level up from this file (e.g. https://example.com/). +// Base URL of the repo root — strings/ and templates/ live here, one level +// up from this file (e.g. https://example.com/). export const BASE_URL = new URL('../', import.meta.url).href; // Path to the marked.js vendor script — lives in vendor/, a sibling of this // file's own js/ directory, not alongside the app's own modules. export const MARKED_URL = new URL('../vendor/marked.min.js', import.meta.url).href; + +// Base URL of the articles/ folder. Defaults to a sibling of this file's own +// js/ directory (the repo root's own articles/), but articles don't have to +// live anywhere near the rest of cmjs — replace this line to point anywhere +// under the same origin (`new URL('/blog-content/articles/', location.origin).href`) +// or even a different one, CORS permitting. Whatever it resolves to must +// still be a directory nginx serves a JSON (or HTML) autoindex listing for +// — see "nginx configuration" in the README. +export const ARTICLES_URL = new URL('articles/', BASE_URL).href; diff --git a/js/discovery.js b/js/discovery.js index 5b5fb3e..303cd15 100644 --- a/js/discovery.js +++ b/js/discovery.js @@ -1,5 +1,5 @@ import { parseFrontmatter } from './frontmatter.js'; -import { BASE_URL } from './config.js'; +import { BASE_URL, ARTICLES_URL } from './config.js'; import { mapWithConcurrency } from './concurrency.js'; import { getAllCacheEntries, setCacheEntry, pruneCache } from './cache.js'; import { t } from './i18n.js'; @@ -280,7 +280,7 @@ async function runCrawl() { const cacheSnapshotPromise = getAllCacheEntries(); // Wave 1 (root) — always fetched live: the author directories. - const authorEntries = await fetchJson(`${BASE_URL}articles/`); + const authorEntries = await fetchJson(ARTICLES_URL); const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.')); // Wave 2 — always fetched live, in full, for every author (not just the @@ -301,7 +301,7 @@ async function runCrawl() { // silently producing an empty article list. let authorsFailed = 0; const authorFileLists = await Promise.all(authors.map(async author => { - const authorRoot = `${BASE_URL}articles/${encodeURIComponent(author.name)}/`; + const authorRoot = `${ARTICLES_URL}${encodeURIComponent(author.name)}/`; const bioFileName = `${author.name}.md`; try { const entries = await fetchJson(authorRoot); @@ -357,7 +357,7 @@ export async function findArticle(hash) { } export async function fetchAuthorProfile(author) { - const path = `${BASE_URL}articles/${encodeURIComponent(author)}/${encodeURIComponent(author)}.md`; + const path = `${ARTICLES_URL}${encodeURIComponent(author)}/${encodeURIComponent(author)}.md`; try { const response = await fetch(path); if (!response.ok) return null; -- 2.54.0 From a0f1ed67d9e35523cb12641ccf3315aaf334fa34 Mon Sep 17 00:00:00 2001 From: biosias <jakub@gdos.sk> Date: Sat, 12 Sep 2026 16:00:20 +0200 Subject: [PATCH 27/30] 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 --- README.md | 50 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7583cd0..4543dd5 100644 --- a/README.md +++ b/README.md @@ -276,15 +276,7 @@ The site-level CSS files (`reset.css`, `header.css`, `layout.css`) are optional <div id="article-view" hidden></div> ``` -Place these wherever you want the CMS to appear. The router toggles `hidden` between them on navigation. - -Optionally add a search form anywhere in the page: - -```html -<form id="search-form" role="search"> - <input type="search" id="search-input" placeholder="Search articles…" autocomplete="off"> -</form> -``` +Place these wherever you want the CMS to appear. The router toggles `hidden` between them on navigation. A search box and tag/year filter lists are optional and can go anywhere else in the page — see "Search, article list, and tag list as independent pieces" below. ### 3. Script — before `</body>` @@ -301,6 +293,46 @@ Optionally add a search form anywhere in the page: > ``` > All internal fetches (articles, templates, marked.js) resolve relative to the module files via `import.meta.url` and work automatically — only the HTML tags need a path pointing at the right place. +### Search, article list, and tag list as independent pieces + +The mount points above don't have to sit next to each other, or even all be present — each is a +plain element the router looks up by `id`, wherever it happens to be in your page. This lets you +scatter search, the article list, and the tag list across a custom layout (search box in the site +header, tag list in a sidebar, articles in the main column) instead of stacking them the way the +demo does. + +- **Article list + single article — `#list-view` / `#article-view`.** These two are the only + required pair; the router toggles `hidden` between them and always needs both present somewhere + on the page. This pair is also where **search results** render — there's no separate results + container, so any page using the search box below still needs `#list-view` on it somewhere to + actually show what was searched for. +- **Search box — `#search-form` / `#search-input`.** Fully optional and independent; place it + anywhere (e.g. a site-wide header, not necessarily near the list). Submitting it navigates to + `#search/query`; matches render into `#list-view`. + ```html + <form id="search-form" role="search"> + <input type="search" id="search-input" placeholder="Search articles…" autocomplete="off"> + </form> + ``` +- **Tag list — `#tag-filters`.** Optional; put a container (a `<nav>` works well) anywhere, e.g. a + sidebar. It's automatically filled in with one link per tag in use (alphabetical, with an + article count) every time a list view renders — no manual updates needed. Clicking a tag + navigates to `#tag/tag-id`, rendering the filtered list into `#list-view`. + ```html + <nav id="tag-filters" aria-label="Filter by tag"></nav> + ``` +- **Year list — `#year-filters`** (optional, not used by this repo's own demo but supported the + same way): a container that gets filled in with one link per year 2026 down to 2009 that has at + least one article, linking to `#category/year-YYYY`. Can appear alongside `#tag-filters` or on + its own. + ```html + <nav id="year-filters" aria-label="Filter by year"></nav> + ``` + +Both filter lists are populated by the same function on every list render, so whichever of +`#tag-filters`/`#year-filters` is present just gets filled in — omit either one (or both) and +nothing breaks, the article list and search still work standalone. + ### Minimal example ```html -- 2.54.0 From 24f9740d9717e5efa4d6688984df57fdee4f3442 Mon Sep 17 00:00:00 2001 From: biosias <jakub@gdos.sk> Date: Sat, 12 Sep 2026 16:23:26 +0200 Subject: [PATCH 28/30] 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 --- README.md | 4 ++ demo/css/reset.css | 4 +- js/cache.js | 22 ++++++-- js/discovery.js | 70 +++++++++++++++++++++---- js/frontmatter.js | 7 ++- js/router.js | 34 ++++++++++-- js/views.js | 125 +++++++++++++++++++++++++++++++++------------ 7 files changed, 210 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 4543dd5..be92382 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,10 @@ metadata never requires fetching anything beyond the directory listing that alre 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 diff --git a/demo/css/reset.css b/demo/css/reset.css index f559178..b4c5ae2 100644 --- a/demo/css/reset.css +++ b/demo/css/reset.css @@ -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; diff --git a/js/cache.js b/js/cache.js index 102befd..2140698 100644 --- a/js/cache.js +++ b/js/cache.js @@ -39,8 +39,13 @@ function openDb() { // lifecycle details. async function runRequest(mode, makeRequest) { if (!cacheAvailable) return undefined; + let db; + try { + db = await openDb(); + } catch { + return undefined; // openDb() itself already flipped cacheAvailable + } try { - const db = await openDb(); const store = db.transaction(STORE_NAME, mode).objectStore(STORE_NAME); return await new Promise((resolve, reject) => { const request = makeRequest(store); @@ -48,7 +53,10 @@ async function runRequest(mode, makeRequest) { request.onerror = () => reject(request.error); }); } catch { - cacheAvailable = false; + // 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; } } @@ -76,8 +84,13 @@ export async function setCacheEntry(path, entry) { 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 db = await openDb(); const store = db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME); await new Promise((resolve, reject) => { const cursorRequest = store.openCursor(); @@ -90,6 +103,7 @@ export async function pruneCache(validPaths) { cursorRequest.onerror = () => reject(cursorRequest.error); }); } catch { - cacheAvailable = false; + // Same reasoning as runRequest() above — a failed prune pass doesn't + // mean IndexedDB itself is unavailable. } } diff --git a/js/discovery.js b/js/discovery.js index 303cd15..19bd930 100644 --- a/js/discovery.js +++ b/js/discovery.js @@ -73,10 +73,13 @@ export function resolveMediaUrl(value, articlePath = '') { if (/^https?:\/\//i.test(path)) { try { const url = new URL(path); - // Legacy WordPress-migrated content sometimes still carries an - // absolute upload URL — reduce it to a bare filename so it - // resolves against the article's own directory like anything - // else, assuming the file was migrated alongside it. + // 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; } @@ -86,7 +89,7 @@ export function resolveMediaUrl(value, articlePath = '') { let articleCache = null; let crawlPromise = null; -let crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0 }; +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 @@ -203,6 +206,17 @@ function buildStub(author, fileName, mtime, authorRoot) { }; } +// 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 @@ -241,8 +255,28 @@ export function deriveContentFields(meta, content, path) { // 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. -export async function hydrateArticle(stub) { - if (stub.hydrated) return stub; +// +// 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}`); @@ -279,8 +313,22 @@ async function runCrawl() { // to the total. const cacheSnapshotPromise = getAllCacheEntries(); - // Wave 1 (root) — always fetched live: the author directories. - const authorEntries = await fetchJson(ARTICLES_URL); + // Wave 1 (root) — always fetched live: the author directories. Unlike + // wave 2 below, a failure here means discovery can't proceed at all + // (wrong origin, CORS, autoindex disabled) — rather than letting that + // reject all the way out to a generic "failed to load" message, treat it + // the same as "every author's listing failed": resolve to an empty, + // fully-indexed (done) result so the list view renders normally and + // renderCrawlHealthWarning() (via rootFailed below) shows the specific + // crawl-health message instead. + let authorEntries; + try { + authorEntries = await fetchJson(ARTICLES_URL); + } catch { + crawlStats = { total: 0, loaded: 0, authorsTotal: 0, authorsFailed: 0, rootFailed: true }; + notifyProgress(true); + return sortAndFilterPublished(partialResolved); + } const authors = authorEntries.filter(entry => entry.type === 'directory' && !entry.name.startsWith('.')); // Wave 2 — always fetched live, in full, for every author (not just the @@ -307,7 +355,7 @@ async function runCrawl() { 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: entry.mtime ?? null, authorRoot })); + .map(entry => ({ author: author.name, fileName: entry.name, mtime: normalizeMtime(entry.mtime), authorRoot })); } catch { authorsFailed += 1; return []; @@ -329,7 +377,7 @@ async function runCrawl() { } await pruneCache(allFiles.map(file => `${file.authorRoot}${encodeURIComponent(file.fileName)}`)); - crawlStats = { total: allFiles.length, loaded, authorsTotal: authors.length, authorsFailed }; + crawlStats = { total: allFiles.length, loaded, authorsTotal: authors.length, authorsFailed, rootFailed: false }; const finalArticles = sortAndFilterPublished(partialResolved); notifyProgress(true); diff --git a/js/frontmatter.js b/js/frontmatter.js index cf63992..8a7c643 100644 --- a/js/frontmatter.js +++ b/js/frontmatter.js @@ -31,7 +31,10 @@ function parseScalar(raw) { } export function parseFrontmatter(raw) { - const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + // The closing "---" doesn't require a trailing newline of its own — a + // file that's nothing but frontmatter (no body, no trailing newline) + // still parses instead of falling through to "no frontmatter at all". + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/); if (!match) return { meta: {}, content: raw }; const meta = {}; let arrayKey = null; @@ -44,5 +47,5 @@ export function parseFrontmatter(raw) { if (!rawValue.trim()) { meta[key] = []; arrayKey = key; } else { meta[key] = parseScalar(rawValue); arrayKey = null; } } - return { meta, content: match[2] }; + return { meta, content: match[2] ?? '' }; } diff --git a/js/router.js b/js/router.js index 3b34b31..c72f967 100644 --- a/js/router.js +++ b/js/router.js @@ -73,11 +73,39 @@ function init() { // 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()])); + ready = loadStrings().then(() => Promise.all([loadScript(MARKED_URL), loadTemplates()])).then(() => { + // Fail with a clear, catchable message here rather than a cryptic + // uncaught TypeError the first time showListView()/showArticleView() + // run — both mount points are a hard requirement, unlike + // search/tag/year, which are optional. + if (!document.getElementById('list-view') || !document.getElementById('article-view')) { + throw new Error('Missing required #list-view and/or #article-view element — see the "Embedding in an existing page" section of the README.'); + } + }); - // Show a top-level error if dependencies fail to load + // Show a top-level error if dependencies fail to load (or a mount point + // is missing). Scoped to cmjs's own mount points when at least one + // exists, rather than replacing the whole page — this can run on a page + // that embeds cmjs alongside plenty of its own content, which wiping + // document.body would destroy too. Falls back to the full-page replacement + // only when neither mount point is present to show the message in. ready.catch(err => { - document.body.textContent = t('appStartError', { message: err.message }); + 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(() => {}); diff --git a/js/views.js b/js/views.js index 68a2e31..003fbae 100644 --- a/js/views.js +++ b/js/views.js @@ -11,6 +11,12 @@ const PAGE_SIZE = 20; // navigated away from (e.g. clicked into a different filter/page). let currentGeneration = 0; +// The hash of the last list view shown (all articles, a filter, a search, +// any page) — used by loadArticle()'s "back" link so it returns to wherever +// the user actually came from instead of always resetting to page 1 of +// everything. +let lastListHash = '#'; + function formatDate(value) { if (!value) return ''; const date = new Date(`${String(value).slice(0, 10)}T12:00:00`); @@ -42,7 +48,8 @@ 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 t('filterYear', { value: filter.value.replace(/^year-/i, '') }); + if (filter.type === 'category') return t('filterYear', { value: filter.value.replace(/^year-/i, '') }); + return filter.value; } function pageHash(page, filter) { @@ -146,6 +153,30 @@ function renderFilterNavigation(articles, activeFilter = null) { } } +// Builds the #post/author/slug hash for an article, encoding each segment +// individually. article.hash itself (a raw, unencoded "author/slug") is only +// ever used for internal matching in findArticle() — router.js decodes the +// two segments of a #post/... hash separately when parsing, so the link +// needs to encode them separately too, rather than interpolating the +// combined hash as-is (which breaks — decodeURIComponent throws — for any +// author/slug containing a literal "%" or other reserved character). +function postHref(article) { + return `#post/${encodeURIComponent(article.author)}/${encodeURIComponent(article.slug)}`; +} + +// Shared between renderCard() and loadArticle() — date, reading-time, and +// the category/year badge are populated identically in both places. +function applyArticleMetaFields(field, { date, readingTime, category, categoryYear }) { + setField(field('date'), date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); }); + setField(field('reading-time'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); }); + // A post only counts as a "category" when one of its directory-name + // tags happens to name a year — not every article has one. + setField(field('category'), category, (categoryEl, value) => { + categoryEl.textContent = t('yearLabel', { year: categoryYear }); + categoryEl.href = `#category/${encodeURIComponent(value)}`; + }); +} + function renderCard(article) { const fragment = document.importNode(templates.card, true); const element = fragment.firstElementChild; @@ -156,7 +187,7 @@ function renderCard(article) { image.src = value; image.alt = article.title || article.slug; if (imageLink) { - imageLink.href = `#post/${article.hash}`; + imageLink.href = postHref(article); imageLink.removeAttribute('aria-hidden'); imageLink.removeAttribute('tabindex'); } @@ -165,20 +196,13 @@ function renderCard(article) { imageLink?.remove(); element.classList.add('card--no-image'); } - field('link').href = `#post/${article.hash}`; + field('link').href = postHref(article); field('title').textContent = article.title || article.slug; - setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); }); + applyArticleMetaFields(field, article); setField(field('description'), article.description, (description, value) => { description.textContent = value; }); - setField(field('reading-time'), article.readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: value }); }); const author = field('author'); author.textContent = article.authorName || article.author; author.href = `#author/${encodeURIComponent(article.author)}`; - // A post only counts as a "category" when one of its directory-name - // tags happens to name an expedition year — not every article has one. - setField(field('category'), article.category, (category, value) => { - category.textContent = t('yearLabel', { year: article.categoryYear }); - category.href = `#category/${encodeURIComponent(value)}`; - }); renderTags(field('tags'), article.tags); return element; } @@ -186,16 +210,21 @@ function renderCard(article) { // 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. -// Two independent signals feed this: every author's own listing failing -// (checked regardless of site size, since discovery.js now silently skips -// individual author failures — see the wave-2 comment in discovery.js) and, -// for larger sites, a near-zero loaded/total ratio at the article level. -// Only meaningful once the crawl has actually finished (`done`) — mid-crawl -// stats are incomplete and would misreport a healthy crawl as broken. +// Three independent signals feed this: the root author-listing fetch itself +// failing outright (rootFailed), every individual author's own listing +// failing (checked regardless of site size, since discovery.js now silently +// skips individual author failures — see the wave-2 comment in +// discovery.js), and, for larger sites, a near-zero loaded/total ratio at +// the article level. Only meaningful once the crawl has actually finished +// (`done`) — mid-crawl stats are incomplete and would misreport a healthy +// crawl as broken. function renderCrawlHealthWarning(view, done) { if (!done) return; - const { total, loaded, authorsTotal, authorsFailed } = getCrawlStats(); - const authorsBroken = authorsTotal > 0 && authorsFailed === authorsTotal; + const { total, loaded, authorsTotal, authorsFailed, rootFailed } = getCrawlStats(); + // rootFailed means the very first listing fetch (the author directories + // themselves) couldn't be read at all — a total breakage same in spirit + // as every author's own listing failing, just one level higher up. + const authorsBroken = rootFailed || (authorsTotal > 0 && authorsFailed === authorsTotal); const articlesBroken = total >= 5 && loaded / total < 0.1; if (!authorsBroken && !articlesBroken) return; const warning = document.createElement('p'); @@ -246,6 +275,7 @@ 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; @@ -255,6 +285,11 @@ export async function loadArticleList(filter = null, page = 1) { if (!filter) return allArticles; return allArticles.filter(article => { if (filter.type === 'tag') return article.tags.includes(filter.value); + // Author names are exact, case-sensitive directory names (used + // as-is for paths and profile lookups elsewhere) — comparing + // them case-insensitively here could merge two distinctly-named + // author directories that differ only in case. + if (filter.type === 'author') return article.author === filter.value; return String(article[filter.type] || '').toLowerCase() === filter.value.toLowerCase(); }); }; @@ -317,6 +352,8 @@ function renderAuthorProfile(profile) { 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)); @@ -330,18 +367,30 @@ export async function loadAuthorView(author, page = 1) { 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 [profile] = await Promise.all([ - fetchAuthorProfile(author), - loadArticleList({ type: 'author', value: author }, page), - ]); - if (page === 1 && profile) view.insertBefore(renderAuthorProfile(profile), view.firstChild); - } catch (error) { view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`; } + const profilePromise = fetchAuthorProfile(author); + const listPromise = loadArticleList({ type: 'author', value: author }, page); + // loadArticleList() bumps currentGeneration synchronously as one of + // its first steps (before any await), so reading it right after + // starting — not awaiting — that call captures the exact generation + // this navigation owns. Without this, a slower-resolving call here + // could insert a stale profile card into a list-view the user has + // since navigated away from (it stays in the DOM invisibly, since + // list-view would be hidden, only to reappear stale later). + generation = currentGeneration; + const [profile] = await Promise.all([profilePromise, listPromise]); + if (generation === currentGeneration && page === 1 && profile) { + view.insertBefore(renderAuthorProfile(profile), view.firstChild); + } + } catch (error) { + if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articlesLoadError', { message: error.message })}</p>`; + } } export const loadCategoryView = (category, page = 1) => loadArticleList({ type: 'category', value: category }, page); @@ -365,6 +414,7 @@ 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'); @@ -466,7 +516,7 @@ function buildTOC(content) { export async function loadArticle(hash) { showArticleView(); window.scrollTo(0, 0); - currentGeneration += 1; // invalidates any still-running loadArticleList progress listener + 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>`; @@ -476,6 +526,11 @@ export async function loadArticle(hash) { 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 @@ -491,23 +546,25 @@ export async function loadArticle(hash) { 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. - setField(field('date'), article.date, (time, value) => { time.dateTime = String(value); time.textContent = formatDate(value); }); + applyArticleMetaFields(field, { date: article.date, readingTime, category: article.category, categoryYear: article.categoryYear }); setField(field('description'), meta.description, (description, value) => { description.textContent = value; }); - setField(field('reading-time'), readingTime, (time, value) => { time.textContent = t('readingTime', { minutes: 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)}`; - setField(field('category'), article.category, (category, value) => { - category.textContent = t('yearLabel', { year: article.categoryYear }); - category.href = `#category/${encodeURIComponent(value)}`; - }); 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 = '#'; back.className = 'back-link'; back.textContent = t('backToArticles'); + 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) { view.innerHTML = `<p class="error">${t('articleLoadError', { message: error.message })}</p>`; } + } catch (error) { + if (generation === currentGeneration) view.innerHTML = `<p class="error">${t('articleLoadError', { message: error.message })}</p>`; + } } -- 2.54.0 From eaf202ec83581bc46031e528245fcf6b7c30ea1d Mon Sep 17 00:00:00 2001 From: biosias <jakub@gdos.sk> Date: Sat, 12 Sep 2026 16:27:17 +0200 Subject: [PATCH 29/30] 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 --- README.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index be92382..c636baa 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ A minimal CMS where articles are Markdown files rendered in the browser. No buil - **Client-side search** — the search box in the header filters all articles by title, description, author, category, and tags in-memory. Navigates to `#search/query` so results are bookmarkable; results paginate the same way the article list does. - **Reading time** — estimated from the article word count (200 wpm) and shown on list cards and in article headers. Computed automatically; no frontmatter field needed. - **Table of contents** — auto-generated from `h2`/`h3` headings and inserted at the top of the article body. Only appears when the article has three or more headings. -- **Back link** — a "← Back to articles" link above every article for one-click navigation back to the list. +- **Back link** — a "← Back to articles" link above every article, returning to whatever list, filter, or search page (and page number) the reader actually came from rather than always resetting to page 1 of everything. - **Pagination** — article list paginates at 20 articles per page; page state is encoded in the hash so browser history works. - **Author, category, and tag filtering** — clicking an author name, category badge, or tag pill shows a filtered list. All three support pagination. - **Responsive layout** — layout, cards, images, and pagination all adapt to narrow screens (breakpoint at 640 px). @@ -130,10 +130,12 @@ Steps: To change an article's date, tags, or draft status later, rename its file — its content doesn't need to change. `author` is derived from the containing directory — no frontmatter field needed -for it either. 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. +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 @@ -280,7 +282,7 @@ 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. A search box and tag/year filter lists are optional and can go anywhere else in the page — see "Search, article list, and tag list as independent pieces" below. +Place these wherever you want the CMS to appear. The router toggles `hidden` between them on navigation. A search box and tag/year filter lists are optional and can go anywhere else in the page — see "Search, article list, and tag list as independent pieces" below. Unlike those, `#list-view`/`#article-view` are both required — router.js checks for them at startup and shows a clear error (scoped to whichever of the two is present, or the whole page if neither is) instead of silently failing if one is missing. ### 3. Script — before `</body>` @@ -485,11 +487,12 @@ of content is ever fetched per page view. a large site to a handful of requests instead of hydrating the entire article set up front. The page count is exact once the whole set's been scanned, and a lower bound (at least one more page) until then. -- **Crawl health** — if every author's directory listing fails outright, or very few of the - known candidate files parsed successfully (a malformed file name, or a total breakage like the - wrong origin or CORS misconfigured), a small warning banner is shown above the article list - instead of silently rendering an empty page (see `getCrawlStats()` / - `renderCrawlHealthWarning()` in `js/discovery.js` / `js/views.js`). +- **Crawl health** — if the root author-listing fetch fails outright, if every individual + author's own directory listing fails, or if very few of the known candidate files parsed + successfully (a malformed file name, or a total breakage like the wrong origin or CORS + misconfigured), a small warning banner is shown above the article list instead of silently + rendering an empty page (see `getCrawlStats()` / `renderCrawlHealthWarning()` in + `js/discovery.js` / `js/views.js`). - **Design rationale** — the original crawler design (wave-based crawling with a per-article listing fetch, recency prioritisation, HTML-autoindex fallback, long-term "deploy and forget" constraints) is documented in -- 2.54.0 From 243319aae3dd37cb249f748428db94d8555ff909 Mon Sep 17 00:00:00 2001 From: biosias <jakub@gdos.sk> Date: Sat, 12 Sep 2026 16:30:01 +0200 Subject: [PATCH 30/30] 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 --- js/cache.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/js/cache.js b/js/cache.js index 2140698..e9b293f 100644 --- a/js/cache.js +++ b/js/cache.js @@ -61,10 +61,6 @@ async function runRequest(mode, makeRequest) { } } -export function isCacheAvailable() { - return cacheAvailable; -} - // 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 -- 2.54.0