feat: new build system using Quartz and Eleventy

This commit is contained in:
cconrad 2026-03-04 12:16:31 +01:00
parent 9576701d85
commit 6436c919ac
12 changed files with 17150 additions and 25 deletions

108
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,108 @@
name: Build Combined Site
on:
push:
branches: [main]
workflow_dispatch:
repository_dispatch:
jobs:
build:
runs-on: ubuntu-latest
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
steps:
- name: Checkout this repo
uses: actions/checkout@v4
- name: Checkout obsidian-personal (private)
uses: actions/checkout@v4
with:
repository: cconrad/obsidian-personal
token: ${{ secrets.GH_PAT_READ_OBSIDIAN_PERSONAL }}
path: .sources/obsidian-personal
- name: Checkout cconrad.github.io
uses: actions/checkout@v4
with:
repository: cconrad/cconrad.github.io
path: .sources/cconrad.github.io
- name: Set up Node.js for Quartz
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Cache Quartz node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-quartz-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-quartz-node-
- name: Install Quartz dependencies
run: npm ci
- name: Install Python dependencies
run: uv sync
- name: Filter published notes
run: |
rm -rf content
mkdir content
uv run scripts/filter_notes.py .sources/obsidian-personal content/
- name: Build Quartz
run: npx quartz build
- name: Cache Eleventy node_modules
uses: actions/cache@v4
with:
path: .sources/cconrad.github.io/node_modules
key: ${{ runner.os }}-eleventy-node20-${{ hashFiles('.sources/cconrad.github.io/package-lock.json') }}
restore-keys: |
${{ runner.os }}-eleventy-node20-
- name: Set up Node.js for Eleventy
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Eleventy dependencies
run: npm install --no-save
working-directory: .sources/cconrad.github.io
- name: Build Eleventy
run: npm run build
working-directory: .sources/cconrad.github.io
- name: Merge outputs into _combined/
run: |
mkdir -p _combined/notes
cp -r .sources/cconrad.github.io/_site/. _combined/
cp -r public/. _combined/notes/
- name: Upload _combined/ artifact
uses: actions/upload-artifact@v4
with:
name: combined-site
path: _combined/
if-no-files-found: error
- name: Deploy to Netlify
if: env.NETLIFY_AUTH_TOKEN != ''
uses: netlify/actions/cli@master
with:
args: deploy --prod --dir=_combined

34
.gitignore vendored
View File

@ -1,11 +1,35 @@
.DS_Store
.gitignore
node_modules
public
prof
node_modules/
public/
prof/
tsconfig.tsbuildinfo
.obsidian
.quartz-cache
.quartz-cache/
private/
.replit
replit.nix
# Build artifacts
.sources/
_combined/
__pycache__/
# Content is generated at build time, but must not be excluded here, or Quartz will not render those files
# IDE
.idea/
.vscode/
*.swp
*.swo
# Environment
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
# Local Netlify folder
.netlify

View File

@ -1,17 +1,13 @@
# Quartz v4
# www.clausconrad.com
> “[One] who works with the door open gets all kinds of interruptions, but [they] also occasionally gets clues as to what the world is and what might be important.” — Richard Hamming
This repo contains the [Quartz v4](https://quartz.jzhao.xyz/) static-site generator
configured for the `/notes/` path of www.clausconrad.com.
Quartz is a set of tools that helps you publish your [digital garden](https://jzhao.xyz/posts/networked-thought) and notes as a website for free.
The `content/` directory is populated at build time by the pipeline; it should not be committed when building locally. (It cannot be added to `.gitignore` because Quartz would ignore it too.)
🔗 Read the documentation and get started: https://quartz.jzhao.xyz/
## Local development
[Join the Discord Community](https://discord.gg/cRFFHYye7t)
## Sponsors
<p align="center">
<a href="https://github.com/sponsors/jackyzha0">
<img src="https://cdn.jsdelivr.net/gh/jackyzha0/jackyzha0/sponsorkit/sponsors.svg" />
</a>
</p>
```bash
npm ci
GH_TOKEN=XXXXX ./build.sh --serve
```

217
build.sh Executable file
View File

@ -0,0 +1,217 @@
#!/usr/bin/env bash
# build.sh — Full build orchestration script
#
# Pipeline:
# 1. Clone / update source repos (.sources/)
# 2. Filter published notes → content/
# 3. Build Quartz → public/
# 4. Build Eleventy in cconrad.github.io → _site/
# 5. Merge into _combined/: Eleventy at root, Quartz at /notes/
# 6. Optionally serve _combined/ locally or deploy to Netlify
#
set -euo pipefail
# ── Paths ────────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCES_DIR="${SCRIPT_DIR}/.sources"
CONTENT_DIR="${SCRIPT_DIR}/content"
PUBLIC_DIR="${SCRIPT_DIR}/public"
COMBINED_DIR="${SCRIPT_DIR}/_combined"
OBSIDIAN_DIR="${SOURCES_DIR}/obsidian-personal"
GH_PAGES_DIR="${SOURCES_DIR}/cconrad.github.io"
SERVE_PORT="${SERVE_PORT:-8080}"
# ── Helpers ──────────────────────────────────────────────────────────────────
log() { echo "==> $*"; }
err() { echo "Error: $*" >&2; exit 1; }
check_tool() {
command -v "$1" &>/dev/null || err "'$1' is required but not found in PATH."
}
current_node_major() {
node -p "process.versions.node.split('.')[0]"
}
load_nvm() {
if [[ -n "${NVM_DIR:-}" && -s "${NVM_DIR}/nvm.sh" ]]; then
# shellcheck disable=SC1090
. "${NVM_DIR}/nvm.sh"
return 0
fi
if [[ -s "${HOME}/.nvm/nvm.sh" ]]; then
export NVM_DIR="${HOME}/.nvm"
# shellcheck disable=SC1090
. "${NVM_DIR}/nvm.sh"
return 0
fi
return 1
}
use_node_major() {
local major="$1"
if load_nvm; then
nvm install "${major}" >/dev/null
nvm use "${major}" >/dev/null
return 0
fi
return 1
}
ensure_node_for_quartz() {
if use_node_major 22; then
log "Using Node $(node -v) for Quartz build."
return
fi
local major
major="$(current_node_major)"
if (( major < 22 )); then
err "Quartz build requires Node 22+ and nvm was not found. Install nvm or use Node 22+."
fi
log "Using existing Node $(node -v) for Quartz build."
}
ensure_node_for_eleventy() {
if use_node_major 20; then
log "Using Node $(node -v) for Eleventy build."
return
fi
local major
major="$(current_node_major)"
if (( major != 20 )); then
err "Eleventy build requires Node 20 and nvm was not found. Install nvm or switch to Node 20."
fi
log "Using existing Node $(node -v) for Eleventy build."
}
# ── Argument parsing ─────────────────────────────────────────────────────────
SERVE=false
DEPLOY_NETLIFY=false
SKIP_CLONE=false
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
--serve Build then serve the site locally (port \$SERVE_PORT, default: 8080)
--deploy-netlify Deploy _combined/ to Netlify after building
--skip-clone Skip cloning/updating source repos (use existing .sources/)
-h, --help Show this help
EOF
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
--serve) SERVE=true; shift ;;
--deploy-netlify) DEPLOY_NETLIFY=true; shift ;;
--skip-clone) SKIP_CLONE=true; shift ;;
-h|--help) usage ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
done
# ── Tool checks ──────────────────────────────────────────────────────────────
log "Checking required tools..."
check_tool node
check_tool npm
check_tool npx
check_tool uv
check_tool git
if [[ "${DEPLOY_NETLIFY}" == "true" ]]; then
check_tool netlify
[[ -n "${NETLIFY_AUTH_TOKEN:-}" ]] || err "NETLIFY_AUTH_TOKEN env var is not set."
[[ -n "${NETLIFY_SITE_ID:-}" ]] || err "NETLIFY_SITE_ID env var is not set."
fi
# ── Clone / update source repos ──────────────────────────────────────────────
if [[ "${SKIP_CLONE}" == "false" ]]; then
[[ -n "${GH_TOKEN:-}" ]] || err "GH_TOKEN env var is required to clone private repos."
mkdir -p "${SOURCES_DIR}"
if [[ -d "${OBSIDIAN_DIR}/.git" ]]; then
log "Updating obsidian-personal..."
git -C "${OBSIDIAN_DIR}" pull --ff-only
else
log "Cloning obsidian-personal (private)..."
git clone "https://${GH_TOKEN}@github.com/cconrad/obsidian-personal.git" "${OBSIDIAN_DIR}"
fi
if [[ -d "${GH_PAGES_DIR}/.git" ]]; then
log "Updating cconrad.github.io..."
git -C "${GH_PAGES_DIR}" pull --ff-only
else
log "Cloning cconrad.github.io..."
git clone "https://github.com/cconrad/cconrad.github.io.git" "${GH_PAGES_DIR}"
fi
fi
# ── Filter notes ─────────────────────────────────────────────────────────────
log "Cleaning content/ directory..."
rm -rf "${CONTENT_DIR}"
mkdir -p "${CONTENT_DIR}"
log "Filtering published notes..."
uv run "${SCRIPT_DIR}/scripts/filter_notes.py" "${OBSIDIAN_DIR}" "${CONTENT_DIR}"
# ── Build Quartz ─────────────────────────────────────────────────────────────
log "Building Quartz..."
ensure_node_for_quartz
cd "${SCRIPT_DIR}"
npx quartz build
# ── Build Eleventy ───────────────────────────────────────────────────────────
ensure_node_for_eleventy
log "Installing Eleventy dependencies..."
cd "${GH_PAGES_DIR}"
npm install --no-save
log "Building Eleventy..."
npm run build
cd "${SCRIPT_DIR}"
# ── Merge into _combined/ ────────────────────────────────────────────────────
# Eleventy at root, Quartz at /notes/ (overwrites any /notes/ from Eleventy).
log "Merging outputs into _combined/..."
rm -rf "${COMBINED_DIR}"
mkdir -p "${COMBINED_DIR}/notes"
cp -r "${GH_PAGES_DIR}/_site/." "${COMBINED_DIR}/"
cp -r "${PUBLIC_DIR}/." "${COMBINED_DIR}/notes/"
# ── Deploy to Netlify ────────────────────────────────────────────────────────
if [[ "${DEPLOY_NETLIFY}" == "true" ]]; then
log "Deploying to Netlify..."
netlify deploy \
--prod \
--dir="${COMBINED_DIR}" \
--auth="${NETLIFY_AUTH_TOKEN}" \
--site="${NETLIFY_SITE_ID}"
fi
# ── Local preview ─────────────────────────────────────────────────────────────
if [[ "${SERVE}" == "true" ]]; then
log ""
log "Build complete! Serving site at http://localhost:${SERVE_PORT}"
log "Press Ctrl+C to stop."
uv run "${SCRIPT_DIR}/scripts/serve.py" --port "${SERVE_PORT}" --dir "${COMBINED_DIR}"
else
log ""
log "Build complete! Output: ${COMBINED_DIR}"
log " Eleventy root: ${COMBINED_DIR}/index.html"
log " Quartz notes: ${COMBINED_DIR}/notes/index.html"
log "Run with --serve to preview locally, or --deploy-netlify to push to Netlify."
fi

15697
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -107,6 +107,7 @@
"@types/ws": "^8.18.1",
"@types/yargs": "^17.0.35",
"esbuild": "^0.27.2",
"netlify-cli": "^24.0.1",
"prettier": "^3.8.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"

16
pyproject.toml Normal file
View File

@ -0,0 +1,16 @@
[project]
name = "www.clausconrad.com"
version = "2.0.0"
requires-python = ">=3.11"
dependencies = [
"pyyaml>=6.0",
]
[dependency-groups]
dev = [
"pytest>=8.0",
]
[tool.pytest.ini_options]
testpaths = ["scripts"]

View File

@ -8,17 +8,15 @@ import * as Plugin from "./quartz/plugins"
*/
const config: QuartzConfig = {
configuration: {
pageTitle: "Quartz 4",
pageTitle: "Claus Conrad",
pageTitleSuffix: "",
enableSPA: true,
enablePopovers: true,
analytics: {
provider: "plausible",
},
analytics: null,
locale: "en-US",
baseUrl: "quartz.jzhao.xyz",
baseUrl: "www.clausconrad.com/notes",
ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "modified",
defaultDateType: "created",
theme: {
fontOrigin: "googleFonts",
cdnCaching: true,
@ -73,7 +71,7 @@ const config: QuartzConfig = {
Plugin.Description(),
Plugin.Latex({ renderEngine: "katex" }),
],
filters: [Plugin.RemoveDrafts()],
filters: [Plugin.ExplicitPublish()],
emitters: [
Plugin.AliasRedirects(),
Plugin.ComponentResources(),

286
scripts/filter_notes.py Normal file
View File

@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""Filter and copy published Obsidian notes to a Quartz content directory."""
import argparse
import re
import shutil
import sys
from pathlib import Path
from typing import Optional
import yaml
def parse_frontmatter(content: str) -> tuple[Optional[dict], str]:
"""Parse YAML frontmatter from markdown content.
Returns (frontmatter_dict, body_after_closing_dashes) or (None, content).
Body is everything after the closing '---', including the leading newline.
"""
if not content.startswith("---\n") and not content.startswith("---\r\n"):
return None, content
# Skip the opening "---\n"
rest = content[4:]
end_idx = rest.find("\n---")
if end_idx == -1:
return None, content
fm_text = rest[:end_idx]
# body starts after the "\n---" marker
body = rest[end_idx + 4:]
try:
fm = yaml.safe_load(fm_text)
if not isinstance(fm, dict):
return None, content
return fm, body
except yaml.YAMLError:
return None, content
def inject_publish(content: str, title: str | None = None) -> str:
"""Inject ``publish: true`` (and optionally ``title``) into existing YAML frontmatter.
If 'publish' already exists it is overwritten. When *title* is provided,
it is written as the ``title`` key (unless there is an existing value).
Returns content unchanged if there is no valid frontmatter.
"""
fm, body = parse_frontmatter(content)
if fm is None:
return content
fm["publish"] = True
if title and not fm.get("title"):
fm["title"] = title
new_fm = yaml.dump(
fm, allow_unicode=True, default_flow_style=False, sort_keys=False
).strip()
return f"---\n{new_fm}\n---{body}"
def find_asset_references(content: str, source_assets_dir: Path) -> set[Path]:
"""Find asset references in *content* that resolve to real files.
Handles two syntaxes:
* ``![alt text](assets/path/to/file.ext)`` standard Markdown image
* ``![[filename.ext]]`` or ``![[filename.ext|width]]`` Obsidian embed
Only paths that actually exist under *source_assets_dir* are returned.
Returns a set of :class:`Path` objects relative to *source_assets_dir*.
"""
found: set[Path] = set()
# Standard Markdown: ![alt](assets/relative/path.ext)
for match in re.finditer(r"!\[.*?\]\(assets/([^)\s]+)\)", content):
rel = match.group(1)
candidate = source_assets_dir / rel
if candidate.is_file():
found.add(Path(rel))
# Obsidian wikilink embed: ![[name.ext]] or ![[name.ext|display]]
for match in re.finditer(r"!\[\[([^\]|]+?)(?:\|[^\]]*)?\]\]", content):
ref = match.group(1).strip()
suffix = Path(ref).suffix.lower()
# Skip if no extension or a Markdown file (it's an embedded note)
if not suffix or suffix == ".md":
continue
# 1. Try exact path inside assets dir (supports ![[sub/file.png]])
exact = source_assets_dir / ref
if exact.is_file():
found.add(Path(ref))
continue
# 2. Search recursively by filename (Obsidian stores by unique name)
filename = Path(ref).name
for hit in source_assets_dir.rglob(filename):
if hit.is_file():
found.add(hit.relative_to(source_assets_dir))
break # Use first match
return found
def build_rename_mapping(source_dir: Path) -> dict[str, str]:
"""Build a case-insensitive mapping from original stem to output stem.
Pass 1: scan all published ``.md`` files and determine output filenames.
* If the note has a ``slug`` frontmatter key new stem = slug value.
* Otherwise new stem = original filename stem lowercased.
The returned dict uses lowercased stems as keys so that lookups from
wikilinks (which are case-insensitive in Obsidian) work correctly.
A warning is printed when two published notes would map to the same output
stem.
"""
mapping: dict[str, str] = {} # lowercase_stem → new_stem
reverse: dict[str, str] = {} # new_stem → original key (for collision check)
for md_file in sorted(source_dir.rglob("*.md")):
rel_path = md_file.relative_to(source_dir)
if rel_path.parts[0] == "assets":
continue
content = md_file.read_text(encoding="utf-8")
fm, _ = parse_frontmatter(content)
if fm is None or fm.get("published") is not True:
continue
original_stem = md_file.stem
slug = fm.get("slug")
new_stem = str(slug) if slug else original_stem.lower()
key = original_stem.lower()
if new_stem in reverse:
print(
f"Warning: output stem collision '{new_stem}': "
f"'{reverse[new_stem]}' and '{key}' both map to the same filename."
)
mapping[key] = new_stem
reverse[new_stem] = key
return mapping
def rewrite_wikilinks(content: str, rename_mapping: dict[str, str]) -> str:
"""Rewrite wikilink targets in *content* using *rename_mapping*.
Handles all four wikilink forms (with and without ``!`` prefix):
* ``[[OldName]]`` ``[[new-name]]``
* ``[[OldName|Title]]`` ``[[new-name|Title]]``
* ``[[OldName#heading]]`` → ``[[new-name#heading]]``
* ``[[OldName#heading|Title]]`` → ``[[new-name#heading|Title]]``
Only rewrites when the link target (case-insensitive, without ``.md``
extension) is present as a key in *rename_mapping*. Asset embeds such as
``![[image.png]]`` are left untouched because non-markdown extensions will
not appear in the mapping.
"""
# Groups: (1) prefix ('[[' or '![['), (2) target, (3) heading, (4) title
pattern = re.compile(
r"(!?\[\[)([^\]|#\n]+?)(?:#([^\]|\n]*))?(?:\|([^\]\n]*))?\]\]"
)
def _replace(m: re.Match) -> str:
prefix = m.group(1)
target = m.group(2).strip()
heading = m.group(3) # None when absent
title = m.group(4) # None when absent
# Strip .md suffix for the lookup
target_stem = target[:-3] if target.lower().endswith(".md") else target
new_stem = rename_mapping.get(target_stem.lower())
if new_stem is None:
# Check if this is an asset reference (has non-md file extension)
has_ext = "." in target
is_md = target.lower().endswith(".md")
if has_ext and not is_md:
# Asset reference (e.g., ![[image.png]]) — leave unchanged
return m.group(0)
# Dead link to unpublished/non-existing page — render as styled text
display = title if title is not None else target_stem
return f'<span class="dead-link">{display}</span>'
result = prefix + new_stem
if heading is not None:
result += f"#{heading}"
if title is not None:
result += f"|{title}"
elif target_stem != new_stem:
# Auto-add the original name as display title when renaming
result += f"|{target_stem}"
result += "]]"
return result
return pattern.sub(_replace, content)
def filter_notes(source_dir: Path, dest_dir: Path) -> None:
"""Filter and copy published notes and their referenced assets."""
notes_copied = 0
notes_skipped = 0
assets_copied = 0
all_asset_refs: set[Path] = set()
source_assets_dir = source_dir / "assets"
# Pass 1: build the rename mapping for all published notes
rename_mapping = build_rename_mapping(source_dir)
# Pass 2: copy each published note with rewritten links and new filename
for md_file in sorted(source_dir.rglob("*.md")):
rel_path = md_file.relative_to(source_dir)
# Skip anything inside the assets/ directory
if rel_path.parts[0] == "assets":
continue
content = md_file.read_text(encoding="utf-8")
fm, _ = parse_frontmatter(content)
if fm is None or fm.get("published") is not True:
notes_skipped += 1
continue
# Determine output filename
slug = fm.get("slug")
new_stem = str(slug) if slug else md_file.stem.lower()
new_filename = new_stem + ".md"
# Inject publish flag (+ title from filename) then rewrite wikilinks
out_content = rewrite_wikilinks(
inject_publish(content, title=md_file.stem), rename_mapping
)
dest_file = dest_dir / rel_path.parent / new_filename
dest_file.parent.mkdir(parents=True, exist_ok=True)
dest_file.write_text(out_content, encoding="utf-8")
notes_copied += 1
# Collect asset references from the *original* content
if source_assets_dir.is_dir():
all_asset_refs.update(find_asset_references(content, source_assets_dir))
# Copy referenced assets
dest_assets_dir = dest_dir / "assets"
for asset_rel in all_asset_refs:
src = source_assets_dir / asset_rel
dst = dest_assets_dir / asset_rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
assets_copied += 1
print(
f"Done: {notes_copied} notes copied, "
f"{assets_copied} assets copied, "
f"{notes_skipped} notes skipped."
)
def main() -> None:
parser = argparse.ArgumentParser(
description="Copy published Obsidian notes to a Quartz content directory."
)
parser.add_argument("source_dir", type=Path, help="Source Obsidian vault directory")
parser.add_argument(
"dest_dir", type=Path, help="Destination Quartz content directory"
)
args = parser.parse_args()
if not args.source_dir.is_dir():
print(
f"Error: source_dir '{args.source_dir}' is not a directory",
file=sys.stderr,
)
sys.exit(1)
filter_notes(args.source_dir, args.dest_dir)
if __name__ == "__main__":
main()

71
scripts/serve.py Normal file
View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Local HTTP server with clean URL support (Netlify-style)."""
import argparse
import os
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
class CleanURLHandler(SimpleHTTPRequestHandler):
"""HTTP handler that resolves clean URLs like Netlify."""
def do_GET(self):
path = self.path.split("?")[0].split("#")[0]
fs_path = Path(self.directory) / path.lstrip("/")
if fs_path.is_file():
return super().do_GET()
if path.endswith("/"):
index = fs_path / "index.html"
if index.is_file():
self.path = path + "index.html"
return super().do_GET()
# Fallback: try path.html (e.g., /notes/ → notes.html)
stripped = path.rstrip("/")
if stripped:
html_file = Path(self.directory) / (stripped.lstrip("/") + ".html")
if html_file.is_file():
self.path = stripped + ".html"
return super().do_GET()
self.send_error(404)
return
if "." not in Path(path).name:
html_file = fs_path.with_suffix(".html")
if html_file.is_file():
self.path = path + ".html"
return super().do_GET()
index = fs_path / "index.html"
if index.is_file():
self.send_response(301)
self.send_header("Location", path + "/")
self.end_headers()
return
self.send_error(404)
def main():
parser = argparse.ArgumentParser(description="Local server with clean URL support")
parser.add_argument("--port", type=int, default=8080, help="Port (default: 8080)")
parser.add_argument("--dir", type=str, default=".", help="Directory to serve")
args = parser.parse_args()
directory = os.path.abspath(args.dir)
handler = lambda *a, **kw: CleanURLHandler(*a, directory=directory, **kw)
server = HTTPServer(("", args.port), handler)
print(f"Serving {directory} at http://localhost:{args.port}")
print("Press Ctrl+C to stop.")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,573 @@
"""Tests for filter_notes.py"""
import sys
from pathlib import Path
# Allow running tests from repo root via `uv run -m pytest scripts/`
sys.path.insert(0, str(Path(__file__).parent))
from filter_notes import (
build_rename_mapping,
filter_notes,
find_asset_references,
inject_publish,
parse_frontmatter,
rewrite_wikilinks,
)
# ---------------------------------------------------------------------------
# parse_frontmatter
# ---------------------------------------------------------------------------
SAMPLE_FM = "---\npublished: true\nslug: truenas\n---\n# Body\n"
def test_parse_frontmatter_valid():
fm, body = parse_frontmatter(SAMPLE_FM)
assert fm == {"published": True, "slug": "truenas"}
assert body == "\n# Body\n"
def test_parse_frontmatter_no_frontmatter():
content = "# Just a heading"
fm, body = parse_frontmatter(content)
assert fm is None
assert body == content
def test_parse_frontmatter_published_false():
content = "---\npublished: false\n---\nbody"
fm, _ = parse_frontmatter(content)
assert fm is not None
assert fm.get("published") is False
def test_parse_frontmatter_missing_published_key():
content = "---\nslug: test\n---\nbody"
fm, _ = parse_frontmatter(content)
assert fm is not None
assert "published" not in fm
def test_parse_frontmatter_invalid_yaml():
content = "---\n: bad: yaml: [\n---\nbody"
fm, body = parse_frontmatter(content)
assert fm is None
assert body == content
def test_parse_frontmatter_non_dict_yaml():
# A YAML scalar at the top level (not a mapping)
content = "---\n- item1\n- item2\n---\nbody"
fm, body = parse_frontmatter(content)
assert fm is None
assert body == content
def test_parse_frontmatter_no_closing_dashes():
content = "---\npublished: true\n"
fm, body = parse_frontmatter(content)
assert fm is None
# ---------------------------------------------------------------------------
# inject_publish
# ---------------------------------------------------------------------------
def test_inject_publish_adds_key():
content = "---\npublished: true\nslug: test\n---\n# Body"
result = inject_publish(content)
fm, body = parse_frontmatter(result)
assert fm is not None
assert fm["publish"] is True
assert fm["published"] is True
assert "# Body" in result
def test_inject_publish_with_title():
content = "---\npublished: true\n---\nbody"
result = inject_publish(content, title="My Note")
fm, _ = parse_frontmatter(result)
assert fm is not None
assert fm["title"] == "My Note"
assert fm["publish"] is True
def test_inject_publish_title_none_does_not_inject():
content = "---\npublished: true\n---\nbody"
result = inject_publish(content, title=None)
fm, _ = parse_frontmatter(result)
assert fm is not None
assert "title" not in fm
def test_inject_publish_overwrites_existing():
content = "---\npublished: true\npublish: false\n---\nbody"
result = inject_publish(content)
fm, _ = parse_frontmatter(result)
assert fm["publish"] is True
def test_inject_publish_no_frontmatter():
content = "# No frontmatter here"
assert inject_publish(content) == content
def test_inject_publish_preserves_body():
body = "\n\nSome *markdown* body.\n\n> blockquote\n"
content = f"---\npublished: true\n---{body}"
result = inject_publish(content)
assert result.endswith(body)
# ---------------------------------------------------------------------------
# find_asset_references
# ---------------------------------------------------------------------------
def test_find_asset_references_markdown_image(tmp_path):
assets = tmp_path / "assets"
assets.mkdir()
(assets / "photo.png").write_bytes(b"x")
refs = find_asset_references("![alt](assets/photo.png)", assets)
assert Path("photo.png") in refs
def test_find_asset_references_markdown_subdir(tmp_path):
assets = tmp_path / "assets"
(assets / "sub").mkdir(parents=True)
(assets / "sub" / "img.jpg").write_bytes(b"x")
refs = find_asset_references("![](assets/sub/img.jpg)", assets)
assert Path("sub/img.jpg") in refs
def test_find_asset_references_obsidian_embed(tmp_path):
assets = tmp_path / "assets"
assets.mkdir()
(assets / "screenshot.png").write_bytes(b"x")
refs = find_asset_references("![[screenshot.png]]", assets)
assert Path("screenshot.png") in refs
def test_find_asset_references_obsidian_with_pipe(tmp_path):
assets = tmp_path / "assets"
assets.mkdir()
(assets / "diagram.svg").write_bytes(b"x")
refs = find_asset_references("![[diagram.svg|300]]", assets)
assert Path("diagram.svg") in refs
def test_find_asset_references_ignores_note_embeds(tmp_path):
assets = tmp_path / "assets"
assets.mkdir()
# No extension → treated as embedded note, must be ignored
refs = find_asset_references("![[Some Note]]", assets)
assert len(refs) == 0
def test_find_asset_references_ignores_md_embeds(tmp_path):
assets = tmp_path / "assets"
assets.mkdir()
(assets / "note.md").write_text("# hi")
refs = find_asset_references("![[note.md]]", assets)
assert len(refs) == 0
def test_find_asset_references_nonexistent_file(tmp_path):
assets = tmp_path / "assets"
assets.mkdir()
refs = find_asset_references("![](assets/ghost.png)", assets)
assert len(refs) == 0
def test_find_asset_references_obsidian_recursive(tmp_path):
"""Obsidian embed resolved recursively when stored in a subdir."""
assets = tmp_path / "assets"
(assets / "deep").mkdir(parents=True)
(assets / "deep" / "image.png").write_bytes(b"x")
refs = find_asset_references("![[image.png]]", assets)
assert Path("deep/image.png") in refs
# ---------------------------------------------------------------------------
# filter_notes (integration)
# ---------------------------------------------------------------------------
def _make_vault(tmp_path: Path):
"""Helper to create a small test vault."""
source = tmp_path / "source"
source.mkdir()
assets = source / "assets"
assets.mkdir()
return source, assets
def test_filter_notes_copies_published(tmp_path):
source, assets = _make_vault(tmp_path)
dest = tmp_path / "dest"
(source / "pub.md").write_text(
"---\npublished: true\nslug: pub\n---\n# Published"
)
(source / "skip.md").write_text("---\npublished: false\n---\n# Skip")
(source / "bare.md").write_text("# No frontmatter")
filter_notes(source, dest)
assert (dest / "pub.md").exists()
assert not (dest / "skip.md").exists()
assert not (dest / "bare.md").exists()
def test_filter_notes_injects_publish_true(tmp_path):
source, _ = _make_vault(tmp_path)
dest = tmp_path / "dest"
(source / "note.md").write_text("---\npublished: true\n---\nbody")
filter_notes(source, dest)
fm, _ = parse_frontmatter((dest / "note.md").read_text())
assert fm is not None
assert fm["publish"] is True
def test_filter_notes_copies_referenced_assets(tmp_path):
source, assets = _make_vault(tmp_path)
dest = tmp_path / "dest"
(assets / "img.png").write_bytes(b"image data")
(assets / "unused.png").write_bytes(b"not referenced")
(source / "note.md").write_text(
"---\npublished: true\n---\n![pic](assets/img.png)"
)
filter_notes(source, dest)
assert (dest / "assets" / "img.png").exists()
assert not (dest / "assets" / "unused.png").exists()
def test_filter_notes_copies_obsidian_embed_assets(tmp_path):
source, assets = _make_vault(tmp_path)
dest = tmp_path / "dest"
(assets / "chart.svg").write_bytes(b"<svg/>")
(source / "note.md").write_text(
"---\npublished: true\n---\n![[chart.svg]]"
)
filter_notes(source, dest)
assert (dest / "assets" / "chart.svg").exists()
def test_filter_notes_skips_unpublished_missing_key(tmp_path):
source, _ = _make_vault(tmp_path)
dest = tmp_path / "dest"
(source / "no_key.md").write_text("---\nslug: test\n---\nbody")
filter_notes(source, dest)
assert not (dest / "no_key.md").exists()
def test_filter_notes_preserves_subdirectory_structure(tmp_path):
source, _ = _make_vault(tmp_path)
dest = tmp_path / "dest"
sub = source / "subdir"
sub.mkdir()
(sub / "deep.md").write_text("---\npublished: true\n---\nbody")
filter_notes(source, dest)
assert (dest / "subdir" / "deep.md").exists()
def test_filter_notes_prints_summary(tmp_path, capsys):
source, _ = _make_vault(tmp_path)
dest = tmp_path / "dest"
(source / "a.md").write_text("---\npublished: true\n---\nbody")
(source / "b.md").write_text("---\npublished: false\n---\nbody")
filter_notes(source, dest)
captured = capsys.readouterr()
assert "1 notes copied" in captured.out
assert "1 notes skipped" in captured.out
# ---------------------------------------------------------------------------
# build_rename_mapping
# ---------------------------------------------------------------------------
def test_build_rename_mapping_slug(tmp_path):
source = tmp_path / "source"
source.mkdir()
(source / "TrueNAS.md").write_text("---\npublished: true\nslug: truenas\n---\nbody")
mapping = build_rename_mapping(source)
assert mapping["truenas"] == "truenas"
def test_build_rename_mapping_lowercase_fallback(tmp_path):
source = tmp_path / "source"
source.mkdir()
(source / "TrueNAS.md").write_text("---\npublished: true\n---\nbody")
mapping = build_rename_mapping(source)
assert mapping["truenas"] == "truenas"
def test_build_rename_mapping_only_published(tmp_path):
source = tmp_path / "source"
source.mkdir()
(source / "Pub.md").write_text("---\npublished: true\nslug: published-note\n---\nbody")
(source / "Draft.md").write_text("---\npublished: false\n---\nbody")
(source / "Bare.md").write_text("# No frontmatter")
mapping = build_rename_mapping(source)
assert "pub" in mapping
assert "draft" not in mapping
assert "bare" not in mapping
def test_build_rename_mapping_collision_warning(tmp_path, capsys):
source = tmp_path / "source"
source.mkdir()
(source / "a.md").write_text("---\npublished: true\nslug: same\n---\nbody")
(source / "b.md").write_text("---\npublished: true\nslug: same\n---\nbody")
build_rename_mapping(source)
captured = capsys.readouterr()
assert "Warning" in captured.out
assert "same" in captured.out
def test_build_rename_mapping_case_insensitive_key(tmp_path):
source = tmp_path / "source"
source.mkdir()
(source / "Jamstack.md").write_text("---\npublished: true\n---\nbody")
mapping = build_rename_mapping(source)
# Key is always lowercased
assert "jamstack" in mapping
assert mapping["jamstack"] == "jamstack"
# ---------------------------------------------------------------------------
# rewrite_wikilinks
# ---------------------------------------------------------------------------
def test_rewrite_wikilinks_basic():
mapping = {"oldname": "new-name"}
result = rewrite_wikilinks("See [[OldName]] for details.", mapping)
assert result == "See [[new-name|OldName]] for details."
def test_rewrite_wikilinks_with_title():
mapping = {"oldname": "new-name"}
result = rewrite_wikilinks("[[OldName|Display Title]]", mapping)
assert result == "[[new-name|Display Title]]"
def test_rewrite_wikilinks_with_anchor():
mapping = {"oldname": "new-name"}
result = rewrite_wikilinks("[[OldName#section-heading]]", mapping)
assert result == "[[new-name#section-heading|OldName]]"
def test_rewrite_wikilinks_with_anchor_and_title():
mapping = {"oldname": "new-name"}
result = rewrite_wikilinks("[[OldName#heading|My Title]]", mapping)
assert result == "[[new-name#heading|My Title]]"
def test_rewrite_wikilinks_note_embed():
mapping = {"oldname": "new-name"}
result = rewrite_wikilinks("![[OldName]]", mapping)
assert result == "![[new-name|OldName]]"
def test_rewrite_wikilinks_asset_embed_unchanged():
"""Asset embeds like ![[image.png]] should not be rewritten."""
mapping = {"image": "image"} # even if stem matches, extension present
result = rewrite_wikilinks("![[image.png]]", mapping)
# image.png has an extension; the lookup key would be "image.png" lowered,
# which is NOT in the mapping (mapping keys are stems without .png)
assert result == "![[image.png]]"
def test_rewrite_wikilinks_case_insensitive():
mapping = {"truenas": "truenas"}
result = rewrite_wikilinks("[[TrueNAS]]", mapping)
# target_stem "TrueNAS" differs from new_stem "truenas" → auto-title added
assert result == "[[truenas|TrueNAS]]"
def test_rewrite_wikilinks_no_title_when_stem_unchanged():
"""When the stem is already correct (same case), no auto-title is added."""
mapping = {"truenas": "truenas"}
result = rewrite_wikilinks("[[truenas]]", mapping)
assert result == "[[truenas]]"
def test_rewrite_wikilinks_auto_title_with_anchor():
mapping = {"truenas": "truenas"}
result = rewrite_wikilinks("[[TrueNAS#setup]]", mapping)
assert result == "[[truenas#setup|TrueNAS]]"
def test_rewrite_wikilinks_existing_title_with_anchor_preserved():
mapping = {"truenas": "truenas"}
result = rewrite_wikilinks("[[TrueNAS#setup|Guide]]", mapping)
assert result == "[[truenas#setup|Guide]]"
def test_rewrite_wikilinks_embed_auto_title():
mapping = {"truenas": "truenas"}
result = rewrite_wikilinks("![[TrueNAS]]", mapping)
assert result == "![[truenas|TrueNAS]]"
def test_rewrite_wikilinks_unknown_link_unchanged():
mapping = {"known": "known-note"}
result = rewrite_wikilinks("[[Unknown]]", mapping)
assert result == '<span class="dead-link">Unknown</span>'
def test_rewrite_wikilinks_preserves_other_content():
mapping = {"a": "alpha"}
content = "Before [[A]] middle [[Unknown]] after."
result = rewrite_wikilinks(content, mapping)
assert result == 'Before [[alpha|A]] middle <span class="dead-link">Unknown</span> after.'
# ---------------------------------------------------------------------------
# filter_notes — slug and link-rewriting integration
# ---------------------------------------------------------------------------
def test_filter_notes_slug_renames_output(tmp_path):
source, _ = _make_vault(tmp_path)
dest = tmp_path / "dest"
(source / "TrueNAS.md").write_text("---\npublished: true\nslug: truenas\n---\nbody")
filter_notes(source, dest)
# Verify by listing actual filenames (case-insensitive FS safe)
names = [p.name for p in dest.iterdir()]
assert "truenas.md" in names
assert "TrueNAS.md" not in names
def test_filter_notes_lowercase_fallback(tmp_path):
source, _ = _make_vault(tmp_path)
dest = tmp_path / "dest"
(source / "Jamstack.md").write_text("---\npublished: true\n---\nbody")
filter_notes(source, dest)
names = [p.name for p in dest.iterdir()]
assert "jamstack.md" in names
assert "Jamstack.md" not in names
def test_filter_notes_subdirectory_slug(tmp_path):
"""Slug replaces only the filename, not the directory path."""
source, _ = _make_vault(tmp_path)
dest = tmp_path / "dest"
sub = source / "subdir"
sub.mkdir()
(sub / "MyNote.md").write_text("---\npublished: true\nslug: my-note\n---\nbody")
filter_notes(source, dest)
assert (dest / "subdir" / "my-note.md").exists()
def test_filter_notes_rewrites_wikilinks(tmp_path):
source, _ = _make_vault(tmp_path)
dest = tmp_path / "dest"
(source / "Home.md").write_text(
"---\npublished: true\n---\nSee [[TrueNAS]] for storage."
)
(source / "TrueNAS.md").write_text(
"---\npublished: true\nslug: truenas\n---\nbody"
)
filter_notes(source, dest)
home_content = (dest / "home.md").read_text()
# auto-title injected because "TrueNAS" != "truenas"
assert "[[truenas|TrueNAS]]" in home_content
assert "[[TrueNAS]]" not in home_content
def test_filter_notes_injects_title_from_filename(tmp_path):
source, _ = _make_vault(tmp_path)
dest = tmp_path / "dest"
(source / "TrueNAS.md").write_text("---\npublished: true\nslug: truenas\n---\nbody")
filter_notes(source, dest)
fm, _ = parse_frontmatter((dest / "truenas.md").read_text())
assert fm is not None
assert fm["title"] == "TrueNAS"
def test_filter_notes_no_rewrite_for_unpublished_links(tmp_path):
source, _ = _make_vault(tmp_path)
dest = tmp_path / "dest"
(source / "note.md").write_text(
"---\npublished: true\n---\nSee [[Draft]] here."
)
(source / "Draft.md").write_text("---\npublished: false\n---\nbody")
filter_notes(source, dest)
note_content = (dest / "note.md").read_text()
assert '<span class="dead-link">Draft</span>' in note_content
def test_rewrite_wikilinks_dead_link_basic():
mapping = {"known": "known"}
result = rewrite_wikilinks("See [[Unpublished]] for details", mapping)
assert result == 'See <span class="dead-link">Unpublished</span> for details'
def test_rewrite_wikilinks_dead_link_with_title():
mapping = {"known": "known"}
result = rewrite_wikilinks("See [[Unpublished|Custom Title]]", mapping)
assert result == 'See <span class="dead-link">Custom Title</span>'
def test_rewrite_wikilinks_dead_link_with_anchor():
mapping = {"known": "known"}
result = rewrite_wikilinks("See [[Unpublished#heading]]", mapping)
assert result == 'See <span class="dead-link">Unpublished</span>'
def test_rewrite_wikilinks_dead_link_asset_embed_unchanged():
mapping = {"known": "known"}
result = rewrite_wikilinks("![[image.png]]", mapping)
assert result == "![[image.png]]"
def test_rewrite_wikilinks_dead_link_note_embed():
mapping = {"known": "known"}
result = rewrite_wikilinks("![[Unpublished]]", mapping)
assert result == '<span class="dead-link">Unpublished</span>'

138
uv.lock generated Normal file
View File

@ -0,0 +1,138 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "www-clausconrad-com"
version = "2.0.0"
source = { virtual = "." }
dependencies = [
{ name = "pyyaml" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [{ name = "pyyaml", specifier = ">=6.0" }]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.0" }]