Initial Commit

This commit is contained in:
biosias committed 2026-08-23 21:54:17 +02:00
1 parent 0ffba293ff
commit 3c08326a32
32 files changed
+2352 -1

No files matched your search

+262 -1
View File
@@ -1,2 +1,263 @@
# jscms
# cmjs — Minimal Client-Side CMS
A minimal CMS where articles are Markdown files rendered in the browser. No build step, no database, no server-side code.
## How it works
1. The browser traverses `cmjs/articles/` via nginx JSON autoindex — discovering authors, categories, and article files.
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.
## 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.
- **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.
- **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`.
## Project structure
```
cmjs/
├── README.md
├── index.html # Single-page shell — served from the repo root
├── css/ # Site-level styles for index.html
│ ├── reset.css # CSS variables, dark mode, box-sizing reset, focus styles
│ ├── header.css # Site header, title link, search form
│ └── layout.css # Main content wrapper, state messages, responsive
├── js/
│ └── marked.min.js # marked.js (local copy, loaded as a global)
└── cmjs/ # App content — articles, templates, component styles
├── css/
│ └── components/
│ ├── card.css # Article list cards and filter heading
│ ├── article.css # Single article view, back link, hero image
│ ├── content.css # Rendered markdown styles (.content *)
│ ├── pagination.css # Prev/next page navigation
│ └── toc.css # Auto-generated table of contents
├── js/
│ ├── config.js # BASE_URL, MARKED_URL, PICTURES_URL (all via import.meta.url)
│ ├── frontmatter.js # parseFrontmatter() — pure YAML-front parser
│ ├── templates.js # loadTemplates(), templates cache
│ ├── discovery.js # discoverArticles(), fetchJson(), article cache
│ ├── views.js # loadArticleList, loadArticle, loadAuthorView, loadCategoryView, loadSearch
│ └── router.js # Hash router and app entry point
├── templates/
│ ├── card.html # Article list card layout
│ └── article.html # Full article view layout
├── articles/
│ └── author/ # One directory per author
│ └── category/ # One directory per category
│ └── slug.md # Article file
└── pictures/ # Article images (referenced via image: frontmatter field)
```
## 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
`author` and `category` are derived from the directory path — no frontmatter fields needed for them.
### Frontmatter format
```yaml
---
title: My Article Title
date: 2026-06-16
description: A short summary shown in the article list.
draft: false
---
Your markdown content here...
```
| 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.
## Author profiles
To add a profile for an author, create a file named `{author}.md` directly inside the author's directory:
```
cmjs/articles/bob/bob.md
```
The profile appears as a card above the article list when a visitor filters by that author. It is optional — authors without a profile file work normally.
### Profile frontmatter
```yaml
---
title: Bob Johnson
avatar: bob-avatar.jpg
github: https://github.com/bob
twitter: https://twitter.com/bob
linkedin: https://linkedin.com/in/bob
mastodon: https://mastodon.social/@bob
website: https://bob.example.com
---
Short bio rendered as markdown.
```
| Field | Notes |
|-------|-------|
| `title` | Display name shown in the profile card. Falls back to the author directory name. |
| `avatar` | Filename in `pictures/` — displayed as a circular thumbnail. |
| `github` / `twitter` / `linkedin` / `mastodon` / `website` | Any subset of these renders as pill links below the bio. |
The body text below the frontmatter is rendered as markdown and shown as the bio.
## Routing
| URL hash | View |
|----------|------|
| *(empty)* | All articles, page 1 |
| `#page/2` | All articles, page 2 |
| `#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 article at `cmjs/articles/alice/linux/my-article.md` |
Author names and category tags on each card and article are clickable links to their respective filter views. A search box in the header navigates to `#search/query`.
## Customising the layout
The HTML structure for the article list card and the full article view live in `cmjs/templates/`:
- `cmjs/templates/card.html` — the card shown in the article list
- `cmjs/templates/article.html` — the full article view (header, hero image, content)
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.
## 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.
### 1. CSS — in `<head>`
```html
<!-- Site-level styles (or replace with your own) -->
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/header.css">
<link rel="stylesheet" href="css/layout.css">
<!-- Component styles -->
<link rel="stylesheet" href="cmjs/css/components/card.css">
<link rel="stylesheet" href="cmjs/css/components/article.css">
<link rel="stylesheet" href="cmjs/css/components/content.css">
<link rel="stylesheet" href="cmjs/css/components/pagination.css">
<link rel="stylesheet" href="cmjs/css/components/toc.css">
```
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.
### 2. Mount points — in `<body>`
```html
<div id="list-view"></div>
<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>
```
### 3. Script — before `</body>`
```html
<script type="module" src="cmjs/js/router.js"></script>
```
`router.js` is the only script tag needed. It loads `marked.min.js` automatically at startup (path configured in `cmjs/js/config.js`).
> **Subdirectory pages:** If the embedding page lives in a subdirectory (e.g. `/blog/index.html`), relative paths like `cmjs/...` will resolve incorrectly. Use root-relative paths with a leading `/` for the CSS links and script tag:
> ```html
> <link rel="stylesheet" href="/cmjs/css/components/card.css">
> <script type="module" src="/cmjs/js/router.js"></script>
> ```
> All internal fetches (articles, templates, marked.js) resolve relative to the module files via `import.meta.url` and work automatically — only the HTML tags need the `/` prefix.
### Minimal example
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Site</title>
<link rel="stylesheet" href="cmjs/css/components/card.css">
<link rel="stylesheet" href="cmjs/css/components/article.css">
<link rel="stylesheet" href="cmjs/css/components/content.css">
<link rel="stylesheet" href="cmjs/css/components/pagination.css">
<link rel="stylesheet" href="cmjs/css/components/toc.css">
</head>
<body>
<div id="list-view"></div>
<div id="article-view" hidden></div>
<script type="module" src="cmjs/js/router.js"></script>
</body>
</html>
```
## nginx configuration
Point nginx `root` at the repo root. The `autoindex` directive applies recursively to all subdirectories under `/cmjs/articles/`.
```nginx
server {
listen 80;
server_name example.com;
root /var/www/cmjs;
# Serve the SPA
location / {
try_files $uri $uri/ /index.html;
}
# Enable JSON directory listing for article discovery (applies to all subdirs)
location /cmjs/articles/ {
autoindex on;
autoindex_format json;
}
}
```
> **Note:** `autoindex_format json` requires nginx 1.7.9+. Check with `nginx -v`.
## Running locally
Using nginx locally (example for Linux):
```bash
# Symlink or copy the repo to your nginx webroot, then reload:
sudo nginx -s reload
```
> Python's `http.server` returns HTML directory listings, not JSON, so article discovery won't work with it. Use nginx.
@@ -0,0 +1,56 @@
---
title: Hello World
date: 2026-06-16
description: A sample article to demonstrate the CMS.
image: hello-world-preview.webp
draft: false
---
Welcome to **cmjs** — a minimal client-side CMS powered by Markdown.
## How it works
Articles are plain Markdown files stored in the `articles/` directory. Each file carries its own metadata via YAML frontmatter, similar to [Hugo](https://gohugo.io/):
```yaml
---
title: My Article Title
date: 2026-06-16
description: A short summary shown in the article list.
draft: false
---
```
The app fetches the directory listing from the server, reads frontmatter from each `.md` file, and renders the list. Clicking an article fetches the file and renders the Markdown body client-side using [marked.js](https://marked.js.org/).
## Adding an article
1. Create a new `.md` file in `articles/`
2. Add a frontmatter block at the top
3. Write your content below the closing `---`
No rebuild, no CMS dashboard — the article appears in the list automatically.
## Frontmatter fields
| Field | Required | Description |
|-------|----------|-------------|
| `title` | yes | Display name shown in the list and article header |
| `date` | yes | ISO date (`YYYY-MM-DD`), used for sorting |
| `description` | no | Short summary shown under the title in the list |
| `draft` | no | Set to `true` to hide from the article list |
## Markdown features
Markdown renders fully — headings, lists, **bold**, _italic_, `inline code`, and fenced code blocks:
```javascript
// Frontmatter is parsed with a small built-in regex parser
function parseFrontmatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { meta: {}, content: raw };
// ...
}
```
> Articles are just files. No database, no server-side code, no login.
+7
View File
@@ -0,0 +1,7 @@
---
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.
@@ -0,0 +1,59 @@
---
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.
@@ -0,0 +1,69 @@
---
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.
@@ -0,0 +1,79 @@
---
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.
+7
View File
@@ -0,0 +1,7 @@
---
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.
@@ -0,0 +1,92 @@
---
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.
@@ -0,0 +1,94 @@
---
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.
@@ -0,0 +1,85 @@
---
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.
+99
View File
@@ -0,0 +1,99 @@
/* -------------------------------------------------------------------------
Single article view
------------------------------------------------------------------------- */
/* Back link above the article */
.back-link {
display: inline-block;
margin-bottom: 1.25rem;
font-size: 0.9rem;
color: var(--color-text-muted);
text-decoration: none;
transition: color 0.15s;
}
.back-link:hover {
color: var(--color-accent);
text-decoration: underline;
}
.article > header {
background: transparent;
border: none;
padding: 0;
margin-bottom: 2rem;
border-bottom: 1px solid var(--color-border);
padding-bottom: 1.25rem;
}
.article > header h1 {
margin: 0 0 0.3rem;
font-size: 2rem;
line-height: 1.2;
font-weight: 700;
}
/* Date + reading-time row */
.article-byline {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.5rem;
}
.article > header time {
display: block;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.article-reading-time {
font-size: 0.82rem;
color: var(--color-text-muted);
}
.article-reading-time::before {
content: '·';
margin-right: 0.4rem;
}
.article > header .description {
margin: 0 0 0.75rem;
color: var(--color-text-soft);
font-size: 1.05rem;
font-style: italic;
}
/* Author + category row in article header — .author-link and .tag defined in card.css */
.article-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
/* Hero image shown between article header and content */
.hero-image {
margin: 0 0 2rem;
border-radius: var(--radius);
overflow: hidden;
}
.hero-image img {
display: block;
width: 100%;
max-height: 400px;
object-fit: cover;
}
/* Divider between article body and author profile — mirrors the article header's border-bottom */
.article-divider {
border: none;
border-top: 1px solid var(--color-border);
margin: 2rem 0 0;
}
@media (max-width: 640px) {
.article > header h1 { font-size: 1.5rem; }
.hero-image img { max-height: 220px; }
}
+97
View File
@@ -0,0 +1,97 @@
/* -------------------------------------------------------------------------
Author profile card — shown at the top of author-filtered views (page 1)
------------------------------------------------------------------------- */
.author-profile {
display: flex;
gap: 1.5rem;
align-items: flex-start;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 2rem;
}
/* When the profile follows an article, space it below the content
the same way the article body is spaced from its header */
#article-view .author-profile {
margin-top: 2rem;
margin-bottom: 0;
}
/* Circular avatar — hidden when no avatar frontmatter field is set */
.author-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border: 2px solid var(--color-border);
}
.author-profile-body {
flex: 1;
min-width: 0;
}
.author-profile-name {
margin: 0 0 0.5rem;
font-size: 1.2rem;
font-weight: 700;
overflow-wrap: break-word;
}
/* Rendered bio markdown — keep margins tight inside the card */
.author-profile-bio {
font-size: 0.95rem;
color: var(--color-text-soft);
line-height: 1.6;
}
.author-profile-bio p {
margin: 0 0 0.5em;
}
.author-profile-bio p:last-child {
margin-bottom: 0;
}
/* Social links row */
.author-social {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.author-social a {
display: inline-block;
padding: 0.2em 0.7em;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-tag-bg);
color: var(--color-text-soft);
font-size: 0.82rem;
text-decoration: none;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.author-social a:hover {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
@media (max-width: 640px) {
.author-profile {
flex-direction: column;
align-items: center;
text-align: center;
gap: 1rem;
padding: 1.25rem 1rem;
}
.author-social {
justify-content: center;
}
}
+125
View File
@@ -0,0 +1,125 @@
/* -------------------------------------------------------------------------
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;
}
.card:hover {
border-color: var(--color-text-muted);
box-shadow: var(--shadow-card);
}
/* Preview thumbnail — shown only when article has an image: field */
.card-image {
display: block;
width: 100%;
height: 200px;
object-fit: cover;
}
/* Text content wrapper */
.card-body {
padding: 1.25rem 1.5rem;
}
.card h2 {
margin: 0 0 0.2rem;
font-size: 1.15rem;
font-weight: 600;
}
.card h2 a {
color: var(--color-text);
text-decoration: none;
}
.card h2 a:hover {
color: var(--color-accent);
}
/* Date + reading-time row */
.card-byline {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.4rem;
}
.card time {
font-size: 0.83rem;
color: var(--color-text-muted);
}
.card-reading-time {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.card-reading-time::before {
content: '·';
margin-right: 0.4rem;
}
.card p {
margin: 0 0 0.6rem;
color: var(--color-text-soft);
font-size: 0.95rem;
}
/* Author + category row at the bottom of each card */
.card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.82rem;
}
/* Shared author link — used in cards and article headers */
.author-link {
color: var(--color-text-muted);
text-decoration: none;
}
.author-link:hover {
color: var(--color-accent);
text-decoration: underline;
}
/* Shared category badge — used in cards and article headers */
.tag {
display: inline-block;
background: var(--color-tag-bg);
border: 1px solid var(--color-tag-border);
border-radius: var(--radius-sm);
padding: 0.1em 0.5em;
color: var(--color-text-soft);
text-decoration: none;
font-size: 0.82rem;
transition: background 0.15s, color 0.15s;
}
.tag:hover {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
/* Heading shown above a filtered list (author or category view) */
.category-heading {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text-soft);
margin: 0 0 1.25rem;
}
@media (max-width: 640px) {
.card-image { height: 150px; }
.card-body { padding: 1rem; }
}
+124
View File
@@ -0,0 +1,124 @@
/* -------------------------------------------------------------------------
Rendered markdown content
------------------------------------------------------------------------- */
.content {
line-height: 1.8;
}
.content h1,
.content h2,
.content h3,
.content h4,
.content h5,
.content h6 {
margin: 1.75em 0 0.5em;
line-height: 1.25;
font-weight: 600;
color: var(--color-text);
}
.content h1 { font-size: 1.75rem; }
.content h2 { font-size: 1.4rem; }
.content h3 { font-size: 1.15rem; }
.content p {
margin: 0 0 1em;
}
.content a {
color: var(--color-accent);
}
.content a:visited {
color: var(--color-text-muted);
}
.content a:hover {
text-decoration: none;
}
.content ul,
.content ol {
padding-left: 1.6em;
margin: 0 0 1em;
}
.content li {
margin-bottom: 0.3em;
}
.content blockquote {
border-left: 4px solid var(--color-border);
margin: 0 0 1em;
padding: 0.5em 1em;
color: var(--color-text-soft);
}
.content blockquote p {
margin: 0;
}
/* Fenced code blocks */
.content pre {
background: var(--color-code-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 1em 1.25em;
overflow-x: auto;
margin: 0 0 1em;
}
.content code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.875em;
}
.content pre code {
background: none;
padding: 0;
border: none;
}
/* Inline code */
.content :not(pre) > code {
background: var(--color-tag-bg);
border: 1px solid var(--color-tag-border);
border-radius: var(--radius-sm);
padding: 0.15em 0.4em;
}
.content img {
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
}
/* Tables scroll horizontally on narrow screens */
.content table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-collapse: collapse;
min-width: max-content;
max-width: 100%;
margin: 0 0 1em;
font-size: 0.95em;
}
.content th,
.content td {
border: 1px solid var(--color-border);
padding: 0.5em 0.75em;
text-align: left;
}
.content th {
background: var(--color-code-bg);
font-weight: 600;
}
.content hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 2em 0;
}
+44
View File
@@ -0,0 +1,44 @@
/* Pagination nav — shown below the article list when there is more than one page */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.pagination a {
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
text-decoration: none;
font-size: 0.9rem;
transition: background 0.15s, border-color 0.15s;
}
.pagination a:hover {
background: var(--color-tag-bg);
border-color: var(--color-text-muted);
}
/* Disabled state — at first or last page */
.pagination a.disabled {
color: var(--color-border);
border-color: var(--color-border);
opacity: 0.5;
pointer-events: none;
cursor: default;
}
.pagination .page-info {
font-size: 0.9rem;
color: var(--color-text-muted);
}
@media (max-width: 640px) {
.pagination { gap: 0.5rem; }
.pagination a { padding: 0.4rem 0.6rem; }
}
+43
View File
@@ -0,0 +1,43 @@
/* Table of contents — injected before .content when an article has 3+ headings */
.toc {
background: var(--color-tag-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin-bottom: 2rem;
font-size: 0.9rem;
}
.toc-title {
margin: 0 0 0.5rem;
font-weight: 600;
color: var(--color-text-soft);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
.toc-list {
margin: 0;
padding-left: 1.25rem;
}
.toc-list li {
margin-bottom: 0.25rem;
line-height: 1.4;
}
/* H3 entries are indented relative to H2 entries */
.toc-list li.toc-h3 {
padding-left: 1rem;
font-size: 0.85rem;
}
.toc-list a {
color: var(--color-accent);
text-decoration: none;
}
.toc-list a:hover {
text-decoration: underline;
}
+11
View File
@@ -0,0 +1,11 @@
// All paths are derived from this module's own location via import.meta.url,
// so they resolve correctly regardless of where the embedding HTML page lives.
// Base URL of the cmjs/ directory (e.g. https://example.com/cmjs/)
export const BASE_URL = new URL('../', import.meta.url).href;
// Path to the marked.js vendor script (lives one level above cmjs/)
export const MARKED_URL = new URL('../../js/marked.min.js', import.meta.url).href;
// Base URL of the pictures directory — prefix article image filenames with this
export const PICTURES_URL = new URL('../pictures/', import.meta.url).href;
+98
View File
@@ -0,0 +1,98 @@
import { parseFrontmatter } from './frontmatter.js';
import { BASE_URL } from './config.js';
// Fetch a URL and parse the response as JSON, throwing on non-OK status.
export async function fetchJson(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
return res.json();
}
// ---------------------------------------------------------------------------
// Article discovery
// Traverses the 3-level directory tree: author → category → article files.
// Results are cached after the first load.
// ---------------------------------------------------------------------------
let articleCache = null;
export async function discoverArticles() {
if (articleCache) return articleCache;
// Level 1: author directories
const authorEntries = await fetchJson(`${BASE_URL}articles/`);
const authorDirs = authorEntries.filter(e => e.type === 'directory');
// Level 2: category directories (parallel across all authors)
const categoryLists = await Promise.all(
authorDirs.map(async author => {
const entries = await fetchJson(`${BASE_URL}articles/${author.name}/`);
return entries
.filter(e => e.type === 'directory')
.map(cat => ({ author: author.name, category: cat.name }));
})
);
const allCategories = categoryLists.flat();
// Level 3: .md files (parallel across all categories)
const fileLists = await Promise.all(
allCategories.map(async ({ author, category }) => {
const entries = await fetchJson(`${BASE_URL}articles/${author}/${category}/`);
return entries
.filter(e => e.type === 'file' && e.name.endsWith('.md'))
.map(f => ({ author, category, name: f.name }));
})
);
const allFiles = fileLists.flat();
// Level 4: fetch frontmatter and compute reading time for each article (parallel)
const articles = await Promise.all(
allFiles.map(async ({ author, category, name }) => {
const slug = name.replace(/\.md$/, '');
const path = `${BASE_URL}articles/${author}/${category}/${name}`;
const hash = `${author}/${category}/${slug}`;
try {
const res = await fetch(path);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw = await res.text();
const { meta, content } = parseFrontmatter(raw);
const words = content.trim().split(/\s+/).filter(Boolean).length;
const readingTime = Math.max(1, Math.round(words / 200));
return { slug, path, hash, author, category, readingTime, ...meta };
} catch {
return { slug, path, hash, author, category, title: slug };
}
})
);
// Filter drafts — treat 'true', 'yes', '1' (case-insensitive) as draft
// Sort newest-first; articles without a date fall to the end
articleCache = articles
.filter(a => {
const d = String(a.draft ?? '').toLowerCase().trim();
return d !== 'true' && d !== 'yes' && d !== '1';
})
.sort((a, b) => {
if (!a.date && !b.date) return 0;
if (!a.date) return 1;
if (!b.date) return -1;
return b.date.localeCompare(a.date);
});
return articleCache;
}
// ---------------------------------------------------------------------------
// Author profile
// Tries to fetch articles/{author}/{author}.md — returns parsed profile or null.
// No cache; called at most once per author view navigation.
// ---------------------------------------------------------------------------
export async function fetchAuthorProfile(author) {
try {
const res = await fetch(`${BASE_URL}articles/${author}/${author}.md`);
if (!res.ok) return null;
const { meta, content } = parseFrontmatter(await res.text());
return { author, ...meta, content };
} catch {
return null;
}
}
+22
View File
@@ -0,0 +1,22 @@
// Parses Hugo-style YAML frontmatter from a raw markdown string.
// Returns { meta: { key: value, ... }, content: '...' }
export function parseFrontmatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { meta: {}, content: raw };
const meta = {};
match[1].split('\n').forEach(line => {
const colon = line.indexOf(':');
if (colon === -1) return;
const key = line.slice(0, colon).trim();
const rawVal = line.slice(colon + 1).trim();
// Strip surrounding quotes only when they form a matched pair
const val = (rawVal.startsWith('"') && rawVal.endsWith('"')) ||
(rawVal.startsWith("'") && rawVal.endsWith("'"))
? rawVal.slice(1, -1)
: rawVal;
meta[key] = val;
});
return { meta, content: match[2] };
}
+78
View File
@@ -0,0 +1,78 @@
// Entry point — loads templates then dispatches routes on hash changes.
//
// Hash routing scheme:
// 0 segments → all articles, page 1
// first = "search" → search results (#search/query)
// 2 segments, first = "page" → all articles, page N (#page/2)
// 2 segments, first = "author" → author filter, page 1 (#author/alice)
// 2 segments, first = "category" → category filter, page 1 (#category/linux)
// 3 segments → single article (#author/category/slug)
// 4 segments, first = "author", third = "page" → author filter, page N (#author/alice/page/2)
// 4 segments, first = "category", third = "page" → category filter, page N (#category/linux/page/2)
import { MARKED_URL } from './config.js';
import { loadTemplates } from './templates.js';
import { loadArticleList, loadAuthorView, loadCategoryView, loadArticle, loadSearch } from './views.js';
// Dynamically inject marked.js as a plain global script before the app starts.
function loadScript(url) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.onload = resolve;
s.onerror = () => reject(new Error(`Failed to load ${url}`));
document.head.appendChild(s);
});
}
// Parse a page-number segment — returns 1 for any non-positive-integer value.
const parsePage = s => { const n = +s; return Number.isInteger(n) && n >= 1 ? n : 1; };
function router() {
const hash = location.hash.slice(1); // strip leading #
const parts = hash.split('/').filter(Boolean);
// Search: decode query and guard against empty string (#search or #search/)
if (parts[0] === 'search') {
const q = decodeURIComponent(parts.slice(1).join('/'));
if (q) loadSearch(q); else loadArticleList();
}
else if (parts.length === 0) loadArticleList();
else if (parts.length === 2 && parts[0] === 'page') loadArticleList(null, parsePage(parts[1]));
else if (parts.length === 2 && parts[0] === 'author') loadAuthorView(decodeURIComponent(parts[1]));
else if (parts.length === 2 && parts[0] === 'category') loadCategoryView(decodeURIComponent(parts[1]));
else if (parts.length === 3) loadArticle(parts.map(decodeURIComponent).join('/'));
else if (parts.length === 4 && parts[0] === 'author' && parts[2] === 'page') loadAuthorView(decodeURIComponent(parts[1]), parsePage(parts[3]));
else if (parts.length === 4 && parts[0] === 'category' && parts[2] === 'page') loadCategoryView(decodeURIComponent(parts[1]), parsePage(parts[3]));
else loadArticleList();
}
// All routing — initial and hashchange — waits for this before running.
let ready;
function init() {
// Wire up search form submission → navigate to #search/query
document.getElementById('search-form')?.addEventListener('submit', e => {
e.preventDefault();
const q = document.getElementById('search-input')?.value?.trim();
if (q) location.hash = `#search/${encodeURIComponent(q)}`;
});
ready = loadScript(MARKED_URL).then(loadTemplates);
// Show a top-level error if dependencies fail to load
ready.catch(err => {
document.body.innerHTML = `<p class="error" style="padding:2rem">Failed to initialise: ${err.message}</p>`;
});
ready.then(router).catch(() => {});
}
// readyState guard in case DOMContentLoaded already fired
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.addEventListener('hashchange', () => ready.then(router).catch(() => {}));
+33
View File
@@ -0,0 +1,33 @@
// Loads HTML templates from templates/ and caches them as DocumentFragments.
// Each template needs its own <template> element — reusing one would overwrite
// the shared DocumentFragment, leaving both pointing to the last HTML assigned.
import { BASE_URL } from './config.js';
export const templates = {};
const fetchTemplate = name =>
fetch(`${BASE_URL}templates/${name}.html`).then(r => {
if (!r.ok) throw new Error(`${name}.html: HTTP ${r.status}`);
return r.text();
});
export async function loadTemplates() {
const [cardHtml, articleHtml, authorProfileHtml] = await Promise.all([
fetchTemplate('card'),
fetchTemplate('article'),
fetchTemplate('author-profile'),
]);
const tCard = document.createElement('template');
tCard.innerHTML = cardHtml;
templates.card = tCard.content;
const tArticle = document.createElement('template');
tArticle.innerHTML = articleHtml;
templates.article = tArticle.content;
const tAuthorProfile = document.createElement('template');
tAuthorProfile.innerHTML = authorProfileHtml;
templates.authorProfile = tAuthorProfile.content;
}
+469
View File
@@ -0,0 +1,469 @@
import { templates } from './templates.js';
import { discoverArticles, fetchAuthorProfile } from './discovery.js';
import { parseFrontmatter } from './frontmatter.js';
import { BASE_URL, PICTURES_URL } from './config.js';
// marked is loaded as a plain global from marked.min.js
const PAGE_SIZE = 20;
// ---------------------------------------------------------------------------
// View helpers — toggle which panel is visible
// ---------------------------------------------------------------------------
export function showListView() {
document.getElementById('list-view').hidden = false;
document.getElementById('article-view').hidden = true;
document.title = 'cmjs';
}
export function showArticleView() {
document.getElementById('list-view').hidden = true;
document.getElementById('article-view').hidden = false;
}
// Set a data-field element's value, or remove it if there is no value.
function setField(el, value, apply) {
if (value != null && value !== '') apply(el, value);
else el.remove();
}
// Clear the search box when navigating away from a search view.
const clearSearchInput = () => {
const el = document.getElementById('search-input');
if (el) el.value = '';
};
// ---------------------------------------------------------------------------
// Pagination nav builder
// Builds hash links that match the router's URL scheme:
// no filter → # (page 1) or #page/N
// author → #author/name (page 1) or #author/name/page/N
// category → #category/name (page 1) or #category/name/page/N
// ---------------------------------------------------------------------------
function pageHash(page, filter) {
if (!filter) return page === 1 ? '#' : `#page/${page}`;
const base = `${filter.type}/${filter.value}`;
return page === 1 ? `#${base}` : `#${base}/page/${page}`;
}
function buildPaginationNav(page, totalPages, filter) {
const nav = document.createElement('nav');
nav.className = 'pagination';
const prev = document.createElement('a');
prev.textContent = '← Previous';
if (page > 1) {
prev.href = pageHash(page - 1, filter);
} else {
prev.classList.add('disabled');
prev.setAttribute('aria-disabled', 'true');
}
const info = document.createElement('span');
info.className = 'page-info';
info.textContent = `Page ${page} of ${totalPages}`;
const next = document.createElement('a');
next.textContent = 'Next →';
if (page < totalPages) {
next.href = pageHash(page + 1, filter);
} else {
next.classList.add('disabled');
next.setAttribute('aria-disabled', 'true');
}
nav.appendChild(prev);
nav.appendChild(info);
nav.appendChild(next);
return nav;
}
// ---------------------------------------------------------------------------
// Card rendering — shared by loadArticleList and loadSearch
// ---------------------------------------------------------------------------
function renderCard(a) {
const el = document.importNode(templates.card, true);
const f = name => el.querySelector(`[data-field="${name}"]`);
setField(f('image'), a.image, (el, v) => {
el.src = `${PICTURES_URL}${v}`;
el.alt = a.title || a.slug;
});
f('link').href = `#${a.hash}`;
f('title').textContent = a.title || a.slug;
setField(f('date'), a.date, (el, v) => {
el.dateTime = v;
el.textContent = v;
});
setField(f('description'), a.description, (el, v) => { el.textContent = v; });
setField(f('reading-time'), a.readingTime, (el, v) => {
el.textContent = `${v} min read`;
});
const authorLink = f('author');
authorLink.textContent = a.author;
authorLink.href = `#author/${a.author}`;
const catLink = f('category');
catLink.textContent = a.category;
catLink.href = `#category/${a.category}`;
return el;
}
// ---------------------------------------------------------------------------
// Article list — with optional author or category filter and pagination
// filter: { type: 'author'|'category', value: string } or null for all
// ---------------------------------------------------------------------------
export async function loadArticleList(filter, page = 1) {
showListView();
window.scrollTo(0, 0);
const listView = document.getElementById('list-view');
listView.innerHTML = '<p class="loading">Loading articles…</p>';
clearSearchInput();
let articles;
try {
articles = await discoverArticles();
} catch (err) {
listView.innerHTML = `<p class="error">Could not load articles: ${err.message}</p>`;
return;
}
// Apply optional author or category filter (case-insensitive)
if (filter) {
articles = articles.filter(
a => a[filter.type] && a[filter.type].toLowerCase() === filter.value.toLowerCase()
);
}
if (articles.length === 0) {
listView.innerHTML = '<p class="empty">No articles found.</p>';
return;
}
// Pagination — clamp page to valid range
const totalPages = Math.ceil(articles.length / PAGE_SIZE);
page = Math.max(1, Math.min(page, totalPages));
const visible = articles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
listView.innerHTML = '';
// Filter heading
if (filter) {
const h = document.createElement('h2');
h.className = 'category-heading';
h.textContent = `${filter.type === 'author' ? 'Author' : 'Category'}: ${filter.value}`;
listView.appendChild(h);
}
visible.forEach(a => listView.appendChild(renderCard(a)));
if (totalPages > 1) {
listView.appendChild(buildPaginationNav(page, totalPages, filter));
}
}
// ---------------------------------------------------------------------------
// Author profile card renderer
// Known social fields rendered as labelled pill links.
// ---------------------------------------------------------------------------
const SOCIAL_LABELS = {
github: 'GitHub',
twitter: 'Twitter',
linkedin: 'LinkedIn',
mastodon: 'Mastodon',
website: 'Website',
};
function renderAuthorProfile(profile) {
const el = document.importNode(templates.authorProfile, true);
const f = name => el.querySelector(`[data-field="${name}"]`);
setField(f('avatar'), profile.avatar, (el, v) => {
el.src = `${PICTURES_URL}${v}`;
el.alt = profile.title || profile.author;
});
f('name').textContent = profile.title || profile.author;
const bioEl = f('bio');
if (profile.content && profile.content.trim()) {
// marked output is trusted (author-controlled markdown), innerHTML is intentional
bioEl.innerHTML = marked.parse(profile.content);
} else {
bioEl.remove();
}
const socialEl = f('social');
const links = Object.entries(SOCIAL_LABELS)
.filter(([key]) => profile[key])
.map(([key, label]) => {
const a = document.createElement('a');
a.href = profile[key];
a.textContent = label;
a.target = '_blank';
a.rel = 'noopener noreferrer';
return a;
});
if (links.length > 0) {
links.forEach(a => socialEl.appendChild(a));
} else {
socialEl.remove();
}
return el;
}
// ---------------------------------------------------------------------------
// Author view — profile card (page 1 only) + filtered article list
// ---------------------------------------------------------------------------
export async function loadAuthorView(author, page = 1) {
showListView();
window.scrollTo(0, 0);
const listView = document.getElementById('list-view');
listView.innerHTML = '<p class="loading">Loading…</p>';
clearSearchInput();
let articles, profile;
try {
[articles, profile] = await Promise.all([
discoverArticles(),
fetchAuthorProfile(author),
]);
} catch (err) {
listView.innerHTML = `<p class="error">Could not load articles: ${err.message}</p>`;
return;
}
articles = articles.filter(
a => a.author.toLowerCase() === author.toLowerCase()
);
listView.innerHTML = '';
// Profile card — page 1 only
if (page === 1 && profile) {
listView.appendChild(renderAuthorProfile(profile));
}
// Author heading
const h = document.createElement('h2');
h.className = 'category-heading';
h.textContent = `Author: ${author}`;
listView.appendChild(h);
if (articles.length === 0) {
const empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = 'No articles found.';
listView.appendChild(empty);
return;
}
const totalPages = Math.ceil(articles.length / PAGE_SIZE);
page = Math.max(1, Math.min(page, totalPages));
const visible = articles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
visible.forEach(a => listView.appendChild(renderCard(a)));
if (totalPages > 1) {
listView.appendChild(buildPaginationNav(page, totalPages, { type: 'author', value: author }));
}
}
export function loadCategoryView(category, page = 1) {
return loadArticleList({ type: 'category', value: category }, page);
}
// ---------------------------------------------------------------------------
// Search — filters all articles in-memory across title, description, author, category
// ---------------------------------------------------------------------------
export async function loadSearch(query) {
showListView();
window.scrollTo(0, 0);
const listView = document.getElementById('list-view');
listView.innerHTML = '<p class="loading">Searching…</p>';
// Populate the search box with the active query
const searchInput = document.getElementById('search-input');
if (searchInput) searchInput.value = query;
let articles;
try {
articles = await discoverArticles();
} catch (err) {
listView.innerHTML = `<p class="error">Could not load articles: ${err.message}</p>`;
return;
}
const q = query.toLowerCase();
const results = articles.filter(a =>
(a.title || '').toLowerCase().includes(q) ||
(a.description || '').toLowerCase().includes(q) ||
a.author.toLowerCase().includes(q) ||
a.category.toLowerCase().includes(q)
);
listView.innerHTML = '';
const heading = document.createElement('h2');
heading.className = 'category-heading';
heading.textContent = `Search: "${query}"`;
listView.appendChild(heading);
if (results.length === 0) {
const empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = 'No articles match your search.';
listView.appendChild(empty);
return;
}
results.forEach(a => listView.appendChild(renderCard(a)));
}
// ---------------------------------------------------------------------------
// Table of contents — built from h2/h3 headings in the rendered content.
// Only generated when there are 3 or more headings.
// ---------------------------------------------------------------------------
function buildTOC(contentEl) {
const headings = [...contentEl.querySelectorAll('h2, h3')];
if (headings.length < 3) return null;
// Assign unique IDs so anchor links work; append -2/-3 on collisions
const seen = new Map();
headings.forEach(h => {
if (!h.id) {
const base = h.textContent.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
const count = (seen.get(base) ?? 0) + 1;
seen.set(base, count);
h.id = count === 1 ? base : `${base}-${count}`;
}
});
const nav = document.createElement('nav');
nav.className = 'toc';
const title = document.createElement('p');
title.className = 'toc-title';
title.textContent = 'Contents';
nav.appendChild(title);
const ol = document.createElement('ol');
ol.className = 'toc-list';
headings.forEach(h => {
const li = document.createElement('li');
if (h.tagName === 'H3') li.classList.add('toc-h3');
const a = document.createElement('a');
a.href = `#${h.id}`;
a.textContent = h.textContent;
li.appendChild(a);
ol.appendChild(li);
});
nav.appendChild(ol);
return nav;
}
// ---------------------------------------------------------------------------
// Single article view
// hash is "author/category/slug"; fetch path is "BASE_URL/articles/author/category/slug.md"
// ---------------------------------------------------------------------------
export async function loadArticle(hash) {
showArticleView();
window.scrollTo(0, 0);
const articleView = document.getElementById('article-view');
articleView.innerHTML = '<p class="loading">Loading…</p>';
clearSearchInput();
const [author, category] = hash.split('/');
// Fetch article and author profile in parallel (author is known from hash)
let raw, profile;
try {
[raw, profile] = await Promise.all([
fetch(`${BASE_URL}articles/${hash}.md`).then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
}),
fetchAuthorProfile(author),
]);
} catch (err) {
articleView.innerHTML = `<p class="error">Could not load article: ${err.message}</p>`;
return;
}
const { meta, content } = parseFrontmatter(raw);
// Reading time computed from the raw content
const words = content.trim().split(/\s+/).filter(Boolean).length;
const readingTime = Math.max(1, Math.round(words / 200));
document.title = meta.title ? `${meta.title} — cmjs` : 'cmjs';
const el = document.importNode(templates.article, true);
const f = name => el.querySelector(`[data-field="${name}"]`);
f('title').textContent = meta.title || hash;
setField(f('date'), meta.date, (el, v) => {
el.dateTime = v;
el.textContent = v;
});
setField(f('description'), meta.description, (el, v) => { el.textContent = v; });
setField(f('reading-time'), readingTime, (el, v) => {
el.textContent = `${v} min read`;
});
// Hero image is inside <figure> — remove the whole figure when there's no image
const imgEl = f('image');
if (meta.image) {
imgEl.src = `${PICTURES_URL}${meta.image}`;
imgEl.alt = meta.title || hash;
} else {
imgEl.closest('figure')?.remove();
}
const authorLink = f('author');
authorLink.textContent = author;
authorLink.href = `#author/${author}`;
const catLink = f('category');
catLink.textContent = category;
catLink.href = `#category/${category}`;
// Render markdown then build TOC from the resulting headings
const contentEl = f('content');
// marked output is trusted (author-controlled markdown), innerHTML is intentional
contentEl.innerHTML = marked.parse(content);
const toc = buildTOC(contentEl);
if (toc) contentEl.insertBefore(toc, contentEl.firstChild);
// Back link above the article
const backLink = document.createElement('a');
backLink.href = '#';
backLink.className = 'back-link';
backLink.textContent = '← Back to articles';
articleView.innerHTML = '';
articleView.appendChild(backLink);
articleView.appendChild(el);
if (profile) {
const divider = document.createElement('hr');
divider.className = 'article-divider';
articleView.appendChild(divider);
articleView.appendChild(renderAuthorProfile(profile));
}
}
View File
Whitespace-only changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

+18
View File
@@ -0,0 +1,18 @@
<article class="article">
<header>
<h1 data-field="title"></h1>
<div class="article-byline">
<time data-field="date" datetime=""></time>
<span class="article-reading-time" data-field="reading-time"></span>
</div>
<p class="description" data-field="description"></p>
<div class="article-meta">
<a class="author-link" data-field="author" href=""></a>
<a class="tag" data-field="category" href=""></a>
</div>
</header>
<figure class="hero-image">
<img data-field="image" src="" alt="">
</figure>
<div class="content" data-field="content"></div>
</article>
+8
View File
@@ -0,0 +1,8 @@
<div class="author-profile">
<img class="author-avatar" data-field="avatar" src="" alt="">
<div class="author-profile-body">
<h2 class="author-profile-name" data-field="name"></h2>
<div class="author-profile-bio" data-field="bio"></div>
<div class="author-social" data-field="social"></div>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
<article class="card">
<img class="card-image" data-field="image" alt="">
<div class="card-body">
<h2><a data-field="link" href=""><span data-field="title"></span></a></h2>
<div class="card-byline">
<time data-field="date" datetime=""></time>
<span class="card-reading-time" data-field="reading-time"></span>
</div>
<p data-field="description"></p>
<div class="card-meta">
<a class="author-link" data-field="author" href=""></a>
<a class="tag" data-field="category" href=""></a>
</div>
</div>
</article>
+65
View File
@@ -0,0 +1,65 @@
/* Site header and title link */
body > header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 0.8rem 1.5rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
#site-title {
font-size: 1.2rem;
font-weight: 700;
color: var(--color-text);
text-decoration: none;
letter-spacing: -0.01em;
flex-shrink: 0;
}
#site-title:hover {
color: var(--color-text-soft);
}
/* Search form — sits in the header, grows to fill available space */
#search-form {
flex: 1;
max-width: 360px;
}
#search-input {
width: 100%;
padding: 0.35rem 0.75rem;
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;
}
#search-input::placeholder {
color: var(--color-text-muted);
}
#search-input:hover {
border-color: var(--color-text-muted);
}
#search-input:focus-visible {
border-color: var(--color-accent);
outline-offset: 0;
}
@media (max-width: 640px) {
body > header {
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
#search-form {
max-width: 100%;
flex-basis: 100%;
}
}
+28
View File
@@ -0,0 +1,28 @@
/* Main content wrapper */
main {
max-width: 720px;
margin: 2rem auto;
padding: 0 1.5rem;
}
@media (max-width: 640px) {
main {
margin: 1rem auto;
padding: 0 1rem;
}
}
/* State messages (loading / error / empty) */
.loading {
color: var(--color-text-muted);
font-style: italic;
}
.error {
color: var(--color-error);
}
.empty {
color: var(--color-text-muted);
font-style: italic;
}
+58
View File
@@ -0,0 +1,58 @@
/* CSS custom properties — single source of truth for colours, radius, etc.
Override these in a downstream stylesheet to theme the whole app. */
:root {
--color-bg: #f7f7f7;
--color-surface: #fff;
--color-border: #e0e0e0;
--color-text: #222;
--color-text-soft: #555;
--color-text-muted: #999;
--color-accent: #0057b8;
--color-tag-bg: #f0f0f0;
--color-tag-border: #ddd;
--color-code-bg: #f4f4f4;
--color-error: #c0392b;
--radius-sm: 3px;
--radius: 6px;
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1a1a1a;
--color-surface: #252525;
--color-border: #3a3a3a;
--color-text: #e0e0e0;
--color-text-soft: #aaa;
--color-text-muted: #666;
--color-accent: #4d9cf0;
--color-tag-bg: #2d2d2d;
--color-tag-border: #3a3a3a;
--color-code-bg: #2a2a2a;
--color-error: #e74c3c;
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.35);
}
}
/* Box-sizing reset and base typography */
*, *::before, *::after {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-size: 1rem;
line-height: 1.65;
color: var(--color-text);
background: var(--color-bg);
margin: 0;
padding: 0;
}
/* Global keyboard-navigation focus indicator */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: 2px;
}
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cmjs</title>
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/header.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="cmjs/css/components/card.css">
<link rel="stylesheet" href="cmjs/css/components/article.css">
<link rel="stylesheet" href="cmjs/css/components/content.css">
<link rel="stylesheet" href="cmjs/css/components/pagination.css">
<link rel="stylesheet" href="cmjs/css/components/toc.css">
<link rel="stylesheet" href="cmjs/css/components/author-profile.css">
</head>
<body>
<header>
<!-- Site title always links back to the article list -->
<a href="#" id="site-title">cmjs</a>
<!-- Search form — submits to #search/query via router.js -->
<form id="search-form" role="search">
<input type="search" id="search-input" placeholder="Search articles…" autocomplete="off">
</form>
</header>
<main>
<!-- Article list: shown when no hash is present -->
<div id="list-view"></div>
<!-- Article view: shown when a slug hash is present -->
<div id="article-view" hidden></div>
</main>
<script type="module" src="cmjs/js/router.js"></script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
/**
* marked v15.0.12 - a markdown parser
* Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed)
* https://github.com/markedjs/marked
*/
/**
* DO NOT EDIT THIS FILE
* The code in this file is generated from files in ./src/
*/
(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports};
"use strict";var H=Object.defineProperty;var be=Object.getOwnPropertyDescriptor;var Te=Object.getOwnPropertyNames;var we=Object.prototype.hasOwnProperty;var ye=(l,e)=>{for(var t in e)H(l,t,{get:e[t],enumerable:!0})},Re=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Te(e))!we.call(l,s)&&s!==t&&H(l,s,{get:()=>e[s],enumerable:!(n=be(e,s))||n.enumerable});return l};var Se=l=>Re(H({},"__esModule",{value:!0}),l);var kt={};ye(kt,{Hooks:()=>L,Lexer:()=>x,Marked:()=>E,Parser:()=>b,Renderer:()=>$,TextRenderer:()=>_,Tokenizer:()=>S,defaults:()=>w,getDefaults:()=>z,lexer:()=>ht,marked:()=>k,options:()=>it,parse:()=>pt,parseInline:()=>ct,parser:()=>ut,setOptions:()=>ot,use:()=>lt,walkTokens:()=>at});module.exports=Se(kt);function z(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var w=z();function N(l){w=l}var I={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,i)=>{let r=typeof i=="string"?i:i.source;return r=r.replace(m.caret,"$1"),t=t.replace(s,r),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^<a /i,endATag:/^<\/a>/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^</,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},$e=/^(?:[ \t]*(?:\n|$))+/,_e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Le=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,O=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ze=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/(?:[*+-]|\d{1,9}[.)])/,ie=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,oe=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Me=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Q=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Pe=/^[^\n]+/,U=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Ae=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",U).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ee=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",K=/<!--(?:-?>|[\s\S]*?(?:-->|$))/,Ce=h("^ {Line truncated
]`).replace("lheading",oe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},qe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ve=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/,D=/[\p{P}\p{S}]/u,W=/[\s\p{P}\p{S}]/u,ce=/[^\s\p{P}\p{S}]/u,Ze=h(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,W).getRegex(),pe=/(?!~)[\p{P}\p{S}]/u,Ge=/(?!~)[\s\p{P}\p{S}]/u,He=/(?:[^\s\p{P}\p{S}]|~)/u,Ne=/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,ue=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,je=h(ue,"u").replace(/punct/g,D).getRegex(),Fe=h(ue,"u").replace(/punct/g,pe).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Qe=h(he,"gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ue=h(he,"gu").replace(/notPunctSpace/g,He).replace(/punctSpace/g,Ge).replace(/punct/g,pe).getRegex(),Ke=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Xe=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),We=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Je=h(K).replace("(?:-->|$)","-->").getRegex(),Ve=h("^comment|^</[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^<![a-zA-Z]+\\s[\\s\\S]*?>|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>").replace("comment",Je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Ye=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",U).getRegex(),ge=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",U).getRegex(),et=h("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",ge).getRegex(),J={_backpedal:I,anyPunctuation:Xe,autolink:We,blockSkip:Ne,br:ae,code:ve,del:I,emStrongLDelim:je,emStrongRDelimAst:Qe,emStrongRDelimUnd:Ke,escape:qe,link:Ye,nolink:ge,punctuation:Ze,reflink:ke,reflinkSearch:et,tag:Ve,text:De,url:I},tt={...J,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},j={...J,emStrongRDelimAst:Ue,emStrongLDelim:Fe,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/},nt={...j,br:h(ae).replace("{2,}","*").getRegex(),text:h(j.text).replace("\\b_","\\b_| {2,}\\n").replace(/\{2,\}/g,"*").getRegex()},B={normal:X,gfm:Oe,pedantic:Be},P={normal:J,gfm:j,breaks:nt,pedantic:tt};var st={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},fe=l=>st[l];function R(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function V(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function Y(l,e){let t=l.replace(m.findPipe,(i,r,o)=>{let a=!1,c=r;for(;--c>=0&&o[c]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length<e;)n.push("");for(;s<n.length;s++)n[s]=n[s].trim().replace(m.slashPipe,"|");return n}function A(l,e,t){let n=l.length;if(n===0)return"";let s=0;for(;s<n;){let i=l.charAt(n-s-1);if(i===e&&!t)s++;else if(i!==e&&t)s++;else break}return l.slice(0,n-s)}function de(l,e){if(l.indexOf(e[1])===-1)return-1;let t=0;for(let n=0;n<l.length;n++)if(l[n]==="\\")n++;else if(l[n]===e[0])t++;else if(l[n]===e[1]&&(t--,t<0))return n;return t>0?-2:-1}fLine truncated
`).map(i=>{let r=i.match(t.other.beginningSpace);if(r===null)return i;let[o]=r;return o.length>=s.length?i.slice(s.length):i}).join(`
`)}var S=class{options;rules;lexer;constructor(e){this.options=e||w}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:A(n,`
`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=rt(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=A(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:A(t[0],`
`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=A(t[0],`
`).split(`
`),s="",i="",r=[];for(;n.length>0;){let o=!1,a=[],c;for(c=0;c<n.length;c++)if(this.rules.other.blockquoteStart.test(n[c]))a.push(n[c]),o=!0;else if(!o)a.push(n[c]);else break;n=n.slice(c);let p=a.join(`
`),u=p.replace(this.rules.other.blockquoteSetextReplace,`
$1`).replace(this.rules.other.blockquoteSetextReplace2,"");s=s?`${s}
${p}`:p,i=i?`${i}
${u}`:u;let d=this.lexer.state.top;if(this.lexer.state.top=!0,this.lexer.blockTokens(u,r,!0),this.lexer.state.top=d,n.length===0)break;let g=r.at(-1);if(g?.type==="code")break;if(g?.type==="blockquote"){let T=g,f=T.raw+`
`+n.join(`
`),y=this.blockquote(f);r[r.length-1]=y,s=s.substring(0,s.length-T.raw.length)+y.raw,i=i.substring(0,i.length-T.text.length)+y.text;break}else if(g?.type==="list"){let T=g,f=T.raw+`
`+n.join(`
`),y=this.list(f);r[r.length-1]=y,s=s.substring(0,s.length-g.raw.length)+y.raw,i=i.substring(0,i.length-T.raw.length)+y.raw,n=f.substring(r.at(-1).raw.length).split(`
`);continue}}return{type:"blockquote",raw:s,tokens:r,text:i}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim(),s=n.length>1,i={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let r=this.rules.other.listItemRegex(n),o=!1;for(;e;){let c=!1,p="",u="";if(!(t=r.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=t[2].split(`
`,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),g=e.split(`
`,1)[0],T=!d.trim(),f=0;if(this.options.pedantic?(f=2,u=d.trimStart()):T?f=t[1].length+1:(f=t[2].search(this.rules.other.nonSpaceChar),f=f>4?1:f,u=d.slice(f),f+=t[1].length),T&&this.rules.other.blankLine.test(g)&&(p+=g+`
`,e=e.substring(g.length+1),c=!0),!c){let Z=this.rules.other.nextBulletRegex(f),te=this.rules.other.hrRegex(f),ne=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),xe=this.rules.other.htmlBeginRegex(f);for(;e;){let G=e.split(`
`,1)[0],C;if(g=G,this.options.pedantic?(g=g.replace(this.rules.other.listReplaceNesting," "),C=g):C=g.replace(this.rules.other.tabCharGlobal," "),ne.test(g)||se.test(g)||xe.test(g)||Z.test(g)||te.test(g))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!g.trim())u+=`
`+C.slice(f);else{if(T||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ne.test(d)||se.test(d)||te.test(d))break;u+=`
`+g}!T&&!g.trim()&&(T=!0),p+=G+`
`,e=e.substring(G.length+1),d=C.slice(f)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let y=null,ee;this.options.gfm&&(y=this.rules.other.listIsTask.exec(u),y&&(ee=y[0]!=="[ ] ",u=u.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!y,checked:ee,loose:!1,text:u,tokens:[]}),i.raw+=p}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let c=0;c<i.items.length;c++)if(this.lexer.state.top=!1,i.items[c].tokens=this.lexer.blockTokens(i.items[c].text,[]),!i.loose){let p=i.items[c].tokens.filter(d=>d.type==="space"),u=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=u}if(i.loose)for(let c=0;c<i.items.length;c++)i.items[c].loose=!0;return i}}html(e){let t=this.rules.block.html.exec(e);if(t)return{type:"html",block:!0,raw:t[0],pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:t[0]}}def(e){let t=this.rules.block.def.exec(e);if(t){let n=t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),s=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",i=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:n,raw:t[0],href:s,title:i}}}table(e){let t=this.rules.block.table.exec(e);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let n=Y(t[1]),s=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),i=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(`
`):[],r={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(let o of s)this.rules.other.tableAlignRight.test(o)?r.align.push("right"):this.rules.other.tableAlignCenter.test(o)?r.align.push("center"):this.rules.other.tableAlignLeft.test(o)?r.align.push("left"):r.align.push(null);for(let o=0;o<n.length;o++)r.header.push({text:n[o],tokens:this.lexer.inline(n[o]),header:!0,align:r.align[o]});for(let o of i)r.rows.push(Y(o,r.header.length).map((a,c)=>({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[c]})));return r}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===`
`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=A(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=de(t[2],"()");if(r===-2)return;if(r>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+r;t[2]=t[2].substring(0,r),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let s=t[2],i="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],i=r[3])}else i=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),me(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[s.toLowerCase()];if(!i){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return me(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let r=[...s[0]].length-1,o,a,c=r,p=0,u=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*e.length+r);(s=u.exec(t))!=null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(a=[...o].length,s[3]||s[4]){c+=a;continue}else if((s[5]||s[6])&&r%3&&!((r+a)%3)){p+=a;continue}if(c-=a,c>0)continue;a=Math.min(a,a+c+p);let d=[...s[0]][0].length,g=e.slice(0,r+s.index+d+a);if(Math.min(r,a)%2){let f=g.slice(1,-1);return{type:"em",raw:g,text:f,tokens:this.lexer.inlineTokens(f)}}let T=g.slice(2,-2);return{type:"strong",raw:g,text:T,tokens:this.lexer.inlineTokens(T)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||w,this.options.tokenizer=this.options.tokenizer||new S,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:P.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=P.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=P.breaks:t.inline=P.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:P}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,`
`),this.blockTokens(e,this.tokens);for(let t=0;t<this.inlineQueue.length;t++){let n=this.inlineQueue[t];this.inlineTokens(n.src,n.tokens)}return this.inlineQueue=[],this.tokens}blockTokens(e,t=[],n=!1){for(this.options.pedantic&&(e=e.replace(m.tabCharGlobal," ").replace(m.spaceLine,""));e;){let s;if(this.options.extensions?.block?.some(r=>(s=r.call({lexer:this},e,t))?(e=e.substring(s.raw.length),t.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let r=t.at(-1);s.raw.length===1&&r!==void 0?r.raw+=`
`:t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=`
`+s.raw,r.text+=`
`+s.text,this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=`
`+s.raw,r.text+=`
`+s.raw,this.inlineQueue.at(-1).src=r.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let i=e;if(this.options.extensions?.startBlock){let r=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},o),typeof a=="number"&&a>=0&&(r=Math.min(r,a))}),r<1/0&&r>=0&&(i=e.substring(0,r+1))}if(this.state.top&&(s=this.tokenizer.paragraph(i))){let r=t.at(-1);n&&r?.type==="paragraph"?(r.raw+=`
`+s.raw,r.text+=`
`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s),n=i.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="text"?(r.raw+=`
`+s.raw,r.text+=`
`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(e){let r="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(r);break}else throw new Error(r)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,r="";for(;e;){i||(r=""),i=!1;let o;if(this.options.extensions?.inline?.some(c=>(o=c.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let c=t.at(-1);o.type==="text"&&c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,r)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let c=1/0,p=e.slice(1),u;this.options.extensions.startInline.forEach(d=>{u=d.call({lexer:this},p),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(a=e.substring(0,c+1))}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(r=o.raw.slice(-1)),i=!0;let c=t.at(-1);c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(e){let c="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return t}};var $=class{options;parser;constructor(e){this.options=e||w}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+`
`;return s?'<pre><code class="language-'+R(s)+'">'+(n?i:R(i,!0))+`</code></pre>
`:"<pre><code>"+(n?i:R(i,!0))+`</code></pre>
`}blockquote({tokens:e}){return`<blockquote>
${this.parser.parse(e)}</blockquote>
`}html({text:e}){return e}heading({tokens:e,depth:t}){return`<h${t}>${this.parser.parseInline(e)}</h${t}>
`}hr(e){return`<hr>
`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o<e.items.length;o++){let a=e.items[o];s+=this.listitem(a)}let i=t?"ol":"ul",r=t&&n!==1?' start="'+n+'"':"";return"<"+i+r+`>
`+s+"</"+i+`>
`}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+R(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`<li>${t}</li>
`}checkbox({checked:e}){return"<input "+(e?'checked="" ':"")+'disabled="" type="checkbox">'}paragraph({tokens:e}){return`<p>${this.parser.parseInline(e)}</p>
`}table(e){let t="",n="";for(let i=0;i<e.header.length;i++)n+=this.tablecell(e.header[i]);t+=this.tablerow({text:n});let s="";for(let i=0;i<e.rows.length;i++){let r=e.rows[i];n="";for(let o=0;o<r.length;o++)n+=this.tablecell(r[o]);s+=this.tablerow({text:n})}return s&&(s=`<tbody>${s}</tbody>`),`<table>
<thead>
`+t+`</thead>
`+s+`</table>
`}tablerow({text:e}){return`<tr>
${e}</tr>
`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`</${n}>
`}strong({tokens:e}){return`<strong>${this.parser.parseInline(e)}</strong>`}em({tokens:e}){return`<em>${this.parser.parseInline(e)}</em>`}codespan({text:e}){return`<code>${R(e,!0)}</code>`}br(e){return"<br>"}del({tokens:e}){return`<del>${this.parser.parseInline(e)}</del>`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),i=V(e);if(i===null)return s;e=i;let r='<a href="'+e+'"';return t&&(r+=' title="'+R(t)+'"'),r+=">"+s+"</a>",r}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let i=V(e);if(i===null)return R(n);e=i;let r=`<img src="${e}" alt="${n}"`;return t&&(r+=` title="${R(t)}"`),r+=">",r}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:R(e.text)}};var _=class{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}};var b=class l{options;renderer;textRenderer;constructor(e){this.options=e||w,this.options.renderer=this.options.renderer||new $,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new _}static parse(e,t){return new l(t).parse(e)}static parseInline(e,t){return new l(t).parseInline(e)}parse(e,t=!0){let n="";for(let s=0;s<e.length;s++){let i=e[s];if(this.options.extensions?.renderers?.[i.type]){let o=i,a=this.options.extensions.renderers[o.type].call({parser:this},o);if(a!==!1||!["space","hr","heading","code","table","blockquote","list","html","paragraph","text"].includes(o.type)){n+=a||"";continue}}let r=i;switch(r.type){case"space":{n+=this.renderer.space(r);continue}case"hr":{n+=this.renderer.hr(r);continue}case"heading":{n+=this.renderer.heading(r);continue}case"code":{n+=this.renderer.code(r);continue}case"table":{n+=this.renderer.table(r);continue}case"blockquote":{n+=this.renderer.blockquote(r);continue}case"list":{n+=this.renderer.list(r);continue}case"html":{n+=this.renderer.html(r);continue}case"paragraph":{n+=this.renderer.paragraph(r);continue}case"text":{let o=r,a=this.renderer.text(o);for(;s+1<e.length&&e[s+1].type==="text";)o=e[++s],a+=`
`+this.renderer.text(o);t?n+=this.renderer.paragraph({type:"paragraph",raw:a,text:a,tokens:[{type:"text",raw:a,text:a,escaped:!0}]}):n+=a;continue}default:{let o='Token with "'+r.type+'" type was not found.';if(this.options.silent)return console.error(o),"";throw new Error(o)}}}return n}parseInline(e,t=this.renderer){let n="";for(let s=0;s<e.length;s++){let i=e[s];if(this.options.extensions?.renderers?.[i.type]){let o=this.options.extensions.renderers[i.type].call({parser:this},i);if(o!==!1||!["escape","html","link","image","strong","em","codespan","br","del","text"].includes(i.type)){n+=o||"";continue}}let r=i;switch(r.type){case"escape":{n+=t.text(r);break}case"html":{n+=t.html(r);break}case"link":{n+=t.link(r);break}case"image":{n+=t.image(r);break}case"strong":{n+=t.strong(r);break}case"em":{n+=t.em(r);break}case"codespan":{n+=t.codespan(r);break}case"br":{n+=t.br(r);break}case"del":{n+=t.del(r);break}case"text":{n+=t.text(r);break}default:{let o='Token with "'+r.type+'" type was not found.';if(this.options.silent)return console.error(o),"";throw new Error(o)}}}return n}};var L=class{options;block;constructor(e){this.options=e||w}static passThroughHooks=new Set(["preprocess","postprocess","processAllTokens"]);preprocess(e){return e}postprocess(e){return e}processAllTokens(e){return e}provideLexer(){return this.block?x.lex:x.lexInline}provideParser(){return this.block?b.parse:b.parseInline}};var E=class{defaults=z();options=this.setOptions;parse=this.parseMarkdown(!0);parseInline=this.parseMarkdown(!1);Parser=b;Renderer=$;TextRenderer=_;Lexer=x;Tokenizer=S;Hooks=L;constructor(...e){this.use(...e)}walkTokens(e,t){let n=[];for(let s of e)switch(n=n.concat(t.call(this,s)),s.type){case"table":{let i=s;for(let r of i.header)n=n.concat(this.walkTokens(r.tokens,t));for(let r of i.rows)for(let o of r)n=n.concat(this.walkTokens(o.tokens,t));break}case"list":{let i=s;n=n.concat(this.walkTokens(i.items,t));break}default:{let i=s;this.defaults.extensions?.childTokens?.[i.type]?this.defaults.extensions.childTokens[i.type].forEach(r=>{let o=i[r].flat(1/0);n=n.concat(this.walkTokens(o,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let r=t.renderers[i.name];r?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=r.apply(this,o)),a}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=t[i.level];r?r.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),s.extensions=t),n.renderer){let i=this.defaults.renderer||new $(this.defaults);for(let r in n.renderer){if(!(r in i))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let o=r,a=n.renderer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u||""}}s.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new S(this.defaults);for(let r in n.tokenizer){if(!(r in i))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let o=r,a=n.tokenizer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new L;for(let r in n.hooks){if(!(r in i))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let o=r,a=n.hooks[o],c=i[o];L.passThroughHooks.has(r)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(d=>c.call(i,d));let u=a.call(i,p);return c.call(i,u)}:i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(o){let a=[];return a.push(r.call(this,o)),i&&(a=a.concat(i.call(this,o))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let i={...s},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype. Line truncated
Please report this to https://github.com/markedjs/marked.`,e){let s="<p>An error occurred:</p><pre>"+R(n.message+"",!0)+"</pre>";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex;
if(__exports != exports)module.exports = exports;return module.exports}));