mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-21 21:45:42 -05:00
feat: new build system using Quartz and Eleventy
This commit is contained in:
parent
9576701d85
commit
6436c919ac
108
.github/workflows/build.yml
vendored
Normal file
108
.github/workflows/build.yml
vendored
Normal 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
34
.gitignore
vendored
@ -1,11 +1,35 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.gitignore
|
node_modules/
|
||||||
node_modules
|
public/
|
||||||
public
|
prof/
|
||||||
prof
|
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
.obsidian
|
.obsidian
|
||||||
.quartz-cache
|
.quartz-cache/
|
||||||
private/
|
private/
|
||||||
.replit
|
.replit
|
||||||
replit.nix
|
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
|
||||||
|
|||||||
22
README.md
22
README.md
@ -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)
|
```bash
|
||||||
|
npm ci
|
||||||
## Sponsors
|
GH_TOKEN=XXXXX ./build.sh --serve
|
||||||
|
```
|
||||||
<p align="center">
|
|
||||||
<a href="https://github.com/sponsors/jackyzha0">
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/jackyzha0/jackyzha0/sponsorkit/sponsors.svg" />
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
217
build.sh
Executable file
217
build.sh
Executable 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
15697
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -107,6 +107,7 @@
|
|||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@types/yargs": "^17.0.35",
|
"@types/yargs": "^17.0.35",
|
||||||
"esbuild": "^0.27.2",
|
"esbuild": "^0.27.2",
|
||||||
|
"netlify-cli": "^24.0.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal 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"]
|
||||||
|
|
||||||
@ -8,17 +8,15 @@ import * as Plugin from "./quartz/plugins"
|
|||||||
*/
|
*/
|
||||||
const config: QuartzConfig = {
|
const config: QuartzConfig = {
|
||||||
configuration: {
|
configuration: {
|
||||||
pageTitle: "Quartz 4",
|
pageTitle: "Claus Conrad",
|
||||||
pageTitleSuffix: "",
|
pageTitleSuffix: "",
|
||||||
enableSPA: true,
|
enableSPA: true,
|
||||||
enablePopovers: true,
|
enablePopovers: true,
|
||||||
analytics: {
|
analytics: null,
|
||||||
provider: "plausible",
|
|
||||||
},
|
|
||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
baseUrl: "quartz.jzhao.xyz",
|
baseUrl: "www.clausconrad.com/notes",
|
||||||
ignorePatterns: ["private", "templates", ".obsidian"],
|
ignorePatterns: ["private", "templates", ".obsidian"],
|
||||||
defaultDateType: "modified",
|
defaultDateType: "created",
|
||||||
theme: {
|
theme: {
|
||||||
fontOrigin: "googleFonts",
|
fontOrigin: "googleFonts",
|
||||||
cdnCaching: true,
|
cdnCaching: true,
|
||||||
@ -73,7 +71,7 @@ const config: QuartzConfig = {
|
|||||||
Plugin.Description(),
|
Plugin.Description(),
|
||||||
Plugin.Latex({ renderEngine: "katex" }),
|
Plugin.Latex({ renderEngine: "katex" }),
|
||||||
],
|
],
|
||||||
filters: [Plugin.RemoveDrafts()],
|
filters: [Plugin.ExplicitPublish()],
|
||||||
emitters: [
|
emitters: [
|
||||||
Plugin.AliasRedirects(),
|
Plugin.AliasRedirects(),
|
||||||
Plugin.ComponentResources(),
|
Plugin.ComponentResources(),
|
||||||
|
|||||||
286
scripts/filter_notes.py
Normal file
286
scripts/filter_notes.py
Normal 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:
|
||||||
|
* ```` — 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: 
|
||||||
|
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
71
scripts/serve.py
Normal 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()
|
||||||
|
|
||||||
573
scripts/test_filter_notes.py
Normal file
573
scripts/test_filter_notes.py
Normal 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("", 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)
|
||||||
|
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)
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
138
uv.lock
generated
Normal 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" }]
|
||||||
Loading…
Reference in New Issue
Block a user