Pagerite: single-user CMS/blog
FastAPI backend rendering HTML with html5tagger, content persisted in a kanta database and rendered per request. Vue only for the editing tools (page editor over a WebSocket, site/structure editor); public pages are plain HTML with fetch navigation. No auth: single trusted author.
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
.ruff_cache/
|
||||
/pagerite/frontend-build
|
||||
pagerite.kanta
|
||||
@@ -0,0 +1,160 @@
|
||||
# AGENTS.md
|
||||
|
||||
- Do NOT test, run the server, write tests etc.
|
||||
- ESPECIALLY DO NOT make repros, do NOT install Playwright etc.
|
||||
|
||||
Please instead ask the user to see from dev tools what you need, e.g. to look up something in DOM or log. Use console.log for debugging where needed (and otherwise for permanently kept useful messages in the app).
|
||||
|
||||
## What this is
|
||||
|
||||
Pagerite: a single-user CMS/blog. FastAPI serves HTML rendered in Python
|
||||
with html5tagger; content is persisted in a kanta database and rendered on
|
||||
the fly per request. Vue is used only for interactive bits (editing tools),
|
||||
not for the public pages. See `docs/design-principles.md` for the design.
|
||||
|
||||
## Layout
|
||||
|
||||
- `pagerite/` — the Python backend package (hatchling build target).
|
||||
- Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed)
|
||||
- Dev mode `scripts/devserver.py` (which the user mostly uses for auto reloads, no build needed)
|
||||
- Avoid running the server yourself, ask the user to test
|
||||
- `app.py` — the FastAPI app. FastAPI's built-in API docs are disabled
|
||||
(`docs_url`/`redoc_url`/`openapi_url=None`) because `/docs` belongs to
|
||||
our content. Our own routes (content pages, `/_/api/...`, `/_/f/...`,
|
||||
`/static/...`) are registered BEFORE `frontend.route(app, "/")` is
|
||||
called: fastapi-vue inserts its file routes at the position where
|
||||
`route()` was called (during `load()` in the lifespan), so anything
|
||||
defined earlier wins. The one exception is the content catch-all
|
||||
`/{path:path}`, registered AFTER `frontend.route()` so that built
|
||||
frontend assets still take priority over content slugs. The `Frontend`
|
||||
is constructed with `spa=False` explicitly: it only serves the built
|
||||
asset files at root without a catch-all; an `index.html` in the build
|
||||
would become a `/` route, so leave it out of the build to keep `/` ours.
|
||||
- `data.py` — msgspec Structs for the kanta database. The site structure
|
||||
is a tree: `Data.menu` maps top-level slugs to `Node`s, each with
|
||||
`children` keyed by slug — the URL path is the slug chain. The front
|
||||
page is whichever top-level node has slug "" (parallel to the other
|
||||
main level pages, not their parent); it cannot have children, and
|
||||
renaming its slug away leaves no front page ("/" redirects to the
|
||||
first nav item). `Node.content` is
|
||||
the Markdown page, or None for a pure category label whose URL
|
||||
redirects to its first child; every label's title and slug are
|
||||
editable. Siblings order by the fractional `Node.order` key: a moved
|
||||
item gets a fresh key relative to its new siblings, all others keep
|
||||
theirs. `resolve`/`find_slot` walk the tree by path; moves are slot
|
||||
detach/attach carrying the whole subtree. Legacy flat `Data.pages`
|
||||
(pre-tree databases) migrates into `menu` on startup. The app owns
|
||||
the `Data` object; reads are plain attribute access, writes in
|
||||
`kanta.transaction(...)`.
|
||||
`Data.files` is a content-addressed store (blake3[:12] + extension)
|
||||
mapping file names to bytes, served at `/_/f/{name}` with immutable
|
||||
caching; pages reference files by absolute `/_/f/` URLs so hierarchy
|
||||
moves never break them. `Node.banner` is a raw trusted HTML snippet
|
||||
for the header banner (img, styled div, canvas+script...); empty
|
||||
inherits from the node's ancestors (front page last), then the default
|
||||
banner.svg artwork. `Data.version` is bumped on every write
|
||||
and embedded in page ETags so nav-affecting changes invalidate caches.
|
||||
`Data.brand` is the site name (header link + `<title>` suffix), editable
|
||||
in the site editor via `/_/api/settings`; empty = no header link and
|
||||
no `<title>` suffix.
|
||||
- `markdown.py` — markdown-it-py renderer (html passthrough + attrs,
|
||||
footnote, deflist, tasklists plugins). Custom image rule: relative srcs
|
||||
resolve against the page path, titled images become figures.
|
||||
- `views.py` — the shared page layout as an html5tagger `Template` with
|
||||
placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav
|
||||
rendering straight from the `Data.menu` tree (siblings sorted by
|
||||
`Node.order`; content-less labels redirect to their first child via
|
||||
`first_leaf`), and page/404 rendering. If the markdown contains its own h1, the page title
|
||||
is NOT rendered as an additional h1 (it still supplies <title> and nav
|
||||
labels). The navbar holds
|
||||
top-level items only; the current section's subitems go to a left
|
||||
`#sidebar` (empty and hidden elsewhere). Dynamic regions have stable ids
|
||||
(`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps.
|
||||
- `seed.py` — demo content written on startup for paths missing from the
|
||||
database (never overwrites existing pages).
|
||||
- `static/` — our own assets served at `/static/`: `style.css` (shared
|
||||
with Vue later), `pagerite.js` (fetch-navigation with rotating-cube
|
||||
`startViewTransition`, scroll-reveal), `banner.svg`
|
||||
(full-width header art) and `fonts/` (self-hosted Fraunces/Literata/
|
||||
Fira Code variable woff2). The `::view-transition*` block at the end of
|
||||
`style.css` (from termotohtori.fi) is fragile — do not tweak.
|
||||
- The database file is `pagerite.kanta` in the cwd (`PAGERITE_DB`
|
||||
overrides); gitignored. Do not delete it without asking.
|
||||
- `scripts/fastapi-vue/` — helper scripts from the fastapi-vue template
|
||||
(build hook etc.), do not edit.
|
||||
- `frontend/` — the Vue editor as **two separate apps** mounted in their
|
||||
own host divs created inside the static document: `PageEditor.vue`
|
||||
(CodeMirror + server-rendered preview over WebSocket `/_/api/ws/editor`,
|
||||
previewing into the visible article; editor scroll drives document
|
||||
scroll) opened by the article pen — it edits content and title only,
|
||||
never the path — and `SiteEditor.vue` (site brand + banner HTML edited in
|
||||
a small CodeMirror window and previewed into `#page-banner` + vue-draggable structure tree with
|
||||
always-editable title/slug inputs per row) opened by
|
||||
the banner pen — everything saves immediately as you edit (brand/title
|
||||
debounced, slug on commit since it renames the path), tree rows navigate
|
||||
in place without transitions when focused, and the front page is a
|
||||
root-only row whose empty slug is editable like any other (empty child
|
||||
lists become drop zones while dragging). The two pens swap the docked
|
||||
panel for the other editor; clicking the open editor's own pen closes it. Normally dynamic-imported onto the content page by
|
||||
pagerite.js when a 🖊️ edit link is clicked (the link carries
|
||||
`data-editor-src`/`data-editor-css`/`data-editor-mode`); the `/admin`
|
||||
route (page selected by location hash) is the no-JS-import fallback shell
|
||||
rendered by `views.render_editor` and keeps its own preview pane.
|
||||
In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`),
|
||||
in prod from the hashed build assets resolved via
|
||||
`frontend-build/.vite/manifest.json`. `vite.config.js` builds with
|
||||
`manifest: true` and a JS-only input (`src/main.js`) so no `index.html`
|
||||
ends up in the build (it would shadow `/`). vite-plugin-fastapi.js has an
|
||||
auto-upgrade marker — edit `vite.config.js`, not the plugin.
|
||||
- `docs/` — design documentation.
|
||||
|
||||
## Toolchain
|
||||
|
||||
- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`,
|
||||
`fastapi-vue`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`,
|
||||
`pygments`, `tracerite`; dev group has `httpx`. Run anything via
|
||||
`uv run ...` (the venv is `.venv`).
|
||||
- Key libraries:
|
||||
- **html5tagger** — all HTML generation (`E`, `Document`, `Template`,
|
||||
`HTML` for trusted/raw HTML).
|
||||
- To create stand alone pages, begin with `doc = Document(...)` that gives a HTML5 page header
|
||||
- Chain with `doc.p("text").br`: every attribute access creates element to doc (returning self), calls add content to current element.
|
||||
- Closing tags are not used where optional, e.g. no `</p>` or `</li>` is ever included in output. Due to this proper "nesting" of content is NOT required and should be avoided. Where needed, () directly after tag define attributes and content INSIDE the element, then close the element. `with doc.ul:` and such may be used for larger chunks.
|
||||
- Prefer building directly on one builder with `with` blocks (recursing
|
||||
inside a with block for hierarchies) over preparing `E.` snippets into
|
||||
variables and composing them. Note `with doc.li:` alone fails (`li`
|
||||
has an optional end tag) — use `with doc.li.ul:` style chains, or
|
||||
`doc.li.a(...)` followed by a nested `with doc.ul:` block.
|
||||
- `Template(builder)` freezes a builder with **Capitalized** attribute
|
||||
placeholders (e.g. `E.Title`, `doc.main(E.Main, id="main")`); calling
|
||||
it fills the slots with escaping — pass `HTML(...)` for raw HTML.
|
||||
Passing a list to a template slot expands it; passing a list to a
|
||||
normal builder call does NOT (spread it: `E.ul(*items)`).
|
||||
- To create plain HTML snippets use `E.div(E.p("content"))` etc using the `E` empty builder.
|
||||
- **kanta** — asyncio-native embedded database: `Kanta(filename, data)`
|
||||
root object, `transaction`, `flush`, snapshot/replay-log persistence.
|
||||
- `async with Kanta(Data(),...) as kanta:` (or await kanta.open/close)
|
||||
- `with kanta.transaction(...) as data:` - transactions only for writes
|
||||
- `data` may be referenced directly to read anywhere and to modify in transactions (`as data` is just a shorthand access)
|
||||
- Data structures should be msgspec.Structs, where JSON restrictions do not apply (we can use `bytes`, `UUID`, `datetime` etc. even as dict keys)
|
||||
- We prefer objects rather than lists, as this works better in change diffs. E.g. `dict[str, True]` where the keys indicate presence and always have value `True`.
|
||||
- Maintaining and owning the app's own `Data` object is preferable; Kanta never copies this, only edits in place
|
||||
- Note: besides opening it every access is immediate direct variable access: no `await`, no locks, no delays
|
||||
- **fastapi-vue** — template glue for serving/building the Vue frontend;
|
||||
keep its integration points (`Frontend`, build hook) intact.
|
||||
- **markdown-it-py** — Markdown rendering with `html=True` raw
|
||||
passthrough; mdit-py-plugins for footnote/deflist/tasklists/attrs;
|
||||
**Pygments** for server-side code highlighting (`nowrap` spans, styles
|
||||
in `static/pygments.css` scoped to "pre code").
|
||||
|
||||
## Conventions
|
||||
|
||||
- Keep dependencies minimal; add via `uv add` and mention it.
|
||||
- The public URL space belongs to content (pretty slugs at root). Reserve
|
||||
only few prefixes (`/_/` for files + API, `/static`, `/admin`) for the
|
||||
machinery; top-level `_` is a reserved slug.
|
||||
- No auth in core code; trusted single author. Never add output
|
||||
sanitization "for safety" against the author — embedded HTML/scripts in
|
||||
Markdown are passed through deliberately.
|
||||
- Update this file and `docs/design-principles.md` when architecture,
|
||||
tooling, or conventions change.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Pagerite
|
||||
|
||||
A single-user CMS/blog. FastAPI serves HTML rendered in Python with
|
||||
html5tagger, content is persisted in a kanta database and rendered on
|
||||
the fly per request. Vue is used only for interactive bits (the editing
|
||||
tools), not for the public pages.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
uv run pagerite # serves the built frontend
|
||||
uv run scripts/devserver.py # dev mode with auto reloads (no build needed)
|
||||
```
|
||||
|
||||
The database lives in `pagerite.kanta` in the working directory
|
||||
(`PAGERITE_DB` overrides). On startup, demo pages from `pagerite/seed.py`
|
||||
are added only if missing.
|
||||
|
||||
## Editing
|
||||
|
||||
Click the 🖊️ pen on any page: the article pen opens the page editor
|
||||
(Markdown with live server-rendered preview over a WebSocket), the banner
|
||||
pen opens the site editor (site brand, banner HTML, and the page
|
||||
structure tree). Everything saves immediately — no save buttons.
|
||||
|
||||
See `docs/design-principles.md` for the design and `AGENTS.md` for the
|
||||
development conventions.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Pagerite Design Principles
|
||||
|
||||
Pagerite is a single-user CMS/blog. This document records the initial
|
||||
high-level design decisions; it will be refined as the implementation
|
||||
evolves.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Server-side rendered.** FastAPI serves complete HTML pages, generated in
|
||||
Python with **html5tagger**. There is no client-side templating or SPA for
|
||||
the public site.
|
||||
- **Vue only where interactivity demands it.** Small interactive islands
|
||||
(editing tools mainly) may be Vue components, either mounted into specific
|
||||
elements of the server-rendered pages or served as standalone apps
|
||||
(e.g. an admin panel). The public reading experience has no scripting
|
||||
requirement.
|
||||
- **Persistence via kanta.** Content is stored in an asyncio-friendly kanta
|
||||
database. Rendering happens on the fly on each request — there are no
|
||||
pre-built static artifacts.
|
||||
|
||||
## Content model
|
||||
|
||||
- Pages and blog articles are fundamentally the same kind of thing: named
|
||||
pieces of content. The blog/website distinction is blurred; an article is
|
||||
just a page (possibly with metadata such as a publication date and
|
||||
listing in a feed).
|
||||
- **Pretty URLs.** Content is addressed by its name (slug), not by technical
|
||||
constructs — no `/cms/...` or `/blog/post1` prefixes. Slugs usually live
|
||||
directly at the site root; structured content may nest
|
||||
(`/docs/design-principles`-style). The URL space is the author's, so
|
||||
reserved prefixes must be kept few and deliberate: everything internal
|
||||
lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`),
|
||||
plus `/static` and `/admin`.
|
||||
- **Single user, trusted author.** No auth concerns in the core design.
|
||||
Everything published is public; only editing tools will later sit behind
|
||||
access control (external SSO when that time comes). The author is trusted
|
||||
to create well-meaning slugs and content — no sanitization for safety,
|
||||
only for correctness.
|
||||
- **Commenting** is not planned now but the model should not preclude it
|
||||
later.
|
||||
|
||||
## Authoring format
|
||||
|
||||
- Content is written in **Markdown** with powerful extensions (tables,
|
||||
footnotes, code highlighting, etc.).
|
||||
- **Embedded HTML is passed through unfiltered**, including inline scripts
|
||||
and other dynamic content the author wants to post. This is safe by the
|
||||
single-trusted-author assumption above.
|
||||
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition
|
||||
lists, task lists, brace-attributes; tables and strikethrough from the
|
||||
default preset), with `html=True` for raw passthrough. Fenced code blocks
|
||||
are highlighted server-side with **Pygments** (github-dark palette in
|
||||
`/static/pygments.css`); a JS copy button appears on hover. Should this
|
||||
prove limiting, we implement our own renderer on top of html5tagger,
|
||||
which we already use for all HTML generation.
|
||||
- **Files are content-addressed.** Uploads (`PUT /_/api/files/{filename}`)
|
||||
are stored by content hash — blake3, first 6 bytes hex + original
|
||||
extension — and served immutable from `/_/f/{hash}.ext`. Absolute URLs
|
||||
that survive page renames and dedupe identical content; pages no longer
|
||||
own files. An image with a title becomes a `<figure>` with
|
||||
`<figcaption>`. Positioning is by attribute classes:
|
||||
`{.right}` — `{.right}`, `{.left}` float,
|
||||
`{.wide}` goes full bleed (viewport edge to edge, or up to the docked
|
||||
editor; the sidebar stacks on top of it); plain attributes like `width=300`
|
||||
work too.
|
||||
|
||||
## Page structure and navigation
|
||||
|
||||
- All pages share one static layout, defined once as an **html5tagger
|
||||
Template** with capitalized placeholders (`Title`, `Banner`, `Nav`,
|
||||
`Sidebar`, `Main`) filled per request. The dynamic regions carry stable
|
||||
ids (`#page-banner`, `#nav`, `#sidebar`, `#main`).
|
||||
- The page top is a **full-width banner header** with the site name and the
|
||||
navigation bar overlaid on it — no separate chrome header. The banner is
|
||||
**per-page configurable**: `Node.banner` holds an arbitrary trusted HTML
|
||||
snippet (an image, a styled div, canvas + script — anything), resolved by
|
||||
walking up the node's ancestors to the front page; when nothing in the
|
||||
chain sets one, the default `/static/banner.svg` artwork shows.
|
||||
- **Fetch-navigation.** Links are plain `<a href>`; a small script
|
||||
(`pagerite/static/pagerite.js`) intercepts same-origin clicks, fetches the
|
||||
page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions
|
||||
and the document title, keeping `<head>` and the layout chrome. Without JS
|
||||
everything works as normal page loads. Scripts inside fetched banner and
|
||||
content regions are re-created so they execute. Swaps run inside `document.startViewTransition` for a rotating
|
||||
cube page transition (CSS adapted from termotohtori.fi — the
|
||||
`::view-transition*` block is fragile, do not tweak; skipped under
|
||||
`prefers-reduced-motion`). Navigation within the same top-level section
|
||||
crossfades instead of rotating; browser back navigation rotates in
|
||||
reverse.
|
||||
- **The site structure is a tree of labels.** `Data.menu` holds the
|
||||
top-level items by slug, each with `children` keyed by slug — the URL
|
||||
path is the slug chain. The front page is a top-level node with slug ""
|
||||
(an item *parallel* to the other main level pages, not their parent) and
|
||||
cannot have children. The header navbar holds only the top level; a
|
||||
top-level item is highlighted when viewing any of its subpages. When the
|
||||
current page is inside a main level section with children, those direct
|
||||
children are listed in a **left sidebar** (`#sidebar`), one level deep;
|
||||
the sidebar is empty (and hidden) elsewhere. Other sections' subitems
|
||||
are never shown without navigating into them first.
|
||||
- **Landing pages are optional.** Every label can either have content
|
||||
(`Node.content`, a Markdown page) or none — a content-less label
|
||||
redirects to its first child instead of 404ing, so categories need no
|
||||
filler content. Title and slug of every label are editable; renaming a
|
||||
slug moves the whole subtree. The sidebar never lists the section
|
||||
itself, avoiding title duplication with the navbar.
|
||||
- **Menu order is manual.** Each node has a fractional `order` key among
|
||||
its siblings; reordering/moving writes only the moved node (it takes a
|
||||
fresh value halfway between its new siblings; all other items keep
|
||||
theirs). New pages append at the end of their menu. Structure edits
|
||||
(reorder, move/rename with the whole subtree, retitle) go through
|
||||
`POST /_/api/structure` and the editor's structure panel.
|
||||
- Unpublished pages are hidden from both nav and URL access (404).
|
||||
|
||||
## Reading experience
|
||||
|
||||
- The article column is sized by the **viewport, never by content**: a
|
||||
symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps
|
||||
the layout stable across navigation. The sidebar occupies the left
|
||||
gutter, the right gutter balances it; wide screens get columns inside
|
||||
long articles without changing the article's width.
|
||||
- A gentle **scroll-reveal** of headings, figures and block-level elements
|
||||
(IntersectionObserver). It is layout-level: articles need no support
|
||||
for it, and `prefers-reduced-motion` disables all motion.
|
||||
|
||||
## Styling
|
||||
|
||||
- A single shared `style.css` covers the server-rendered pages and the Vue
|
||||
components. Vue may add per-component styles on top where needed.
|
||||
- Fonts are self-hosted under `/static/fonts/` (Fraunces for headings,
|
||||
Literata for body, Fira Code for code — variable woff2 files with local
|
||||
`@font-face`). No third-party requests.
|
||||
|
||||
## Editing
|
||||
|
||||
- Editing happens **in place**, in two modes opened by two pens:
|
||||
- **Page mode** — the 🖊️ next to a page's heading (including 404s, which
|
||||
is how new pages start) opens a CodeMirror Markdown editor docked to
|
||||
the left of the article: the host sits inside `#content` (below the
|
||||
banner, never over the footer), the content shifts right and the
|
||||
sidebar hides while editing. Preview renders server-side per keystroke
|
||||
(no debouncing) straight into the visible article's heading and body.
|
||||
- **Site mode** — the 🖊️ on the banner opens a panel with the site
|
||||
**brand** (applied to the header live), the page's **banner HTML**
|
||||
field (previewed into the real banner region, so you see exactly
|
||||
which banner you're editing) and the **structure tree**. Everything
|
||||
saves immediately as you edit — no save button, no edit mode.
|
||||
- Clicking a pen again closes the editor (without saving; a dirty preview
|
||||
reloads the page). The pens are `<button>`s wired up by `pagerite.js` —
|
||||
editing is an action, not a navigation. The editor's WebSocket
|
||||
**reconnects automatically** with local text and pending saves preserved.
|
||||
A standalone shell also exists at `/admin#/path` with its own preview
|
||||
pane. (All users are trusted authors for now; access control later with
|
||||
SSO.)
|
||||
- **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published
|
||||
controls.
|
||||
Images can be pasted straight into the editor or chosen via a file
|
||||
input: they upload to the content store (`PUT /_/api/files/...`) and
|
||||
insert `` at the cursor.
|
||||
- The **structure panel** (vue-draggable tree of the whole site, in site
|
||||
mode) covers page management: reorder any menu level, drag across
|
||||
sections (empty child lists appear as drop zones while dragging), add,
|
||||
delete (two clicks: the button arms, then deletes — no dialogs).
|
||||
Every node is a real label — content-less category rows offer a
|
||||
➕ to give them a landing page.
|
||||
Deleting a category removes only its landing page (the label and its
|
||||
subpages stay). The ➕ in the panel header starts a new page as a
|
||||
local-only tree row that can be dragged into place before its title and
|
||||
slug are filled in; it is persisted only on commit. Rows are always
|
||||
editable: titles save while typing, slug edits commit on blur/Enter
|
||||
since they rename the path (moving the whole subtree). The front page
|
||||
is the root row with an empty slug — renaming it away leaves no front
|
||||
page ("/" redirects to the first nav item), and giving another
|
||||
top-level row the empty slug makes it the front page.
|
||||
- Preview and saving go over a **WebSocket** (`/_/api/ws/editor`) with a
|
||||
stateless JSON protocol (`open`/`render`/`save`; on save all fields are
|
||||
optional and absent ones keep their old values, `move_from` renames),
|
||||
avoiding REST polling and races. Rendering always stays server-side.
|
||||
- A REST API also exists for scripting, all under `/_/api/`:
|
||||
`GET pages` (the full tree), `PUT/DELETE pages/{path}`,
|
||||
`GET/PUT settings` (site brand), `POST structure` (reorder/move/
|
||||
retitle), file upload/removal via `PUT/DELETE files/{name}`.
|
||||
- On startup, seed pages from `pagerite/seed.py` are added **only if
|
||||
missing** — existing user content is never overwritten.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Playwright browser downloads (if ever installed locally)
|
||||
.pw-browsers/
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pagerite</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2845
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"codemirror": "^6.0.2",
|
||||
"vue": "^3.5.26",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-vue-devtools": "^8.0.5"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,423 @@
|
||||
<script setup>
|
||||
// Page editor: CodeMirror for Markdown, live server-rendered preview
|
||||
// applied straight into the visible article, saving over one WebSocket
|
||||
// (/_/api/ws/editor). Docked left of the article on the page itself
|
||||
// (main.js openEditor) or standalone at /admin with its own preview pane.
|
||||
// The socket reconnects automatically; unsaved text and pending saves
|
||||
// survive a disconnect. Editor scroll drives the document scroll, keeping
|
||||
// the rendered article at the cursor's position.
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { cmHighlight, cmTheme } from './cmtheme'
|
||||
|
||||
const props = defineProps({
|
||||
pagePath: { type: String, default: '' },
|
||||
standalone: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const path = ref('')
|
||||
const title = ref('')
|
||||
const published = ref(true)
|
||||
const status = ref('connecting…')
|
||||
const previewHtml = ref('')
|
||||
const previewHasH1 = ref(false)
|
||||
const editorEl = ref(null)
|
||||
const previewEl = ref(null)
|
||||
const fileInput = ref(null)
|
||||
|
||||
let ws = null
|
||||
let view = null
|
||||
let savedResolve = null
|
||||
let pendingSave = null
|
||||
let reconnectTimer = null
|
||||
let everConnected = false
|
||||
let dirty = false
|
||||
let syncingScroll = false
|
||||
|
||||
function currentPath() {
|
||||
return location.hash.replace(/^#\/?/, '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function send(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
function normPath(p) {
|
||||
return p.trim().replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
function requestRender() {
|
||||
// No debounce: server-side rendering is fast enough per keystroke.
|
||||
if (!view) return
|
||||
dirty = true
|
||||
send({ type: 'render', path: path.value, markdown: view.state.doc.toString() })
|
||||
}
|
||||
|
||||
function save() {
|
||||
// Path is not editable here (that's the site editor's job); saving
|
||||
// never moves the page.
|
||||
const msg = {
|
||||
type: 'save',
|
||||
path: path.value,
|
||||
title: title.value,
|
||||
markdown: view.state.doc.toString(),
|
||||
published: published.value,
|
||||
}
|
||||
pendingSave = msg
|
||||
status.value = ws && ws.readyState === WebSocket.OPEN
|
||||
? 'saving…'
|
||||
: 'offline — will save on reconnect'
|
||||
send(msg)
|
||||
return new Promise((resolve) => { savedResolve = resolve })
|
||||
}
|
||||
|
||||
async function saveAndClose() {
|
||||
await save()
|
||||
// Reload so nav/sidebar changes apply, then the editor is gone.
|
||||
if (props.standalone) location.href = `/${path.value}`
|
||||
else { dirty = false; emit('close'); location.reload() }
|
||||
}
|
||||
|
||||
function close() {
|
||||
// Reload if the visible page is showing unsaved preview edits.
|
||||
const stale = dirty
|
||||
dirty = false
|
||||
emit('close')
|
||||
if (stale && !props.standalone) location.reload()
|
||||
}
|
||||
|
||||
function insertAtCursor(text) {
|
||||
view.dispatch(view.state.replaceSelection(text))
|
||||
view.focus()
|
||||
}
|
||||
|
||||
async function uploadImage(file) {
|
||||
if (!file) return
|
||||
const name = file.name.replace(/[^\w.-]/g, '-')
|
||||
const res = await fetch(`/_/api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
|
||||
if (res.ok) {
|
||||
const { path: stored } = await res.json()
|
||||
const alt = name.replace(/\.[^.]+$/, '')
|
||||
insertAtCursor(``)
|
||||
status.value = `uploaded ${name}`
|
||||
} else {
|
||||
status.value = `upload failed (${res.status})`
|
||||
}
|
||||
}
|
||||
|
||||
function openPath(p) {
|
||||
path.value = p
|
||||
send({ type: 'open', path: p })
|
||||
}
|
||||
|
||||
function setDocument(text) {
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
const p = currentPath()
|
||||
if (p !== path.value) openPath(p)
|
||||
}
|
||||
|
||||
function runScripts(root) {
|
||||
// Scripts injected via innerHTML do not execute; re-create them.
|
||||
for (const old of root.querySelectorAll('script')) {
|
||||
const s = document.createElement('script')
|
||||
for (const a of old.attributes) s.setAttribute(a.name, a.value)
|
||||
s.textContent = old.textContent
|
||||
old.replaceWith(s)
|
||||
}
|
||||
}
|
||||
|
||||
function previewIntoArticle(html, hasH1) {
|
||||
// Docked mode previews into the article on the page itself;
|
||||
// standalone mode has its own preview pane. When the markdown owns its
|
||||
// h1, the title-derived h1 is hidden (matching server-side rendering).
|
||||
previewHasH1.value = hasH1
|
||||
if (props.standalone) {
|
||||
previewHtml.value = html
|
||||
nextTick(() => { if (previewEl.value) runScripts(previewEl.value) })
|
||||
return
|
||||
}
|
||||
const article = document.querySelector('#main article')
|
||||
if (!article) return
|
||||
const h1 = article.querySelector('h1')
|
||||
const body = article.querySelector('.body')
|
||||
// The edit pen may be tucked inside an h1 (title or markdown-owned);
|
||||
// detach it before textContent/innerHTML wipes destroy the element.
|
||||
// pagerite.js re-places it into the first visible h1 on pagerite:preview.
|
||||
const pen = article.querySelector('button.edit-link')
|
||||
if (pen && (h1?.contains(pen) || body?.contains(pen))) article.prepend(pen)
|
||||
if (h1) {
|
||||
h1.style.display = hasH1 ? 'none' : ''
|
||||
h1.textContent = title.value
|
||||
}
|
||||
if (body) {
|
||||
body.innerHTML = html
|
||||
runScripts(body)
|
||||
dispatchEvent(new CustomEvent('pagerite:preview'))
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(ev) {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if (msg.type === 'doc' && msg.path === path.value) {
|
||||
title.value = msg.title
|
||||
published.value = msg.published
|
||||
setDocument(msg.markdown)
|
||||
requestRender()
|
||||
dirty = false // just loaded from the server, nothing unsaved
|
||||
status.value = msg.exists ? '' : 'new page'
|
||||
} else if (msg.type === 'html' && msg.path === path.value) {
|
||||
previewIntoArticle(msg.html, msg.has_h1)
|
||||
} else if (msg.type === 'saved') {
|
||||
status.value = `saved ${new Date().toLocaleTimeString()}`
|
||||
pendingSave = null
|
||||
savedResolve?.()
|
||||
savedResolve = null
|
||||
} else if (msg.type === 'error') {
|
||||
status.value = `error: ${msg.detail}`
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(ev) {
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
|
||||
ev.preventDefault()
|
||||
save()
|
||||
}
|
||||
if (ev.key === 'Escape') close()
|
||||
}
|
||||
|
||||
function syncScroll() {
|
||||
// Editor scroll drives the document: keep the rendered article at the
|
||||
// same proportional position as the cursor area in the editor.
|
||||
if (syncingScroll || !view) return
|
||||
syncingScroll = true
|
||||
requestAnimationFrame(() => {
|
||||
const scroller = view.scrollDOM
|
||||
const max = scroller.scrollHeight - scroller.clientHeight
|
||||
const pct = max > 0 ? scroller.scrollTop / max : 0
|
||||
if (props.standalone) {
|
||||
const pv = previewEl.value
|
||||
if (pv) pv.scrollTop = pct * (pv.scrollHeight - pv.clientHeight)
|
||||
} else {
|
||||
const doc = document.documentElement
|
||||
window.scrollTo(0, pct * (doc.scrollHeight - innerHeight))
|
||||
}
|
||||
syncingScroll = false
|
||||
})
|
||||
}
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(
|
||||
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_/api/ws/editor`,
|
||||
)
|
||||
ws.onmessage = onMessage
|
||||
ws.onopen = () => {
|
||||
status.value = ''
|
||||
if (everConnected) {
|
||||
// Reconnected: local text is authoritative — don't re-open (that
|
||||
// would clobber the editor), just resync preview and pending saves.
|
||||
requestRender()
|
||||
if (pendingSave) send(pendingSave)
|
||||
} else {
|
||||
openPath(props.standalone ? currentPath() : normPath(props.pagePath))
|
||||
}
|
||||
everConnected = true
|
||||
}
|
||||
ws.onclose = () => {
|
||||
status.value = 'offline — reconnecting…'
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(connect, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connect()
|
||||
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: '',
|
||||
extensions: [
|
||||
basicSetup,
|
||||
markdown(),
|
||||
cmTheme,
|
||||
cmHighlight,
|
||||
EditorView.lineWrapping, // Markdown lines are long: soft-wrap them
|
||||
EditorView.updateListener.of((u) => { if (u.docChanged) requestRender() }),
|
||||
EditorView.domEventHandlers({
|
||||
paste(ev) {
|
||||
// Paste an image straight into the article: upload + insert
|
||||
const file = [...(ev.clipboardData?.files || [])]
|
||||
.find((f) => f.type.startsWith('image/'))
|
||||
if (file) {
|
||||
ev.preventDefault()
|
||||
uploadImage(file)
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
parent: editorEl.value,
|
||||
})
|
||||
view.scrollDOM.addEventListener('scroll', syncScroll)
|
||||
if (props.standalone) addEventListener('hashchange', onHashChange)
|
||||
addEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(reconnectTimer)
|
||||
if (ws) {
|
||||
ws.onclose = null // intentional close, no reconnect
|
||||
ws.close()
|
||||
}
|
||||
view?.destroy()
|
||||
if (props.standalone) removeEventListener('hashchange', onHashChange)
|
||||
removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-root" :class="{ overlay: !standalone }">
|
||||
<header class="toolbar">
|
||||
<input v-model="title" placeholder="Title" class="title" @input="requestRender" />
|
||||
<label><input v-model="published" type="checkbox" /> published</label>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
@change="(ev) => { uploadImage(ev.target.files[0]); ev.target.value = '' }"
|
||||
/>
|
||||
<button type="button" @click="fileInput.click()">image</button>
|
||||
<button type="button" @click="saveAndClose">save</button>
|
||||
<span class="status">{{ status }}</span>
|
||||
<button v-if="!standalone" type="button" class="close" title="close" @click="close">✕</button>
|
||||
</header>
|
||||
<div class="panes">
|
||||
<div ref="editorEl" class="editor" />
|
||||
<div v-if="standalone" ref="previewEl" class="preview">
|
||||
<article>
|
||||
<h1 v-if="!previewHasH1">{{ title }}</h1>
|
||||
<!-- server-rendered markdown preview -->
|
||||
<div class="body" v-html="previewHtml" />
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Docked-overlay positioning lives in the global style.css (.editor-host /
|
||||
.editor-root.overlay) since the host element is created by main.js. */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.toolbar .title {
|
||||
flex: 1;
|
||||
min-width: 4rem;
|
||||
font: inherit;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toolbar label {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
font: inherit;
|
||||
padding: 0.25rem 0.8rem;
|
||||
background: var(--accent2);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Window-style close button, top right corner. */
|
||||
.toolbar .close {
|
||||
margin-left: auto;
|
||||
padding: 0 0.3rem;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.toolbar .close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
gap: 1rem;
|
||||
background: var(--surface); /* dialog body, same as the toolbar */
|
||||
}
|
||||
|
||||
/* CodeMirror sits inside a bordered box, like a dialog's input area, with
|
||||
a slight margin to the panel edges. Wheel scroll stays in the editor and
|
||||
drives the document (syncScroll) instead of double-scrolling. */
|
||||
.editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.editor :deep(.cm-editor) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* No line numbers / gutter chrome. */
|
||||
.editor :deep(.cm-gutters) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.preview article {
|
||||
max-width: 44rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,767 @@
|
||||
<script setup>
|
||||
// Site editor: site-wide brand, banner HTML for the current page
|
||||
// (previewed into the real #page-banner region, so you see exactly which
|
||||
// banner you're editing) and the draggable site structure tree. Opened
|
||||
// from the pen on the banner. Everything saves immediately as you edit —
|
||||
// no save button, no edit mode. Focusing a page's row navigates to it in
|
||||
// place (no transitions).
|
||||
//
|
||||
// The tree comes from the server nested (GET /_/api/pages); every node is
|
||||
// real — a label with a title and slug, with content (landing page) or
|
||||
// without (category redirecting to its first child). The front page is a
|
||||
// top-level row with an empty slug, not the parent of the others.
|
||||
import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
|
||||
import StructureTree from './StructureTree.vue'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { EditorState, Compartment } from '@codemirror/state'
|
||||
import { placeholder } from '@codemirror/view'
|
||||
import { html } from '@codemirror/lang-html'
|
||||
import { cmHighlight, cmTheme } from './cmtheme'
|
||||
|
||||
const props = defineProps({
|
||||
pagePath: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const path = ref('')
|
||||
const banner = ref('')
|
||||
const status = ref('connecting…')
|
||||
const tree = ref([])
|
||||
const fileInput = ref(null)
|
||||
const bannerEl = ref(null)
|
||||
|
||||
let ws = null
|
||||
let pendingSave = null
|
||||
let reconnectTimer = null
|
||||
let everConnected = false
|
||||
let view = null // CodeMirror for the banner HTML
|
||||
let syncing = false // set while replacing the document programmatically
|
||||
const bannerPh = new Compartment() // placeholder shows the inherited source
|
||||
|
||||
// path -> node, for quick lookups (current title, delete checks).
|
||||
const flatMap = computed(() => {
|
||||
const map = {}
|
||||
const walk = (nodes) => {
|
||||
for (const n of nodes) {
|
||||
map[n.path] = n
|
||||
walk(n.children)
|
||||
}
|
||||
}
|
||||
walk(tree.value)
|
||||
return map
|
||||
})
|
||||
|
||||
function normPath(p) {
|
||||
return p.trim().replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
function send(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
// Debounce per key: text edits save while typing, without a request per
|
||||
// keystroke.
|
||||
const timers = {}
|
||||
function debounce(key, fn, ms = 600) {
|
||||
clearTimeout(timers[key])
|
||||
timers[key] = setTimeout(fn, ms)
|
||||
}
|
||||
|
||||
// Banner saves are fire-and-forget, with the pending save resent if the
|
||||
// socket reconnects mid-edit.
|
||||
function save() {
|
||||
const msg = { type: 'save', path: normPath(path.value), banner: banner.value }
|
||||
pendingSave = msg
|
||||
if (ws && ws.readyState !== WebSocket.OPEN) {
|
||||
status.value = 'offline — will save on reconnect'
|
||||
}
|
||||
send(msg)
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function openPath(p) {
|
||||
path.value = p
|
||||
send({ type: 'open', path: p })
|
||||
}
|
||||
|
||||
// --- In-place navigation (no transitions, replaceState) ------------------
|
||||
function swapRegions(doc) {
|
||||
for (const id of ['page-banner', 'nav', 'sidebar', 'main']) {
|
||||
const fresh = doc.getElementById(id)
|
||||
const el = document.getElementById(id)
|
||||
if (fresh && el) el.replaceWith(document.importNode(fresh, true))
|
||||
}
|
||||
// The brand link lives in the header, outside the swappable regions,
|
||||
// and is absent entirely when no brand is configured.
|
||||
const freshBrand = doc.getElementById('brand')
|
||||
const curBrand = document.getElementById('brand')
|
||||
if (freshBrand && curBrand) {
|
||||
curBrand.textContent = freshBrand.textContent
|
||||
} else if (curBrand) {
|
||||
curBrand.remove()
|
||||
} else if (freshBrand) {
|
||||
document.getElementById('nav')?.before(document.importNode(freshBrand, true))
|
||||
}
|
||||
document.title = doc.title
|
||||
}
|
||||
|
||||
async function loadPlain(p) {
|
||||
let doc
|
||||
let finalUrl = `/${p}`
|
||||
try {
|
||||
const res = await fetch(finalUrl)
|
||||
const type = res.headers.get('content-type') || ''
|
||||
if (!type.includes('text/html')) return
|
||||
// Category URLs redirect to their first child; reflect that. A 404
|
||||
// layout is fine too (new pages are created by editing them).
|
||||
if (res.redirected) finalUrl = res.url
|
||||
doc = new DOMParser().parseFromString(await res.text(), 'text/html')
|
||||
} catch { return }
|
||||
if (!doc.getElementById('main')) return
|
||||
swapRegions(doc)
|
||||
history.replaceState(null, '', finalUrl)
|
||||
runScripts(document.getElementById('page-banner'))
|
||||
runScripts(document.getElementById('main'))
|
||||
dispatchEvent(new CustomEvent('pagerite:preview')) // re-tuck the edit pen
|
||||
// The swap brought in the server-rendered (inherited) banner; overlay
|
||||
// the page's own banner if one is being edited.
|
||||
if (banner.value.trim()) previewBanner()
|
||||
}
|
||||
|
||||
// Tree row focus: switch the edited page and show it, skipping transitions.
|
||||
function navigate(p) {
|
||||
openPath(p)
|
||||
loadPlain(p)
|
||||
}
|
||||
|
||||
// If the currently edited page moved (rename/move of itself or an
|
||||
// ancestor), follow it to the new path.
|
||||
function followMove(oldPath, newPath) {
|
||||
if (path.value === oldPath) navigate(newPath)
|
||||
else if (oldPath && path.value.startsWith(`${oldPath}/`)) {
|
||||
navigate(newPath + path.value.slice(oldPath.length))
|
||||
}
|
||||
}
|
||||
|
||||
// --- New page flow -------------------------------------------------------
|
||||
// The ➕ in the pages header adds a *pending* row to the tree: a local-only
|
||||
// item that can be dragged into place before anything is filled in. It is
|
||||
// persisted only on commit (✓/Enter), at wherever it currently sits.
|
||||
const pending = ref(null)
|
||||
|
||||
function newPage() {
|
||||
if (pending.value) return // one at a time
|
||||
pending.value = {
|
||||
slug: '',
|
||||
path: '',
|
||||
title: '',
|
||||
order: 0,
|
||||
published: true,
|
||||
has_content: true,
|
||||
children: [],
|
||||
pending: true,
|
||||
}
|
||||
tree.value.push(pending.value)
|
||||
}
|
||||
|
||||
// Where does the pending row currently sit? -> {parentPath, list, index}.
|
||||
function locatePending(nodes, parentPath) {
|
||||
const i = nodes.indexOf(pending.value)
|
||||
if (i >= 0) return { parentPath, list: nodes, i }
|
||||
for (const n of nodes) {
|
||||
const found = locatePending(n.children, n.path)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function discardPending() {
|
||||
const loc = locatePending(tree.value, '')
|
||||
if (loc) loc.list.splice(loc.i, 1)
|
||||
pending.value = null
|
||||
}
|
||||
|
||||
async function commitPending() {
|
||||
const node = pending.value
|
||||
if (!node) return
|
||||
const slug = node.slug.trim().replace(/\/+/g, '')
|
||||
if (!slug) {
|
||||
status.value = 'a slug is needed'
|
||||
return
|
||||
}
|
||||
const loc = locatePending(tree.value, '')
|
||||
const parentPath = loc?.parentPath ?? ''
|
||||
const newPath = parentPath ? `${parentPath}/${slug}` : slug
|
||||
const res = await fetch(`/_/api/pages/${newPath}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: node.title.trim() || slug,
|
||||
markdown: '',
|
||||
published: true,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
status.value = `create failed (${res.status})`
|
||||
return
|
||||
}
|
||||
// Place it exactly where the row was dropped: a fresh order key halfway
|
||||
// between its new siblings (the PUT appended it at the end).
|
||||
if (loc) {
|
||||
const prev = loc.list[loc.i - 1]
|
||||
const next = loc.list[loc.i + 1]
|
||||
const order = prev && next ? (prev.order + next.order) / 2
|
||||
: prev ? prev.order + 1
|
||||
: next ? next.order - 1
|
||||
: 1
|
||||
await postStructure({ path: newPath, order })
|
||||
}
|
||||
pending.value = null
|
||||
await refreshPages()
|
||||
navigate(newPath)
|
||||
}
|
||||
|
||||
// Give a content-less category a landing page (empty page at its path).
|
||||
async function addContent(node) {
|
||||
const res = await fetch(`/_/api/pages/${node.path}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: node.title, markdown: '', published: node.published }),
|
||||
})
|
||||
if (res.ok) {
|
||||
await refreshPages()
|
||||
navigate(node.path)
|
||||
} else {
|
||||
status.value = `failed (${res.status})`
|
||||
}
|
||||
}
|
||||
|
||||
// --- Site-wide brand (header link + <title> suffix) ----------------------
|
||||
// Edits apply to the live page immediately and save while typing. An
|
||||
// empty brand removes the header link and the title suffix entirely.
|
||||
const brand = ref('')
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
brand.value = (await (await fetch('/_/api/settings')).json()).brand
|
||||
} catch { /* keep default */ }
|
||||
}
|
||||
|
||||
function currentTitle() {
|
||||
return flatMap.value[path.value]?.title
|
||||
|| document.title.replace(/ – [^–]*$/, '')
|
||||
}
|
||||
|
||||
function applyBrand(b) {
|
||||
let el = document.getElementById('brand')
|
||||
if (b) {
|
||||
if (!el) {
|
||||
el = document.createElement('a')
|
||||
el.id = 'brand'
|
||||
el.href = '/'
|
||||
document.getElementById('nav')?.before(el)
|
||||
}
|
||||
el.textContent = b
|
||||
document.title = `${currentTitle()} – ${b}`
|
||||
} else {
|
||||
if (el) el.remove()
|
||||
document.title = currentTitle()
|
||||
}
|
||||
}
|
||||
|
||||
function onBrandInput() {
|
||||
applyBrand(brand.value)
|
||||
debounce('brand', saveBrand)
|
||||
}
|
||||
|
||||
async function saveBrand() {
|
||||
const res = await fetch('/_/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ brand: brand.value }),
|
||||
})
|
||||
if (!res.ok) status.value = `brand save failed (${res.status})`
|
||||
}
|
||||
|
||||
// Two-step delete (no dialogs): the first click arms the row's button for
|
||||
// a few seconds, the second actually deletes.
|
||||
const arming = ref(null)
|
||||
let armTimer = null
|
||||
|
||||
function armRemove(node) {
|
||||
if (arming.value === node.path) {
|
||||
clearTimeout(armTimer)
|
||||
arming.value = null
|
||||
removePage(node)
|
||||
} else {
|
||||
arming.value = node.path
|
||||
clearTimeout(armTimer)
|
||||
armTimer = setTimeout(() => { arming.value = null }, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
async function removePage(node) {
|
||||
const res = await fetch(`/_/api/pages/${node.path}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
refreshPages()
|
||||
const p = node.path
|
||||
if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
|
||||
// The current page was deleted — or reduced to a category that now
|
||||
// redirects to its first child. Either way, re-render from the server.
|
||||
if (node.children.length) loadPlain(path.value)
|
||||
else { status.value = 'deleted'; navigate('') }
|
||||
} else {
|
||||
loadPlain(path.value) // refresh menus
|
||||
}
|
||||
} else {
|
||||
status.value = `delete failed (${res.status})`
|
||||
}
|
||||
}
|
||||
|
||||
// --- Site structure tree (drag-and-drop ordering/moving) ----------------
|
||||
async function refreshPages() {
|
||||
try {
|
||||
tree.value = await (await fetch('/_/api/pages')).json()
|
||||
} catch { /* list stays stale; not fatal */ }
|
||||
}
|
||||
|
||||
async function postStructure(op) {
|
||||
const res = await fetch('/_/api/structure', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(op),
|
||||
})
|
||||
if (!res.ok) status.value = `structure change failed (${res.status})`
|
||||
else loadPlain(path.value) // refresh menus and content from the server
|
||||
await refreshPages()
|
||||
return res.ok
|
||||
}
|
||||
|
||||
async function onReorder(parentPath, list, evt) {
|
||||
// vuedraggable already mutated `list`; persist the moved item only: a
|
||||
// fresh order key halfway between its new siblings (all other items
|
||||
// keep theirs), plus the new path when the parent changed. The pending
|
||||
// new-page row is local-only — its position is read at commit time.
|
||||
const change = evt.moved || evt.added
|
||||
if (!change) return
|
||||
const el = change.element
|
||||
if (el.pending) return
|
||||
const i = change.newIndex
|
||||
let prev, next
|
||||
for (let j = i - 1; j >= 0 && !prev; j--) if (!list[j].pending) prev = list[j]
|
||||
for (let j = i + 1; j < list.length && !next; j++) if (!list[j].pending) next = list[j]
|
||||
const order = prev && next ? (prev.order + next.order) / 2
|
||||
: prev ? prev.order + 1
|
||||
: next ? next.order - 1
|
||||
: 1
|
||||
const newPath = parentPath ? `${parentPath}/${el.slug}` : el.slug
|
||||
const op = { path: el.path, order }
|
||||
if (newPath !== el.path) op.move_to = newPath
|
||||
if (await postStructure(op) && op.move_to) followMove(el.path, op.move_to)
|
||||
}
|
||||
|
||||
// Inline title/slug editing: rows are always editable. Title saves while
|
||||
// typing (debounced); the slug commits on blur/Enter, since it renames
|
||||
// the path (moving the whole subtree with it).
|
||||
function onTitleInput(node, ev) {
|
||||
const title = ev.target.value.trim()
|
||||
if (!title || title === node.title) return
|
||||
debounce(`title:${node.path}`, async () => {
|
||||
await postStructure({ path: node.path, title })
|
||||
})
|
||||
}
|
||||
|
||||
async function commitSlug(node, ev) {
|
||||
const slug = ev.target.value.trim().replace(/\/+/g, '')
|
||||
if (slug === node.slug) return
|
||||
const parent = node.path.split('/').slice(0, -1).join('/')
|
||||
// Empty slug at top level = the front page (path "").
|
||||
const moveTo = parent ? (slug ? `${parent}/${slug}` : parent) : slug
|
||||
if (await postStructure({ path: node.path, move_to: moveTo })) {
|
||||
followMove(node.path, moveTo)
|
||||
} else {
|
||||
ev.target.value = node.slug // rename failed: put the old slug back
|
||||
}
|
||||
}
|
||||
|
||||
provide('structureHandlers', {
|
||||
current: () => path.value,
|
||||
open: navigate,
|
||||
arming: () => arming.value,
|
||||
armRemove,
|
||||
reorder: onReorder,
|
||||
titleInput: onTitleInput,
|
||||
commitSlug,
|
||||
addContent,
|
||||
commitPending,
|
||||
discardPending,
|
||||
})
|
||||
|
||||
// --- Banner editing ------------------------------------------------------
|
||||
// The banner HTML is edited in a small CodeMirror window (HTML syntax),
|
||||
// previewed into the real #page-banner region on every keystroke.
|
||||
function setDocument(text) {
|
||||
syncing = true
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
|
||||
syncing = false
|
||||
banner.value = text
|
||||
}
|
||||
|
||||
function runScripts(root) {
|
||||
// Scripts injected via innerHTML do not execute; re-create them.
|
||||
for (const old of root.querySelectorAll('script')) {
|
||||
const s = document.createElement('script')
|
||||
for (const a of old.attributes) s.setAttribute(a.name, a.value)
|
||||
s.textContent = old.textContent
|
||||
old.replaceWith(s)
|
||||
}
|
||||
}
|
||||
|
||||
function previewBanner() {
|
||||
const el = document.getElementById('page-banner')
|
||||
if (!el) return
|
||||
if (banner.value.trim()) {
|
||||
// Own banner: preview it live over the region.
|
||||
el.innerHTML = banner.value
|
||||
runScripts(el)
|
||||
} else {
|
||||
// No banner of its own: the region must show the inherited/default
|
||||
// banner — re-render from the server (an empty write here would wipe it).
|
||||
loadPlain(path.value)
|
||||
}
|
||||
}
|
||||
|
||||
function onBannerInput() {
|
||||
previewBanner()
|
||||
debounce('banner-html', save, 400)
|
||||
}
|
||||
|
||||
function stripBannerMedia(html) {
|
||||
// A banner has one piece of media: uploading replaces earlier img/video
|
||||
// tags instead of stacking them. (Other HTML, e.g. canvas+script, stays.)
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
for (const el of doc.querySelectorAll('img, video')) el.remove()
|
||||
return doc.body.innerHTML.trim()
|
||||
}
|
||||
|
||||
async function uploadBannerMedia(file) {
|
||||
// Banner media goes to the shared content store, like article images.
|
||||
if (!file || !/^(image|video)\//.test(file.type)) return
|
||||
const name = file.name.replace(/[^\w.-]/g, '-')
|
||||
const res = await fetch(`/_/api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
|
||||
if (!res.ok) {
|
||||
status.value = `upload failed (${res.status})`
|
||||
return
|
||||
}
|
||||
const { path: stored } = await res.json()
|
||||
const tag = file.type.startsWith('video/')
|
||||
? `<video src="${stored}" autoplay muted loop playsinline></video>`
|
||||
: `<img src="${stored}" alt="">`
|
||||
const rest = stripBannerMedia(banner.value)
|
||||
setDocument(rest ? `${tag}\n${rest}` : tag)
|
||||
previewBanner()
|
||||
save()
|
||||
status.value = `uploaded ${name}`
|
||||
}
|
||||
|
||||
function onBannerPaste(ev) {
|
||||
const file = [...(ev.clipboardData?.files || [])]
|
||||
.find((f) => /^(image|video)\//.test(f.type))
|
||||
if (file) {
|
||||
ev.preventDefault()
|
||||
uploadBannerMedia(file)
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(ev) {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if (msg.type === 'doc' && msg.path === path.value) {
|
||||
setDocument(msg.banner ?? '')
|
||||
// Placeholder tells where an empty banner falls back to.
|
||||
view.dispatch({
|
||||
effects: bannerPh.reconfigure(placeholder(
|
||||
msg.banner_from == null
|
||||
? 'using default artwork'
|
||||
: `inherited from /${msg.banner_from}`,
|
||||
)),
|
||||
})
|
||||
// Overlay this page's own banner on the swapped region. Empty means
|
||||
// inherited: the server-rendered region already shows the right one.
|
||||
if (banner.value.trim()) previewBanner()
|
||||
status.value = msg.exists ? '' : 'new page'
|
||||
} else if (msg.type === 'saved') {
|
||||
status.value = `saved ${new Date().toLocaleTimeString()}`
|
||||
pendingSave = null
|
||||
refreshPages()
|
||||
} else if (msg.type === 'error') {
|
||||
status.value = `error: ${msg.detail}`
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(ev) {
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
|
||||
ev.preventDefault()
|
||||
save()
|
||||
}
|
||||
if (ev.key === 'Escape') close()
|
||||
}
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(
|
||||
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_/api/ws/editor`,
|
||||
)
|
||||
ws.onmessage = onMessage
|
||||
ws.onopen = () => {
|
||||
status.value = ''
|
||||
if (everConnected) {
|
||||
// Reconnected: resend any save attempted while offline.
|
||||
if (pendingSave) send(pendingSave)
|
||||
} else {
|
||||
openPath(normPath(props.pagePath))
|
||||
}
|
||||
everConnected = true
|
||||
}
|
||||
ws.onclose = () => {
|
||||
status.value = 'offline — reconnecting…'
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(connect, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshPages()
|
||||
loadSettings()
|
||||
connect()
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: '',
|
||||
extensions: [
|
||||
basicSetup,
|
||||
html(),
|
||||
cmTheme,
|
||||
cmHighlight,
|
||||
EditorView.lineWrapping,
|
||||
bannerPh.of(placeholder('')),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged && !syncing) {
|
||||
banner.value = view.state.doc.toString()
|
||||
onBannerInput()
|
||||
}
|
||||
}),
|
||||
],
|
||||
}),
|
||||
parent: bannerEl.value,
|
||||
})
|
||||
addEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(reconnectTimer)
|
||||
clearTimeout(armTimer)
|
||||
for (const t of Object.values(timers)) clearTimeout(t)
|
||||
if (ws) {
|
||||
ws.onclose = null // intentional close, no reconnect
|
||||
ws.close()
|
||||
}
|
||||
view?.destroy()
|
||||
removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-root overlay">
|
||||
<header class="toolbar">
|
||||
<span class="mode-label">site editor</span>
|
||||
<span class="status">{{ status }}</span>
|
||||
<button type="button" class="close" title="close" @click="close">✕</button>
|
||||
</header>
|
||||
|
||||
<section class="block">
|
||||
<label class="field">
|
||||
<span class="field-label">site</span>
|
||||
<input
|
||||
v-model="brand"
|
||||
class="text-input"
|
||||
placeholder="Site name (header link and window title)"
|
||||
@input="onBrandInput"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="block" @paste="onBannerPaste">
|
||||
<div class="block-head">
|
||||
<span class="field-label">Banner on /{{ path }}</span>
|
||||
<button
|
||||
type="button"
|
||||
title="upload banner image/video (replaces existing media) — pasting works too"
|
||||
@click="fileInput.click()"
|
||||
>add image/video</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
hidden
|
||||
@change="(ev) => { uploadBannerMedia(ev.target.files[0]); ev.target.value = '' }"
|
||||
/>
|
||||
</div>
|
||||
<div ref="bannerEl" class="banner-cm" />
|
||||
</section>
|
||||
|
||||
<section class="block structure">
|
||||
<StructureTree :nodes="tree" />
|
||||
<button
|
||||
type="button"
|
||||
class="add"
|
||||
title="new page — drag the new row into place, then fill in title and slug"
|
||||
@click="newPage"
|
||||
>➕</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Docked-overlay positioning lives in the global style.css (.editor-host /
|
||||
.editor-root.overlay) since the host element is created by main.js. */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.mode-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Window-style close button, top right corner. */
|
||||
.toolbar .close {
|
||||
margin-left: auto;
|
||||
padding: 0 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolbar .close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.block-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.block-head button {
|
||||
margin-left: auto;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
background: var(--accent2);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1;
|
||||
min-width: 4rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Small CodeMirror window for the banner HTML; scrolls internally. */
|
||||
.banner-cm {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-cm :deep(.cm-editor) {
|
||||
max-height: 7rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.banner-cm :deep(.cm-scroller) {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* No line numbers / gutter chrome. */
|
||||
.banner-cm :deep(.cm-gutters) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.structure {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Icon buttons (➕) keep the emoji's own color, no button chrome. */
|
||||
.structure .add {
|
||||
align-self: flex-start; /* don't stretch to the block's full width */
|
||||
margin-top: 0.3rem;
|
||||
margin-left: 1.2em; /* align with the row titles, past the drag handle */
|
||||
padding: 0.1rem 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.structure .add:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,251 @@
|
||||
<script setup>
|
||||
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
|
||||
// Nodes come from the server (GET /_/api/pages via SiteEditor.vue) as
|
||||
// {slug, path, title, order, published, has_content, children}.
|
||||
// Every node is real: a label whose title and slug are always editable
|
||||
// inline — the title saves while typing (and focusing it opens the page),
|
||||
// the slug commits on blur/Enter since it renames the path, moving the
|
||||
// whole subtree. Nodes without content are category labels that redirect
|
||||
// to their first child; the ➕ on their row gives them a landing page.
|
||||
// The ➕ in the panel header adds a *pending* row: a local-only item that
|
||||
// can be dragged into place before its title/slug are filled in, and is
|
||||
// persisted to the server only on commit (✓/Enter, Esc discards).
|
||||
// The front page is the root row with an empty slug: renaming it away
|
||||
// leaves no front page, and giving another top-level row the empty slug
|
||||
// makes it the front page. Delete is a two-step inline button (no dialog).
|
||||
// Actions are injected from SiteEditor.vue to avoid per-level event
|
||||
// forwarding.
|
||||
import { inject } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
defineOptions({ name: 'StructureTree' })
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, required: true },
|
||||
parentPath: { type: String, default: '' },
|
||||
depth: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const handlers = inject('structureHandlers')
|
||||
|
||||
// Focus the title input of a fresh pending row.
|
||||
const vFocus = { mounted: (el) => el.focus() }
|
||||
|
||||
function onChange(evt) {
|
||||
handlers.reorder(props.parentPath, props.nodes, evt)
|
||||
}
|
||||
|
||||
// Drag guard: the front page (slug "") is a top-level item — it cannot be
|
||||
// dropped into a section (its empty slug is only valid at the root). And
|
||||
// nothing may be dropped under itself or one of its own descendants.
|
||||
function onMove(evt) {
|
||||
const el = evt.draggedContext.element
|
||||
if (el.pending) return true // unsaved row: position it anywhere
|
||||
const targetParent = evt.to.dataset.parent || ''
|
||||
if (el.slug === '') return targetParent === ''
|
||||
if (targetParent === el.path || targetParent.startsWith(`${el.path}/`)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// While dragging, reveal empty child lists as drop zones (style.css) so a
|
||||
// page can be moved under a childless page.
|
||||
function onStart() {
|
||||
document.body.classList.add('tree-dragging')
|
||||
}
|
||||
|
||||
function onEnd() {
|
||||
document.body.classList.remove('tree-dragging')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<draggable
|
||||
class="treelist"
|
||||
:data-parent="parentPath"
|
||||
:list="nodes"
|
||||
item-key="path"
|
||||
group="sitetree"
|
||||
ghost-class="ghost"
|
||||
:move="onMove"
|
||||
@change="onChange"
|
||||
@start="onStart"
|
||||
@end="onEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div class="node">
|
||||
<!-- Indentation is row padding, not a container margin, so the
|
||||
slug and action columns stay aligned across nesting levels. -->
|
||||
<div
|
||||
class="row"
|
||||
:class="{ current: element.path === handlers.current() }"
|
||||
:style="depth ? { paddingLeft: `${depth * 1.1}rem` } : null"
|
||||
>
|
||||
<span class="drag" title="drag to reorder/move">⠿</span>
|
||||
<template v-if="element.pending">
|
||||
<input
|
||||
v-model="element.title"
|
||||
v-focus
|
||||
class="edit title-edit"
|
||||
placeholder="Title"
|
||||
@keyup.enter="handlers.commitPending()"
|
||||
@keyup.esc="handlers.discardPending()"
|
||||
/>
|
||||
<input
|
||||
v-model="element.slug"
|
||||
class="edit slug-edit"
|
||||
placeholder="slug"
|
||||
@keyup.enter="handlers.commitPending()"
|
||||
@keyup.esc="handlers.discardPending()"
|
||||
/>
|
||||
<span class="acts">
|
||||
<button type="button" class="act" title="create page" @click="handlers.commitPending()">✓</button>
|
||||
<button type="button" class="act del" title="discard" @click="handlers.discardPending()">✕</button>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input
|
||||
class="edit title-edit"
|
||||
:value="element.title"
|
||||
placeholder="Title"
|
||||
title="Label in the navigation — saves while typing; click opens the page"
|
||||
@input="handlers.titleInput(element, $event)"
|
||||
@focus="handlers.open(element.path)"
|
||||
/>
|
||||
<input
|
||||
class="edit slug-edit"
|
||||
:value="element.slug"
|
||||
placeholder="front page"
|
||||
title="Slug (last path segment) — renames move the whole subtree. Empty at top level = front page"
|
||||
@change="handlers.commitSlug(element, $event)"
|
||||
/>
|
||||
<span class="acts">
|
||||
<span v-if="!element.published" class="draft">draft</span>
|
||||
<button
|
||||
v-if="!element.has_content"
|
||||
type="button"
|
||||
class="act"
|
||||
title="add a landing page (currently redirects to the first child)"
|
||||
@click="handlers.addContent(element)"
|
||||
>➕</button>
|
||||
<button
|
||||
type="button"
|
||||
class="act del"
|
||||
:class="{ armed: handlers.arming() === element.path }"
|
||||
:title="element.children.length
|
||||
? 'delete the landing page (the category keeps its subpages)'
|
||||
: 'delete page'"
|
||||
@click="handlers.armRemove(element)"
|
||||
>{{ handlers.arming() === element.path ? 'delete?' : '✕' }}</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<StructureTree
|
||||
v-if="element.slug !== '' && !element.pending"
|
||||
:nodes="element.children"
|
||||
:parent-path="element.path"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.treelist {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.node {
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
|
||||
/* Grid rows: handle / title / slug / actions line up as columns. Rows are
|
||||
full width at every level (indentation is row padding) and the slug and
|
||||
action columns are fixed-width, so they align across nesting levels. */
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2em minmax(3rem, 1fr) 7rem 5rem;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.row.current .title-edit {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drag {
|
||||
color: var(--muted);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* Rows are always editable: inputs stay borderless until interacted with. */
|
||||
.edit {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.edit:hover {
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.edit:focus {
|
||||
background: var(--bg);
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.title-edit {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title-edit:focus {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.slug-edit {
|
||||
font-family: "Fira Code", monospace;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.draft {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.act {
|
||||
padding: 0 0.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Two-step delete: the first click arms the button, the second deletes. */
|
||||
.act.armed {
|
||||
color: #e06c75;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.del:hover {
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
// Shared CodeMirror styling for both editors: a light theme matching the
|
||||
// site's inputs, and a highlight style in the site palette — the default
|
||||
// highlight style (from basicSetup) underlines headings/links and uses
|
||||
// colors that clash with the page.
|
||||
import { EditorView } from 'codemirror'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
|
||||
// The base theme sets monospace on .cm-scroller, so the font must be set
|
||||
// there, not on "&".
|
||||
export const cmTheme = EditorView.theme({
|
||||
"&": {
|
||||
backgroundColor: "var(--bg)",
|
||||
color: "var(--text)",
|
||||
},
|
||||
".cm-scroller": { fontFamily: '"Fira Code", monospace' },
|
||||
".cm-content": { caretColor: "var(--text)" },
|
||||
".cm-cursor": { borderLeftColor: "var(--text)" },
|
||||
// basicSetup's active-line highlight assumes a dark theme.
|
||||
".cm-activeLine": { backgroundColor: "transparent" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground":
|
||||
{ backgroundColor: "var(--line)" },
|
||||
"&.cm-focused": { outline: "none" },
|
||||
})
|
||||
|
||||
export const cmHighlight = syntaxHighlighting(HighlightStyle.define([
|
||||
{ tag: tags.heading, fontWeight: "600", color: "var(--accent)" },
|
||||
{ tag: tags.strong, fontWeight: "700" },
|
||||
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
||||
{ tag: tags.link, color: "var(--accent2)" },
|
||||
{ tag: tags.url, color: "var(--muted)" },
|
||||
{ tag: tags.monospace, color: "var(--accent2)" },
|
||||
{ tag: tags.quote, color: "var(--muted)", fontStyle: "italic" },
|
||||
// HTML (banner editor) and Markdown raw blocks
|
||||
{ tag: tags.tagName, color: "var(--accent)" },
|
||||
{ tag: tags.attributeName, color: "var(--accent2)" },
|
||||
{ tag: tags.attributeValue, color: "var(--text)" },
|
||||
{ tag: tags.comment, color: "var(--muted)" },
|
||||
{ tag: tags.processingInstruction, color: "var(--muted)" },
|
||||
]))
|
||||
@@ -0,0 +1,55 @@
|
||||
// Pagerite editor entries. Two separate apps, mounted in their own
|
||||
// dynamically created host divs inside the static document:
|
||||
// - PageEditor ("page" mode): pen next to an article heading — Markdown
|
||||
// editing with the preview rendered into the visible article.
|
||||
// - SiteEditor ("site" mode): pen on the banner — banner HTML editing
|
||||
// (previewed into the real banner) and the site structure tree.
|
||||
// The standalone /admin shell (#app in the DOM) mounts PageEditor with the
|
||||
// page selected by location hash, as a no-dynamic-import fallback.
|
||||
import { createApp } from 'vue'
|
||||
import PageEditor from './PageEditor.vue'
|
||||
import SiteEditor from './SiteEditor.vue'
|
||||
|
||||
let host = null
|
||||
|
||||
export function openEditor(path, { standalone = false, mode = 'page' } = {}) {
|
||||
closeEditor()
|
||||
host = document.createElement('div')
|
||||
host.className = 'editor-host'
|
||||
// Docked inside #content: below the banner, next to the article only.
|
||||
const container = (!standalone && document.getElementById('content')) || document.body
|
||||
container.prepend(host)
|
||||
if (!standalone) {
|
||||
document.body.classList.add('editing')
|
||||
// Which kind of editor is open; pagerite.js uses this to decide
|
||||
// whether a pen click closes the panel or swaps in the other editor.
|
||||
document.body.dataset.editorMode = mode
|
||||
}
|
||||
createApp(mode === 'site' ? SiteEditor : PageEditor, {
|
||||
pagePath: path,
|
||||
standalone,
|
||||
onClose: closeEditor,
|
||||
}).mount(host)
|
||||
}
|
||||
|
||||
export function closeEditor() {
|
||||
if (!host) return
|
||||
document.body.classList.remove('editing')
|
||||
delete document.body.dataset.editorMode
|
||||
// Slide the panel out in sync with the page shifting back.
|
||||
host.firstElementChild?.classList.add('closing')
|
||||
const old = host
|
||||
host = null
|
||||
setTimeout(() => old.remove(), 250)
|
||||
}
|
||||
|
||||
const shell = document.getElementById('app')
|
||||
if (shell) {
|
||||
// Standalone /admin shell: mount into it and follow the location hash.
|
||||
host = shell
|
||||
createApp(PageEditor, {
|
||||
pagePath: location.hash.replace(/^#\/?/, '').replace(/\/$/, ''),
|
||||
standalone: true,
|
||||
onClose: () => {},
|
||||
}).mount(shell)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* FastAPI-Vue Vite Plugin
|
||||
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
|
||||
*
|
||||
* Configures Vite for FastAPI backend integration:
|
||||
* - Proxies /api/* requests to the FastAPI backend
|
||||
* - Builds to the Python module's frontend-build directory
|
||||
*
|
||||
* Options:
|
||||
* paths - Array of paths to proxy (default: ["/api"])
|
||||
*/
|
||||
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
const backendUrl = process.env.PAGERITE_BACKEND_URL || "http://localhost:3200"
|
||||
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
for (const path of paths) {
|
||||
proxy[path] = {
|
||||
target: backendUrl,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "vite-plugin-fastapi-pagerite",
|
||||
config: () => ({
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../pagerite/frontend-build",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import fastapiVue from './vite-plugin-fastapi.js'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
fastapiVue(),
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
build: {
|
||||
// JS entry only: no index.html in the build (it would shadow our /),
|
||||
// and a manifest so the backend can resolve hashed asset names.
|
||||
manifest: true,
|
||||
rollupOptions: {
|
||||
input: fileURLToPath(new URL('./src/main.js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
"""Backend package with FastAPI application and Vue frontend integration."""
|
||||
@@ -0,0 +1,33 @@
|
||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||
"""Command-line entry point for running the backend server."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from fastapi_vue import server
|
||||
|
||||
DEFAULT_PORT = 3100
|
||||
DEVMODE = os.getenv("PAGERITE_DEV") == "1"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the backend server with optional arguments."""
|
||||
parser = argparse.ArgumentParser(description="Run the pagerite server.")
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--listen",
|
||||
action="append",
|
||||
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
dev = {"reload": True, "reload_dirs": ["pagerite"]} if DEVMODE else {}
|
||||
server.run(
|
||||
"pagerite.app:app",
|
||||
listen=args.listen,
|
||||
default_port=DEFAULT_PORT,
|
||||
**dev,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+525
@@ -0,0 +1,525 @@
|
||||
"""FastAPI application: server-rendered content pages plus Vue assets.
|
||||
|
||||
Route ordering matters: our routes are defined before
|
||||
``frontend.route(app, "/")`` is called, so they take priority over the
|
||||
asset routes that fastapi-vue inserts at that position during ``load()``.
|
||||
The content catch-all (``/{path:path}``) is defined last, so built
|
||||
frontend assets still win over content slugs; anything unmatched falls
|
||||
through to content (and 404 if no page exists there).
|
||||
|
||||
The site structure is a tree of Nodes (see data.py); URL paths resolve by
|
||||
walking the tree (``resolve``), moves are slot detach/attach
|
||||
(``find_slot``) with a fresh order key from the new siblings.
|
||||
"""
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import blake3
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
|
||||
from fastapi_vue import Frontend
|
||||
from kanta import Kanta
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pagerite import seed, views
|
||||
from pagerite.__main__ import DEVMODE
|
||||
from pagerite.data import (
|
||||
Data,
|
||||
Node,
|
||||
append_order,
|
||||
find_slot,
|
||||
prettify,
|
||||
resolve,
|
||||
sorted_nodes,
|
||||
)
|
||||
from pagerite.markdown import has_h1, render
|
||||
|
||||
DB_PATH = os.getenv("PAGERITE_DB", "pagerite.kanta")
|
||||
STATIC = Path(__file__).with_name("static")
|
||||
|
||||
# Our own data root; kanta edits it in place, reads are plain attribute access.
|
||||
data = Data()
|
||||
kanta = Kanta(DB_PATH, data)
|
||||
|
||||
# Vue build assets served at root, no SPA catch-all (assets only).
|
||||
frontend = Frontend(Path(__file__).with_name("frontend-build"), spa=False)
|
||||
|
||||
|
||||
def _hash_name(body: bytes, orig: str) -> str:
|
||||
"""Content-addressed file name: blake3 hash prefix + original extension."""
|
||||
ext = "".join(c for c in Path(orig).suffix.lower() if c.isalnum() or c == ".")
|
||||
return blake3.blake3(body).hexdigest()[:12] + ext
|
||||
|
||||
|
||||
def _store_seed_file(markdown: str, banner: str, orig: str, body: bytes) -> tuple[str, str]:
|
||||
"""Store a seed file content-addressed and point references at /_/f/."""
|
||||
name = _hash_name(body, orig)
|
||||
data.files.setdefault(name, body)
|
||||
markdown = markdown.replace(f"]({orig}", f"](/_/f/{name}")
|
||||
banner = banner.replace(f'src="/{orig}"', f'src="/_/f/{name}"')
|
||||
banner = banner.replace(f'src="{orig}"', f'src="/_/f/{name}"')
|
||||
return markdown, banner
|
||||
|
||||
|
||||
def _ensure(menu: dict[str, Node], path: str) -> Node:
|
||||
"""Return the node at ``path``, creating it and any missing ancestors
|
||||
(content-less category labels) appended at the end of their level."""
|
||||
nodes = menu
|
||||
node = None
|
||||
for seg in path.split("/"):
|
||||
node = nodes.get(seg)
|
||||
if node is None:
|
||||
node = Node(title=prettify(seg), order=append_order(nodes))
|
||||
nodes[seg] = node
|
||||
nodes = node.children
|
||||
return node
|
||||
|
||||
|
||||
def _migrate_legacy() -> None:
|
||||
"""Rebuild the legacy flat page store as a tree (one-time migration)."""
|
||||
if not data.pages:
|
||||
return
|
||||
with kanta.transaction("migrate pages to tree"):
|
||||
for path, page in data.pages.items():
|
||||
node = _ensure(data.menu, path)
|
||||
node.title = page.title
|
||||
node.content = page.markdown
|
||||
node.banner = page.banner
|
||||
node.published = page.published
|
||||
node.order = page.order
|
||||
node.created = page.created
|
||||
node.modified = page.modified
|
||||
data.pages.clear()
|
||||
data.version += 1
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Open the database, migrate/seed content, load assets."""
|
||||
await kanta.open()
|
||||
_migrate_legacy()
|
||||
missing = [p for p in seed.PAGES if resolve(data.menu, p) is None]
|
||||
if missing:
|
||||
with kanta.transaction("seed missing pages"):
|
||||
for path in missing:
|
||||
title, markdown, files, banner, order = seed.PAGES[path]
|
||||
for orig, body in files.items():
|
||||
markdown, banner = _store_seed_file(markdown, banner, orig, body)
|
||||
node = _ensure(data.menu, path)
|
||||
node.title = title
|
||||
node.content = markdown
|
||||
node.banner = banner
|
||||
node.order = order
|
||||
await frontend.load()
|
||||
yield
|
||||
await kanta.close()
|
||||
|
||||
|
||||
# docs_url/openapi_url disabled: /docs belongs to our content, and the API
|
||||
# is not meant to be browsable by the public anyway.
|
||||
app = FastAPI(
|
||||
title="Pagerite",
|
||||
debug=DEVMODE,
|
||||
lifespan=lifespan,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
|
||||
|
||||
class PageIn(BaseModel):
|
||||
"""Payload for creating or replacing a page."""
|
||||
|
||||
title: str
|
||||
markdown: str
|
||||
published: bool = True
|
||||
banner: str | None = None # None keeps the existing banner
|
||||
|
||||
|
||||
@app.get("/_/api/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
"""Return backend status for health monitoring."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/_/api/pages")
|
||||
async def list_pages() -> list[dict]:
|
||||
"""The site tree for the structure editor (all nodes, drafts included).
|
||||
|
||||
Nested by slug; each node carries its full path, menu order and flags.
|
||||
"""
|
||||
|
||||
def dump(nodes: dict[str, Node], prefix: str) -> list[dict]:
|
||||
out = []
|
||||
for slug, node in sorted_nodes(nodes):
|
||||
path = f"{prefix}/{slug}" if prefix else slug
|
||||
out.append({
|
||||
"slug": slug,
|
||||
"path": path,
|
||||
"title": node.title,
|
||||
"order": node.order,
|
||||
"published": node.published,
|
||||
"has_content": node.content is not None,
|
||||
"children": dump(node.children, path),
|
||||
})
|
||||
return out
|
||||
|
||||
return dump(data.menu, "")
|
||||
|
||||
|
||||
@app.put("/_/api/pages/{path:path}", status_code=204)
|
||||
async def save_page(path: str, page: PageIn) -> None:
|
||||
"""Create or replace the page at a slug path ("" or "/" = front page).
|
||||
|
||||
Missing ancestors are created as content-less category labels. Giving
|
||||
a category markdown turns it into a landing page.
|
||||
"""
|
||||
path = path.strip("/")
|
||||
_check_reserved(path)
|
||||
with kanta.transaction("save page", extra=path):
|
||||
node = _ensure(data.menu, path)
|
||||
node.title = page.title
|
||||
node.content = page.markdown
|
||||
node.published = page.published
|
||||
if page.banner is not None:
|
||||
node.banner = page.banner
|
||||
node.modified = datetime.now(UTC)
|
||||
data.version += 1
|
||||
|
||||
|
||||
class StructureOp(BaseModel):
|
||||
"""Rearrange the site tree: reorder, move/rename or retitle a node.
|
||||
|
||||
`order` is a fresh fractional key computed client-side from the node's
|
||||
new siblings (a value halfway between them); all other items keep
|
||||
theirs. `move_to` is the full target path — the parent must exist and
|
||||
the new slug be free. Moves carry the whole subtree. The front page is
|
||||
just the top-level node with slug "": renaming it away leaves no front
|
||||
page ("/" then redirects to the first nav item), and any childless
|
||||
top-level node can take the empty slug to become the front page.
|
||||
"""
|
||||
|
||||
path: str
|
||||
order: float | None = None
|
||||
move_to: str | None = None
|
||||
title: str | None = None
|
||||
|
||||
|
||||
@app.post("/_/api/structure", status_code=204)
|
||||
async def update_structure(op: StructureOp) -> None:
|
||||
"""Apply one structure operation (see StructureOp)."""
|
||||
path = op.path.strip("/")
|
||||
chain = resolve(data.menu, path)
|
||||
if chain is None:
|
||||
raise HTTPException(404, "no such page")
|
||||
node = chain[-1]
|
||||
target = op.move_to.strip("/") if op.move_to is not None else None
|
||||
if target is not None and target != path:
|
||||
_check_reserved(target)
|
||||
if path and target.startswith(f"{path}/"):
|
||||
raise HTTPException(400, "cannot move a page under itself")
|
||||
slot = find_slot(data.menu, target)
|
||||
if slot is None:
|
||||
raise HTTPException(404, "target parent does not exist")
|
||||
tnodes, tslug = slot
|
||||
if tslug in tnodes:
|
||||
raise HTTPException(400, "target path exists")
|
||||
if not tslug and node.children:
|
||||
raise HTTPException(400, "the front page cannot have children")
|
||||
with kanta.transaction("update structure", extra=path):
|
||||
if op.title is not None:
|
||||
node.title = op.title
|
||||
if target is not None and target != path:
|
||||
snodes, sslug = find_slot(data.menu, path)
|
||||
del snodes[sslug]
|
||||
# A pure rename (same parent) keeps its position; only a move
|
||||
# to another level appends at the end (unless an order came
|
||||
# with the drop).
|
||||
same_level = path.rpartition("/")[0] == target.rpartition("/")[0]
|
||||
node.order = (
|
||||
op.order
|
||||
if op.order is not None
|
||||
else node.order if same_level else append_order(tnodes)
|
||||
)
|
||||
tnodes[tslug] = node
|
||||
elif op.order is not None:
|
||||
node.order = op.order
|
||||
node.modified = datetime.now(UTC)
|
||||
data.version += 1
|
||||
|
||||
|
||||
@app.get("/_/api/settings")
|
||||
async def get_settings() -> dict[str, str]:
|
||||
"""Site-wide settings (the brand text)."""
|
||||
return {"brand": data.brand}
|
||||
|
||||
|
||||
class SettingsIn(BaseModel):
|
||||
"""Payload for updating site-wide settings."""
|
||||
|
||||
brand: str
|
||||
|
||||
|
||||
@app.put("/_/api/settings", status_code=204)
|
||||
async def put_settings(settings: SettingsIn) -> None:
|
||||
"""Update site-wide settings; bumps the version so ETags invalidate."""
|
||||
with kanta.transaction("update settings"):
|
||||
data.brand = settings.brand
|
||||
data.version += 1
|
||||
|
||||
|
||||
@app.put("/_/api/files/{name}")
|
||||
async def upload_file(name: str, request: Request) -> dict[str, str]:
|
||||
"""Store an upload (image, video...) in the content-addressed store.
|
||||
|
||||
The stored name is a blake3 hash prefix + the original extension,
|
||||
served immutable at "/_/f/{name}"; returns {"path": "/_/f/..."}.
|
||||
"""
|
||||
if "/" in name or name in {".", ".."}:
|
||||
raise HTTPException(400, "bad file name")
|
||||
body = await request.body()
|
||||
stored = _hash_name(body, name)
|
||||
with kanta.transaction("upload file", extra=name):
|
||||
data.files[stored] = body
|
||||
data.version += 1
|
||||
return {"path": f"/_/f/{stored}"}
|
||||
|
||||
|
||||
@app.delete("/_/api/files/{name}", status_code=204)
|
||||
async def delete_file(name: str) -> None:
|
||||
"""Remove a file from the content-addressed store (no refcounting:
|
||||
other pages referencing the same content will 404)."""
|
||||
if name not in data.files:
|
||||
raise HTTPException(404, "no such file")
|
||||
with kanta.transaction("delete file", extra=name):
|
||||
del data.files[name]
|
||||
data.version += 1
|
||||
|
||||
|
||||
@app.get("/_/f/{name}")
|
||||
async def stored_file(name: str, request: Request) -> Response:
|
||||
"""Serve a file from the content-addressed store (immutable: the name
|
||||
is its own hash, so cache forever)."""
|
||||
body = data.files.get(name)
|
||||
if body is None:
|
||||
raise HTTPException(404)
|
||||
if request.headers.get("if-none-match") == name:
|
||||
return Response(status_code=304)
|
||||
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
return Response(
|
||||
body,
|
||||
media_type=mime,
|
||||
headers={"etag": name, "cache-control": "public, max-age=31536000, immutable"},
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/_/api/pages/{path:path}", status_code=204)
|
||||
async def delete_page(path: str) -> None:
|
||||
"""Delete a node by slug path.
|
||||
|
||||
A category (node with children) loses only its landing page and stays
|
||||
as a content-less label; a childless node is removed entirely.
|
||||
"""
|
||||
path = path.strip("/")
|
||||
_check_reserved(path)
|
||||
slot = find_slot(data.menu, path)
|
||||
node = slot[0].get(slot[1]) if slot else None
|
||||
if node is None:
|
||||
raise HTTPException(404, "no such page")
|
||||
with kanta.transaction("delete page", extra=path):
|
||||
if node.children:
|
||||
node.content = None
|
||||
node.modified = datetime.now(UTC)
|
||||
else:
|
||||
del slot[0][slot[1]]
|
||||
data.version += 1
|
||||
|
||||
|
||||
def _check_reserved(path: str) -> None:
|
||||
"""Reject slugs that collide with machinery prefixes.
|
||||
|
||||
The public URL space belongs to content; only "/_/" (files + API),
|
||||
"/static" and "/admin" are reserved.
|
||||
"""
|
||||
if path.split("/", 1)[0] in {"_", "static", "admin"}:
|
||||
raise HTTPException(400, "reserved path prefix")
|
||||
|
||||
|
||||
@app.websocket("/_/api/ws/editor")
|
||||
async def editor_ws(ws: WebSocket) -> None:
|
||||
"""Editor session: open pages, render previews, save — over one socket.
|
||||
|
||||
Stateless protocol (each message carries the path):
|
||||
<- {"type": "open", "path"}
|
||||
-> {"type": "doc", "path", "exists", "title", "markdown", "published",
|
||||
"banner"}
|
||||
<- {"type": "render", "path", "markdown"}
|
||||
-> {"type": "html", "path", "html"}
|
||||
<- {"type": "save", "path", "title"?, "markdown"?, "published"?,
|
||||
"banner"?, "move_from"?} (absent fields keep their old values;
|
||||
move_from: rename/move a page, subtree included)
|
||||
-> {"type": "saved", "path"} | {"type": "error", "detail"}
|
||||
"""
|
||||
await ws.accept()
|
||||
try:
|
||||
while True:
|
||||
msg = await ws.receive_json()
|
||||
path = msg.get("path", "").strip("/")
|
||||
try:
|
||||
_check_reserved(path)
|
||||
except HTTPException:
|
||||
await ws.send_json({"type": "error", "detail": "reserved path"})
|
||||
continue
|
||||
match msg.get("type"):
|
||||
case "open":
|
||||
chain = resolve(data.menu, path)
|
||||
node = chain[-1] if chain else None
|
||||
await ws.send_json({
|
||||
"type": "doc",
|
||||
"path": path,
|
||||
"exists": node is not None,
|
||||
"title": node.title if node else "",
|
||||
"markdown": node.content if node and node.content is not None else "",
|
||||
"published": node.published if node else True,
|
||||
"banner": node.banner if node else "",
|
||||
# Which node's banner applies here ("" = front page,
|
||||
# null = default artwork); the site editor shows it
|
||||
# as the banner field's placeholder.
|
||||
"banner_from": views.banner_source(data.menu, path),
|
||||
})
|
||||
case "render":
|
||||
markdown = msg.get("markdown", "")
|
||||
await ws.send_json({
|
||||
"type": "html",
|
||||
"path": path,
|
||||
"html": render(markdown, path),
|
||||
"has_h1": has_h1(markdown),
|
||||
})
|
||||
case "save":
|
||||
move_from = (msg.get("move_from") or path).strip("/")
|
||||
try:
|
||||
_check_reserved(move_from)
|
||||
except HTTPException:
|
||||
await ws.send_json({"type": "error", "detail": "reserved path"})
|
||||
continue
|
||||
old_chain = resolve(data.menu, move_from)
|
||||
old = old_chain[-1] if old_chain else None
|
||||
if old is None and move_from != path:
|
||||
move_from = path # nothing to carry over; plain save
|
||||
if move_from != path:
|
||||
# Rename/move: detach the node (subtree included)
|
||||
# and attach it at the new path. The target slug
|
||||
# must be free and the front page childless.
|
||||
if move_from and path.startswith(f"{move_from}/"):
|
||||
await ws.send_json({
|
||||
"type": "error",
|
||||
"detail": "cannot move a page under itself",
|
||||
})
|
||||
continue
|
||||
tslug = path.rpartition("/")[2]
|
||||
if not tslug and old.children:
|
||||
await ws.send_json({
|
||||
"type": "error",
|
||||
"detail": "the front page cannot have children",
|
||||
})
|
||||
continue
|
||||
tchain = resolve(data.menu, path)
|
||||
if tchain is not None:
|
||||
await ws.send_json({
|
||||
"type": "error",
|
||||
"detail": "target path exists",
|
||||
})
|
||||
continue
|
||||
with kanta.transaction("editor save", extra=path):
|
||||
if move_from != path:
|
||||
same_menu = (
|
||||
move_from.rpartition("/")[0] == path.rpartition("/")[0]
|
||||
)
|
||||
snodes, sslug = find_slot(data.menu, move_from)
|
||||
node = snodes.pop(sslug)
|
||||
parent = path.rpartition("/")[0]
|
||||
if parent:
|
||||
_ensure(data.menu, parent)
|
||||
tnodes, tslug = find_slot(data.menu, path)
|
||||
node.order = (
|
||||
node.order if same_menu else append_order(tnodes)
|
||||
)
|
||||
tnodes[tslug] = node
|
||||
else:
|
||||
node = old if old is not None else _ensure(data.menu, path)
|
||||
if "title" in msg:
|
||||
node.title = msg["title"]
|
||||
if "markdown" in msg:
|
||||
node.content = msg["markdown"]
|
||||
if "published" in msg:
|
||||
node.published = bool(msg["published"])
|
||||
if "banner" in msg:
|
||||
node.banner = msg["banner"]
|
||||
node.modified = datetime.now(UTC)
|
||||
data.version += 1
|
||||
await ws.send_json({"type": "saved", "path": path})
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
async def admin() -> HTMLResponse:
|
||||
"""Serve the editor app shell (Vue mounts into #app)."""
|
||||
return HTMLResponse(views.render_editor())
|
||||
|
||||
|
||||
@app.get("/static/{path:path}")
|
||||
async def static_file(path: str) -> FileResponse:
|
||||
"""Serve our own static assets (style.css, pagerite.js)."""
|
||||
file = STATIC / path
|
||||
if not file.is_file() or not file.resolve().is_relative_to(STATIC):
|
||||
raise HTTPException(404)
|
||||
return FileResponse(file)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def front_page(request: Request) -> Response:
|
||||
"""Render the front page (slug path "")."""
|
||||
return await show_page(request, "")
|
||||
|
||||
|
||||
# Vue build asset routes are inserted at this position during load().
|
||||
frontend.route(app, "/")
|
||||
|
||||
|
||||
@app.get("/{path:path}", response_model=None)
|
||||
async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
||||
"""Render the content page at a slug path, or 404.
|
||||
|
||||
A node without content is a category label: its URL redirects to the
|
||||
first child page in menu order.
|
||||
"""
|
||||
path = path.strip("/")
|
||||
chain = resolve(data.menu, path)
|
||||
node = chain[-1] if chain else None
|
||||
if node is not None and node.published and node.content is not None:
|
||||
# ETag on content + render version; clients revalidate cheaply,
|
||||
# which keeps prefetched pages warm and current.
|
||||
etag = f'"{path}@{node.modified.timestamp()}v{data.version}"'
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304)
|
||||
return HTMLResponse(
|
||||
views.render_page(data.menu, path, data.brand),
|
||||
headers={"etag": etag},
|
||||
)
|
||||
if node is not None and node.published and node.content is None:
|
||||
# Category label without a landing page: open its first child.
|
||||
if (leaf := views.first_leaf(data.menu, path)) is not None:
|
||||
return RedirectResponse(f"/{leaf}")
|
||||
if node is None and not path:
|
||||
# No front page (no top-level node with slug ""): "/" opens the
|
||||
# first item of the navigation instead.
|
||||
for slug, item in sorted_nodes(data.menu):
|
||||
if item.published:
|
||||
return RedirectResponse(f"/{slug}")
|
||||
return HTMLResponse(views.render_not_found(data.menu, path, data.brand), 404)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Data model persisted in the kanta database.
|
||||
|
||||
The site structure is a tree of Nodes. Every node is a menu label with a
|
||||
configurable title and slug (its key in the parent's ``children``); the
|
||||
URL path is the chain of slugs from the top level. ``content`` is the
|
||||
node's Markdown page, or None for a pure category label, whose URL
|
||||
redirects to the first child page.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import msgspec
|
||||
|
||||
|
||||
class Node(msgspec.Struct, omit_defaults=True):
|
||||
"""One item of the site hierarchy.
|
||||
|
||||
Siblings are ordered by the fractional ``order`` key (never list
|
||||
positions): a moved item takes a fresh key relative to its new
|
||||
siblings, all other items keep theirs.
|
||||
|
||||
The front page is whichever top-level node has slug "" (URL "/") — an
|
||||
item parallel to the other main-level pages, not their parent, so it
|
||||
cannot have children. Renaming it away leaves no front page ("/"
|
||||
redirects to the first nav item); any childless top-level node can
|
||||
take the empty slug.
|
||||
"""
|
||||
|
||||
title: str = ""
|
||||
order: float = 0
|
||||
#: Markdown source of the node's page; None = pure category label
|
||||
#: (redirects to the first child page).
|
||||
content: str | None = None
|
||||
#: Raw HTML for the header banner (img, styled div, canvas+script...).
|
||||
#: Empty inherits the nearest ancestor's banner, front page last.
|
||||
banner: str = ""
|
||||
published: bool = True
|
||||
children: dict[str, "Node"] = {}
|
||||
created: datetime = msgspec.field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
)
|
||||
modified: datetime = msgspec.field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class Page(msgspec.Struct, omit_defaults=True):
|
||||
"""Legacy flat page record, from before the tree model.
|
||||
|
||||
Kept only so old databases still decode; app.py migrates any entries
|
||||
into ``Data.menu`` on startup and clears this.
|
||||
"""
|
||||
|
||||
title: str
|
||||
markdown: str
|
||||
published: bool = True
|
||||
order: float = 0
|
||||
banner: str = ""
|
||||
created: datetime = msgspec.field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
)
|
||||
modified: datetime = msgspec.field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class Data(msgspec.Struct):
|
||||
"""Root object of the kanta database. Owned and edited in place by us."""
|
||||
|
||||
#: Top-level menu items by slug; "" is the front page.
|
||||
menu: dict[str, Node] = {}
|
||||
#: Content-addressed file store: name (blake3 hash prefix + extension)
|
||||
#: -> bytes, served immutable at "/_/{name}". Absolute URLs that stay
|
||||
#: valid when pages move.
|
||||
files: dict[str, bytes] = {}
|
||||
#: Bumped on every structure/content write, so page ETags (which embed
|
||||
#: it) invalidate cached copies when navigation-affecting changes happen.
|
||||
version: int = 0
|
||||
#: Site name shown in the header and <title> suffix; editable in the
|
||||
#: site editor. Empty = no brand link in the header, no title suffix.
|
||||
brand: str = "Pagerite"
|
||||
#: Legacy flat page store (pre-tree databases); migrated into `menu`
|
||||
#: on startup, then cleared. Never written otherwise.
|
||||
pages: dict[str, Page] = {}
|
||||
|
||||
|
||||
def prettify(slug: str) -> str:
|
||||
"""Human-readable default title for a slug segment."""
|
||||
return slug.replace("-", " ").replace("_", " ").title()
|
||||
|
||||
|
||||
def resolve(menu: dict[str, Node], path: str) -> list[Node] | None:
|
||||
"""Chain of nodes from the top level down to ``path`` ("" = front page).
|
||||
|
||||
chain[0] is a top-level node, chain[-1] the node itself — the chain is
|
||||
useful for banner inheritance. None when any segment is missing.
|
||||
"""
|
||||
chain = []
|
||||
nodes = menu
|
||||
for seg in path.split("/"):
|
||||
node = nodes.get(seg)
|
||||
if node is None:
|
||||
return None
|
||||
chain.append(node)
|
||||
nodes = node.children
|
||||
return chain
|
||||
|
||||
|
||||
def find_slot(menu: dict[str, Node], path: str) -> tuple[dict[str, Node], str] | None:
|
||||
"""The (children dict, slug) slot holding the node at ``path``.
|
||||
|
||||
The returned dict is live: deleting or inserting the slug moves the
|
||||
node (its whole subtree travels with it). None when the parent chain
|
||||
does not resolve.
|
||||
"""
|
||||
segs = path.split("/")
|
||||
nodes = menu
|
||||
for seg in segs[:-1]:
|
||||
node = nodes.get(seg)
|
||||
if node is None:
|
||||
return None
|
||||
nodes = node.children
|
||||
return nodes, segs[-1]
|
||||
|
||||
|
||||
def sorted_nodes(nodes: dict[str, Node]) -> list[tuple[str, Node]]:
|
||||
"""(slug, node) pairs in menu order: fractional order key, then title."""
|
||||
return sorted(nodes.items(), key=lambda kv: (kv[1].order, kv[1].title.lower()))
|
||||
|
||||
|
||||
def append_order(nodes: dict[str, Node]) -> float:
|
||||
"""Order value appending an item at the end of a sibling level."""
|
||||
return max((n.order for n in nodes.values()), default=0) + 1
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Markdown rendering.
|
||||
|
||||
Raw HTML (including inline scripts) is passed through unfiltered: the
|
||||
single author is trusted. Extensions: tables and strikethrough (from the
|
||||
"default" preset), footnotes, definition lists, task lists and
|
||||
brace-attributes (`{.class width=300}` on any element, images in
|
||||
particular).
|
||||
|
||||
Images get special treatment: a relative `src` is resolved against the
|
||||
page's own path (so `` in `/docs/design` is served from
|
||||
`/docs/design/photo.avif`), and an image with a title becomes a
|
||||
`<figure>` with `<figcaption>`. Positioning is done with attribute
|
||||
classes, e.g. `{.right}`.
|
||||
"""
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
from markdown_it.common.utils import escapeHtml
|
||||
from markdown_it.renderer import RendererHTML
|
||||
from mdit_py_plugins.attrs import attrs_plugin
|
||||
from mdit_py_plugins.deflist import deflist_plugin
|
||||
from mdit_py_plugins.footnote import footnote_plugin
|
||||
from mdit_py_plugins.tasklists import tasklists_plugin
|
||||
from pygments import highlight
|
||||
from pygments.formatters import HtmlFormatter
|
||||
from pygments.lexers import get_lexer_by_name
|
||||
from pygments.util import ClassNotFound
|
||||
|
||||
# Styles in /static/pygments.css match this formatter (regenerate:
|
||||
# HtmlFormatter(style="github-dark").get_style_defs("pre code"))
|
||||
_formatter = HtmlFormatter(style="github-dark", nowrap=True)
|
||||
|
||||
|
||||
def _highlight(text: str, lang: str, _attrs: str) -> str:
|
||||
"""Syntax-highlight a fenced code block with Pygments.
|
||||
|
||||
Returns bare spans (nowrap): markdown-it adds the <pre><code> wrapper,
|
||||
and the stylesheet is scoped to "pre code" to match.
|
||||
"""
|
||||
try:
|
||||
lexer = get_lexer_by_name(lang)
|
||||
except ClassNotFound:
|
||||
return "" # fall back to default <pre><code>
|
||||
return highlight(text, lexer, _formatter)
|
||||
|
||||
|
||||
def _image_rule(
|
||||
self: RendererHTML,
|
||||
tokens,
|
||||
idx: int,
|
||||
options,
|
||||
env: dict,
|
||||
) -> str:
|
||||
"""Render images, resolving relative srcs against the page path."""
|
||||
token = tokens[idx]
|
||||
src = token.attrs["src"]
|
||||
if not src.startswith(("/", "http://", "https://", "data:")):
|
||||
page = env.get("page_path", "")
|
||||
token.attrs["src"] = f"/{page}/{src}" if page else f"/{src}"
|
||||
token.attrs["alt"] = self.renderInlineAsText(token.children, options, env)
|
||||
img = self.renderToken(tokens, idx, options, env)
|
||||
if title := token.attrs.get("title"):
|
||||
return f"<figure>{img}<figcaption>{escapeHtml(title)}</figcaption></figure>"
|
||||
return img
|
||||
|
||||
|
||||
def _unwrap_lone_figures(state) -> None:
|
||||
"""Drop the <p> wrapper around a lone titled image.
|
||||
|
||||
markdown-it wraps inline content in a paragraph, but our image rule
|
||||
turns titled images into <figure> — a block element that is invalid
|
||||
inside <p>. Browsers hoist it out, leaving an empty paragraph whose
|
||||
margins disturb the layout.
|
||||
"""
|
||||
tokens = state.tokens
|
||||
for i, token in enumerate(tokens):
|
||||
if token.type != "inline" or not token.children:
|
||||
continue
|
||||
[child] = token.children if len(token.children) == 1 else [None]
|
||||
if child and child.type == "image" and child.attrs.get("title"):
|
||||
if (tokens[i - 1].type == "paragraph_open"
|
||||
and tokens[i + 1].type == "paragraph_close"):
|
||||
tokens[i - 1].hidden = True
|
||||
tokens[i + 1].hidden = True
|
||||
|
||||
|
||||
md = (
|
||||
MarkdownIt("default", {"html": True, "highlight": _highlight})
|
||||
.use(attrs_plugin)
|
||||
.use(footnote_plugin)
|
||||
.use(deflist_plugin)
|
||||
.use(tasklists_plugin)
|
||||
)
|
||||
def _checkbox_emojis(state) -> None:
|
||||
"""Render task-list checkboxes as emoji instead of disabled inputs.
|
||||
|
||||
A disabled <input> renders grey and washed out; a colored emoji
|
||||
shows the state without any styling.
|
||||
"""
|
||||
for token in state.tokens:
|
||||
if token.type != "inline" or not token.children:
|
||||
continue
|
||||
for child in token.children:
|
||||
if child.type == "html_inline" and 'type="checkbox"' in child.content:
|
||||
child.type = "text"
|
||||
child.content = "✅" if "checked" in child.content else "⬜"
|
||||
|
||||
|
||||
md.add_render_rule("image", _image_rule)
|
||||
md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
|
||||
md.core.ruler.push("checkbox_emojis", _checkbox_emojis)
|
||||
|
||||
|
||||
def render(text: str, page_path: str = "") -> str:
|
||||
"""Render Markdown text to an HTML string."""
|
||||
return md.render(text, {"page_path": page_path})
|
||||
|
||||
|
||||
def has_h1(text: str) -> bool:
|
||||
"""True if the Markdown source itself contains an h1 heading.
|
||||
|
||||
When it does, the article owns its heading and the page title is not
|
||||
rendered as an additional h1 (the title is still used for the document
|
||||
<title> and navigation labels).
|
||||
"""
|
||||
return any(t.type == "heading_open" and t.tag == "h1" for t in md.parse(text))
|
||||
@@ -0,0 +1,449 @@
|
||||
"""Seed content written to the database on first run (when it is empty).
|
||||
|
||||
Demonstrates the formatting options: images attached to pages and served
|
||||
from the page path, figures with captions, attribute classes for
|
||||
positioning, footnotes, definition lists, task lists, tables and raw HTML.
|
||||
"""
|
||||
|
||||
WELCOME = """\
|
||||
Welcome to your new **Pagerite** site. Pages are written in Markdown —
|
||||
including raw HTML — and served from pretty URLs.
|
||||
|
||||
Have a look around:
|
||||
|
||||
- The [docs](/docs) section explains [how to write content](/docs/editing),
|
||||
including images and positioning.
|
||||
- [The Long Read](/blog/the-long-read) demonstrates a longer article with
|
||||
scroll effects.
|
||||
- The [about](/about) page shows off assorted formatting.
|
||||
|
||||

|
||||
"""
|
||||
|
||||
ABOUT = """\
|
||||
This site runs on **Pagerite**: FastAPI + html5tagger + kanta, with content
|
||||
written in Markdown.
|
||||
|
||||
Some formatting samples:
|
||||
|
||||
- [x] Write content in Markdown
|
||||
- [x] Attach images to pages
|
||||
- [ ] Add editing UI
|
||||
|
||||
Term
|
||||
: A definition list entry, rendered by the deflist plugin.
|
||||
|
||||
And a table:
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Pages | done |
|
||||
| Images | done |
|
||||
| Comments| later |
|
||||
|
||||
Footnotes work too.[^1]
|
||||
|
||||
[^1]: Rendered at the bottom of the page, with a back-reference.
|
||||
"""
|
||||
|
||||
EDITING = """\
|
||||
Pages are written in Markdown with extensions. Everything below is plain
|
||||
Markdown source — no special support from the article is needed for the
|
||||
site's layout or scroll effects.
|
||||
|
||||
## Images
|
||||
|
||||
Upload a file (`PUT /_/api/files/{filename}`) and it lands in the
|
||||
content-addressed store, served immutable from `/_/f/{hash}.ext` — an
|
||||
absolute URL that survives page moves:
|
||||
|
||||
```
|
||||
{.right width=280}
|
||||
```
|
||||
|
||||
{.right width=280}
|
||||
|
||||
The title becomes a `<figcaption>`, and brace attributes (the attrs
|
||||
plugin) control positioning: `{.right}`, `{.left}`, `{.wide}`, plus plain
|
||||
attributes like `width=280`. Absolute and external URLs pass through
|
||||
unchanged.
|
||||
|
||||
## Text
|
||||
|
||||
*Emphasis*, **strong**, ~~strikethrough~~, `inline code`, and
|
||||
[links](/about) as usual. Blockquotes:
|
||||
|
||||
> The URL space is the author's. Pretty slugs at the root, nesting only
|
||||
> where the content is genuinely structured.
|
||||
|
||||
## Code
|
||||
|
||||
```python
|
||||
def render(text: str, page_path: str) -> str:
|
||||
return md.render(text, {"page_path": page_path})
|
||||
```
|
||||
"""
|
||||
|
||||
LONG_READ = """\
|
||||
*An essay long enough to scroll, to demonstrate the gentle reveal of
|
||||
headings, figures and code blocks as they enter the viewport.*
|
||||
|
||||
{.wide}
|
||||
|
||||
## Chapter one
|
||||
|
||||
The distinction between a blog and a website is largely an accident of
|
||||
history. Early content management systems filed everything under "posts",
|
||||
stamped them with a date, and arranged them in reverse chronological order
|
||||
under a `/blog/` prefix. Anything else was a "page", which lived somewhere
|
||||
else entirely, often in a separate editing interface with separate rules.
|
||||
|
||||
But readers do not think in these terms. A reader follows a link, reads
|
||||
what is there, and follows another link. The URL is a promise about where
|
||||
something lives, not about which database table it came from. Pagerite
|
||||
therefore treats every piece of content as a page: named, addressable, and
|
||||
rendered on the fly.
|
||||
|
||||
## Chapter two
|
||||
|
||||
Consider what happens to URLs when the tooling leads the design. You get
|
||||
addresses like `/cms/frontpage` or `/blog/post1` — the name of the machine
|
||||
leaking into the name of the thing. The slug should be chosen by the
|
||||
author, the way a book's title is chosen, and it should sit at the root of
|
||||
the site like the title sits on the cover.
|
||||
|
||||
Nesting still has its place. Structured content — documentation, a series,
|
||||
a portfolio — benefits from paths that mirror the structure. The
|
||||
navigation on this very site is derived from the paths: open a section,
|
||||
and you see what it contains. No menu editor, no duplication of structure
|
||||
in two places.
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
|
||||
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
|
||||
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
|
||||
velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
|
||||
cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
||||
est laborum.
|
||||
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium
|
||||
doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo
|
||||
inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
|
||||
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut
|
||||
fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem
|
||||
sequi nesciunt.
|
||||
|
||||
## Chapter three
|
||||
|
||||
On the reading experience itself: motion on the web is usually either
|
||||
absent or obnoxious. The interesting middle ground is motion that
|
||||
acknowledges the reader's own movement — the scroll. Elements that fade
|
||||
in as they enter the viewport give the page a sense of depth, as if the
|
||||
content were arriving just in time.
|
||||
|
||||
Crucially, none of this may depend on the article. The author writes
|
||||
Markdown; the effects come from the layout. And when the reader prefers
|
||||
reduced motion, everything must hold still.
|
||||
|
||||
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet,
|
||||
consectetur, adipisci velit, sed quia non numquam eius modi tempora
|
||||
incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad
|
||||
minima veniam, quis nostrum exercitationem ullam corporis suscipit
|
||||
laboriosam, nisi ut aliquid ex ea commodi consequatur?
|
||||
|
||||
```text
|
||||
Quis autem vel eum iure reprehenderit
|
||||
qui in ea voluptate velit esse quam nihil
|
||||
molestiae consequatur, vel illum qui
|
||||
dolorem eum fugiat quo voluptas nulla pariatur?
|
||||
```
|
||||
|
||||
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis
|
||||
praesentium voluptatum deleniti atque corrupti quos dolores et quas
|
||||
molestias excepturi sint occaecati cupiditate non provident, similique
|
||||
sunt in culpa qui officia deserunt mollitia animi, id est laborum et
|
||||
dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.
|
||||
|
||||
## Chapter four
|
||||
|
||||
Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil
|
||||
impedit quo minus id quod maxime placeat facere possimus, omnis voluptas
|
||||
assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut
|
||||
officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates
|
||||
repudiandae sint et molestiae non recusandae.
|
||||
|
||||
Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis
|
||||
voluptatibus maiores alias consequatur aut perferendis doloribus
|
||||
asperiores repellat. And so we arrive back where we started: the blog and
|
||||
the website were one thing all along. [Return to the front page](/).
|
||||
"""
|
||||
|
||||
NOTES_ON_URLS = """\
|
||||
A URL is part of the content. A few rules of thumb I keep coming back to:
|
||||
|
||||
- Pick slugs like book titles, not like database keys.
|
||||
- Nest only when the structure is real.
|
||||
- Once published, a URL is a promise. Redirect if you must break it.
|
||||
|
||||
> Cool URIs don't change; uncool ones at least apologise.
|
||||
|
||||
That's all. Short posts are posts too.
|
||||
"""
|
||||
|
||||
CANVAS_NIGHTS = """\
|
||||
This post's banner is not an image at all — it's a `<canvas>` animated by
|
||||
a few lines of JavaScript embedded in the page's banner HTML.
|
||||
|
||||
Banners on this site are arbitrary markup: an image, a gradient div, or a
|
||||
small animated scene like the one above. Subpages inherit the nearest
|
||||
banner up their path, so a whole section can share one look.
|
||||
|
||||
```js
|
||||
// the essence of the banner above
|
||||
stars.forEach(s => { s.x = (s.x + s.speed * dt) % 1 })
|
||||
```
|
||||
|
||||
No build step, no framework — the snippet is stored with the page and
|
||||
dropped into the header as-is.
|
||||
"""
|
||||
|
||||
SMALL_RELEASES = """\
|
||||
Software wants to be shipped. The longer a change sits unmerged, the more
|
||||
it rots: context fades, conflicts accumulate, and the diff grows teeth.
|
||||
|
||||
1. Cut the scope until it fits in a day.
|
||||
2. Ship it behind whatever door you like.
|
||||
3. Let real use argue with your assumptions.
|
||||
|
||||
A release is a conversation with reality. Small releases keep the
|
||||
conversation lively.
|
||||
"""
|
||||
|
||||
CANVAS_BANNER = """\
|
||||
<canvas id="stars"></canvas>
|
||||
<script>
|
||||
(() => {
|
||||
const c = document.getElementById("stars");
|
||||
const ctx = c.getContext("2d");
|
||||
const fit = () => { c.width = c.clientWidth; c.height = c.clientHeight; };
|
||||
fit();
|
||||
addEventListener("resize", fit);
|
||||
const stars = Array.from({ length: 110 }, () => ({
|
||||
x: Math.random(), y: Math.random(),
|
||||
r: Math.random() * 1.4 + 0.3, v: Math.random() * 0.05 + 0.01,
|
||||
}));
|
||||
let prev = performance.now();
|
||||
(function frame(now) {
|
||||
if (!c.isConnected) return;
|
||||
const dt = Math.min(now - prev, 100); prev = now;
|
||||
ctx.fillStyle = "#0b0e1d";
|
||||
ctx.fillRect(0, 0, c.width, c.height);
|
||||
ctx.fillStyle = "#cdd6ff";
|
||||
for (const s of stars) {
|
||||
s.x = (s.x + s.v * dt / 1000) % 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x * c.width, s.y * c.height, s.r, 0, 7);
|
||||
ctx.fill();
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
})(prev);
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
EYES_BANNER = """\
|
||||
<canvas id="eyes"></canvas>
|
||||
<script>
|
||||
(() => {
|
||||
const c = document.getElementById("eyes");
|
||||
const ctx = c.getContext("2d");
|
||||
const BG = "#f3e9d7";
|
||||
const fit = () => { c.width = c.clientWidth; c.height = c.clientHeight; };
|
||||
fit();
|
||||
addEventListener("resize", fit);
|
||||
|
||||
// Mouse in canvas coordinates; pupils wander idly when it goes stale.
|
||||
let mx = 0, my = 0, lastMove = 0;
|
||||
addEventListener("mousemove", (e) => {
|
||||
const r = c.getBoundingClientRect();
|
||||
mx = e.clientX - r.left;
|
||||
my = e.clientY - r.top;
|
||||
lastMove = performance.now();
|
||||
});
|
||||
|
||||
// The pair of eyes is one critter: it wanders around the banner, and
|
||||
// every so often ducks below the bottom edge, then pops back up.
|
||||
let gx = 0.5, gy = 0.5; // group position (fractions of the canvas)
|
||||
let tx = 0.5, ty = 0.5; // wander target
|
||||
let yoff = 0, vy = 0; // vertical hide/pop spring (px)
|
||||
let hidePhase = 0; // 0 = up, 1 = ducking, 2 = down, waiting
|
||||
let nextMove = 0, nextHide = 4000 + Math.random() * 5000, resurfaceAt = 0;
|
||||
|
||||
// Per-eye pupil state: spring physics for goofy lag and overshoot.
|
||||
const eyes = [{ x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 }, { x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 }];
|
||||
|
||||
let prev = performance.now();
|
||||
(function frame(now) {
|
||||
if (!c.isConnected) return;
|
||||
const dt = Math.min(now - prev, 100) / 16.7; prev = now;
|
||||
ctx.fillStyle = BG;
|
||||
ctx.fillRect(0, 0, c.width, c.height);
|
||||
const R = Math.min(c.height * 0.32, 70);
|
||||
|
||||
// Wander: ease toward a spot, pick a new one every few seconds.
|
||||
if (now > nextMove && !hidePhase) {
|
||||
tx = 0.15 + Math.random() * 0.7;
|
||||
ty = 0.3 + Math.random() * 0.4;
|
||||
nextMove = now + 2500 + Math.random() * 3500;
|
||||
}
|
||||
gx += (tx - gx) * 0.02 * dt;
|
||||
gy += (ty - gy) * 0.02 * dt;
|
||||
|
||||
// Duck down, wait hidden, then spring back (underdamped = pops past
|
||||
// the resting point and wobbles). Resurfaces at a new spot.
|
||||
if (hidePhase === 0 && now > nextHide) hidePhase = 1;
|
||||
if (hidePhase === 1 && yoff > c.height * 0.9) {
|
||||
hidePhase = 2;
|
||||
resurfaceAt = now + 500 + Math.random() * 900;
|
||||
}
|
||||
if (hidePhase === 2 && now > resurfaceAt) {
|
||||
hidePhase = 0;
|
||||
nextHide = now + 5000 + Math.random() * 7000;
|
||||
tx = 0.15 + Math.random() * 0.7;
|
||||
gx = tx;
|
||||
nextMove = now + 3000 + Math.random() * 3000;
|
||||
}
|
||||
const yTarget = hidePhase ? c.height : 0;
|
||||
vy += (yTarget - yoff) * 0.06 * dt;
|
||||
vy *= 0.85;
|
||||
yoff += vy * dt;
|
||||
|
||||
const cy0 = gy * c.height + yoff;
|
||||
const cx0 = gx * c.width;
|
||||
const watching = now - lastMove < 4000;
|
||||
eyes.forEach((e, i) => {
|
||||
const cx = cx0 + (i ? 1.3 : -1.3) * R;
|
||||
// Pupil target: toward the cursor, or a slow idle drift.
|
||||
let ptx, pty;
|
||||
if (watching) {
|
||||
const dx = mx - cx, dy = my - cy0;
|
||||
const d = Math.hypot(dx, dy) || 1;
|
||||
const reach = R * 0.45 * Math.min(1, d / 200);
|
||||
ptx = (dx / d) * reach; pty = (dy / d) * reach;
|
||||
} else {
|
||||
ptx = Math.sin(now / 900 + i * 2) * R * 0.3;
|
||||
pty = Math.cos(now / 1300 + i * 3) * R * 0.2;
|
||||
}
|
||||
// Spring toward the target (underdamped: overshoots, wobbles).
|
||||
e.vx += (ptx - e.x) * 0.08 * dt; e.vy += (pty - e.y) * 0.08 * dt;
|
||||
e.vx *= 0.82; e.vy *= 0.82;
|
||||
e.x += e.vx * dt; e.y += e.vy * dt;
|
||||
// Pupils dilate when the cursor comes close to the eye.
|
||||
const near = Math.hypot(mx - cx, my - cy0) < R * 2.5;
|
||||
e.pr += ((near ? 0.42 : 0.3) - e.pr) * 0.1 * dt;
|
||||
// Sclera.
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy0, R, R * 1.15, 0, 0, 7);
|
||||
ctx.fill();
|
||||
// Iris + pupil + glint, clipped to the sclera.
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy0, R, R * 1.15, 0, 0, 7);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = "#7c5cff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx + e.x, cy0 + e.y, R * 0.55, 0, 7);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#1d1730";
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx + e.x, cy0 + e.y, R * e.pr, 0, 7);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx + e.x - R * 0.15, cy0 + e.y - R * 0.18, R * 0.09, 0, 7);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = "#2b2440";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy0, R, R * 1.15, 0, 0, 7);
|
||||
ctx.stroke();
|
||||
});
|
||||
requestAnimationFrame(frame);
|
||||
})(prev);
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
BLOG_BANNER = '<div style="background: linear-gradient(100deg, #14243d, #3d2b6b 45%, #7c5cff 75%, #ff5c8a)"></div>'
|
||||
|
||||
FRONT_BANNER = '<img src="/waves.svg" alt="">'
|
||||
|
||||
WAVES_SVG = """\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400">
|
||||
<defs>
|
||||
<linearGradient id="g1" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#7c5cff"/>
|
||||
<stop offset="1" stop-color="#00d4c8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="g2" x1="0" y1="1" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#ff5c8a"/>
|
||||
<stop offset="1" stop-color="#7c5cff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="800" height="400" fill="#12101c"/>
|
||||
<path d="M0 260 Q 200 180 400 260 T 800 260 V400 H0 Z" fill="url(#g1)" opacity="0.85"/>
|
||||
<path d="M0 310 Q 200 240 400 310 T 800 310 V400 H0 Z" fill="url(#g2)" opacity="0.75"/>
|
||||
<circle cx="600" cy="110" r="70" fill="#ffd75c" opacity="0.9"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
SHAPES_SVG = """\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
|
||||
<rect width="400" height="400" fill="#0f1a1c"/>
|
||||
<circle cx="140" cy="150" r="90" fill="#00d4c8" opacity="0.85"/>
|
||||
<rect x="180" y="180" width="150" height="150" rx="24" fill="#ffb35c" opacity="0.9"/>
|
||||
<path d="M140 60 L 220 200 L 60 200 Z" fill="#ff5c8a" opacity="0.8"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
DUNES_SVG = """\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 300">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#2b1b4d"/>
|
||||
<stop offset="1" stop-color="#ff8a5c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="300" fill="url(#sky)"/>
|
||||
<path d="M0 210 Q 300 150 600 210 T 1200 210 V300 H0 Z" fill="#3d2b6b"/>
|
||||
<path d="M0 250 Q 300 200 600 250 T 1200 250 V300 H0 Z" fill="#241842"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
#: path -> (title, markdown, {filename: bytes}, banner HTML, menu order).
|
||||
#: Note there are deliberately no "docs" or "blog" landing pages: those
|
||||
#: labels are created without content, so entering them redirects to the
|
||||
#: first child (see views.first_leaf).
|
||||
PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float]] = {
|
||||
"": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, FRONT_BANNER, 1),
|
||||
"about": ("About", ABOUT, {}, "", 2),
|
||||
"docs/editing": (
|
||||
"Writing Content",
|
||||
EDITING,
|
||||
{"shapes.svg": SHAPES_SVG.encode()},
|
||||
"",
|
||||
1,
|
||||
),
|
||||
"blog/the-long-read": (
|
||||
"The Long Read",
|
||||
LONG_READ,
|
||||
{"dunes.svg": DUNES_SVG.encode()},
|
||||
BLOG_BANNER,
|
||||
1,
|
||||
),
|
||||
"blog/notes-on-urls": ("Notes on URLs", NOTES_ON_URLS, {}, EYES_BANNER, 2),
|
||||
"blog/canvas-nights": ("Canvas Nights", CANVAS_NIGHTS, {}, CANVAS_BANNER, 3),
|
||||
"blog/small-releases": ("Small Releases", SMALL_RELEASES, {}, "", 4),
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 360">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0b0918"/>
|
||||
<stop offset="0.6" stop-color="#241842"/>
|
||||
<stop offset="1" stop-color="#3d2b6b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="aur1" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#00d4c8" stop-opacity="0"/>
|
||||
<stop offset="0.5" stop-color="#00d4c8" stop-opacity="0.7"/>
|
||||
<stop offset="1" stop-color="#7c5cff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="aur2" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#7c5cff" stop-opacity="0"/>
|
||||
<stop offset="0.5" stop-color="#ff5c8a" stop-opacity="0.55"/>
|
||||
<stop offset="1" stop-color="#ffd75c" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1600" height="360" fill="url(#sky)"/>
|
||||
<g fill="#ffffff">
|
||||
<circle cx="120" cy="60" r="1.6" opacity="0.9"/>
|
||||
<circle cx="300" cy="30" r="1.1" opacity="0.7"/>
|
||||
<circle cx="470" cy="90" r="1.4" opacity="0.8"/>
|
||||
<circle cx="640" cy="45" r="1" opacity="0.6"/>
|
||||
<circle cx="820" cy="70" r="1.5" opacity="0.85"/>
|
||||
<circle cx="990" cy="35" r="1.1" opacity="0.7"/>
|
||||
<circle cx="1150" cy="85" r="1.6" opacity="0.9"/>
|
||||
<circle cx="1320" cy="50" r="1" opacity="0.6"/>
|
||||
<circle cx="1480" cy="95" r="1.3" opacity="0.8"/>
|
||||
</g>
|
||||
<path d="M0 190 Q 400 90 800 170 T 1600 150 V240 Q 1200 210 800 240 T 0 250 Z" fill="url(#aur1)"/>
|
||||
<path d="M0 230 Q 400 140 800 210 T 1600 190 V280 Q 1200 250 800 280 T 0 290 Z" fill="url(#aur2)"/>
|
||||
<path d="M0 300 Q 400 260 800 300 T 1600 290 V360 H0 Z" fill="#12101c"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
@font-face { font-family: 'Fraunces'; font-weight: 100 1000; font-style: normal; font-display: swap; src: url('/static/fonts/fraunces.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Literata'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('/static/fonts/literata.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 300 700; font-style: normal; font-display: swap; src: url('/static/fonts/firacode.woff2') format('woff2'); }
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,258 @@
|
||||
// Fetch-navigation: swap dynamic regions (#nav, #main) instead of full
|
||||
// page loads. Real <a href> links are used throughout, so this is pure
|
||||
// progressive enhancement - without JS every link does a normal load.
|
||||
//
|
||||
// Also: scroll-reveal effects and code copy buttons. These need no
|
||||
// support from the article itself and are re-applied after each swap.
|
||||
(() => {
|
||||
const REGIONS = ["page-banner", "nav", "sidebar", "main"];
|
||||
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let editorModule = null;
|
||||
|
||||
function runScripts(root) {
|
||||
// Scripts inserted via DOM swapping do not execute; re-create them.
|
||||
for (const old of root.querySelectorAll("script")) {
|
||||
const s = document.createElement("script");
|
||||
for (const a of old.attributes) s.setAttribute(a.name, a.value);
|
||||
s.textContent = old.textContent;
|
||||
old.replaceWith(s);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scroll reveal + code block copy buttons -------------------------
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add("in");
|
||||
observer.unobserve(e.target);
|
||||
}
|
||||
}
|
||||
}, { rootMargin: "0px 0px -8% 0px" });
|
||||
|
||||
function addCopyButtons(main) {
|
||||
for (const pre of main.querySelectorAll("pre")) {
|
||||
if (pre.querySelector(".copy")) continue;
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "copy";
|
||||
btn.type = "button";
|
||||
btn.textContent = "copy";
|
||||
btn.addEventListener("click", async () => {
|
||||
const code = pre.querySelector("code");
|
||||
await navigator.clipboard.writeText(
|
||||
(code || pre).textContent.replace(/\n$/, ""),
|
||||
);
|
||||
btn.textContent = "copied";
|
||||
btn.classList.add("copied");
|
||||
setTimeout(() => {
|
||||
btn.textContent = "copy";
|
||||
btn.classList.remove("copied");
|
||||
}, 1500);
|
||||
});
|
||||
pre.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
// Tuck the article edit pen at the end of the first h1 (which may come
|
||||
// from the markdown itself). Re-runs when the editor replaces the
|
||||
// previewed body, since that wipes elements inside it.
|
||||
function placeEditPen() {
|
||||
const article = document.querySelector("#main article");
|
||||
const btn = article?.querySelector("button.edit-link");
|
||||
// First visible h1: the title h1 may be display:none when the
|
||||
// markdown owns its heading (editor preview state).
|
||||
const h1 = [...(article?.querySelectorAll("h1") || [])]
|
||||
.find((h) => h.offsetParent !== null);
|
||||
if (btn && h1 && btn.parentElement !== h1) h1.append(btn);
|
||||
}
|
||||
|
||||
addEventListener("pagerite:preview", placeEditPen);
|
||||
|
||||
function applyEffects() {
|
||||
(window.requestIdleCallback || setTimeout)(preload);
|
||||
const main = document.getElementById("main");
|
||||
addCopyButtons(main);
|
||||
placeEditPen();
|
||||
// Multi-column layout only when there is enough text to justify it.
|
||||
// Split the body into columned segments: h2s and wide figures are
|
||||
// full-width separators and never go inside columns.
|
||||
const article = main.querySelector("article");
|
||||
if (article) {
|
||||
const body = article.querySelector(".body");
|
||||
article.classList.toggle(
|
||||
"multicol",
|
||||
!!body && body.textContent.trim().length > 1800,
|
||||
);
|
||||
if (body && article.classList.contains("multicol")
|
||||
&& !body.querySelector(".colseg")) {
|
||||
// h2s and anything holding a wide image are full-width separators
|
||||
const isSeparator = (el) =>
|
||||
el.tagName === "H2" || el.querySelector("img.wide") !== null;
|
||||
let seg = null;
|
||||
for (const el of [...body.children]) {
|
||||
if (isSeparator(el)) {
|
||||
seg = null;
|
||||
body.append(el);
|
||||
} else {
|
||||
if (!seg) {
|
||||
seg = document.createElement("div");
|
||||
seg.className = "colseg";
|
||||
body.append(seg);
|
||||
}
|
||||
seg.append(el);
|
||||
}
|
||||
}
|
||||
// Columns are per section: only segments with enough text get them,
|
||||
// so a short ingress or a brief section stays single-column.
|
||||
for (const s of body.querySelectorAll(".colseg")) {
|
||||
s.classList.toggle("cols", s.textContent.trim().length > 600);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reduceMotion.matches) return;
|
||||
for (const el of main.querySelectorAll(
|
||||
"h2, h3, figure, img, pre, blockquote, table, dl, .task-list-item",
|
||||
)) {
|
||||
if (!el.classList.contains("reveal")) {
|
||||
el.classList.add("reveal");
|
||||
observer.observe(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Preloading ------------------------------------------------------
|
||||
// Warm the HTTP cache with all linked pages and their resources, so
|
||||
// navigation (and the cube transition) is instant. Pages carry ETags,
|
||||
// so re-running this after each navigation revalidates cheaply (304)
|
||||
// and picks up changed content and images.
|
||||
function preload() {
|
||||
const urls = new Set();
|
||||
for (const a of document.querySelectorAll('#nav a[href^="/"], #main a[href^="/"]')) {
|
||||
if (!a.pathname.startsWith("/admin")) urls.add(a.pathname);
|
||||
}
|
||||
for (const url of urls) {
|
||||
if (url === location.pathname) continue;
|
||||
fetch(url)
|
||||
.then((r) => (r.ok ? r.text() : ""))
|
||||
.then((html) => {
|
||||
if (!html) return;
|
||||
// Off-screen parse: load the page's images and other resources
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
for (const img of doc.querySelectorAll("img")) {
|
||||
const i = new Image();
|
||||
i.src = img.src;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// The path we are currently showing. location.pathname is unusable for
|
||||
// this on popstate (it has already changed to the target); the editors
|
||||
// signal their replaceState navigation with pagerite:preview.
|
||||
let currentPath = location.pathname;
|
||||
addEventListener("pagerite:preview", () => {
|
||||
currentPath = location.pathname;
|
||||
});
|
||||
|
||||
// --- Fetch navigation ------------------------------------------------
|
||||
async function load(url, push = true, back = false) {
|
||||
// Navigating with the editor open closes it; unsaved edits are lost
|
||||
// (the region swap discards the previewed changes anyway).
|
||||
if (document.body.classList.contains("editing")) {
|
||||
editorModule?.then((m) => m.closeEditor());
|
||||
}
|
||||
let doc;
|
||||
let finalUrl = url;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const type = res.headers.get("content-type") || "";
|
||||
if (!res.ok || !type.includes("text/html")) throw new Error("not a page");
|
||||
// Section URLs redirect to their first child; reflect that.
|
||||
if (res.redirected) finalUrl = res.url;
|
||||
doc = new DOMParser().parseFromString(await res.text(), "text/html");
|
||||
} catch {
|
||||
location.href = url; // fall back to a normal navigation
|
||||
return;
|
||||
}
|
||||
if (REGIONS.some((id) => !doc.getElementById(id))) {
|
||||
location.href = url;
|
||||
return;
|
||||
}
|
||||
const doit = () => {
|
||||
for (const id of REGIONS) {
|
||||
const el = document.getElementById(id);
|
||||
el.replaceWith(document.importNode(doc.getElementById(id), true));
|
||||
}
|
||||
document.title = doc.title;
|
||||
// Banners may contain scripts (canvas etc.), content pages may too.
|
||||
runScripts(document.getElementById("page-banner"));
|
||||
runScripts(document.getElementById("main"));
|
||||
applyEffects();
|
||||
};
|
||||
// Rotating cube page transition (see the FRAGILE block in style.css);
|
||||
// mirrored when navigating back through history. Navigation within the
|
||||
// same top-level section crossfades instead, in either direction.
|
||||
if (document.startViewTransition && !reduceMotion.matches) {
|
||||
const seg = (u) => new URL(u, location.href).pathname.split("/")[1];
|
||||
const fade = seg(finalUrl) === seg(currentPath);
|
||||
const root = document.documentElement.classList;
|
||||
root.toggle("nav-fade", fade);
|
||||
root.toggle("nav-back", back && !fade);
|
||||
document.startViewTransition(doit).finished.finally(() => {
|
||||
root.remove("nav-fade", "nav-back");
|
||||
});
|
||||
} else {
|
||||
doit();
|
||||
}
|
||||
currentPath = new URL(finalUrl, location.href).pathname;
|
||||
if (push) history.pushState(null, "", finalUrl);
|
||||
scrollTo(0, 0);
|
||||
}
|
||||
|
||||
addEventListener("click", (ev) => {
|
||||
if (ev.defaultPrevented || ev.button !== 0
|
||||
|| ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
||||
// Edit buttons toggle the editor panel docked on this page: load the
|
||||
// Vue app on demand (with any extra styles) and mount it in place.
|
||||
// Clicking the pen of the already-open editor closes it; clicking the
|
||||
// other pen swaps the panel for the other editor type.
|
||||
const editBtn = ev.target.closest("button.edit-link");
|
||||
if (editBtn && editBtn.dataset.editorSrc) {
|
||||
ev.preventDefault();
|
||||
const mode = editBtn.dataset.editorMode || "page";
|
||||
if (document.body.classList.contains("editing")
|
||||
&& document.body.dataset.editorMode === mode) {
|
||||
editorModule?.then((m) => m.closeEditor());
|
||||
return;
|
||||
}
|
||||
for (const css of (editBtn.dataset.editorCss || "").split(",")) {
|
||||
if (css && !document.querySelector(`link[href="${css}"]`)) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = css;
|
||||
document.head.append(link);
|
||||
}
|
||||
}
|
||||
const path = location.pathname.replace(/^\/+|\/+$/g, "");
|
||||
editorModule = import(/* @vite-ignore */ editBtn.dataset.editorSrc);
|
||||
editorModule
|
||||
.then((m) => m.openEditor(path, { mode }))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
const a = ev.target.closest("a[href]");
|
||||
if (!a || a.target || a.hasAttribute("download")) return;
|
||||
const url = new URL(a.href, location.href);
|
||||
if (url.origin !== location.origin) return;
|
||||
// Same-page anchor links (footnotes etc.): let the browser handle them
|
||||
if (url.pathname === location.pathname && url.hash) return;
|
||||
if (url.pathname.startsWith("/_/") || url.pathname.startsWith("/static/")
|
||||
|| url.pathname === "/admin") return;
|
||||
ev.preventDefault();
|
||||
load(url);
|
||||
});
|
||||
|
||||
addEventListener("popstate", () => load(location.href, false, true));
|
||||
|
||||
applyEffects();
|
||||
})();
|
||||
@@ -0,0 +1,86 @@
|
||||
pre { line-height: 125%; }
|
||||
td.linenos .normal { color: #6e7681; background-color: #0d1117; padding-left: 5px; padding-right: 5px; }
|
||||
span.linenos { color: #6e7681; background-color: #0d1117; padding-left: 5px; padding-right: 5px; }
|
||||
td.linenos .special { color: #e6edf3; background-color: #6e7681; padding-left: 5px; padding-right: 5px; }
|
||||
span.linenos.special { color: #e6edf3; background-color: #6e7681; padding-left: 5px; padding-right: 5px; }
|
||||
pre code .hll { background-color: #6e7681 }
|
||||
pre code { color: #E6EDF3 }
|
||||
pre code .c { color: #8B949E; font-style: italic } /* Comment */
|
||||
pre code .err { color: #F85149 } /* Error */
|
||||
pre code .esc { color: #E6EDF3 } /* Escape */
|
||||
pre code .g { color: #E6EDF3 } /* Generic */
|
||||
pre code .k { color: #FF7B72 } /* Keyword */
|
||||
pre code .l { color: #A5D6FF } /* Literal */
|
||||
pre code .n { color: #E6EDF3 } /* Name */
|
||||
pre code .o { color: #FF7B72; font-weight: bold } /* Operator */
|
||||
pre code .x { color: #E6EDF3 } /* Other */
|
||||
pre code .p { color: #E6EDF3 } /* Punctuation */
|
||||
pre code .ch { color: #8B949E; font-style: italic } /* Comment.Hashbang */
|
||||
pre code .cm { color: #8B949E; font-style: italic } /* Comment.Multiline */
|
||||
pre code .cp { color: #8B949E; font-weight: bold; font-style: italic } /* Comment.Preproc */
|
||||
pre code .cpf { color: #8B949E; font-style: italic } /* Comment.PreprocFile */
|
||||
pre code .c1 { color: #8B949E; font-style: italic } /* Comment.Single */
|
||||
pre code .cs { color: #8B949E; font-weight: bold; font-style: italic } /* Comment.Special */
|
||||
pre code .gd { color: #FFA198; background-color: #490202 } /* Generic.Deleted */
|
||||
pre code .ge { color: #E6EDF3; font-style: italic } /* Generic.Emph */
|
||||
pre code .ges { color: #E6EDF3; font-weight: bold; font-style: italic } /* Generic.EmphStrong */
|
||||
pre code .gr { color: #FFA198 } /* Generic.Error */
|
||||
pre code .gh { color: #79C0FF; font-weight: bold } /* Generic.Heading */
|
||||
pre code .gi { color: #56D364; background-color: #0F5323 } /* Generic.Inserted */
|
||||
pre code .go { color: #8B949E } /* Generic.Output */
|
||||
pre code .gp { color: #8B949E } /* Generic.Prompt */
|
||||
pre code .gs { color: #E6EDF3; font-weight: bold } /* Generic.Strong */
|
||||
pre code .gu { color: #79C0FF } /* Generic.Subheading */
|
||||
pre code .gt { color: #FF7B72 } /* Generic.Traceback */
|
||||
pre code .g-Underline { color: #E6EDF3; text-decoration: underline } /* Generic.Underline */
|
||||
pre code .kc { color: #79C0FF } /* Keyword.Constant */
|
||||
pre code .kd { color: #FF7B72 } /* Keyword.Declaration */
|
||||
pre code .kn { color: #FF7B72 } /* Keyword.Namespace */
|
||||
pre code .kp { color: #79C0FF } /* Keyword.Pseudo */
|
||||
pre code .kr { color: #FF7B72 } /* Keyword.Reserved */
|
||||
pre code .kt { color: #FF7B72 } /* Keyword.Type */
|
||||
pre code .ld { color: #79C0FF } /* Literal.Date */
|
||||
pre code .m { color: #A5D6FF } /* Literal.Number */
|
||||
pre code .s { color: #A5D6FF } /* Literal.String */
|
||||
pre code .na { color: #E6EDF3 } /* Name.Attribute */
|
||||
pre code .nb { color: #E6EDF3 } /* Name.Builtin */
|
||||
pre code .nc { color: #F0883E; font-weight: bold } /* Name.Class */
|
||||
pre code .no { color: #79C0FF; font-weight: bold } /* Name.Constant */
|
||||
pre code .nd { color: #D2A8FF; font-weight: bold } /* Name.Decorator */
|
||||
pre code .ni { color: #FFA657 } /* Name.Entity */
|
||||
pre code .ne { color: #F0883E; font-weight: bold } /* Name.Exception */
|
||||
pre code .nf { color: #D2A8FF; font-weight: bold } /* Name.Function */
|
||||
pre code .nl { color: #79C0FF; font-weight: bold } /* Name.Label */
|
||||
pre code .nn { color: #FF7B72 } /* Name.Namespace */
|
||||
pre code .nx { color: #E6EDF3 } /* Name.Other */
|
||||
pre code .py { color: #79C0FF } /* Name.Property */
|
||||
pre code .nt { color: #7EE787 } /* Name.Tag */
|
||||
pre code .nv { color: #79C0FF } /* Name.Variable */
|
||||
pre code .ow { color: #FF7B72; font-weight: bold } /* Operator.Word */
|
||||
pre code .pm { color: #E6EDF3 } /* Punctuation.Marker */
|
||||
pre code .w { color: #6E7681 } /* Text.Whitespace */
|
||||
pre code .mb { color: #A5D6FF } /* Literal.Number.Bin */
|
||||
pre code .mf { color: #A5D6FF } /* Literal.Number.Float */
|
||||
pre code .mh { color: #A5D6FF } /* Literal.Number.Hex */
|
||||
pre code .mi { color: #A5D6FF } /* Literal.Number.Integer */
|
||||
pre code .mo { color: #A5D6FF } /* Literal.Number.Oct */
|
||||
pre code .sa { color: #79C0FF } /* Literal.String.Affix */
|
||||
pre code .sb { color: #A5D6FF } /* Literal.String.Backtick */
|
||||
pre code .sc { color: #A5D6FF } /* Literal.String.Char */
|
||||
pre code .dl { color: #79C0FF } /* Literal.String.Delimiter */
|
||||
pre code .sd { color: #A5D6FF } /* Literal.String.Doc */
|
||||
pre code .s2 { color: #A5D6FF } /* Literal.String.Double */
|
||||
pre code .se { color: #79C0FF } /* Literal.String.Escape */
|
||||
pre code .sh { color: #79C0FF } /* Literal.String.Heredoc */
|
||||
pre code .si { color: #A5D6FF } /* Literal.String.Interpol */
|
||||
pre code .sx { color: #A5D6FF } /* Literal.String.Other */
|
||||
pre code .sr { color: #79C0FF } /* Literal.String.Regex */
|
||||
pre code .s1 { color: #A5D6FF } /* Literal.String.Single */
|
||||
pre code .ss { color: #A5D6FF } /* Literal.String.Symbol */
|
||||
pre code .bp { color: #E6EDF3 } /* Name.Builtin.Pseudo */
|
||||
pre code .fm { color: #D2A8FF; font-weight: bold } /* Name.Function.Magic */
|
||||
pre code .vc { color: #79C0FF } /* Name.Variable.Class */
|
||||
pre code .vg { color: #79C0FF } /* Name.Variable.Global */
|
||||
pre code .vi { color: #79C0FF } /* Name.Variable.Instance */
|
||||
pre code .vm { color: #79C0FF } /* Name.Variable.Magic */
|
||||
pre code .il { color: #A5D6FF } /* Literal.Number.Integer.Long */
|
||||
@@ -0,0 +1,695 @@
|
||||
/* Shared styles for server-rendered pages and Vue components. */
|
||||
@import url("/static/fonts/fonts.css");
|
||||
@import url("/static/pygments.css");
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #12101c;
|
||||
--surface: #1b1830;
|
||||
--text: #e8e6f2;
|
||||
--muted: #9a94b8;
|
||||
--accent: #00d4c8;
|
||||
--accent2: #7c5cff;
|
||||
--line: #ffffff1a;
|
||||
/* Width of the docked editor panel (used both here for shifting the page
|
||||
and in the Vue editor's own styles). */
|
||||
--editor-w: min(46rem, 50vw);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Literata", Georgia, serif;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
/* Full-bleed elements (.wide) size to 100vw, which counts the vertical
|
||||
scrollbar; clip the few stray pixels instead of scrolling. */
|
||||
overflow-x: clip;
|
||||
/* Full height even on short pages: the footer sits at the bottom and the
|
||||
docked editor (sized by #content) never collapses. */
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent2);
|
||||
}
|
||||
|
||||
/* Full-width banner: image header with the brand and nav overlaid. */
|
||||
#banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 11rem;
|
||||
background: url("/static/banner.svg") center 40% / cover;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* Per-page banner content (img, styled div, canvas...) overlays the
|
||||
default artwork; swapped along with #nav/#main on fetch-navigation. */
|
||||
#page-banner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#page-banner>* {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#brand,
|
||||
#nav {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#brand {
|
||||
font-family: "Fraunces", serif;
|
||||
font-weight: 700;
|
||||
font-size: 2.4rem;
|
||||
text-decoration: none;
|
||||
margin: auto 1.25rem 0;
|
||||
padding-top: 1.5rem;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: none;
|
||||
filter: drop-shadow(0 0 0.1rem #000);
|
||||
}
|
||||
|
||||
/* Nav overlaid at the bottom of the banner */
|
||||
#nav {
|
||||
font-size: 1.3em;
|
||||
text-shadow: 0 0 0.1em black;
|
||||
padding: 0.35rem 1.25rem;
|
||||
}
|
||||
|
||||
#nav ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 0.25rem 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
#nav a {
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#nav ul ul a {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#nav a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#nav span {
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
#nav .current {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Sidebar + main row. A symmetric grid: the article column is sized by the
|
||||
viewport alone (never by content), with equally sized flexible gutters
|
||||
on both sides. The sidebar sits in the left gutter, so it appearing or
|
||||
disappearing never shifts the article; the right gutter balances it. */
|
||||
#content {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(0, 78rem) 1fr;
|
||||
/* The docked editor pushes the content (not the header) right. */
|
||||
transition: margin-left 0.25s ease;
|
||||
}
|
||||
|
||||
body.editing #content {
|
||||
margin-left: var(--editor-w);
|
||||
padding-left: 1rem;
|
||||
/* gap between the docked editor and the content */
|
||||
/* No overflow clipping here: .editor-host lives outside this box
|
||||
(negative left), and .wide shrink-wraps to the remaining space. */
|
||||
}
|
||||
|
||||
/* The sidebar's gutter space is needed by the editor instead. */
|
||||
body.editing #sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The editor host lives inside #content: it starts below the banner and
|
||||
ends above the footer. The panel itself sticks to the viewport while
|
||||
scrolling (but never taller than the content area). */
|
||||
.editor-host {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(0px - var(--editor-w));
|
||||
width: var(--editor-w);
|
||||
}
|
||||
|
||||
.editor-root.overlay {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
max-height: 100%;
|
||||
background: var(--bg);
|
||||
animation: editor-slide-in 0.25s ease;
|
||||
}
|
||||
|
||||
.editor-root.overlay.closing {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes editor-slide-in {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Site structure tree: while dragging, empty child lists appear as drop
|
||||
zones so a page can be moved under a childless page. */
|
||||
body.tree-dragging .treelist:empty {
|
||||
min-height: 1.2rem;
|
||||
outline: 1px dashed var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* The banner's own pen: opens the site editor (banner + structure).
|
||||
Qualified with `button` to beat the later .edit-link rule's left offset
|
||||
(both classes apply to the same element). */
|
||||
button.banner-edit-link {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 1.25rem;
|
||||
left: auto;
|
||||
z-index: 10;
|
||||
font: inherit;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.banner-edit-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
grid-column: 1;
|
||||
/* Pinned to the page's left edge (not the article's) and kept in view
|
||||
while scrolling. Translucent + blurred rather than an opaque box, so
|
||||
full-bleed .wide images can pass underneath without a hard edge. */
|
||||
justify-self: start;
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
width: 12rem;
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1rem 1rem 1.25rem;
|
||||
border-radius: 0 0 0.5rem 0;
|
||||
background: color-mix(in srgb, var(--bg) 75%, transparent);
|
||||
backdrop-filter: blur(0.5rem);
|
||||
}
|
||||
|
||||
#sidebar:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#sidebar ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8em;
|
||||
line-height: 1.0;
|
||||
}
|
||||
|
||||
#sidebar a {
|
||||
text-decoration: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#sidebar a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#sidebar .current {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
main {
|
||||
grid-column: 2;
|
||||
/* No top padding: a leading wide image sits flush under the banner, and
|
||||
text-first pages get their spacing from the h1's top margin instead. */
|
||||
padding: 0 1.25rem 3rem;
|
||||
}
|
||||
|
||||
article h1,
|
||||
article h2,
|
||||
article h3 {
|
||||
font-family: "Fraunces", serif;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
article h1 {
|
||||
font-size: 2.2rem;
|
||||
margin: 2rem 0 1.2rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Margin strategy: bottom-only inside articles. Top margins misalign
|
||||
column tops and collapse unpredictably; spacing comes from below. */
|
||||
article p,
|
||||
article ul,
|
||||
article ol,
|
||||
article dl,
|
||||
article blockquote,
|
||||
article pre,
|
||||
article figure {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
article h3 {
|
||||
margin: 1.4rem 0 0.4rem;
|
||||
color: color-mix(in oklab, var(--accent2) 60%, var(--muted));
|
||||
}
|
||||
|
||||
/* Lists: small diamond emoji markers — blue 🔹 on odd nesting levels,
|
||||
orange 🔸 on even. The marker occupies a 1em outdented box so wrapped
|
||||
lines align. */
|
||||
article ul {
|
||||
list-style: none;
|
||||
padding-inline-start: 1em;
|
||||
}
|
||||
|
||||
article ul li::before {
|
||||
content: "🔹";
|
||||
display: inline-block;
|
||||
margin-left: -1.3em;
|
||||
width: 1.3em;
|
||||
}
|
||||
|
||||
article ul ul li::before {
|
||||
content: "🔸";
|
||||
}
|
||||
|
||||
article ul ul ul li::before {
|
||||
content: "🔹";
|
||||
}
|
||||
|
||||
/* Task lists render emoji checkmarks (see markdown.py), no diamond. */
|
||||
article .task-list-item::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
article {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.edit-link {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
/* In the left gutter, on the same side as the docked editor panel. */
|
||||
left: -2.2rem;
|
||||
z-index: 2;
|
||||
/* stay above full-bleed .wide images */
|
||||
font: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 0.1em black;
|
||||
}
|
||||
|
||||
/* pagerite.js tucks the pen at the end of the article's first h1. */
|
||||
article h1 .edit-link {
|
||||
position: static;
|
||||
font-size: 1.1rem;
|
||||
vertical-align: 0.3em;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.edit-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
article p,
|
||||
article li,
|
||||
article dd {
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Multi-column reading on wide displays, but only for long articles
|
||||
(pagerite.js adds .multicol based on content length and splits the body
|
||||
into .colseg segments separated by full-width h2s and wide figures;
|
||||
only segments with enough text get .cols and thus columns). Columns only
|
||||
reflow text inside the article; the article's width never changes. */
|
||||
@media (min-width: 100rem) {
|
||||
.multicol .colseg.cols {
|
||||
columns: 2;
|
||||
column-gap: 3.5rem;
|
||||
column-rule: 1px solid var(--line);
|
||||
}
|
||||
}
|
||||
|
||||
.multicol .colseg {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
p,
|
||||
li {
|
||||
break-inside: avoid-column;
|
||||
}
|
||||
|
||||
figure,
|
||||
pre,
|
||||
blockquote,
|
||||
table,
|
||||
dl {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
article h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 2.2rem 0 0.6rem;
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
article a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
article a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Blockquotes: inner paragraphs carry no margins (spacing comes from the
|
||||
blockquote itself, bottom-only like everything else in articles). The
|
||||
negative left margin pushes the bar out past the text edge, so quoted
|
||||
text aligns with the surrounding paragraphs — same trick as code blocks. */
|
||||
blockquote {
|
||||
margin: 0 0 1rem -0.5rem;
|
||||
padding: 0 0 0 0.25rem;
|
||||
border-left: 0.25rem solid var(--accent2);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem 0.8rem;
|
||||
/* Code text aligns with the surrounding paragraphs: the box extends
|
||||
past them by its own padding. */
|
||||
margin-left: -0.8rem;
|
||||
margin-right: -0.8rem;
|
||||
background: #ffffff09;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Inline code integrates with the text, no box of its own */
|
||||
p code,
|
||||
li code {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Click-to-copy button (added by pagerite.js) */
|
||||
.copy {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
pre:hover .copy,
|
||||
.copy:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy.copied {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Fira Code", ui-monospace, monospace;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.35rem 0.8rem;
|
||||
}
|
||||
|
||||
/* Images and figures */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
/* Captions of full-bleed images: centered and kept to a readable width. */
|
||||
figure:has(.wide) figcaption {
|
||||
max-width: 65ch;
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Positioning via brace-attribute classes: {.right}, {.left}, {.wide} */
|
||||
figure:has(.right),
|
||||
img.right {
|
||||
float: right;
|
||||
margin: 0.3rem 0 1rem 1.5rem;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
figure:has(.left),
|
||||
img.left {
|
||||
float: left;
|
||||
margin: 0.3rem 1.5rem 1rem 0;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
/* .wide is full bleed: edge to edge of the viewport (or of the space left
|
||||
of the docked editor). Centered on the article column — which is itself
|
||||
centered in the available space — via margin-left: 50% + translateX(-50%).
|
||||
The sidebar stacks above it (z-index + opaque background). */
|
||||
figure:has(.wide),
|
||||
img.wide {
|
||||
display: block;
|
||||
/* no inline strut/descender gaps around the image */
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
margin-left: 50%;
|
||||
margin-right: 0;
|
||||
/* kill the UA figure margin, it overflowed the body */
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* A paragraph wrapping only a wide image must not add its line height. */
|
||||
p:has(> img.wide:only-child) {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
body.editing figure:has(.wide),
|
||||
body.editing img.wide {
|
||||
width: calc(100vw - var(--editor-w) - 1rem);
|
||||
}
|
||||
|
||||
/* Scroll reveal (pagerite.js adds .reveal/.in; JS off = fully visible) */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
translate: 0 14px;
|
||||
transition:
|
||||
opacity 0.6s ease,
|
||||
translate 0.6s ease;
|
||||
}
|
||||
|
||||
.reveal.in {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
translate: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.task-list-item {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* The footer element is kept (the editor host ends above it) but currently
|
||||
empty and zero-height. */
|
||||
footer {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Rotating-cube page transition, adapted from termotohtori.fi.
|
||||
FRAGILE: do not tweak; the view-transition pseudo-tree is picky. */
|
||||
::view-transition {
|
||||
perspective: 1000px;
|
||||
background: #000;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
::view-transition-group(root),
|
||||
::view-transition-image-pair(root) {
|
||||
transform-style: preserve-3d;
|
||||
isolation: auto;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
mix-blend-mode: normal;
|
||||
backface-visibility: hidden;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes group-rotate {
|
||||
to {
|
||||
transform: rotateY(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out-a-bit {
|
||||
to {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-a-bit {
|
||||
from {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-group(root) {
|
||||
transform-origin: 50% 50% -50vw;
|
||||
animation: 300ms ease-in-out forwards group-rotate;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: 300ms ease-in-out forwards fade-out-a-bit;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
transform-origin: 0 0;
|
||||
transform: rotateY(90deg);
|
||||
inset: 0 auto 0 100%;
|
||||
animation: 300ms ease-in-out forwards fade-in-a-bit;
|
||||
}
|
||||
|
||||
/* Reverse direction for browser back navigation (same geometry, mirrored). */
|
||||
@keyframes group-rotate-back {
|
||||
to {
|
||||
transform: rotateY(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
html.nav-back::view-transition-group(root) {
|
||||
animation-name: group-rotate-back;
|
||||
}
|
||||
|
||||
html.nav-back::view-transition-new(root) {
|
||||
transform-origin: 100% 0;
|
||||
transform: rotateY(-90deg);
|
||||
inset: 0 100% 0 auto;
|
||||
}
|
||||
|
||||
/* Same-section navigation: a plain crossfade instead of the cube. These
|
||||
rules only override animation/geometry, leaving the FRAGILE block's
|
||||
perspective and layering untouched. The old snapshot stays fully opaque
|
||||
underneath while the new one fades in on top — never a dip to black. */
|
||||
@keyframes nav-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
html.nav-fade::view-transition-group(root) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
html.nav-fade::view-transition-old(root) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
html.nav-fade::view-transition-new(root) {
|
||||
transform: none;
|
||||
inset: 0;
|
||||
animation: 200ms ease-in-out nav-fade-in;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
"""HTML rendering: page layout template, navigation, content pages.
|
||||
|
||||
All pages share one static layout, defined once as an html5tagger Template
|
||||
with placeholders (capitalized attributes) filled per request. The dynamic
|
||||
regions carry stable ids (#nav, #main) so that the fetch-navigation script
|
||||
can swap them without reloading the page chrome.
|
||||
|
||||
Navigation walks the Node tree directly (see data.py): nav_html lists the
|
||||
top level — the front page (slug "") is an ordinary top-level item, not
|
||||
the parent of the others — and sidebar_html the children of the current
|
||||
top-level section. Nodes without content are category labels; their URLs
|
||||
redirect to the first child page (first_leaf).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
|
||||
from html5tagger import HTML, Document, E, Template
|
||||
|
||||
from pagerite.data import Node, prettify, resolve, sorted_nodes
|
||||
from pagerite.markdown import has_h1, render
|
||||
|
||||
SITE_NAME = "Pagerite"
|
||||
BUILD = Path(__file__).with_name("frontend-build")
|
||||
|
||||
Layout = Template(
|
||||
Document(
|
||||
E.Title,
|
||||
lang="en",
|
||||
_urls=["/static/style.css", "/static/pagerite.js"],
|
||||
)
|
||||
.header(
|
||||
E.div(E.Banner, id="page-banner"),
|
||||
E.BannerEdit,
|
||||
E.Brand,
|
||||
E.nav(E.Nav, id="nav"),
|
||||
id="banner",
|
||||
)
|
||||
.div(
|
||||
E.aside(E.Sidebar, id="sidebar"),
|
||||
E.main(E.Main, id="main"),
|
||||
id="content",
|
||||
)
|
||||
.footer(None), # kept empty for now; zero-height (see style.css)
|
||||
)
|
||||
|
||||
|
||||
def _brand_link(brand: str) -> HTML:
|
||||
"""Header brand link; omitted entirely when no brand is configured."""
|
||||
return HTML(str(E.a(brand, href="/", id="brand"))) if brand else HTML("")
|
||||
|
||||
|
||||
def _title(slug: str, node: Node) -> str:
|
||||
"""Menu label: the configured title, prettified slug, "Home" fallback."""
|
||||
return node.title or prettify(slug) or "Home"
|
||||
|
||||
|
||||
def _nav_link(doc, node: Node, path: str, current: str) -> None:
|
||||
"""Render one <li> linking the node (category labels redirect to their
|
||||
first child server-side, so linking them is always fine)."""
|
||||
# A top-level item is current also when viewing any of its subpages.
|
||||
is_current = current == path or (path and current.startswith(f"{path}/"))
|
||||
doc.li.a(
|
||||
_title(path.rpartition("/")[2], node),
|
||||
href=f"/{path}",
|
||||
**{"class": "current"} if is_current else {},
|
||||
)
|
||||
|
||||
|
||||
def nav_html(menu: dict[str, Node], current: str) -> HTML:
|
||||
"""Render the contents of the #nav element for the current path.
|
||||
|
||||
Top-level items in menu order; the front page (slug "", href "/")
|
||||
competes by its order key like any sibling. Subitems of the current
|
||||
section go to the sidebar (sidebar_html).
|
||||
"""
|
||||
nav = E.ul
|
||||
with nav:
|
||||
for slug, node in sorted_nodes(menu):
|
||||
if node.published:
|
||||
_nav_link(nav, node, slug, current)
|
||||
return HTML(str(nav))
|
||||
|
||||
|
||||
def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
|
||||
"""Render the contents of the #sidebar element for the current path.
|
||||
|
||||
Lists the direct children of the current main level section; empty when
|
||||
the path is not inside a section or the section has no children.
|
||||
"""
|
||||
if not current:
|
||||
return HTML("")
|
||||
section = current.split("/", 1)[0]
|
||||
node = menu.get(section)
|
||||
if node is None:
|
||||
return HTML("")
|
||||
nav = E.ul
|
||||
with nav:
|
||||
for slug, child in sorted_nodes(node.children):
|
||||
if child.published:
|
||||
_nav_link(nav, child, f"{section}/{slug}", current)
|
||||
return HTML(str(nav))
|
||||
|
||||
|
||||
def first_leaf(menu: dict[str, Node], path: str) -> str | None:
|
||||
"""First published descendant page (content set) in menu order.
|
||||
|
||||
This is the redirect target for content-less category labels.
|
||||
"""
|
||||
chain = resolve(menu, path)
|
||||
if chain is None:
|
||||
return None
|
||||
return _first_leaf(chain[-1], path)
|
||||
|
||||
|
||||
def _first_leaf(node: Node, path: str) -> str | None:
|
||||
for slug, child in sorted_nodes(node.children):
|
||||
cpath = f"{path}/{slug}" if path else slug
|
||||
if child.published and child.content is not None:
|
||||
return cpath
|
||||
if (leaf := _first_leaf(child, cpath)) is not None:
|
||||
return leaf
|
||||
return None
|
||||
|
||||
|
||||
def banner_html(menu: dict[str, Node], path: str) -> HTML:
|
||||
"""Resolve the banner for a path: the nearest node on the ancestor
|
||||
chain (the node itself first), then the front page, then the default
|
||||
CSS artwork. The front page is a top-level *sibling* of the other
|
||||
main-level nodes, not their parent, so it never appears in the chain
|
||||
and is consulted explicitly, last. The snippet is raw trusted HTML,
|
||||
so a banner can be anything — an img, a styled div, canvas + script.
|
||||
"""
|
||||
source = banner_source(menu, path)
|
||||
if source is None:
|
||||
return HTML("")
|
||||
return HTML(resolve(menu, source)[-1].banner)
|
||||
|
||||
|
||||
def banner_source(menu: dict[str, Node], path: str) -> str | None:
|
||||
"""Which node's banner applies at ``path``: the nearest ancestor with
|
||||
one set (the front page, a top-level sibling of the chain, last).
|
||||
None = the default artwork."""
|
||||
chain = resolve(menu, path) or []
|
||||
segs = path.split("/")
|
||||
for i in range(len(chain) - 1, -1, -1):
|
||||
if chain[i].banner:
|
||||
return "/".join(segs[: i + 1])
|
||||
front = menu.get("")
|
||||
if front and front.banner:
|
||||
return ""
|
||||
return None
|
||||
|
||||
|
||||
def _edit_attrs(path: str, mode: str = "page") -> dict:
|
||||
"""Attributes for a 🖊️ edit button.
|
||||
|
||||
pagerite.js wires these buttons to dynamic-import the editor app
|
||||
(data-editor-src, plus any extra styles it needs) and open the docked
|
||||
editor without leaving the page. mode="page" edits content; mode="site"
|
||||
(the pen on the banner) edits the banner and site structure. They are
|
||||
buttons, not links: editing is an action, not a navigation.
|
||||
"""
|
||||
scripts, styles = _editor_assets()
|
||||
return {
|
||||
"type": "button",
|
||||
"class": "edit-link" if mode == "page" else "edit-link banner-edit-link",
|
||||
"title": "edit",
|
||||
"data-editor-src": scripts[-1],
|
||||
"data-editor-css": ",".join(s for s in styles if s != "/static/style.css"),
|
||||
"data-editor-mode": mode,
|
||||
}
|
||||
|
||||
|
||||
def page_content(menu: dict[str, Node], path: str) -> HTML:
|
||||
"""Render the contents of the #main element for a page."""
|
||||
node = resolve(menu, path)[-1]
|
||||
doc = E.article
|
||||
with doc:
|
||||
# An h1 in the markdown owns the article heading; the title is
|
||||
# only rendered as h1 when the markdown has none of its own.
|
||||
if not has_h1(node.content or ""):
|
||||
doc.h1(node.title)
|
||||
# All users are trusted authors for now, so the edit button is public.
|
||||
doc.button("🖊️", **_edit_attrs(path))
|
||||
doc.div(HTML(render(node.content or "", path)), class_="body")
|
||||
return HTML(str(doc))
|
||||
|
||||
|
||||
def render_page(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> str:
|
||||
"""Render a full HTML page for the slug path."""
|
||||
node = resolve(menu, path)[-1]
|
||||
title = _title(path.rpartition("/")[2], node)
|
||||
return str(
|
||||
Layout(
|
||||
Title=f"{title} – {brand}" if brand else title,
|
||||
Brand=_brand_link(brand),
|
||||
Nav=nav_html(menu, path),
|
||||
Sidebar=sidebar_html(menu, path),
|
||||
Banner=banner_html(menu, path),
|
||||
BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site")))),
|
||||
Main=page_content(menu, path),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def render_not_found(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> str:
|
||||
"""Render a 404 page within the normal layout."""
|
||||
doc = E.article
|
||||
with doc:
|
||||
doc.h1("Not Found")
|
||||
# Editing works here too: this is how brand new pages get created.
|
||||
doc.button("🖊️", **_edit_attrs(path))
|
||||
doc.p(f"No page at /{path}.")
|
||||
return str(
|
||||
Layout(
|
||||
Title=f"Not Found – {brand}" if brand else "Not Found",
|
||||
Brand=_brand_link(brand),
|
||||
Nav=nav_html(menu, path),
|
||||
Sidebar=sidebar_html(menu, path),
|
||||
Banner=banner_html(menu, path),
|
||||
BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site")))),
|
||||
Main=HTML(str(doc)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _editor_assets() -> tuple[list[str], list[str]]:
|
||||
"""Script and CSS URLs for the admin editor (Vue app).
|
||||
|
||||
Dev mode loads the modules from the Vite dev server; production uses
|
||||
the Vite build manifest to resolve the hashed asset names.
|
||||
"""
|
||||
if vite_url := os.environ.get("PAGERITE_VITE_URL"):
|
||||
return (
|
||||
[f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"],
|
||||
["/static/style.css"],
|
||||
)
|
||||
manifest = json.loads((BUILD / ".vite/manifest.json").read_text())
|
||||
entry = manifest["src/main.js"]
|
||||
styles = [f"/{css}" for css in entry.get("css", [])]
|
||||
return [f"/{entry['file']}"], ["/static/style.css", *styles]
|
||||
|
||||
|
||||
def render_editor() -> str:
|
||||
"""Render the admin editor shell: a mount point for the Vue app."""
|
||||
scripts, styles = _editor_assets()
|
||||
doc = Document(f"Admin – {SITE_NAME}", lang="en", _urls=styles)
|
||||
for src in scripts:
|
||||
doc.script("", src=src, type="module")
|
||||
doc.div(None, id="app")
|
||||
return str(doc)
|
||||
@@ -0,0 +1,37 @@
|
||||
[project]
|
||||
name = "pagerite"
|
||||
version = "0.1.0"
|
||||
description = "A single-user CMS/blog: FastAPI + kanta, Vue only for editing tools"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"blake3>=1.0.9",
|
||||
"fastapi-vue>=1.3.1",
|
||||
"fastapi[standard]>=0.141.1",
|
||||
"html5tagger>=2.0.0",
|
||||
"kanta>=0.8.1",
|
||||
"markdown-it-py>=4.2.0",
|
||||
"mdit-py-plugins>=0.6.1",
|
||||
"pygments>=2.20.0",
|
||||
"tracerite>=2.6.4",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
pagerite = "pagerite.__main__:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build]
|
||||
packages = ["pagerite"]
|
||||
artifacts = ["pagerite/frontend-build"]
|
||||
only-packages = true
|
||||
|
||||
[tool.hatch.build.targets.sdist.hooks.custom]
|
||||
path = "scripts/fastapi-vue/buildhook.py"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||
"""Run Vite development server for Vue app and FastAPI backend with auto-reload."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||
from devutil import (
|
||||
ProcessGroup,
|
||||
check_ports_free,
|
||||
logger,
|
||||
ready,
|
||||
setup_cli,
|
||||
setup_vite,
|
||||
)
|
||||
|
||||
DEFAULT_VITE_PORT = 3100
|
||||
DEFAULT_DEV_PORT = 3200
|
||||
HEALTH = "/_/api/health?from=devserver.py"
|
||||
|
||||
|
||||
async def run_devserver(
|
||||
listen: str,
|
||||
backend: str,
|
||||
extra_args: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Start Vite and FastAPI dev servers with hot reload."""
|
||||
reporoot = Path(__file__).parent.parent
|
||||
front = reporoot / "frontend"
|
||||
if not (front / "package.json").exists():
|
||||
logger.warning("Frontend source not found at %s", front)
|
||||
raise SystemExit(1)
|
||||
|
||||
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
|
||||
backurl, pagerite = setup_cli("pagerite", backend, DEFAULT_DEV_PORT)
|
||||
|
||||
# Tell the everyone by environment (vite proxy and backend devmode use these)
|
||||
os.environ["PAGERITE_VITE_URL"] = viteurl
|
||||
os.environ["PAGERITE_BACKEND_URL"] = backurl
|
||||
os.environ["PAGERITE_DEV"] = "1"
|
||||
|
||||
async with ProcessGroup() as pg:
|
||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(*pagerite, *(extra_args or []))
|
||||
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
||||
await pg.spawn(*vite, cwd=front)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Parse CLI arguments and run the devserver."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run Vite and FastAPI development servers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=HELP_EPILOG,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--listen",
|
||||
metavar="addr",
|
||||
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
metavar="addr",
|
||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||
)
|
||||
args, extra_args = parser.parse_known_args()
|
||||
with suppress(KeyboardInterrupt):
|
||||
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
||||
|
||||
|
||||
HELP_EPILOG = """
|
||||
Other options are forwarded to pagerite [args]
|
||||
|
||||
JS_RUNTIME environment variable can be used to select the JS runtime:
|
||||
npm, deno, bun, or full path to the runtime executable (node maps to npm).
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
# ruff: noqa: INP001
|
||||
"""Hatch build hook for building Vue frontend during package build."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from buildutil import build
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface): # type: ignore[misc]
|
||||
"""Hatch build hook that builds Vue frontend during package build."""
|
||||
|
||||
def initialize(self, version: str, build_data: dict) -> None: # type: ignore[override]
|
||||
"""Build frontend before package is built."""
|
||||
super().initialize(version, build_data)
|
||||
build("frontend")
|
||||
@@ -0,0 +1,231 @@
|
||||
# ruff: noqa: INP001
|
||||
"""Utilities used at build time and in devserver script. No dependencies."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
MIN_NODE_VERSION = 20
|
||||
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"⚠️ {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
"""Check if Node.js version is >= 20.
|
||||
|
||||
Raises RuntimeError if version is too old or cannot be determined.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run( # noqa: S603
|
||||
[node_path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
version_str = result.stdout.strip()
|
||||
# Parse version like "v20.10.0" or "v18.17.1"
|
||||
match = re.match(r"v(\d+)", version_str)
|
||||
if match:
|
||||
major_version = int(match.group(1))
|
||||
if major_version >= MIN_NODE_VERSION:
|
||||
return
|
||||
msg = f"Node.js {version_str} found, but v{MIN_NODE_VERSION}+ required"
|
||||
raise RuntimeError(msg)
|
||||
except subprocess.CalledProcessError, FileNotFoundError, ValueError:
|
||||
pass
|
||||
msg = "Could not determine Node.js version"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def _validate_npm_runtime(tool: str) -> bool:
|
||||
"""Validate npm runtime by checking Node.js version. Returns True if valid."""
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
return False
|
||||
try:
|
||||
_check_node_version(node_path)
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _find_runtime_from_env(options: list[str]) -> tuple[str, str] | None:
|
||||
"""Find runtime specified by JS_RUNTIME environment variable."""
|
||||
js_runtime_env = os.environ.get("JS_RUNTIME")
|
||||
if not js_runtime_env:
|
||||
return None
|
||||
|
||||
js_runtime = js_runtime_env
|
||||
js_path = Path(js_runtime)
|
||||
runtime_name = js_path.name
|
||||
|
||||
# Map node to npm
|
||||
if runtime_name == "node":
|
||||
runtime_name = "npm"
|
||||
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||
|
||||
for option in options:
|
||||
if option != runtime_name and not runtime_name.startswith(option):
|
||||
continue
|
||||
|
||||
tool = shutil.which(js_runtime)
|
||||
if tool is None:
|
||||
msg = f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if option == "npm":
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
msg = f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||
raise RuntimeError(msg)
|
||||
_check_node_version(node_path)
|
||||
|
||||
return tool, option
|
||||
|
||||
msg = f"JS_RUNTIME={js_runtime_env} not recognized"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def _auto_detect_runtime(options: list[str]) -> tuple[str, str]:
|
||||
"""Auto-detect JavaScript runtime from available options."""
|
||||
node_version_error: RuntimeError | None = None
|
||||
|
||||
for option in options:
|
||||
tool = shutil.which(option)
|
||||
if not tool:
|
||||
continue
|
||||
|
||||
if option == "npm" and not _validate_npm_runtime(tool):
|
||||
try:
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path:
|
||||
_check_node_version(node_path)
|
||||
except RuntimeError as e:
|
||||
node_version_error = e
|
||||
continue
|
||||
|
||||
return tool, option
|
||||
|
||||
if node_version_error:
|
||||
raise node_version_error
|
||||
msg = "Node.js (v20+), Deno or Bun is required but none was found"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def find_js_runtime() -> tuple[str, str]:
|
||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||
|
||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||
Raises RuntimeError if no suitable runtime is found.
|
||||
"""
|
||||
options = ["npm", "deno", "bun"]
|
||||
|
||||
# Check for JS_RUNTIME environment variable
|
||||
if result := _find_runtime_from_env(options):
|
||||
return result
|
||||
|
||||
# Auto-detect
|
||||
return _auto_detect_runtime(options)
|
||||
|
||||
|
||||
def find_build_tool() -> tuple[list[str], list[str]]:
|
||||
"""Find JavaScript runtime and construct install/build commands.
|
||||
|
||||
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
install = {
|
||||
"deno": ("install", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install",),
|
||||
"bun": ("--bun", "install"),
|
||||
}
|
||||
# Run vite directly for deno to avoid npm-run-all2/run-p issues
|
||||
build = {
|
||||
"deno": ("run", "-A", "npm:vite", "build"),
|
||||
"npm": ("run", "build"),
|
||||
"bun": ("--bun", "run", "build"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
return [tool, *install[name]], [tool, *build[name]]
|
||||
|
||||
|
||||
def find_dev_tool() -> list[str]:
|
||||
"""Find JavaScript runtime and construct dev command.
|
||||
|
||||
Returns dev_cmd (without vite-specific args).
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
dev_args = {
|
||||
"deno": ("run", "-A", "npm:vite"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"bun": ("run", "dev", "--"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
|
||||
if name == "bun":
|
||||
logger.warning(
|
||||
"Bun has a WS proxy bug (github.com/oven-sh/bun/issues/9882). Consider npm.",
|
||||
)
|
||||
|
||||
return [tool, *dev_args[name]]
|
||||
|
||||
|
||||
def find_install_tool() -> list[str]:
|
||||
"""Find JavaScript runtime and construct install command.
|
||||
|
||||
Returns install_cmd.
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
install_args = {
|
||||
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install", "--silent"),
|
||||
"bun": ("install", "--silent"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
return [tool, *install_args[name]]
|
||||
|
||||
|
||||
def build(folder: str = "frontend") -> None:
|
||||
"""Build the frontend in the specified folder.
|
||||
|
||||
Raises SystemExit(1) on failure.
|
||||
"""
|
||||
logger.info(">>> Building %s", folder)
|
||||
|
||||
try:
|
||||
install_cmd, build_cmd = find_build_tool()
|
||||
except RuntimeError as e:
|
||||
logger.warning(e)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
def run(cmd: list[str]) -> None:
|
||||
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||
logger.info("### %s", " ".join(display_cmd))
|
||||
subprocess.run(cmd, check=True, cwd=folder) # noqa: S603
|
||||
|
||||
try:
|
||||
run(install_cmd)
|
||||
logger.info("")
|
||||
run(build_cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
raise SystemExit(1) from None
|
||||
@@ -0,0 +1,226 @@
|
||||
# ruff: noqa: INP001
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
|
||||
import httpx
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize empty process tracking."""
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
*cmd: str,
|
||||
cwd: str | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a subprocess and track it."""
|
||||
cmd_name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._procs.append(proc)
|
||||
self._cmds[proc.pid] = cmd_name
|
||||
return proc
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
*waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]",
|
||||
) -> None:
|
||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||
|
||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
||||
returncode = await proc.wait()
|
||||
if returncode != 0:
|
||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||
|
||||
tasks = [
|
||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
||||
for w in waitables
|
||||
]
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Enter the async context manager."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||
await self._cleanup(immediate=exc_type is not None)
|
||||
|
||||
async def _cleanup(self, *, immediate: bool = False) -> None:
|
||||
running = [p for p in self._procs if p.returncode is None]
|
||||
if not running:
|
||||
return
|
||||
|
||||
if not immediate:
|
||||
# Wait for any one process to exit
|
||||
with suppress(asyncio.CancelledError):
|
||||
await asyncio.wait(
|
||||
[asyncio.create_task(p.wait()) for p in running],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Terminate remaining processes
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.terminate()
|
||||
|
||||
# Wait for all to finish (with overall timeout), shielded from cancellation
|
||||
still_running = [p for p in self._procs if p.returncode is None]
|
||||
if still_running:
|
||||
with suppress(asyncio.CancelledError):
|
||||
try:
|
||||
await asyncio.shield(
|
||||
asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
),
|
||||
)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.kill()
|
||||
await p.wait()
|
||||
|
||||
|
||||
async def check_ports_free(*urls: str) -> None:
|
||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||
|
||||
async def check(client: httpx.AsyncClient, url: str) -> None:
|
||||
with suppress(httpx.RequestError):
|
||||
res = await client.get(url, timeout=0.1)
|
||||
server = res.headers.get("server", "server")
|
||||
logger.warning("Conflicting %s already running at %s", server, url)
|
||||
raise SystemExit(1)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
await asyncio.gather(*[check(client, url) for url in urls])
|
||||
|
||||
|
||||
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||
"""Wait for the server to be ready by polling an endpoint.
|
||||
|
||||
Use empty path to disable the check and make this return immediately.
|
||||
Raises SystemExit(1) if server doesn't start in time.
|
||||
"""
|
||||
if not path:
|
||||
return
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
await client.get(f"{url}{path}", timeout=1.0)
|
||||
except httpx.RequestError:
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1) from None
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
logger.info("✓ Backend ready!")
|
||||
return
|
||||
|
||||
|
||||
def setup_vite(
|
||||
endpoint: str,
|
||||
default_port: int = 5173,
|
||||
) -> tuple[str, list[str], list[str]]:
|
||||
"""Parse frontend endpoint and build commands.
|
||||
|
||||
Returns (url, install_cmd, dev_cmd).
|
||||
Raises SystemExit(1) on invalid config.
|
||||
"""
|
||||
endpoints = parse_endpoint(endpoint, default_port)
|
||||
|
||||
if "uds" in endpoints[0]:
|
||||
logger.warning("Unix sockets not supported with vite devserver")
|
||||
raise SystemExit(1)
|
||||
|
||||
port = endpoints[0]["port"]
|
||||
host = endpoints[0]["host"]
|
||||
|
||||
install_cmd = find_install_tool()
|
||||
dev_cmd = find_dev_tool()
|
||||
if host != "localhost":
|
||||
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
|
||||
dev_cmd.append(f"--port={port}")
|
||||
|
||||
return f"http://{host}:{port}", install_cmd, dev_cmd
|
||||
|
||||
|
||||
def setup_fastapi(
|
||||
endpoint: str,
|
||||
module: str,
|
||||
default_port: int = 8000,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend endpoint and build uvicorn command.
|
||||
|
||||
Returns (url, uvicorn_cmd).
|
||||
Raises SystemExit(1) on invalid config.
|
||||
"""
|
||||
endpoints = parse_endpoint(endpoint, default_port)
|
||||
|
||||
if "uds" in endpoints[0]:
|
||||
logger.warning("Unix sockets not supported with vite devserver")
|
||||
raise SystemExit(1)
|
||||
|
||||
host = endpoints[0]["host"]
|
||||
port = endpoints[0]["port"]
|
||||
reload_dir = module.split(".", maxsplit=1)[0] # Don't reload on frontend changes
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
module,
|
||||
f"--host={host}",
|
||||
f"--port={port}",
|
||||
"--reload",
|
||||
f"--reload-dir={reload_dir}",
|
||||
"--forwarded-allow-ips=*",
|
||||
]
|
||||
return f"http://{host}:{port}", cmd
|
||||
|
||||
|
||||
def setup_cli(
|
||||
cli: str,
|
||||
endpoint: str,
|
||||
default_port: int = 8000,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend endpoint and build CLI command.
|
||||
|
||||
Returns (url, cli_cmd).
|
||||
Raises SystemExit(1) on invalid config.
|
||||
"""
|
||||
endpoints = parse_endpoint(endpoint, default_port)
|
||||
|
||||
if "uds" in endpoints[0]:
|
||||
logger.warning("Unix sockets not supported with vite devserver")
|
||||
raise SystemExit(1)
|
||||
|
||||
host = endpoints[0]["host"]
|
||||
port = endpoints[0]["port"]
|
||||
|
||||
cmd = [cli, f"--listen={host}:{port}"]
|
||||
return f"http://{host}:{port}", cmd
|
||||
@@ -0,0 +1,983 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/6a/4cc5a9dd40fd8a6d283fd3761e5f59c490109571ef8e3c73245417e5a305/blake3-1.0.9.tar.gz", hash = "sha256:5fa374fa5070ca084368776c19b420157eb0f2d3f091343d6bc59189929d62e2", size = 116872, upload-time = "2026-06-22T18:02:25.366Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/4b/b2dd7c25378a3b5de30ed908d38e6427bc4c644c0c12e8359361abd3a9ca/blake3-1.0.9-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ab0c030cf6644c30e786b0e785bde4e4596013ae9ea6ce9877e39d52383e25d7", size = 345406, upload-time = "2026-06-22T18:01:10.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/dc/c0dab2963ddf04a4a938363f61716f9b75de6d3a9bc4a89e78f0854d4d31/blake3-1.0.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:83b4a2336105af3800f7e17ac4b943f293a3927a2d66a6308d50dba944a6953e", size = 330077, upload-time = "2026-06-22T18:01:11.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/f1/d03950a86d105a6332a8c422cb87658a7d247e214f1ea8f29ed09ff04e00/blake3-1.0.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95fc3545f80901b0dcd0508d16bc40f15ae39556709fa6cf86675f742d4f3c9c", size = 375147, upload-time = "2026-06-22T18:01:13.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/75/711b1842e0a90aaad6a1c9a9022e90aa16206ac1f224516118bc24482532/blake3-1.0.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1bd981dc318c05375c3160a99df493b7cc4c83fffa1a34d14b18a071b47b262b", size = 373711, upload-time = "2026-06-22T18:01:14.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/a0/f512799d1d0c0b4718fa6f0e99ccbe108e98bac7bf82c200803a62b57876/blake3-1.0.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:689a7e4069de681d9c5d9445b8b6473ee880ad04d7960a6789c60bd788980250", size = 446993, upload-time = "2026-06-22T18:01:15.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/fb/6636ae8a46fc3352694188f5a5a325567782bc88fd1823b0b67be2c92184/blake3-1.0.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8adb0b0032e53919ee95b3d4f911448d3268316c28cd7df232ff2a1e7c9a4ba4", size = 488478, upload-time = "2026-06-22T18:01:17.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c5/a2b3c086f7e37c9db6017dc2890a76ad2a729e4a554896e855e511811e6b/blake3-1.0.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32bd4521ec2d477627ad93eb70f9ac4d01e12d1489024159bcaeff79466332f6", size = 384900, upload-time = "2026-06-22T18:01:18.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/b8/1298806dd6c464a6f807df24c9640ad3bf27ee54ff4de82b2b5a823a8aba/blake3-1.0.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f65d77eb05331495485048f6804f53885b192b998acb7e6fe1487d941bf08435", size = 384333, upload-time = "2026-06-22T18:01:20.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/cc/0c29d9404155adfd6db716e9765d36ea6cbed287060759f5d764f0d9d99e/blake3-1.0.9-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ca7dfe8fb197ff8a3f5c915424183ccd52a99e8afb12680f51b2e1f4c9c6c97f", size = 381142, upload-time = "2026-06-22T18:01:21.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/91/9af20d563f0ced71e08a60fc0ee534146da4e265710ed6792d5d799f4c0f/blake3-1.0.9-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:f5c9d57f0dcb92243b6ae575c3065793edc9df9008d0ebd98d8245cdeb7c3f84", size = 550587, upload-time = "2026-06-22T18:01:23.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/fa/06f46fc0aa486b799d776f9a80ed0b3605e2be1570cf48007860948aa5d9/blake3-1.0.9-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:172d44245a19dfec08ab771c1b7a506b97783163cdc65f559fe020007e403c99", size = 591888, upload-time = "2026-06-22T18:01:24.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/68/d6198f4069a7c4a184ed854df45b82cc3e2d4b0be476b2a3ee65ad2344cf/blake3-1.0.9-cp314-cp314-win32.whl", hash = "sha256:249e5964fa9e768924bc7cc3d4efe75a425bb5dd3fb7671c3eda8eeddfa50591", size = 229410, upload-time = "2026-06-22T18:01:26.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/ab/f29af72a8312b3827b50e55491f1bf9ae2347591de5c47365c5cbd2525a9/blake3-1.0.9-cp314-cp314-win_amd64.whl", hash = "sha256:0aba416bb2e3ef0c65e74d5eba21062483c714cd78e7e303c9d03c547fc7d015", size = 218526, upload-time = "2026-06-22T18:01:27.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/7e/d932fe437ccf656cfba77abc466fb3d1a0ce3c31df92e760d9e4c34932b4/blake3-1.0.9-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5b35abe24a66a7b3db423eb4f8668ed7be1a362aa9c0024ab6483ec0b2c16058", size = 345049, upload-time = "2026-06-22T18:01:29.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/1e/d92fb284fcacf86f5d1083e29d0a8c834b60432786928915238d9760f514/blake3-1.0.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bbdff61e049297ef3180867ce1f079cea7e5b372fd76953c3183da5b8124206", size = 329367, upload-time = "2026-06-22T18:01:30.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/da/e25fa75d5bfea4527fc21024dde86a9376db798e469a084741968299f215/blake3-1.0.9-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a69fcedf06785bb81d4d3d39f95ee65dbaf2cb246e174cfc9ff64d027f7551", size = 374203, upload-time = "2026-06-22T18:01:31.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/4d/0224916202b773dfdf08dcbe4ed1ad1018d4ddcd4df7a7e2978d28f89b74/blake3-1.0.9-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5d5bf0f68cd77108a942c95db98e960d9c3d5643b95172f783822ce22667759", size = 373713, upload-time = "2026-06-22T18:01:33.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/e5/4ba968831b7afaec431c588c826cef76a96d6d6976188ed07d932072e673/blake3-1.0.9-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9767f16199b99aa022b61ff825ac4dbd39864bf637ae712605a2ce1f8b6a55e0", size = 446574, upload-time = "2026-06-22T18:01:34.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/f5/08a9099c7177f282d2563abe4f7cc626c636642f7979cf58f2ab7ded2096/blake3-1.0.9-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4865a8cfb2b3d7c0baf5267f2fa6816a3384e836cd1bd0caf359f406cb1e8fba", size = 487232, upload-time = "2026-06-22T18:01:36.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/16/9392bf1ebc81b5b09ce58b94613fa2d37308e825ff2dc7b54d00ee622c77/blake3-1.0.9-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42609e4adc4b2d7423137f2cb35135bca598b925c5af09d2bc0a2c368b25aeb1", size = 384751, upload-time = "2026-06-22T18:01:37.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/fc/b6e9aef02ca14ef62fa47783b9eeeb5b2d3f73fdf698d8bb94c36f5dd69f/blake3-1.0.9-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7f648fa425138452d1e585ac625c7aefddb946d9765906c4c12d564a1523cd8", size = 384546, upload-time = "2026-06-22T18:01:38.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/cb/452e92dba9402b36a953aa8b9b06253445ccce43dcd0bcf521c5e3c3e15d/blake3-1.0.9-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:9cef6d4d07a7de0c44f5ba17f6383d55276d9efc8d601f75113538fcaa35008b", size = 380596, upload-time = "2026-06-22T18:01:40.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/01/7a84a7e10c5d14e6ed8a4403bd7f64c1e01f8ebabea0d6fe5f093b894cbd/blake3-1.0.9-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:28404301de485e9546365d01b30f65eaa835520c4211d6ef61242975b6722b60", size = 550032, upload-time = "2026-06-22T18:01:41.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/7d/7aea0222f59cf84044ec52e2bfdaa0e3c355d221292b0ea1b722cf1edd6c/blake3-1.0.9-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8a99f896e7718050ed033a888245098aab3d6a5338f91cc9450c563b53f90ad5", size = 592244, upload-time = "2026-06-22T18:01:43.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/e5/b44c230108745ff9c70c7bbafe22563772bc0c22322a8d15c10455f6ca02/blake3-1.0.9-cp314-cp314t-win32.whl", hash = "sha256:021309d760b390706fecf13498f9a25aa8f689bbb65a0896029b8fa223aae18b", size = 229481, upload-time = "2026-06-22T18:01:45.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/a6/ac03f37dc9aeebf398d42089720648b3bc8438e733d3e522196c5d12ab39/blake3-1.0.9-cp314-cp314t-win_amd64.whl", hash = "sha256:5ea0c60dd9c1e3d05610606579e4bf80f562854c46ed55f9ee8545e18987a480", size = 217979, upload-time = "2026-06-22T18:01:46.629Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||
]
|
||||
|
||||
[[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 = "detect-installer"
|
||||
version = "0.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/ce/6897d812825e9d4c53e3c7112726e800cc5231b013b2223bf64f653ff362/detect_installer-0.1.0.tar.gz", hash = "sha256:00ad7ba0a36e3cf7d08a40d3643011746dbc112597c7d475cc91c416710ca4e7", size = 3049, upload-time = "2026-02-23T10:40:22.567Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/34/8cc73273414405086c58852916e4031812a6a30fe04c057e37ad99397b7f/detect_installer-0.1.0-py3-none-any.whl", hash = "sha256:034fb20fd665c36e6ba52b8821525ea07fb4f7f938cac459df889fb33801528a", size = 4539, upload-time = "2026-02-23T10:40:23.807Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.141.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi-cli", extra = ["standard"] },
|
||||
{ name = "fastar" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "pydantic-extra-types" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-cli"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "rich-toolkit" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/eb/3b534c6f8e157f9ddbf2a153512307c886cad0b258739c200dd8ff8c4452/fastapi_cli-0.0.32.tar.gz", hash = "sha256:38024d2345275e1b37ce8848727a580d84901b570e96b3256d9d36a9a5039424", size = 26636, upload-time = "2026-07-16T12:16:58.678Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/53/56ae5ae17bb0a5d89d1d31e5320eb1865553ebbfbde91cdc4c221245f2a8/fastapi_cli-0.0.32-py3-none-any.whl", hash = "sha256:8dcc286fa32f01bbd3f65dd09cfd5a2540ed5f2230b77db7fd30978d6165f3c4", size = 14670, upload-time = "2026-07-16T12:16:57.297Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "fastapi-cloud-cli" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-cloud-cli"
|
||||
version = "0.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "detect-installer" },
|
||||
{ name = "fastar" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "rich-toolkit" },
|
||||
{ name = "rignore" },
|
||||
{ name = "sentry-sdk" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/dc/63aaf9913f455e39a7027c27140edd887a87d47d65ac43532d77a51718e5/fastapi_cloud_cli-0.23.0.tar.gz", hash = "sha256:840895bb8d14309aeffc905e0dcd1334d18c6f5da54b735413a8f1cb385e581e", size = 95295, upload-time = "2026-07-28T14:03:33.463Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/86/96/7e9aba6fabce3cb05f320abeef5b81efd5134823ae85d1a517872cb83cbc/fastapi_cloud_cli-0.23.0-py3-none-any.whl", hash = "sha256:1cd2ffa56e92e92c1fc63acc426c214dd928cbeed2a4c7c6a9a5fc85ea73de16", size = 78058, upload-time = "2026-07-28T14:03:34.386Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-vue"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blake3" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/7d/2ce1a890f5f9317a50f7468fa38f8f92801342c3a0c74a4656f9053f3cc2/fastapi_vue-1.3.1.tar.gz", hash = "sha256:17b6388f005db7f13a569eb886c2ae4aeb694138111aa7740e024d6a141168ad", size = 7752, upload-time = "2026-03-08T17:31:04.69Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/f4/9246d90c475fb9255129d89983d0638f16bc3be12b95160fdd31cbe51947/fastapi_vue-1.3.1-py3-none-any.whl", hash = "sha256:981a86658267526889291bc307a561134aada162e86abc8faf31159e1385a68b", size = 9314, upload-time = "2026-03-08T17:31:03.784Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastar"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ba/1e4f67148223ff219612b6281a6000357abbcc2417964fa5c83f11d68fce/fastar-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cad7fa41e3e66554387481c1a09365e4638becd322904932674159d5f4046728", size = 760921, upload-time = "2026-04-13T17:07:59.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/82/09d11fb6d12f17993ffaf32ffd30c3c121a11e2966e84f19fb6f66430118/fastar-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf36652fa71b83761717c9899b98732498f8a2cb6327ff16bbf07f6be85c3437", size = 757012, upload-time = "2026-04-13T17:08:14.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/1f/5aeeacc4cb65615e2c9292cd9c5b0cd6fb6d2e6ee472ca6adc6c1b1b22ef/fastar-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f68ff8c17833053da4841720e95edde80ce45bb994b6b7d51418dddaac70ee47", size = 924510, upload-time = "2026-04-13T17:08:28.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1a/1e5bdabbeaf2e856928956292609f2ff6a650f94480fb8afaca30229e483/fastar-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4563ed37a12ea1cdc398af8571258d24b988bf342b7b3bf5451bd5891243280c", size = 816602, upload-time = "2026-04-13T17:08:59.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/24/f960147910da3bed41a3adfcb026e17d5f50f4cf467a3324237a7088f61a/fastar-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee63c9875cba3b70dc44338c560facc5d6e763047dcc4a30501f9a68cf5f890", size = 819452, upload-time = "2026-04-13T17:09:29.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/f4/3e77d7901d5707fd7f8a352e153c8ae09ea974e6fabad0b7c4eb9944b8d4/fastar-0.11.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:bd76bfffae6d0a91f4ac4a612f721e7aec108db97dccdd120ae063cd66959f27", size = 885254, upload-time = "2026-04-13T17:08:44.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/01/1585edd5ec47782ae93cd94edf05828e0ab02ef00aec00aea4194a600464/fastar-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5b707501ec01c1bc0518f741f01d322e50c9adc19a451aa24f67a2316e9397", size = 971496, upload-time = "2026-04-13T17:10:17.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/e9/6874c9d1236ded565a0bed54b320ac9f165f287b1d89490fb70f9f323c81/fastar-0.11.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:37c0b5a88a657839aad98b0a6c9e4ac4c2c15d6b49c44ee3935c6b08e9d3e479", size = 1034685, upload-time = "2026-04-13T17:10:34.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d8/4ab20613ce2983427aee958e39be878dba874aa227c530a845e32429c4f6/fastar-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6c55f536c62a6efb180c1af0d5182948bff576bbfe6276e8e1359c9c7d2215d8", size = 1072675, upload-time = "2026-04-13T17:10:50.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/ae/5ac3b7c20ce4b08f011dd2b979f96caabe64f9b10b157f211ea91bdfadca/fastar-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3082eeca59e189b9039335862f4c2780c0c8871d656bfdf559db4414a105b251", size = 1029330, upload-time = "2026-04-13T17:11:08.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/e7/37cd6a1d4e288292170b64e19d79ecce2a7de8bb76790323399a2abc4619/fastar-0.11.0-cp314-cp314-win32.whl", hash = "sha256:b201a0a4e29f9fec2a177e13154b8725ec65ab9f83bd6415483efaa2aa18344b", size = 453940, upload-time = "2026-04-13T17:11:48.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/1c/795c878b1ee29d79021cf8ed81f18f2b25ccde58453b0d34b9bdc7e025ea/fastar-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:868fddb26072a43e870a8819134b9f80ee602931be5a76e6fb873e04da343637", size = 486334, upload-time = "2026-04-13T17:11:34.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/a4/113f104301df8bddcc0b3775b611a30cb7610baa3add933c7ccac9386467/fastar-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:3db39c9cc42abb0c780a26b299f24dfbc8be455985e969e15336d70d7b2f833b", size = 461534, upload-time = "2026-04-13T17:11:24.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/a6/5c5f2c2c8e0c63e56a5636ebc7721589c889e94c0092cec7eb28ae7207e6/fastar-0.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:49c3299dec5e125e7ebaa27545714da9c7391777366015427e0ae62d548b442b", size = 707156, upload-time = "2026-04-13T17:10:02.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/f7/982c01b61f0fc135ad2b16d01e6d0ee53cf8791e68827f5f7c5a65b2e5b1/fastar-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3328ed1ed56d31f5198350b17dd60449b8d6b9d47abb4688bab6aef4450a165b", size = 627032, upload-time = "2026-04-13T17:09:46.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c3/38f1dac77ae0c71c37b176277c96d830796b8ce2fe69705f917829b53829/fastar-0.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd3eca3bbfec84a614bcb4143b4ad4f784d0895babc26cfc88436af88ca23c7a", size = 864403, upload-time = "2026-04-13T17:09:16.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/f0/e69c363bdb3e5a5848e937b662b5469581ee6682c51bc1c0556494773929/fastar-0.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff86a967acb0d621dd24063dda090daa67bf4993b9570e97fe156de88a9006ca", size = 759480, upload-time = "2026-04-13T17:08:00.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/29/4d8737590c2a6357d614d7cc7288e8f68e7e449680b8922997cc4349e65e/fastar-0.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86eaf7c0e985d93a7734168be2fb232b2a8cca53e41431c2782d7c12b12c03b1", size = 756219, upload-time = "2026-04-13T17:08:15.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/ec/400de7b3b7d48801908f19cf5462177104395799472671b3e8152b2b04ca/fastar-0.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91f07b0b8eb67e2f177733a1f884edad7dfb9f8977ffef15927b20cb9604027d", size = 923669, upload-time = "2026-04-13T17:08:30.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/01/8926c53da923fed7ab4b96e7fbf7f73b663beb4f02095b654d6fab46f9ad/fastar-0.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f85c896885eb4abf1a635d54dea22cac6ae48d04fc2ea26ae652fcf1febe1220", size = 815729, upload-time = "2026-04-13T17:09:01.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/f0/5fef4c7946e352651b504b1a4235dac3505e7cfd24020788ab50552e84bf/fastar-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:075c07095c8de4b774ba8f28b9c0a02b1a2cd254da50cbe464dd3bb2432e9158", size = 819812, upload-time = "2026-04-13T17:09:31.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/c8/0ebc3298b4a45e7bddc50b169ae6a6f5b80c939394d4befe6e60de535ee7/fastar-0.11.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:07f028933820c65750baf3383b807ecce1cd9385cf00ce192b79d263ad6b856c", size = 884074, upload-time = "2026-04-13T17:08:45.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/9f/7baa4cdff8d6fbca41fa5c764b48a941fed8a9ec6c4cc92de65895a28299/fastar-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:039f875efa0f01fa43c20bf4e2fc7305489c61d0ac76eda991acfba7820a0e63", size = 969450, upload-time = "2026-04-13T17:10:18.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/dc/1ebbfb58a47056ba866494f19efbcdd2ba2897096b94f36e796594b4d05b/fastar-0.11.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:fff12452a9a5c6814a012445f26365541cc3d99dcca61f09762e6a389f7a32ea", size = 1033775, upload-time = "2026-04-13T17:10:36.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/5f/ce4e3914066f08c99eb8c32952cc07c1a013e81b1db1b0f598130bf6b974/fastar-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2bf733e09f942b6fa876efe30a90508d1f4caef5630c00fb2a84fba355873712", size = 1072158, upload-time = "2026-04-13T17:10:52.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/2a/6bca72992c84151c387cc6558f3867f5ebe5fb3684ee6fa9b76280ba4b8e/fastar-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d1531fa848fdd3677d2dce0a4b436ea64d9ae38fb8babe2ddbc180dd153cb7a3", size = 1028577, upload-time = "2026-04-13T17:11:09.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/18/7a7c15657a3da5569b26fc51cde6a80f8d84cb54b3b1aea6d74a103db4ad/fastar-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:5744551bc67c6fc6581cbd0e34a0fd6e2cd0bd30b43e94b1c3119cf35064b162", size = 453601, upload-time = "2026-04-13T17:11:53.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/d8/331b59a6de279f3ad75c10c02c40a12f21d64a437d9c3d6f1af2dcbd7a76/fastar-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f4ce44e3b56c47cf38244b98d29f269b259740a580c47a2552efa5b96a5458fb", size = 486436, upload-time = "2026-04-13T17:11:40.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5tagger"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/f2/1f61d4a121c5b37018bbd1d4cd010dce42f5b519c7552acd5fac1289635e/html5tagger-2.0.0.tar.gz", hash = "sha256:0dee3c9054443930e9a3225edcc810b6df76c0aeabfee463e9437a90a1ac1624", size = 15383, upload-time = "2026-07-10T01:52:04.747Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/d7/410f7b8109958405f6fb3d104f094e2a96f9d5b2670de73a68fb1fbc391f/html5tagger-2.0.0-py3-none-any.whl", hash = "sha256:c7a46fa74c81fdbd33b7e4a9630f4d592ec818181fc58702585e9bad0b76f891", size = 17783, upload-time = "2026-07-10T01:52:03.532Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsondiff"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/35/48/841137f1843fa215ea284834d1514b8e9e20962bda63a636c7417e02f8fb/jsondiff-2.2.1.tar.gz", hash = "sha256:658d162c8a86ba86de26303cd86a7b37e1b2c1ec98b569a60e2ca6180545f7fe", size = 26649, upload-time = "2024-08-29T04:09:06.201Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/94/a8066f84d62ab666d61ef97deba1a33126e3e5c0c0da2c458ada17053ed6/jsondiff-2.2.1-py3-none-any.whl", hash = "sha256:b1f0f7e2421881848b1d556d541ac01a91680cfcc14f51a9b62cdf4da0e56722", size = 13440, upload-time = "2024-08-29T04:09:04.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kanta"
|
||||
version = "0.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blake3" },
|
||||
{ name = "jsondiff" },
|
||||
{ name = "msgspec" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/35/7373e80e6af2145f9753a2417f33aed75901a97882562b7724048f0e371b/kanta-0.8.1.tar.gz", hash = "sha256:c47c1dc5c44cd47afb8ab3baee5e69a5e4e5be12573d269c376769c15a8ecb9d", size = 63611, upload-time = "2026-08-13T21:59:52.214Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/fa/3e48ae9c88660b1b0e6dbb1457590923838a47c4adf819d3479acb58c815/kanta-0.8.1-py3-none-any.whl", hash = "sha256:907d1fc991c7681f4d07d2090b7c07ca68e84a6ba11506b4c03ed9b430b3356f", size = 52681, upload-time = "2026-08-13T21:59:50.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdit-py-plugins"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "msgspec"
|
||||
version = "0.21.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pagerite"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "blake3" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "fastapi-vue" },
|
||||
{ name = "html5tagger" },
|
||||
{ name = "kanta" },
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "mdit-py-plugins" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tracerite" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "blake3", specifier = ">=1.0.9" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" },
|
||||
{ name = "fastapi-vue", specifier = ">=1.3.1" },
|
||||
{ name = "html5tagger", specifier = ">=2.0.0" },
|
||||
{ name = "kanta", specifier = ">=0.8.1" },
|
||||
{ name = "markdown-it-py", specifier = ">=4.2.0" },
|
||||
{ name = "mdit-py-plugins", specifier = ">=0.6.1" },
|
||||
{ name = "pygments", specifier = ">=2.20.0" },
|
||||
{ name = "tracerite", specifier = ">=2.6.4" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "httpx", specifier = ">=0.28.1" }]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
email = [
|
||||
{ name = "email-validator" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-extra-types"
|
||||
version = "2.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[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/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 = "rich"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich-toolkit"
|
||||
version = "0.20.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/3a/a258c2fbc6c6bdf428611388f5698ba5d57ffdf0755e1cab474d9cc47813/rich_toolkit-0.20.3.tar.gz", hash = "sha256:223dd2cfba325ed55e94933b9e53f3aca13e9fdf76622bd564c18109a2273c1b", size = 205355, upload-time = "2026-07-13T14:38:06.837Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/ce/639d0d0ce3d25c5edbd1afecd308bb35dc04883a45ac9f0855c8aee4e919/rich_toolkit-0.20.3-py3-none-any.whl", hash = "sha256:419aa87516d5f3849cca553c6dcf707c02a36d508fcf996946606725d34a3002", size = 36195, upload-time = "2026-07-13T14:38:05.687Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rignore"
|
||||
version = "0.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/7e/aa0640d74f6b4bb68466f5899bd5ed1680480732344c31a408504e215801/rignore-0.8.1.tar.gz", hash = "sha256:2b6cf58501e9ff1b6a71c3fd66c8a105311e1f23237626fd4c9c00606bb3d30f", size = 55535, upload-time = "2026-08-04T22:27:08.237Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/13/7ceb0fe7a5a5a67e7f1cee65d8a9b09c52df53a4ff406d14940130a420b7/rignore-0.8.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3054fab93e2d2ad483cb89417f7b883cee025cb22286eb824af0073f2ffd5f7e", size = 849303, upload-time = "2026-08-04T22:24:17.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/87/fe151f1a3d93483b36e22e8a0e84382cff3b7fe9d7347cfb586727054c56/rignore-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60f1ae9eaa51b50d5afe99bd25ffb2cfc10729701ca08c179489555d2614f3ae", size = 817293, upload-time = "2026-08-04T22:24:18.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/08/cb82a725f040cc5d106a1fe33dec4031fb9d8863a4874a8dfb60c4db79f9/rignore-0.8.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6012766ea3a5a635d9b79f3e8c3797d5e47ce5e5dd81993c9f92a3ea4ff68b4c", size = 885260, upload-time = "2026-08-04T22:24:20.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/77/6ba8d24fd151513347d9420fb383ec732b54886c6a5500e13f0bd7ec4072/rignore-0.8.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90e60f0073caae0f1d59c993adedafa3a57bc6fac551669677cb25aa7fa9d9b8", size = 857548, upload-time = "2026-08-04T22:24:21.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/66/c54f448b077e89de99e85746677425fbb3a62d3721aeb9afa556dc3ca10f/rignore-0.8.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:121ab7ac93e39fd1d70098461c1ed9a6fb89d54e7bf8c60ae351b23b05cbb8c1", size = 1135817, upload-time = "2026-08-04T22:24:23.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/92/4b47da84a36687f10c09b43de7600d7dde8f56f2981e68b8822cfadd26aa/rignore-0.8.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:befb772556c8463c640b290f632b57440182edd39996708a33c50bcf437796f9", size = 913513, upload-time = "2026-08-04T22:24:24.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/a4/8428734c0217b5c0adbe752afc9603ca34434c6033584f63d6ca7ba50331/rignore-0.8.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a184fa45db8cdc7a8604d2df020a107be3fc0adc2e522b4e4eb5cd5b57d5f84", size = 929324, upload-time = "2026-08-04T22:24:26.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/f6/ab3738e42d9d351033b8e8fb46ac4b8eb193545aaf3ed2f52f58fb8d5212/rignore-0.8.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e68a572efc126aa45195f1581a5ea97c4e36eeba6874a6635ae44daa4fbec7a4", size = 891130, upload-time = "2026-08-04T22:24:27.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/ba/9f374cf332eeea0e26cca6b6550678d0c69208e7ba4aea8ece0dc6b205eb/rignore-0.8.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c75ac1952ca8892422de328a925f4804120b149543042313bb3af7cbfdd65d64", size = 964769, upload-time = "2026-08-04T22:24:28.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/46/7ee48265e34cb90ef4bcf85c63874da6d6a31af1c63fb7bca84fd5dbc188/rignore-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3538084cef4a66ba3fee7c453d17db7cfb32a6653456381b62afd8d53090d6fa", size = 1061779, upload-time = "2026-08-04T22:24:30Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/23/51c725ba23f06b2809a0a676127ca3613e852bcab0532e58cab9f09b6fbb/rignore-0.8.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:28a3baab3d1b7ea42c38e492d4a100de3e5c67217432364c62b5719e0f04e96a", size = 1132595, upload-time = "2026-08-04T22:24:31.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/a0/671192e2cc8711694cf564f77e9e59d5e8c41f100bce67fbb384f2befec5/rignore-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:74996d8ed1a494ff8e61d9194a0dd3637e6b19a582cbed6fe0e1a4cc60e7b266", size = 1141117, upload-time = "2026-08-04T22:24:33.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/df/b3a9a821b37361debc29fd009d21dc4af7f8313b76371c1640aff2765a59/rignore-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a2e5df9ba53e502f676b054c4d12371273204e2b9c6ec29afad3a155b9ad3399", size = 1140515, upload-time = "2026-08-04T22:24:34.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/69/bcf0f38713c86d462002c47b0ad14d655f8766a3b9235d8f6813e8245695/rignore-0.8.1-cp314-cp314-win32.whl", hash = "sha256:69db47c2fa51d88e93b4e4de4f44220ce7a9337e4b7526a16f5660b19974a8ca", size = 637894, upload-time = "2026-08-04T22:24:36.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/f8/0c0e0bb59aea492e288d5c058ad873599d43dd5674495033fe9e8444b80d/rignore-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:1c668a541ecd8af3d7bd06c48c62e1c3e0a755028d57c07cee697a25d9b0678d", size = 728394, upload-time = "2026-08-04T22:24:37.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/7c/c23926385e01d9b99fdf7bb7b86aaa60c2c1eb4de2a6303549f08497a0fa/rignore-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:d63559131efa9cbba82494ec8698e38bea403f214969066c661b3eb4da4ad4f9", size = 667896, upload-time = "2026-08-04T22:24:38.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/9d/a70a895b47fa30446ffadd0a7add23494e18335ddc85c27cbc5a740374c7/rignore-0.8.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:b60f3751f681a12798927d51d1d6a46b81fe7f9923a513dd8fcc7947d785e12f", size = 847278, upload-time = "2026-08-04T22:24:40.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/cc/24fe37a08fd2083882422509c31bb58c14d85f2c7ab3b4ef3cc6f430d807/rignore-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7d56bf138418e31991ce17b738c491fa1ac098bfcc8c3ba67fc0faeecfed357", size = 815968, upload-time = "2026-08-04T22:24:41.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/3d/1a260407a17e062859995d9f557b4ccbb36e6a0ef44a0d99409c836e5942/rignore-0.8.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f020b018577d0081a2b23da199d22c58f4cbd63935c8e991adffbb1a755b467", size = 884941, upload-time = "2026-08-04T22:24:43.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/80/70291634f2f2257fcabe1156926d7a10a87b0da1a8ba9ab2cafac37a4ded/rignore-0.8.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d4a70c9b857657c3a542fd367e16ae106fb6e3e4448cddb30e4edc604d5a025", size = 856667, upload-time = "2026-08-04T22:24:44.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/84/3811795d0b6fb3e8865d6a4f4eb3d3184e68a8557d7ee5b6d5a463196f54/rignore-0.8.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ab668664e6388afc08fec50186ec21daf17c738ee71bb039d12cf5f14e964dde", size = 1134272, upload-time = "2026-08-04T22:24:46.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/51/6a60ea8291346459e3f788bac4ecc9711b13271804c6eef47dc2ec59e6e7/rignore-0.8.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a3454cdd8bc145fd055dd45f650ccf1509e7b4edfc720152eb2be594b230f03", size = 914263, upload-time = "2026-08-04T22:24:47.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/72/620d4baa44e44ee53ae516efcb714183fd592ba923dc7990bf57947ff610/rignore-0.8.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:425b962f3d68b86ebb785409153708a38b0658542c918ee706ea433e2547c805", size = 928135, upload-time = "2026-08-04T22:24:48.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/60/06495efc96c2edcda647dbf7db770ab9307459d3b2f8598d9ae1f1ce9ea5/rignore-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:5094965794d163e1c4a71dccf3d1c3b3df86802e3afbfa461b300aa923cd7ea5", size = 890677, upload-time = "2026-08-04T22:24:50.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/92/396f751e69d543bfb7e9faa5b0ae3ee988fcd76d9c743857fdb26db0651b/rignore-0.8.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1467756e8454d3f816131bbad8b0efc52b7f6924e9b151f2962cec9e2f4af706", size = 964291, upload-time = "2026-08-04T22:24:51.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/0b/642483058dfbcbfc319d9cf48a05d8dbf9f7f6939b410eebdbc73dd9414c/rignore-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b5acf12993258f0eac4db81c30bed5469ebadd1b5bb2863c985fe0d802d94e0f", size = 1060972, upload-time = "2026-08-04T22:24:53.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/2d/69b54a53d94baf2fb16ea1246f96bf093aae6a0e5d5c5cf5099c1fee4fb8/rignore-0.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:329ae0bea9598541cb818bbecb1e1f56fc1faae77828ebb3846ef2b24a050d05", size = 1130938, upload-time = "2026-08-04T22:24:55.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/36/c213d838c3c2237da8c51163a6c55acaeedc59759d4884d81ab6d7250721/rignore-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4069fcdff01999cd2d5a426eccda45a8b31a3e0eeee8b5a9da5a452714cbb2c7", size = 1140646, upload-time = "2026-08-04T22:24:56.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/70/28125520727d72e2878c1a6a3dbd333e57f27730b463d629d170e2da3b62/rignore-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf28097a83e1237f87e11e33fb12def022e103cab2a20340a19753e8e222aebf", size = 1139989, upload-time = "2026-08-04T22:24:58.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/e6/8c0d1f2fa6d9b2f15e6a4bd20e7bfc8f4af436b8328ced782deda0a46057/rignore-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:c978f26c25e66c118da1e6218f34cc5ce2bf9c5cc3969011cde47bacbfe4a072", size = 640012, upload-time = "2026-08-04T22:24:59.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c4/ef25f5b8d65ddb0e5266b13892c0c996dc221faac5b76a7b6fff4295ce44/rignore-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:790e14dba9e1f8a532071d64e1c2fa7529c71db75996e95519d06d3378cbbcd5", size = 728195, upload-time = "2026-08-04T22:25:01.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/6a/603b522d76a3acd66952e20e2ad7fa84c09d89436d707d6bb9ecb5ae55d3/rignore-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:484c0a4803a1eb9097d875df3200e60e58cbf316d4d40357f92e8e757ce529dd", size = 666282, upload-time = "2026-08-04T22:25:02.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fb/b8de8a872bdb9150aa55783273f73e9aa253951d8f82856fe916fce3cae8/rignore-0.8.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1c2a29e7f54e879fa54982887de480b2cfa6e3cb51f01d70dd4e4035f5b79670", size = 849669, upload-time = "2026-08-04T22:25:04.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/24/4ca7ce428378e7ef938cb36573f22e842dcdb2655e266b32711eea08d16f/rignore-0.8.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:58449c827c36dba68133a5ee4137b67d7d6e050234a95c3a8f5e3cefbd6970ec", size = 817738, upload-time = "2026-08-04T22:25:05.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/5a/78311079595c96e01a2404eff725468e25fad16d2836408046ffbdb33a69/rignore-0.8.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e250726b08957aabcf4e28aef4ea18bcccbd95f240cebb75a74b424f067dce4b", size = 885906, upload-time = "2026-08-04T22:25:06.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/57/7d456d2dcc881e6bf08275222ae36fff58343ee0fc47e1b26722f97b38ea/rignore-0.8.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62470f4d70d83f124381975614ed7c7db6f5c5fb4d777ee88f7bdb9a7d14b65c", size = 858240, upload-time = "2026-08-04T22:25:08.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/3c/0f9bd7b9b3af37b216096e1dc81f9368d64c13625ddd34b4d595b316b47c/rignore-0.8.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d886d6bbdd0a1a3ef73bd38d6768cabc9b30bfd1be7b157b27a4ac7ec6c5244b", size = 1135145, upload-time = "2026-08-04T22:25:09.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/06/f0087a520e3bf3d9532aa823ea141e5a076432b9d036bb506a99ddb534c2/rignore-0.8.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ed31657a20df59b0bd63283d278e8678749aa0154ba98fae0838a9845599695", size = 914331, upload-time = "2026-08-04T22:25:11.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/65/8ac37040f162cb965520286ef48472a87f47cb137df4068a0d32634166dc/rignore-0.8.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e425a42f6601bfed266f76f3294bca48efcfdb10c3c0c279fb2f977b1e2cc2bc", size = 929202, upload-time = "2026-08-04T22:25:13.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/93/19b752fda8a56424ee156ef6a9799c582eb6d2ffb233c7b91e103b79d54e/rignore-0.8.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:ff09db59f672d929bca88ee7089d3697256967df76a2bab8b187208f2b517bc0", size = 891752, upload-time = "2026-08-04T22:25:14.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/94/a5db364ddb136360c7c4ba75cb56ba2f7cd8321b296602f6ea7438fc1a83/rignore-0.8.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8dbc5898945c027dc0ae451de0c983e20bd8fa8297f40ee38d0ce1a3ed924d4c", size = 964714, upload-time = "2026-08-04T22:25:16.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/11/63316cd5a87402a42c75b6ada0331745975f0274cabb6a8aa85c043468ab/rignore-0.8.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1160abcd855964a9dd69f3d603ef57be14b1ea51ebaf50b07f737a3f3a8b89a6", size = 1062141, upload-time = "2026-08-04T22:25:18.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/6e/19b4bdde3f53bb0c9427d65544f92f864a892d695559423ce01fd14c1a31/rignore-0.8.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:be994859b2cbbc69338351bc9908dd7d049de232e6eab5e998ec1feac3faf785", size = 1133487, upload-time = "2026-08-04T22:25:19.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/d3/fc41ea3164001fe84a2ec8b84d7bb48e7a73ebdcac6d68961c80f874dd4e/rignore-0.8.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:f52098f3245c7557229d8253ea3a53a03de03704685aba8a1ccd24a5f004db70", size = 1141434, upload-time = "2026-08-04T22:25:21.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/35/b9d415a7e90c37b28b2d5fd6a074a4a3d572d5a0e0b596df0b4b85af8a78/rignore-0.8.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c676080bb70cbd5429052bb5a3827b35543876a42c792c6d713d9e162bdaa00d", size = 1140492, upload-time = "2026-08-04T22:25:22.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/9b/49ac5f66862c796a04f0b7aad47ace4dea0a3ef52d313d2fb154821dd88a/rignore-0.8.1-cp315-cp315-win32.whl", hash = "sha256:ec800546b960d5044d2468d22dd0689210e846350c42cc9f02519c2a2528b3ef", size = 638365, upload-time = "2026-08-04T22:25:24.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/76/90568f3af555074c5e1936b80573539658cda1dce4e89dd87f3174d88aa8/rignore-0.8.1-cp315-cp315-win_amd64.whl", hash = "sha256:4225f2d2f0b3e3c39a815f15dbe551f39f0d7f82e2a92d16bdbdb07ff4b6718e", size = 728711, upload-time = "2026-08-04T22:25:25.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e0/3afb8bb7c6c538615d58c97f6e3b9ff7071f19442cd9cfb6f3ae6bbe0bee/rignore-0.8.1-cp315-cp315-win_arm64.whl", hash = "sha256:42480707cdd5f92d6b1252faada600e03ab22fbfd53b124c6f7b33c21e06b6cf", size = 668102, upload-time = "2026-08-04T22:25:27.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/08/ce533f5c0b677dd7e82a3461e86afdf0cd99db9a73f3f561d1fabcd03fc8/rignore-0.8.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:51e608acd3c1aa834f2b7d956ff5380a0a6b6b595df80e665f2a2889982f1855", size = 846662, upload-time = "2026-08-04T22:25:28.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/43/4bb308ac5751881d7ba600e93d7f2e4a2c884af5028ac0ced4f1274c09c3/rignore-0.8.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6602011b6e6ecd157f3b0a0a25b1777b40c541ca21de6ebe2137af713d0efa15", size = 816657, upload-time = "2026-08-04T22:25:30.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/f1/10a11bf9ebf03f7a8d9876ff023634cd4ce61845be9929f816ece4dfff11/rignore-0.8.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8db2be0d49175d5db4cd503f8dc62cbe687b9ebc71af4cede1c8b40bab8f4f4a", size = 884975, upload-time = "2026-08-04T22:25:31.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/97/c8dddb2dab738bd1b3a0b1639bc42d696bdd2765515a302620a35795c475/rignore-0.8.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:601ff49d8458a21d745908e35f792c02b45ecae4a37fa3cd1fc03fa065962bc0", size = 856951, upload-time = "2026-08-04T22:25:33.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/1d/ddcc1cddfb0a79c82eedbcd25ba0b014d346251cc08c1938b90951660cf8/rignore-0.8.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb58e93997d546fad4cfcd730a54d7c12cd7a61fe7a9b31bfc75402403dc559f", size = 1134401, upload-time = "2026-08-04T22:25:34.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0a/519d2e081e0b89c705bb4b282ed7540c185accc748c97c40dd80a333eb3a/rignore-0.8.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0378cf77b8d64560e0cb433deada18438b36ef7933dd284dd65347d03c56c429", size = 914389, upload-time = "2026-08-04T22:25:36.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/aa/c1e857c4b8f9dd388c38c8ca430917f24f1de44d9279d4f5d9edbcc94a0c/rignore-0.8.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0097a8c35106997d2b52851f0888777efe10e34772140a9fdb018b2f99238159", size = 928845, upload-time = "2026-08-04T22:25:38.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9c/a9651d49fbf2a336074df0a09d01ee2d968dae902e88e51e32d822f5e9fc/rignore-0.8.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:2a809a250f1532b93dcc52e173ee71adcafdb536a125532687666047c4537ac0", size = 891090, upload-time = "2026-08-04T22:25:39.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/98/d3242c45edfdc059fb80b73be9e27dac34639da40002ebbcd5c9b9acb821/rignore-0.8.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:580e019787032b430b857335a66db53d7ae0a200586b3a6b5d3ef227648300d6", size = 965283, upload-time = "2026-08-04T22:25:41.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/37/e19e70395c2e5a06ba67e0a51e86ede1b4269b8d3ba091963c4f0a950f63/rignore-0.8.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f922dcf01e6a7ad26adfcb7f42635ae63066b883d409876129db62297061327e", size = 1061223, upload-time = "2026-08-04T22:25:43.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/bd/24b737f97c542e4883cfcc557b08565adc7c698e30836b8bc9855676d749/rignore-0.8.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4900bd1ba8938e5a5e601306504c7b8169783a2d44a8396c8fda4a9b659eed51", size = 1131772, upload-time = "2026-08-04T22:25:45.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/8d/bedc51ca98696998797af67163c039e0adc7835035fb929f10d33fb3d997/rignore-0.8.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:3d581bf107490abae4d40a9aa823a79e18c88a21658514ccaf95e42e85d278f7", size = 1141631, upload-time = "2026-08-04T22:25:46.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9c/946e55a7e5bc7b165faed3475451683580ddf8b45d63e67c39a0cdee5ce2/rignore-0.8.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:78490b93aec14a87fa4c23e634b1d1dcb2bf5dcb90923f3b03a3428892a13d9f", size = 1140328, upload-time = "2026-08-04T22:25:48.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/02/211daf0a7f957386d2223ef7a1601d36b42a6c1a6b93aae32db712868390/rignore-0.8.1-cp315-cp315t-win32.whl", hash = "sha256:e8315151a83b982fe972420372e80c550616095181d1549faaa1ec31f363f5ae", size = 640253, upload-time = "2026-08-04T22:25:50.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/0e/ccb682455ca380cc640c2a9536390fd907cc5ac0396118643f9d386e4af5/rignore-0.8.1-cp315-cp315t-win_amd64.whl", hash = "sha256:69be37202a052d9e13affed6b774f86d8bf3b417b15d54ad737033e56da07ec0", size = 728332, upload-time = "2026-08-04T22:25:51.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ec/a2b634938c39600205dcd37015343e779dc8f112c234d905666dec2a66fd/rignore-0.8.1-cp315-cp315t-win_arm64.whl", hash = "sha256:4268e83bcb88f240ed4f43d73e7de9e4bcd8026bdfcfa836567d6c29863e5361", size = 666611, upload-time = "2026-08-04T22:25:53.857Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.68.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/94/23b7dd072acb9628907bd3f4fbf61794a7b12a9db8f33c1276f70ae5ac92/sentry_sdk-2.68.0.tar.gz", hash = "sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3", size = 1008854, upload-time = "2026-08-13T09:06:21.268Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/9b/e2421d08956d0bc4691d995393d835e563886bff499d8fb10fdefae85a8d/sentry_sdk-2.68.0-py3-none-any.whl", hash = "sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4", size = 518670, upload-time = "2026-08-13T09:06:19.735Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracerite"
|
||||
version = "2.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "html5tagger" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/27/b71f8949d62dacdc88dce63814a41a9235a089ca96aafe5eb2ac17a5ec6f/tracerite-2.6.4.tar.gz", hash = "sha256:c661f9067f1e5d3fd30f995174e6db61be15bed621b827e1e31f2d402a45fcbc", size = 105964, upload-time = "2026-08-14T22:17:40.522Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/20/45a2df0c0ed93c8d669684f0c5386c674ad53906317499ccfc608003face/tracerite-2.6.4-py3-none-any.whl", hash = "sha256:c850621a5b77c42db141966c53dc637802dfe23332e12389437386d9f001d3b3", size = 112431, upload-time = "2026-08-14T22:17:39.301Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.27.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.52.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/28/64ca011edf31c715b4fad359c587ea52391aaffa125065695590241ff617/uvicorn-0.52.3.tar.gz", hash = "sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58", size = 100621, upload-time = "2026-08-13T16:50:02.899Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "17.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user