diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 376c359..7c5f473 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -78,6 +78,7 @@ jobs:
environment:
name: preview
permissions:
+ deployments: write
pull-requests: write
steps:
- name: Download build artifact
diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml
new file mode 100644
index 0000000..d2e6bf2
--- /dev/null
+++ b/.github/workflows/sync.yml
@@ -0,0 +1,76 @@
+name: Sync Docs from Source
+
+on:
+ repository_dispatch:
+ types: [sync-docs]
+ workflow_dispatch:
+ inputs:
+ repo:
+ description: 'Repository to sync'
+ required: true
+ default: 'dataenginex'
+ type: choice
+ options:
+ - dataenginex
+ - dex-studio
+ version:
+ description: 'Version tag to sync'
+ required: true
+ default: 'v0.1.0'
+
+permissions:
+ contents: write
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Parse payload
+ id: parse
+ run: |
+ if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
+ echo "repo=${{ github.event.inputs.repo }}" >> $GITHUB_OUTPUT
+ echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
+ else
+ echo "repo=${{ github.event.client_payload.repo }}" >> $GITHUB_OUTPUT
+ echo "version=${{ github.event.client_payload.version }}" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Clone source docs
+ run: |
+ REPO="${{ steps.parse.outputs.repo }}"
+ VERSION="${{ steps.parse.outputs.version }}"
+ git clone --depth 1 --branch "$VERSION" \
+ "https://github.com/TheDataEngineX/$REPO.git" /tmp/$REPO
+ rm -rf "docs-store/$REPO/$VERSION"
+ mkdir -p "docs-store/$REPO/$VERSION"
+ if [ -d "/tmp/$REPO/docs" ]; then
+ cp -r "/tmp/$REPO/docs/"* "docs-store/$REPO/$VERSION/"
+ fi
+
+ - name: Build site
+ run: |
+ uv python install 3.13
+ uv sync
+ PYTHONPATH=. uv run poe build
+
+ - name: Commit docs-store
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add docs-store/
+ git diff --cached --quiet || git commit -m "chore: sync docs [skip ci]"
+ git push
+
+ - name: Deploy to Cloudflare Pages
+ uses: cloudflare/pages-action@v1
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ projectName: thedataenginex-org
+ directory: output/
+ gitHubToken: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index d36290c..fdf8437 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ output/
.uv/
*.egg-info/
.DS_Store
+docs-store/
diff --git a/CLAUDE.md b/CLAUDE.md
index 86355e4..a9b98c1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,31 +1,50 @@
# CLAUDE.md — DEX Website
-> Workspace-wide rules in `../CLAUDE.md`.
-
## Project Overview
+Marketing site + versioned documentation for thedataenginex.org.
+Static site generated with Pelican (marketing) + custom build script (docs).
+Deployed to Cloudflare Pages.
-Marketing site for thedataenginex.org. Static site generated with Pelican, deployed to Cloudflare Pages.
-
-**Stack:** Pelican · Python 3.13+ · uv · Custom `dex` theme
-
+**Stack:** Pelican 4.12 · Python 3.13+ · uv · Custom `dex` theme · Jinja2 · Markdown
**Live site:** https://thedataenginex.org
## Commands
-
-```bash
-uv run poe dev # Dev server at http://localhost:8080 (live reload)
-uv run poe build # Production build → output/
-uv run poe serve # Serve output/ locally
-uv run poe clean # Remove output/
-```
+uv run poe dev # Dev server at http://localhost:8080 (Pelican only, live reload)
+uv run poe build # Production build: Pelican + docs -> output/
+uv run poe build-pelican # Pelican only
+uv run poe build-docs # Docs only (after Pelican build)
+uv run poe serve # Serve output/ locally
+uv run poe clean # Remove output/
## Key Files
-
| Path | Purpose |
|------|---------|
-| `pelicanconf.py` | Dev config (localhost) |
-| `publishconf.py` | Production config (thedataenginex.org) |
-| `content/pages/` | Static pages (why.md, changelog.md) |
-| `themes/dex/` | Custom Pelican theme |
-| `content/extra/CNAME` | Cloudflare/GitHub Pages CNAME |
-| `.github/workflows/deploy.yml` | CI deploy via Cloudflare Pages Action |
+| pelicanconf.py | Pelican dev config |
+| publishconf.py | Pelican production config |
+| content/pages/ | Marketing pages (why.md, changelog.md) |
+| themes/dex/ | Custom Pelican theme + docs CSS/JS |
+| scripts/build_site.py | Docs build script (runs after Pelican) |
+| templates/docs/ | Docs Jinja2 templates |
+| config/site.yml | Docs site configuration |
+| docs-store/ | Versioned documentation (synced from source repos) |
+| .github/workflows/deploy.yml | CI deploy via Cloudflare Pages Action |
+| .github/workflows/sync.yml | Docs sync triggered by source repo releases |
+
+## Docs Build
+
+`scripts/build_site.py` walks `docs-store/` and renders .md -> .html.
+- Output goes to Pelican's `output/docs/` directory
+- Generates `output/sitemap.xml`, `output/robots.txt` (overwriting Pelican's)
+- Generates `output/search-index.json` for client-side search
+- Minifies HTML output
+
+### Adding docs from a new version
+```bash
+# Manually add to docs-store/
+mkdir -p docs-store/dataenginex/v0.2.0/
+cp -r /path/to/docs/* docs-store/dataenginex/v0.2.0/
+uv run poe build-docs
+```
+
+### Sync from source repo release
+Source repos fire `repository_dispatch` -> `.github/workflows/sync.yml`.
diff --git a/config/site.yml b/config/site.yml
new file mode 100644
index 0000000..f1fe140
--- /dev/null
+++ b/config/site.yml
@@ -0,0 +1,14 @@
+site:
+ name: TheDataEngineX
+ url: https://thedataenginex.org
+ description: DataEngineX platform — run AI agents, data pipelines, and ML workflows on your own infrastructure
+
+repos:
+ dataenginex:
+ display_name: DataEngineX
+ description: Python library for data engineering & AI agents
+ dex-studio:
+ display_name: DEX Studio
+ description: Self-hosted web UI for the DataEngineX platform
+
+og_default_image: /theme/img/og-card.png
diff --git a/content/pages/changelog.md b/content/pages/changelog.md
index dacb82b..24a257d 100644
--- a/content/pages/changelog.md
+++ b/content/pages/changelog.md
@@ -11,7 +11,7 @@ Full release notes are on [GitHub Releases](https://github.com/TheDataEngineX/DE
The current version is tracked in [`pyproject.toml`](https://github.com/TheDataEngineX/DEX/blob/main/pyproject.toml).
-**Current:** `v1.1.1` — CLI compatibility fix (removed `rich` dep; Reflex pin conflict). `v1.1.0` added enterprise auth (JWKS/RS256), RBAC, SCIM v2, LiteLLM + vLLM providers, Langfuse LLM tracing, LightRAG graph retrieval, domain Prometheus metrics.
+**Current:** `v0.4.0` — Post-scope-reset milestone with focused feature set. See [GitHub Releases](https://github.com/TheDataEngineX/dataenginex/releases) for the full history.
[View all releases on GitHub →](https://github.com/TheDataEngineX/DEX/releases)
diff --git a/content/pages/why.md b/content/pages/why.md
index 54f645a..31d7492 100644
--- a/content/pages/why.md
+++ b/content/pages/why.md
@@ -1,7 +1,7 @@
---
-title: Why DataEngineX — Complete Data + ML + AI Engineering
+title: Why DataEngineX — Data + ML + AI Engineering
slug: why
-description: DataEngineX is an open-source, self-hosted Python framework for the complete platform for Data + ML + AI engineering — production-ready pipelines, ML lifecycle, and AI agents unified under one dex.yaml. Native integrations with Airflow, MLflow, LangChain, and more.
+description: DataEngineX is an open-source, self-hosted Python framework for data pipelines, ML lifecycle, and AI agents — unified under one dex.yaml. Native integrations with DuckDB, Spark, MLflow, LiteLLM, and more.
template: page
---
@@ -28,7 +28,7 @@ DataEngineX does not rip out those tools. It ships production-ready implementati
## One File, Entire Stack
-DataEngineX is the complete platform for your Data + ML + AI engineering lifecycle. Define once, run anywhere:
+DataEngineX is a Python framework for your Data + ML + AI engineering lifecycle. Define once, run anywhere:
```yaml
# dex.yaml
@@ -51,16 +51,12 @@ ai:
- name: analyst
tools: [sql, search]
-server:
- auth: jwt
- rate_limit: 100/min
-
observability:
metrics: prometheus
tracing: otel
```
-One `dex serve` command starts everything. No glue code.
+Use it from Python, the `dex` CLI, or the self-hosted web UI. No glue code.
---
@@ -68,11 +64,11 @@ One `dex serve` command starts everything. No glue code.
DataEngineX ships production-ready implementations of every layer:
-- **Data pipelines** — DuckDB engine, S3/GCS connectors, quality gates, medallion lakehouse
+- **Data pipelines** — DuckDB engine, S3/GCS connectors, quality gates, medallion lakehouse, optional Spark backend
- **ML lifecycle** — experiment tracking, model registry, training, serving, drift detection
- **AI agents** — LLM routing via LiteLLM, hybrid BM25+dense retrieval, LangGraph runtime
-- **API server** — FastAPI with JWT auth, rate limiting, RBAC, SCIM v2
-- **Observability** — Prometheus metrics, OpenTelemetry tracing, Langfuse LLM tracing
+- **DEX Studio** — self-hosted web UI (FastAPI + Jinja2 + HTMX, port 7860)
+- **Observability** — structlog structured logging, Prometheus metrics, OpenTelemetry tracing — built in, no extra packages
When you already run external tools, DEX integrates natively — not against them. Airflow only schedules. MLflow only tracks. LangChain only chains. DEX gives you the complete end-to-end stack:
@@ -82,7 +78,7 @@ When you already run external tools, DEX integrates natively — not against the
| MLflow | Point `ml.backend: mlflow` — tracking_uri wired automatically |
| LangChain / LiteLLM | LLM routing layer — 100+ providers, swap without code changes |
| Qdrant | Vector store for RAG — configured in `ai.retrieval` |
-| Langfuse | LLM observability — enabled via `[observability]` extra |
+| Langfuse | LLM observability — enabled via config, not code |
| PySpark | Big-data backend — swap `data.backend: spark` in config |
---
@@ -93,8 +89,7 @@ Opinionated defaults, zero lock-in. Every layer is swappable:
```bash
pip install "dataenginex[cloud]" # S3 + GCS + BigQuery connectors
-pip install "dataenginex[auth]" # RS256/JWKS + SCIM v2 + LDAP sync
-pip install "dataenginex[observability]" # Langfuse LLM tracing
+pip install "dataenginex[qdrant]" # Qdrant vector store
pip install 'litellm>=1.83.3' --no-deps # 100+ LLM providers (separate install)
```
@@ -102,12 +97,6 @@ The config stays the same. The backend changes.
---
-## Enterprise-Ready Auth
-
-RBAC, SCIM v2 user provisioning, LDAP/AD sync, and OIDC federation (RS256/JWKS) ship as first-class extras — not bolt-ons. Enable them with env vars, not code changes.
-
----
-
## Self-Hosted
Your data never leaves your infrastructure. No SaaS subscription. No vendor lock-in. Run on a VPS, K3s cluster, or bare metal.
diff --git a/docs-store/dataenginex/v0.1.0/getting-started.md b/docs-store/dataenginex/v0.1.0/getting-started.md
new file mode 100644
index 0000000..b87a4c0
--- /dev/null
+++ b/docs-store/dataenginex/v0.1.0/getting-started.md
@@ -0,0 +1,13 @@
+# Getting Started
+
+Install DataEngineX and run your first pipeline.
+
+## Installation
+
+```bash
+pip install dataenginex
+```
+
+## First Pipeline
+
+Create a `dex.yaml` file and run `dex run`.
diff --git a/docs-store/dataenginex/v0.1.0/index.md b/docs-store/dataenginex/v0.1.0/index.md
new file mode 100644
index 0000000..dd18636
--- /dev/null
+++ b/docs-store/dataenginex/v0.1.0/index.md
@@ -0,0 +1,12 @@
+# DataEngineX Documentation
+
+Welcome to the DataEngineX documentation. Build and run AI agents, data pipelines, and ML workflows.
+
+## Quick Start
+
+Install with `pip install dataenginex`.
+
+## Guides
+
+- [Getting Started](getting-started)
+- [Configuration](configuration)
diff --git a/docs-store/dex-studio/v0.4.0/index.md b/docs-store/dex-studio/v0.4.0/index.md
new file mode 100644
index 0000000..c511c66
--- /dev/null
+++ b/docs-store/dex-studio/v0.4.0/index.md
@@ -0,0 +1,11 @@
+# DEX Studio Documentation
+
+Welcome to DEX Studio, the self-hosted web UI for the DataEngineX platform.
+
+## Quick Start
+
+```bash
+dex-studio
+```
+
+Then open http://localhost:7860.
diff --git a/pelicanconf.py b/pelicanconf.py
index be83556..5a8c1f9 100644
--- a/pelicanconf.py
+++ b/pelicanconf.py
@@ -22,17 +22,16 @@
# Theme
THEME = "themes/dex"
-# Navigation
+# Navigation — Docs now points to local
MENUITEMS = [
("Why DEX", "/why/"),
- ("Docs", "https://docs.thedataenginex.org"),
+ ("Docs", "/docs/dataenginex/latest/"),
("Changelog", "/changelog/"),
("GitHub", "https://github.com/TheDataEngineX/DEX"),
]
-# Plugins
-PLUGINS = ["pelican.plugins.sitemap"]
-SITEMAP = {"format": "xml"}
+# Plugins — disabled sitemap; build_site.py generates complete sitemap
+PLUGINS = []
# Static paths
STATIC_PATHS = ["extra"]
@@ -50,4 +49,4 @@
# Social
GITHUB_URL = "https://github.com/TheDataEngineX/DEX"
PYPI_URL = "https://pypi.org/project/dataenginex/"
-DOCS_URL = "https://docs.thedataenginex.org"
+DOCS_URL = "https://thedataenginex.org/docs/dataenginex/latest/"
diff --git a/pyproject.toml b/pyproject.toml
index e0b6d70..b46b34f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,17 +1,24 @@
[project]
name = "dataenginex-website"
version = "0.1.0"
-description = "thedataenginex.org marketing site"
+description = "thedataenginex.org marketing site + docs"
requires-python = ">=3.13"
dependencies = [
"pelican[markdown]>=4.10",
- "pelican-sitemap>=1.0",
"Pygments>=2.18",
+ "jinja2>=3.1",
+ "markdown>=3.6",
+ "pyyaml>=6.0",
]
+[project.optional-dependencies]
+images = ["pillow>=10.0"]
+
[tool.poe.tasks]
dev = { shell = "PYTHONPATH=. pelican --autoreload --listen --port 8080 content -s pelicanconf.py" }
-build = { shell = "PYTHONPATH=. pelican content -o output -s publishconf.py" }
+build = { shell = "PYTHONPATH=. pelican content -o output -s publishconf.py && python scripts/build_site.py" }
+build-pelican = { shell = "PYTHONPATH=. pelican content -o output -s publishconf.py" }
+build-docs = { shell = "python scripts/build_site.py" }
clean = { shell = "rm -rf output" }
serve = { shell = "cd output && python -m http.server 8080" }
diff --git a/scripts/build_site.py b/scripts/build_site.py
new file mode 100644
index 0000000..3a18afb
--- /dev/null
+++ b/scripts/build_site.py
@@ -0,0 +1,346 @@
+#!/usr/bin/env python3
+"""Build documentation pages for thedataenginex.org.
+
+Runs after Pelican. Walks docs-store/, renders .md -> .html via Jinja2,
+generates sitemap.xml, robots.txt (overwriting Pelican's), and
+search-index.json. Outputs to Pelican's output/ directory.
+"""
+
+import json
+import os
+import re
+import shutil
+from datetime import datetime
+from pathlib import Path
+
+import yaml
+from jinja2 import Environment, FileSystemLoader
+from markdown import markdown
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+CONFIG_PATH = REPO_ROOT / "config" / "site.yml"
+DOCS_STORE = REPO_ROOT / "docs-store"
+TEMPLATES_DIR = REPO_ROOT / "templates"
+OUTPUT_DIR = REPO_ROOT / "output"
+
+
+def load_config():
+ with open(CONFIG_PATH) as f:
+ return yaml.safe_load(f)
+
+
+def discover_docs_versions(docs_store: Path) -> dict:
+ """Return {repo_name: [version_str, ...]} sorted newest first."""
+ result = {}
+ if not docs_store.exists():
+ return result
+ for repo_dir in sorted(docs_store.iterdir()):
+ if not repo_dir.is_dir() or repo_dir.name.startswith("."):
+ continue
+ versions = []
+ for ver_dir in sorted(repo_dir.iterdir(), reverse=True):
+ if ver_dir.is_dir() and not ver_dir.name.startswith("."):
+ versions.append(ver_dir.name)
+ if versions:
+ result[repo_dir.name] = sorted(versions, key=parse_semver, reverse=True)
+ return result
+
+
+def parse_semver(version_str: str) -> tuple:
+ match = re.match(r"^v?(\d+)\.(\d+)\.(\d+)$", version_str)
+ if match:
+ return tuple(int(g) for g in match.groups())
+ return (0, 0, 0)
+
+
+def get_doc_tree(repo_dir: Path) -> list[dict]:
+ """Return [{slug, title, path}] sorted alphabetically."""
+ docs = []
+ for f in sorted(repo_dir.iterdir()):
+ if f.suffix == ".md":
+ slug = f.stem
+ content = f.read_text(encoding="utf-8")
+ title = extract_title(content) or slug.replace("-", " ").title()
+ docs.append({"slug": slug, "title": title, "path": f})
+ return docs
+
+
+def extract_title(md_content: str) -> str | None:
+ match = re.search(r"^#\s+(.+)$", md_content, re.MULTILINE)
+ return match.group(1).strip() if match else None
+
+
+def extract_description(md_content: str) -> str | None:
+ lines = md_content.split("\n")
+ in_para = False
+ for line in lines:
+ stripped = line.strip()
+ if stripped.startswith("# "):
+ in_para = True
+ continue
+ if in_para and stripped:
+ return (stripped[:152] + "...") if len(stripped) > 155 else stripped
+ if in_para and not stripped:
+ continue
+ if in_para and stripped.startswith("#"):
+ break
+ return None
+
+
+def extract_toc(md_content: str) -> list[dict]:
+ items = []
+ for line in md_content.split("\n"):
+ match = re.match(r"^(#{2,3})\s+(.+)$", line)
+ if match:
+ text = match.group(2).strip()
+ anchor_id = text.lower().replace(" ", "-").replace(".", "").replace(",", "")
+ items.append({"level": len(match.group(1)), "text": text, "id": anchor_id})
+ return items
+
+
+def render_markdown_to_html(md_content: str) -> str:
+ extensions = ["fenced_code", "codehilite", "tables", "sane_lists", "attr_list"]
+ return markdown(md_content, extensions=extensions)
+
+
+def get_highest_semver(versions: list[str]) -> str:
+ if not versions:
+ return ""
+ sorted_vers = sorted(versions, key=parse_semver, reverse=True)
+ return sorted_vers[0]
+
+
+def compute_is_stale(last_updated_str: str | None, months_threshold: int = 6) -> tuple[bool, int]:
+ if not last_updated_str:
+ return False, 0
+ try:
+ updated = datetime.strptime(last_updated_str, "%Y-%m-%d").date()
+ except ValueError:
+ return False, 0
+ delta = datetime.now().date() - updated
+ months = delta.days // 30
+ return months > months_threshold, months
+
+
+def generate_sitemap(pages: list[dict], site_url: str) -> str:
+ lines = [
+ '',
+ '',
+ ]
+ for p in pages:
+ loc = f"{site_url}{p['url']}"
+ lastmod = p.get("last_updated") or datetime.now().strftime("%Y-%m-%d")
+ priority = p.get("priority", "0.7")
+ changefreq = p.get("changefreq", "monthly")
+ lines.append(" ")
+ lines.append(f" {loc}")
+ lines.append(f" {lastmod}")
+ lines.append(f" {priority}")
+ lines.append(f" {changefreq}")
+ lines.append(" ")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def generate_robots_txt(site_url: str) -> str:
+ return f"User-agent: *\nAllow: /\n\nSitemap: {site_url}/sitemap.xml\n"
+
+
+def minify_html(html: str) -> str:
+ html = re.sub(r"\s+", " ", html)
+ html = re.sub(r">\s+<", "><", html)
+ return html.strip()
+
+
+def minify_css(css: str) -> str:
+ css = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
+ css = re.sub(r"\s+", " ", css)
+ css = re.sub(r"\s*([{}:;,])\s*", r"\1", css)
+ css = re.sub(r";}", "}", css)
+ return css.strip()
+
+
+def minify_js(js: str) -> str:
+ js = re.sub(r"//.*", "", js)
+ js = re.sub(r"/\*.*?\*/", "", js, flags=re.DOTALL)
+ js = re.sub(r"\s+", " ", js)
+ return js.strip()
+
+
+def check_thin_content(md_content: str, slug: str) -> None:
+ word_count = len(md_content.split())
+ if word_count < 100:
+ print(f" WARNING: '{slug}' has only {word_count} words (below 100)")
+
+
+def main():
+ config = load_config()
+ site = config["site"]
+ repos_config = config.get("repos", {})
+ all_versions = discover_docs_versions(DOCS_STORE)
+
+ if not OUTPUT_DIR.exists():
+ print(f"ERROR: Pelican output directory '{OUTPUT_DIR}' does not exist. Run Pelican build first.")
+ return
+
+ # Setup Jinja2
+ env = Environment(loader=FileSystemLoader(str(REPO_ROOT)))
+ env.globals["now"] = datetime.now()
+
+ # Collect all pages for sitemap
+ sitemap_pages = []
+
+ # Scan Pelican output for existing pages (/, /why/, /changelog/) to include in sitemap
+ pelican_html_dirs = ["", "why", "changelog"]
+ for subdir in pelican_html_dirs:
+ index_path = OUTPUT_DIR / subdir / "index.html"
+ if index_path.exists():
+ url = f"/{subdir}/" if subdir else "/"
+ sitemap_pages.append({
+ "url": url,
+ "last_updated": None,
+ "priority": "1.0" if not subdir else "0.8",
+ "changefreq": "monthly",
+ })
+
+ # Render docs
+ search_pages = []
+
+ for repo_name, versions in all_versions.items():
+ repo_display = repos_config.get(repo_name, {}).get("display_name", repo_name)
+ latest_version = get_highest_semver(versions)
+
+ for version in versions:
+ repo_dir = DOCS_STORE / repo_name / version
+ if not repo_dir.is_dir():
+ continue
+
+ docs = get_doc_tree(repo_dir)
+ is_latest = version == latest_version
+
+ for doc in docs:
+ md_content = doc["path"].read_text(encoding="utf-8")
+ check_thin_content(md_content, doc["slug"])
+
+ html_content = render_markdown_to_html(md_content)
+ title = doc["title"]
+ description = extract_description(md_content)
+ last_updated = None # placeholder; could come from git date
+ is_stale, months_since = compute_is_stale(last_updated)
+
+ toc_items = extract_toc(md_content)
+ slug = doc["slug"]
+ page_url = f"/docs/{repo_name}/{version}/" if slug == "index" else f"/docs/{repo_name}/{version}/{slug}/"
+ canonical_url = page_url
+
+ # Sidebar
+ sidebar_items = [
+ {
+ "title": d["title"],
+ "slug": d["slug"],
+ "active": d["slug"] == slug,
+ "url": f"/docs/{repo_name}/{version}/" if d["slug"] == "index" else f"/docs/{repo_name}/{version}/{d['slug']}/",
+ }
+ for d in docs
+ ]
+
+ page_ctx = {
+ "title": title,
+ "description": description or "",
+ "url": page_url,
+ "canonical_url": canonical_url,
+ "content": html_content,
+ "slug": slug,
+ "type": "doc",
+ "last_updated": last_updated,
+ "is_stale": is_stale,
+ "months_since_update": months_since if is_stale else 0,
+ "og_image": None,
+ "toc_items": toc_items,
+ }
+
+ repo_ctx = {
+ "name": repo_name,
+ "display_name": repo_display,
+ "version": version,
+ "latest_version": latest_version,
+ "available_versions": versions,
+ }
+
+ nav_ctx = {"sidebar": sidebar_items}
+
+ template = env.get_template("templates/docs/doc.html")
+ output = template.render(
+ site=site,
+ page=page_ctx,
+ repo=repo_ctx,
+ navigation=nav_ctx,
+ )
+
+ # Write versioned page
+ out_path = OUTPUT_DIR / "docs" / repo_name / version / "index.html" if slug == "index" else OUTPUT_DIR / "docs" / repo_name / version / slug / "index.html"
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(minify_html(output), encoding="utf-8")
+
+ # Write to latest/
+ if is_latest:
+ latest_ctx = dict(page_ctx)
+ latest_ctx["canonical_url"] = canonical_url
+ latest_output = template.render(
+ site=site,
+ page=latest_ctx,
+ repo=dict(repo_ctx, version="latest"),
+ navigation=nav_ctx,
+ )
+ latest_path = OUTPUT_DIR / "docs" / repo_name / "latest" / "index.html" if slug == "index" else OUTPUT_DIR / "docs" / repo_name / "latest" / slug / "index.html"
+ latest_path.parent.mkdir(parents=True, exist_ok=True)
+ latest_path.write_text(minify_html(latest_output), encoding="utf-8")
+
+ # Sitemap entry (only for real version, not latest alias)
+ sitemap_pages.append({
+ "url": page_url,
+ "last_updated": last_updated or datetime.now().strftime("%Y-%m-%d"),
+ "priority": "0.8",
+ "changefreq": "monthly",
+ })
+
+ # Search index entry
+ search_pages.append({
+ "title": title,
+ "url": page_url,
+ "description": description or "",
+ "repo": repo_name,
+ "version": version,
+ })
+
+ # Write sitemap (overwrites Pelican's)
+ sitemap_xml = generate_sitemap(sitemap_pages, site["url"])
+ (OUTPUT_DIR / "sitemap.xml").write_text(sitemap_xml, encoding="utf-8")
+
+ # Write robots.txt (overwrites Pelican's static one)
+ robots_txt = generate_robots_txt(site["url"])
+ (OUTPUT_DIR / "robots.txt").write_text(robots_txt, encoding="utf-8")
+
+ # Write search index
+ search_json = json.dumps(search_pages, ensure_ascii=False, indent=2)
+ (OUTPUT_DIR / "search-index.json").write_text(search_json, encoding="utf-8")
+
+ # Minify Pelican's CSS and JS
+ css_path = OUTPUT_DIR / "theme" / "css" / "main.css"
+ if css_path.exists():
+ css_path.write_text(minify_css(css_path.read_text(encoding="utf-8")), encoding="utf-8")
+
+ docs_css_path = OUTPUT_DIR / "theme" / "css" / "docs.css"
+ if docs_css_path.exists():
+ docs_css_path.write_text(minify_css(docs_css_path.read_text(encoding="utf-8")), encoding="utf-8")
+
+ for js_path in (OUTPUT_DIR / "theme" / "js").rglob("*.js") if (OUTPUT_DIR / "theme" / "js").exists() else []:
+ js_path.write_text(minify_js(js_path.read_text(encoding="utf-8")), encoding="utf-8")
+
+ print(f"Docs built at {OUTPUT_DIR}")
+ print(f" Docs pages: {len(search_pages)}")
+ print(f" Sitemap URLs: {len(sitemap_pages)}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/templates/docs/base.html b/templates/docs/base.html
new file mode 100644
index 0000000..718343d
--- /dev/null
+++ b/templates/docs/base.html
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+ {% block title %}{{ site.name }} Docs{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if page.type == "doc" and page.last_updated %}
+
+
+ {% endif %}
+
+
+
+
+
+
+ {% block json_ld %}{% endblock %}
+ {% block head_extra %}{% endblock %}
+
+
+
+
+
+
+ {% block sidebar %}{% endblock %}
+
+ {% block content %}{% endblock %}
+
+
+
+
+
+
+
+
+
diff --git a/templates/docs/doc.html b/templates/docs/doc.html
new file mode 100644
index 0000000..71ff2e7
--- /dev/null
+++ b/templates/docs/doc.html
@@ -0,0 +1,99 @@
+{% extends "templates/docs/base.html" %}
+
+{% block title %}{{ page.title }} | {{ repo.display_name }} Docs{% endblock %}
+{% block description %}{{ page.description or site.description }}{% endblock %}
+
+{% block sidebar %}
+
+{% endblock %}
+
+{% block content %}
+
+ {% if repo.version != repo.latest_version %}
+
+ {% endif %}
+
+
+
+ {{ page.title }}
+
+ {% if page.toc_items %}
+
+ {% endif %}
+
+ {{ page.content | safe }}
+
+ {% if page.last_updated %}
+ Last updated: {{ page.last_updated }}
+ {% endif %}
+
+ {% if page.is_stale %}
+
+ This doc was last updated {{ page.months_since_update }} months ago.
+ Some information may be outdated.
+
+ {% endif %}
+
+{% endblock %}
+
+{% block json_ld %}
+
+{% endblock %}
diff --git a/themes/dex/static/css/docs.css b/themes/dex/static/css/docs.css
new file mode 100644
index 0000000..f9b9169
--- /dev/null
+++ b/themes/dex/static/css/docs.css
@@ -0,0 +1,289 @@
+/* ── Docs layout: sidebar + content ─────────────────────── */
+
+.docs-layout {
+ display: flex;
+ margin-top: 60px; /* matches nav height */
+ min-height: calc(100vh - 60px);
+}
+
+.docs-sidebar {
+ width: 280px;
+ flex-shrink: 0;
+ background: var(--color-bg-alt, #f8f9fa);
+ border-right: 1px solid var(--color-border, #dee2e6);
+ padding: 1.5rem;
+ overflow-y: auto;
+ position: sticky;
+ top: 60px;
+ height: calc(100vh - 60px);
+}
+
+.docs-content {
+ flex: 1;
+ padding: 2rem 3rem;
+ max-width: 800px;
+ min-width: 0;
+}
+
+/* ── Version selector ──────────────────────────────────── */
+
+.docs-version-select {
+ margin-bottom: 1.5rem;
+}
+
+.docs-version-select label {
+ font-size: 0.6875rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-text-muted, #6c757d);
+ display: block;
+ margin-bottom: 0.375rem;
+}
+
+.docs-version-select select {
+ width: 100%;
+ padding: 0.5rem;
+ font-size: 0.8125rem;
+ border: 1px solid var(--color-border, #dee2e6);
+ border-radius: 6px;
+ background: #fff;
+ color: var(--color-text, #212529);
+ cursor: pointer;
+}
+
+/* ── Sidebar nav ────────────────────────────────────────── */
+
+.docs-sidebar-nav {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.docs-sidebar-nav li {
+ margin-bottom: 0.125rem;
+}
+
+.docs-sidebar-nav a {
+ display: block;
+ padding: 0.375rem 0.5rem;
+ font-size: 0.8125rem;
+ border-radius: 4px;
+ color: var(--color-text-muted, #6c757d);
+ text-decoration: none;
+ transition: background 0.15s, color 0.15s;
+}
+
+.docs-sidebar-nav a:hover {
+ background: var(--color-bg-hover, #e9ecef);
+ color: var(--color-text, #212529);
+}
+
+.docs-sidebar-nav a.active {
+ background: var(--color-primary, #00897b);
+ color: #fff;
+ font-weight: 600;
+}
+
+/* ── Breadcrumbs ────────────────────────────────────────── */
+
+.docs-breadcrumbs {
+ font-size: 0.75rem;
+ margin-bottom: 1rem;
+ color: var(--color-text-muted, #6c757d);
+}
+
+.docs-breadcrumbs ol {
+ list-style: none;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+ padding: 0;
+ margin: 0;
+}
+
+.docs-breadcrumbs li:not(:last-child)::after {
+ content: "/";
+ margin-left: 0.375rem;
+ color: var(--color-border, #dee2e6);
+}
+
+.docs-breadcrumbs li[aria-current="page"] {
+ font-weight: 600;
+ color: var(--color-text, #212529);
+}
+
+/* ── On this page TOC ───────────────────────────────────── */
+
+.docs-page-toc {
+ background: var(--color-bg-alt, #f8f9fa);
+ padding: 1rem 1.25rem;
+ border-radius: 8px;
+ margin-bottom: 1.5rem;
+ border: 1px solid var(--color-border, #dee2e6);
+}
+
+.docs-page-toc strong {
+ font-size: 0.6875rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-text-muted, #6c757d);
+}
+
+.docs-page-toc ul {
+ list-style: none;
+ padding: 0;
+ margin: 0.5rem 0 0;
+}
+
+.docs-page-toc li {
+ margin-bottom: 0.125rem;
+}
+
+.docs-page-toc a {
+ font-size: 0.8125rem;
+}
+
+/* ── Version banner ─────────────────────────────────────── */
+
+.docs-version-banner {
+ background: #fff3cd;
+ border: 1px solid #ffc107;
+ padding: 0.75rem 1rem;
+ border-radius: 6px;
+ margin-bottom: 1.5rem;
+ font-size: 0.8125rem;
+}
+
+/* ── Last updated ────────────────────────────────────────── */
+
+.docs-last-updated {
+ font-size: 0.75rem;
+ color: var(--color-text-muted, #6c757d);
+ margin-top: 2rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--color-border, #dee2e6);
+}
+
+.docs-staleness-banner {
+ background: #fff3cd;
+ border: 1px solid #ffc107;
+ padding: 0.75rem 1rem;
+ border-radius: 6px;
+ margin-top: 1rem;
+ font-size: 0.8125rem;
+}
+
+/* ── Search ──────────────────────────────────────────────── */
+
+.docs-search-toggle {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 1rem;
+ padding: 0.375rem;
+ color: var(--color-text-muted, #6c757d);
+ transition: color 0.15s;
+}
+
+.docs-search-toggle:hover {
+ color: var(--color-text, #212529);
+}
+
+.docs-search-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 999;
+ background: rgba(0, 0, 0, 0.35);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding-top: 20vh;
+}
+
+.docs-search-input {
+ width: 90%;
+ max-width: 520px;
+ padding: 0.75rem 1rem;
+ font-size: 1.125rem;
+ border: 2px solid var(--color-primary, #00897b);
+ border-radius: 8px;
+ outline: none;
+ background: #fff;
+}
+
+.docs-search-results {
+ width: 90%;
+ max-width: 520px;
+ max-height: 50vh;
+ overflow-y: auto;
+ background: #fff;
+ border-radius: 8px;
+ margin-top: 0.5rem;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
+}
+
+.docs-search-result {
+ display: block;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--color-border, #dee2e6);
+ text-decoration: none;
+ color: inherit;
+ transition: background 0.15s;
+}
+
+.docs-search-result:last-child {
+ border-bottom: none;
+}
+
+.docs-search-result:hover {
+ background: var(--color-bg-alt, #f8f9fa);
+}
+
+.docs-search-result-title {
+ display: block;
+ font-weight: 600;
+ font-size: 0.875rem;
+ color: var(--color-text, #212529);
+}
+
+.docs-search-result-desc {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--color-text-muted, #6c757d);
+ margin-top: 0.125rem;
+}
+
+.docs-search-result-meta {
+ display: block;
+ font-size: 0.6875rem;
+ color: var(--color-text-muted, #6c757d);
+ margin-top: 0.125rem;
+}
+
+.docs-search-empty {
+ padding: 1.5rem;
+ text-align: center;
+ color: var(--color-text-muted, #6c757d);
+}
+
+mark {
+ background: #cfe8ff;
+ padding: 0 0.125em;
+ border-radius: 2px;
+}
+
+/* ── Responsive ──────────────────────────────────────────── */
+
+@media (max-width: 768px) {
+ .docs-sidebar {
+ display: none;
+ }
+ .docs-content {
+ padding: 1.5rem;
+ }
+ .docs-search-overlay {
+ padding-top: 15vh;
+ }
+}
diff --git a/themes/dex/static/css/main.css b/themes/dex/static/css/main.css
index 7b99d68..04e850a 100644
--- a/themes/dex/static/css/main.css
+++ b/themes/dex/static/css/main.css
@@ -17,6 +17,8 @@
--color-bg: #ffffff;
--color-bg-light: #eceff1;
--color-bg-code: #1e272e;
+ --color-bg-alt: #f8f9fa;
+ --color-bg-hover: #e9ecef;
--color-border: #cfd8dc;
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
diff --git a/themes/dex/static/js/search.js b/themes/dex/static/js/search.js
new file mode 100644
index 0000000..5531e0c
--- /dev/null
+++ b/themes/dex/static/js/search.js
@@ -0,0 +1,116 @@
+(function () {
+ 'use strict';
+ var searchIndex = [];
+ var overlay = null;
+ var input = null;
+ var results = null;
+ var toggle = null;
+
+ function esc(str) {
+ var d = document.createElement('div');
+ d.appendChild(document.createTextNode(str));
+ return d.innerHTML;
+ }
+
+ function init() {
+ fetch('/search-index.json')
+ .then(function (r) { return r.json(); })
+ .then(function (data) { searchIndex = data; })
+ .catch(function () {});
+
+ input = document.createElement('input');
+ input.type = 'search';
+ input.placeholder = 'Search docs...';
+ input.className = 'docs-search-input';
+ input.setAttribute('aria-label', 'Search documentation');
+
+ results = document.createElement('div');
+ results.className = 'docs-search-results';
+ results.style.display = 'none';
+
+ overlay = document.createElement('div');
+ overlay.className = 'docs-search-overlay';
+ overlay.style.display = 'none';
+ overlay.appendChild(input);
+ overlay.appendChild(results);
+ document.body.appendChild(overlay);
+
+ toggle = document.createElement('button');
+ toggle.className = 'docs-search-toggle';
+ toggle.textContent = '\uD83D\uDD0D';
+ toggle.setAttribute('aria-label', 'Open search');
+ toggle.addEventListener('click', function (e) {
+ e.preventDefault();
+ overlay.style.display = 'flex';
+ input.focus();
+ });
+
+ var navLinks = document.querySelector('.nav-links');
+ if (navLinks) {
+ var li = document.createElement('li');
+ li.appendChild(toggle);
+ navLinks.appendChild(li);
+ }
+
+ input.addEventListener('input', onInput);
+ input.addEventListener('keydown', onKeydown);
+ overlay.addEventListener('click', onOverlayClick);
+ }
+
+ function onInput() {
+ var q = this.value.toLowerCase().trim();
+ if (q.length < 2) {
+ results.style.display = 'none';
+ return;
+ }
+ var hits = searchIndex.filter(function (p) {
+ return p.title.toLowerCase().indexOf(q) !== -1 ||
+ p.description.toLowerCase().indexOf(q) !== -1;
+ });
+ if (hits.length === 0) {
+ results.innerHTML = 'No results found
';
+ } else {
+ results.innerHTML = hits.slice(0, 20).map(function (h) {
+ return '' +
+ '' + highlight(esc(h.title), q) + '' +
+ '' + highlight(esc(h.description), q) + '' +
+ '' + esc(h.repo) + ' ' + esc(h.version) + '' +
+ '';
+ }).join('');
+ }
+ results.style.display = 'block';
+ }
+
+ function onKeydown(e) {
+ if (e.key === 'Escape') {
+ close();
+ }
+ }
+
+ function onOverlayClick(e) {
+ if (e.target === overlay) {
+ close();
+ }
+ }
+
+ function close() {
+ overlay.style.display = 'none';
+ results.style.display = 'none';
+ input.value = '';
+ if (toggle) toggle.focus();
+ }
+
+ function highlight(text, query) {
+ var idx = text.toLowerCase().indexOf(query);
+ if (idx === -1) return text;
+ return text.slice(0, idx) +
+ '' + text.slice(idx, idx + query.length) + '' +
+ text.slice(idx + query.length);
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/themes/dex/templates/base.html b/themes/dex/templates/base.html
index f1796e0..d13d62a 100644
--- a/themes/dex/templates/base.html
+++ b/themes/dex/templates/base.html
@@ -70,7 +70,7 @@
"priceCurrency": "USD"
},
"softwareRequirements": "Python 3.13+",
- "softwareVersion": "1.1.1",
+ "softwareVersion": "0.4.0",
"downloadUrl": "https://pypi.org/project/dataenginex/",
"installUrl": "https://docs.thedataenginex.org/getting-started/",
"sameAs": [
diff --git a/themes/dex/templates/index.html b/themes/dex/templates/index.html
index d248f9d..d259d50 100644
--- a/themes/dex/templates/index.html
+++ b/themes/dex/templates/index.html
@@ -12,7 +12,7 @@
- v1.1.2
+ v0.4.0
stable
@@ -321,7 +321,7 @@ One core. Four components.<
-
v1.1.2
+
v0.4.0
dataenginex
pip install dataenginex
@@ -415,9 +415,9 @@ Ready to unify your stack?
Extras
-
pip install "dataenginex[cloud]" # S3 · GCS · BigQuery
-pip install "dataenginex[observability]" # Langfuse LLM tracing
-pip install 'litellm>=1.83.3' --no-deps # 100+ LLM providers
+
pip install "dataenginex[cloud]" # S3 · GCS · BigQuery
+pip install "dataenginex[qdrant]" # Qdrant vector store
+pip install 'litellm>=1.83.3' --no-deps # 100+ LLM providers
diff --git a/uv.lock b/uv.lock
index c307317..b20222f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -28,9 +28,16 @@ name = "dataenginex-website"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
+ { name = "jinja2" },
+ { name = "markdown" },
{ name = "pelican", extra = ["markdown"] },
- { name = "pelican-sitemap" },
{ name = "pygments" },
+ { name = "pyyaml" },
+]
+
+[package.optional-dependencies]
+images = [
+ { name = "pillow" },
]
[package.dev-dependencies]
@@ -40,10 +47,14 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "jinja2", specifier = ">=3.1" },
+ { name = "markdown", specifier = ">=3.6" },
{ name = "pelican", extras = ["markdown"], specifier = ">=4.10" },
- { name = "pelican-sitemap", specifier = ">=1.0" },
+ { name = "pillow", marker = "extra == 'images'", specifier = ">=10.0" },
{ name = "pygments", specifier = ">=2.18" },
+ { name = "pyyaml", specifier = ">=6.0" },
]
+provides-extras = ["images"]
[package.metadata.requires-dev]
dev = [{ name = "poethepoet", specifier = ">=0.31" }]
@@ -218,15 +229,61 @@ markdown = [
]
[[package]]
-name = "pelican-sitemap"
-version = "1.2.2"
+name = "pillow"
+version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pelican" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/5c/c1/d9e1d539decfba947d711bcb37427ad15914557a728418bd928d56e58a32/pelican_sitemap-1.2.2.tar.gz", hash = "sha256:93f72d035dd0ec2f5cee38b2766bbd7f6a54bf186635ae1261a6deac422dcfb7", size = 8013, upload-time = "2025-10-12T15:24:36.727Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/68/c2/d2f98364b49a40bac9f315f29d8c22322b47bc4ebdec6a8e3a4f657ebc24/pelican_sitemap-1.2.2-py3-none-any.whl", hash = "sha256:09ba150dd1b54ef855f18e7ec920160d7118e932e0dac1c6e8bf3a41e938e682", size = 8046, upload-time = "2025-10-12T15:24:35.678Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
+ { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
+ { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
+ { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
+ { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
+ { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
+ { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
+ { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
+ { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
+ { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
+ { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
+ { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
+ { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
+ { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]]