diff --git a/Makefile b/Makefile index a15f344..1a0d12c 100644 --- a/Makefile +++ b/Makefile @@ -28,11 +28,15 @@ export PYTHON # treat them as goals of their own, so each gets a no-op rule. ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) ifneq ($(ARGS),) +# Declares each extra word as a target that does nothing: `:` is an empty rule +# body and `@` silences it. Without this, `make build sample` runs the build and +# then fails with "No rule to make target 'sample'", because make reads every +# word on the line as something it has been asked to build. $(eval $(ARGS):;@:) endif .DEFAULT_GOAL := help -.PHONY: help build start stop cluster deploy component +.PHONY: help build start stop dist docs cluster deploy component help: ## list targets @grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16 @@ -48,6 +52,14 @@ start: ## run a built room [] [-d] [--build] stop: ## stop a running room [] bash ctrl/stop.sh $(or $(ARGS),$(ROOM)) +dist: ## compile the plexus UIs to single files [] + bash ctrl/dist.sh $(or $(ARGS),$(ROOM)) + +# ── docs ─────────────────────────────────────────────────────────────────── + +docs: ## documentation [serve [port]|graphs [theme]] (default serve) + bash ctrl/docs.sh $(or $(ARGS),serve) + # ── cluster ──────────────────────────────────────────────────────────────── cluster: ## shared kind cluster [up|down|status] (default status) diff --git a/build.py b/build.py index cacdd8a..c3530c5 100644 --- a/build.py +++ b/build.py @@ -23,6 +23,7 @@ Generated structure for managed rooms: """ import argparse +import importlib.util import json import logging import shutil @@ -532,6 +533,148 @@ def _append_cabinet_env(output_dir: Path, cabinets: list[dict]): example.write_text(existing.rstrip("\n") + "\n" + "\n".join(lines) + "\n") +def load_plexuses(room: str) -> list[dict]: + """The plexuses a room asked for. Same shape as its sibling data/*.json.""" + path = SPR_ROOT / "cfg" / room / "data" / "plexuses.json" + if not path.exists(): + return [] + try: + raw = json.loads(path.read_text()) + except ValueError as e: + log.warning(f" plexuses.json is not valid JSON, ignoring: {e}") + return [] + + entries = raw.get("items", raw) if isinstance(raw, dict) else raw + out = [] + for entry in entries if isinstance(entries, list) else []: + if isinstance(entry, str): + entry = {"name": entry} + if isinstance(entry, dict) and entry.get("name"): + out.append(entry) + return out + + +def _theme_css(theme: str) -> str: + """The token contract plus one theme, flattened for inlining. + + Only the named theme ships alongside the others it can switch to, because + the export has to work with no server: there is no /theme.css to fetch. + """ + theme_dir = SPR_ROOT / "soleprint" / "common" / "theme" + parts = [] + tokens = theme_dir / "tokens.css" + if tokens.exists(): + parts.append(tokens.read_text()) + # Every theme, so the switcher in the page has something to switch to. + for sheet in sorted((theme_dir / "themes").glob("*.css")): + parts.append(sheet.read_text()) + return "\n".join(parts) + + +def _inline_svg(name: str, theme: str) -> str: + """A rendered graph, stripped of its XML prolog so it can sit in HTML. + + Inlined rather than -linked so the page's CSS can recolour it when the + theme switches — graphviz writes class="node accent" into the SVG, and CSS + outranks the presentation attributes it bakes in. + """ + graphs = SPR_ROOT / "docs" / "graphs" + for candidate in (graphs / f"{name}.{theme}.svg", graphs / f"{name}.svg"): + if candidate.exists(): + svg = candidate.read_text() + start = svg.find("= 0 else svg + log.warning(f" no rendered graph '{name}' — run docs/graphs/render.sh") + return "

diagram not rendered

" + + +def build_plexuses(output_dir: Path, room: str): + """Export each plexus the room declared to a single self-contained file. + + A plexus is exported, not served. The output is one index.html carrying its + theme, its data and its diagram, so it survives a locked-down machine, a zip + attachment and a double-click — which is the whole point of the format. + """ + requested = load_plexuses(room) + if not requested: + return + + source_root = SPR_ROOT / "soleprint" / "artery" / "plexuses" + built = [] + + for entry in requested: + name = entry["name"] + source = source_root / name + manifest_path = source / "plexus.json" + if not manifest_path.exists(): + available = sorted( + p.name for p in source_root.iterdir() if p.is_dir() + ) if source_root.exists() else [] + log.warning( + f" no such plexus: {name} (available: {', '.join(available) or 'none'})" + ) + continue + + try: + manifest = json.loads(manifest_path.read_text()) + except ValueError as e: + log.warning(f" plexus {name} has invalid plexus.json: {e}") + continue + + # The room may override anything the plexus declares — theme first. + manifest.update({k: v for k, v in entry.items() if k != "name"}) + + template_path = source / "app" / "index.html" + if not template_path.exists(): + log.warning(f" plexus {name} has no app/index.html") + continue + + theme = manifest.get("theme", "soleprint") + data = {k: v for k, v in manifest.items() if not k.startswith("_")} + + # A plexus may ship a showcase.py exposing collect(): anything it returns + # is merged into the page's data. The bundle uses it to run the real + # tools over the real fixtures at build time, so what the page shows + # cannot drift from what the tools do. + collector = source / "showcase.py" + if collector.exists(): + try: + spec = importlib.util.spec_from_file_location( + f"plexus_{name}_showcase", collector + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + data["showcase"] = module.collect() + except Exception as e: + log.warning(f" {name}: showcase.py failed ({type(e).__name__}: {e})") + + page = template_path.read_text() + for token, value in ( + ("%%TITLE%%", manifest.get("title", name)), + ("%%DESCRIPTION%%", manifest.get("description", "")), + ("%%DEFAULT_THEME%%", theme), + ("%%BUILT%%", f"{room} · built by soleprint build.py"), + ("%%THEME_CSS%%", _theme_css(theme)), + ("%%GRAPH%%", _inline_svg(manifest.get("graph", "system_overview"), theme)), + ("%%BUNDLE%%", json.dumps(data, indent=2)), + ): + page = page.replace(token, value) + + target = output_dir / "plexuses" / name + ensure_dir(target) + (target / "index.html").write_text(page) + + # Anything else in app/ rides along, for a plexus that outgrows one file. + for extra in (source / "app").iterdir(): + if extra.name != "index.html": + copy_path(extra, target / extra.name, quiet=True) + + built.append(f"{name} ({theme})") + + if built: + log.info(f" plexuses: {', '.join(built)}") + + def build_soleprint(output_dir: Path, room: str): """Build soleprint folder with core + room config merged.""" soleprint = SPR_ROOT / "soleprint" @@ -568,6 +711,11 @@ def build_soleprint(output_dir: Path, room: str): log.info("Composing cabinets...") compose_cabinets(output_dir, room) + # Plexuses are exported rather than served, so this is a compile step like + # the cabinet merge above — not something run.py does at request time. + log.info("Exporting plexuses...") + build_plexuses(output_dir, room) + # Generate models log.info("Generating models...") if not generate_models(output_dir, room): @@ -624,6 +772,33 @@ def build_models_only(): sys.exit(1) +def build_plexuses_only(room: str): + """Compile just the plexus UIs, without rebuilding the room around them. + + The equivalent of `vite build` for this repo: the iteration loop when you + are working on the UI itself is edit, compile, reopen the file — and a full + room build to see a CSS change is a slow way to do that. + """ + output_dir = SPR_ROOT / "gen" / room + if not output_dir.exists(): + log.error(f"Room '{room}' is not built — run: python build.py --cfg {room}") + sys.exit(1) + + log.info(f"Compiling plexus UIs for {room}...") + build_plexuses(output_dir, room) + + built = sorted((output_dir / "plexuses").glob("*/index.html")) + if not built: + log.warning( + f" nothing compiled — does cfg/{room}/data/plexuses.json list one?" + ) + return + for page in built: + log.info(f" {page.relative_to(SPR_ROOT)} ({page.stat().st_size // 1024} KB)") + log.info("\n✓ Open directly — no server needed:") + log.info(f" xdg-open {built[0]}") + + def main(): parser = argparse.ArgumentParser(description="Soleprint Build Tool") @@ -631,10 +806,17 @@ def main(): parser.add_argument("--cfg", "-c", type=str, help="Room config name") parser.add_argument("--all", action="store_true", help="Build all rooms") parser.add_argument("--models", action="store_true", help="Only regenerate models") + parser.add_argument( + "--plexuses", + action="store_true", + help="Only compile the plexus UIs into an already-built room", + ) args = parser.parse_args() - if args.models: + if args.plexuses: + build_plexuses_only(args.cfg or "standalone") + elif args.models: build_models_only() elif args.all: build(SPR_ROOT / "gen" / "standalone", None) diff --git a/cfg/standalone/data/plexuses.json b/cfg/standalone/data/plexuses.json index 2feb210..15b8b45 100644 --- a/cfg/standalone/data/plexuses.json +++ b/cfg/standalone/data/plexuses.json @@ -1,3 +1,7 @@ { - "items": [] + "items": [ + { + "name": "bundle" + } + ] } diff --git a/ctrl/deploy.sh b/ctrl/deploy.sh index d6dfb4b..e8b1917 100755 --- a/ctrl/deploy.sh +++ b/ctrl/deploy.sh @@ -41,6 +41,14 @@ if [ "$SYNC_ONLY" = true ]; then fi echo "Restarting soleprint on server..." -ssh "$SERVER" "cd $REMOTE_DIR && docker compose up -d --build" +# The compose file runs the container as ${UID:-1000}:${GID:-1000} and bind-mounts +# the deployed tree at /app. Those have to be the ids that OWN the tree, and they +# are not 1000 on every host — mcrn.ar's user is 1001. Without this the container +# starts fine and then 500s on the first file it reads, which reads as an app bug +# rather than a permissions one. +# +# `env` rather than a prefix assignment: UID is readonly in bash, so +# `UID=$(id -u) docker ...` fails outright. +ssh "$SERVER" "cd $REMOTE_DIR && env UID=\$(id -u) GID=\$(id -g) docker compose up -d --build" echo "Deploy complete" diff --git a/ctrl/dist.sh b/ctrl/dist.sh new file mode 100755 index 0000000..81a9e3c --- /dev/null +++ b/ctrl/dist.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Compile the plexus UIs to distributable files — this repo's `vite build`. +# +# Usage: +# ./ctrl/dist.sh # standalone +# ./ctrl/dist.sh sample # a named room +# +# A plexus is a UI that gets EXPORTED, not served. The output is a single +# index.html carrying its theme, its data and its diagrams inline, so it opens +# from a double-click on a machine with no server, no node and no network — the +# state a regulated Windows box is usually in. +# +# `make build` runs this as one of its steps. This exists for the loop where the +# UI is what you are working on: rebuilding a whole room to see a CSS change is +# a slow way to iterate. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +cd "$ROOT_DIR" + +PYTHON="${PYTHON:-python3}" +ROOM="${1:-standalone}" + +if [[ ! -d "cfg/$ROOM" ]]; then + echo "No such room: cfg/$ROOM" >&2 + echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2 + exit 1 +fi + +exec "$PYTHON" build.py --plexuses --cfg "$ROOM" diff --git a/ctrl/docs.sh b/ctrl/docs.sh new file mode 100755 index 0000000..b5563e3 --- /dev/null +++ b/ctrl/docs.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Documentation: serve the pages, and re-render the diagrams. +# +# Usage: docs.sh serve [port] | graphs [theme] +# +# The docs are a static SPA — index.html plus data/*.md read at runtime — so +# they need a server only because fetch() refuses file:// origins. Any static +# server does; python is already a hard dependency here (build.py is python), so +# there is no reason to reach for docker the way rig does. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +DOCS_DIR="$ROOT_DIR/docs" + +PYTHON="${PYTHON:-python3}" +PORT="${DOCS_PORT:-8080}" + +serve() { + [ -n "${1:-}" ] && PORT="$1" + if [ ! -f "$DOCS_DIR/index.html" ]; then + echo "no docs/index.html" >&2 + exit 1 + fi + echo "docs on http://localhost:${PORT}/" + echo " (ctrl-c to stop; nothing is installed and nothing persists)" + cd "$DOCS_DIR" + exec "$PYTHON" -m http.server "$PORT" +} + +# Re-render docs/graphs/*.dot through every theme. The sources carry structure; +# the palette lives in docs/graphs/themes/*.gvpr. See docs/graphs/README.md. +graphs() { + if [ ! -x "$DOCS_DIR/graphs/render.sh" ]; then + echo "no docs/graphs/render.sh" >&2 + exit 1 + fi + exec bash "$DOCS_DIR/graphs/render.sh" "$@" +} + +case "${1:-serve}" in + serve) shift || true; serve "$@" ;; + graphs) shift || true; graphs "$@" ;; + # `make docs 8090` is the obvious thing to type, so take it. + ''|*[!0-9]*) echo "usage: $0 [serve [port]|graphs [theme]]" >&2; exit 1 ;; + *) serve "$1" ;; +esac diff --git a/docs/data/en/artery-plexuses.md b/docs/data/en/artery-plexuses.md new file mode 100644 index 0000000..88a1dd5 --- /dev/null +++ b/docs/data/en/artery-plexuses.md @@ -0,0 +1,93 @@ +# Plexuses + +A **plexus** is a full app — the vocabulary has always said so +(*"full app with backend, frontend and DB"*). What was missing is that a plexus +is **exported, not served**. It is compiled to a distributable file the way vite +builds for production, and that compile is the whole point of the format. + +```bash +make dist # compile the plexus UIs for standalone +make dist sample # for a named room +``` + +Output is one `index.html` per plexus under `gen//plexuses//`, +carrying its theme, its data and its diagrams inline. No server, no node, no +network. Zip it, mail it, double-click it. + +`make build` runs the same step as part of a room build. `make dist` exists for +the loop where the UI is what you are working on — rebuilding a whole room to +see a CSS change is a slow way to iterate. + +## The constraint that shapes it + +It has to open from a **double-clicked file on a machine with no egress**. That +is the state a regulated Windows box is usually in, and it rules out three +things a normal web app does: + +| Ruled out | Because | +| --- | --- | +| `fetch("bundle.json")` | `file://` treats every sibling file as cross-origin | +| `` | an absolute path assumes a server at the root | +| a webfont `@import` | a blocked stylesheet is a stall, not a fallback | + +So the data is a JS object, the theme is inlined at compile time, and the fonts +are stacks. The test that matters is opening the output with the network off and +seeing zero failed requests — everything else is cosmetic. + +## Declaring one + +A room opts in through `cfg//data/plexuses.json`, the same shape and the +same place as its sibling `data/*.json` files: + +```json +{ "items": [ { "name": "bundle" } ] } +``` + +A room may override anything the plexus declares — most usefully the theme: + +```json +{ "items": [ { "name": "bundle", "theme": "mcrn" } ] } +``` + +## Writing one + +``` +soleprint/artery/plexuses// + plexus.json identity, theme, the data the page renders + app/index.html the template +``` + +`build.py` fills these placeholders and writes one file: + +| Placeholder | Becomes | +| --- | --- | +| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has something to switch to | +| `%%BUNDLE%%` | `plexus.json` as a JS object | +| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, inlined | +| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest | + +Anything else in `app/` is copied alongside, for a plexus that outgrows one file. + +## The bundle plexus + +The one that ships. It answers "what does a rig installation have at its +disposal" — tools, cabinets, veins, themes — and embeds the system diagram. + +Because the SVG is **inlined** rather than ``-linked, the theme switch +recolours the diagram too: graphviz writes `class="node accent"` into the SVG, +and CSS outranks the presentation attributes it bakes in. Switching to `lucid` +turns both the page and the diagram into something printable, which is the +demonstration the format exists for. + +## Not the same as rig's bundle + +Two artifacts, both called bundle, generated by different repos: + +| | soleprint | rig | +| --- | --- | --- | +| Command | `make dist` | `make manifest` in `sample-rig` | +| Artifact | `plexuses//index.html` | `generated/.yaml` | +| Needs | nothing | kind + MetalLB | +| Answers | what shipped, on any machine | whether this cluster install is sound | + +Complementary. One proves the environment, the other travels. diff --git a/docs/data/en/export.md b/docs/data/en/export.md index 9b06ac5..305d3d9 100644 --- a/docs/data/en/export.md +++ b/docs/data/en/export.md @@ -19,6 +19,8 @@ make start # run it | Command | Runs | Does | | --- | --- | --- | | `make build [\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` | +| `make dist []` | `ctrl/dist.sh` | compile just the plexus UIs to single files | +| `make docs [serve\|graphs]` | `ctrl/docs.sh` | serve the docs, or re-render the diagrams | | `make start [] [-d]` | `ctrl/start.sh` | run a built room's compose stack | | `make stop []` | `ctrl/stop.sh` | stop it | | `make cluster [up\|down\|status]` | `ctrl/cluster.sh` | the shared kind cluster | @@ -50,9 +52,12 @@ make component ARGS="publish soleprint-ui /tmp/out --dist" 4. **Compose cabinets.** The dependency containers the room declared in `data/cabinets.json` are merged into its `docker-compose.yml`. See [Cabinets](#station-cabinets). -5. **Generate models.** modelgen reads the room's `config.json` and writes +5. **Export plexuses.** Each plexus the room declared in `data/plexuses.json` is + compiled to a single self-contained `index.html` — theme, data and diagrams + inlined, so it opens with no server. See [Plexuses](#artery-plexuses). +6. **Generate models.** modelgen reads the room's `config.json` and writes `models/pydantic/__init__.py`. -6. **Render k8s** (optional). When the room's config enables it, +7. **Render k8s** (optional). When the room's config enables it, `soleprint/ctrl/k8s/` writes manifests and lifecycle scripts. ## What comes out @@ -66,6 +71,7 @@ gen/standalone/ cfg/config.json data/*.json models/pydantic/ + plexuses//index.html # one file each, opens with no server ``` A **managed** room — one that wraps an existing application — is three folders @@ -122,3 +128,17 @@ the far side. `.env` is excluded, so server secrets stay on the server. default) straight from the source tree. It is for developing the framework itself; a room's `cfg/config.json` does not exist there, so the landing pages fall back to their defaults. Rooms use docker. + +## Diagrams + +The `.dot` sources under `docs/graphs/` carry structure; the palette lives in +`docs/graphs/themes/*.gvpr` and is applied at render time, so one source renders +in every theme. + +```bash +make docs graphs # every graph, every theme +make docs graphs lucid # one theme +``` + +`.svg` is the dark default the docs link to; other themes write +`..svg`. See [Themes](#themes). diff --git a/docs/data/en/themes.md b/docs/data/en/themes.md new file mode 100644 index 0000000..a529687 --- /dev/null +++ b/docs/data/en/themes.md @@ -0,0 +1,110 @@ +# Themes + +Three themes ship, and the same palettes drive the diagrams as well as the +pages. `soleprint` is the default; the others are switched to on purpose. + +| Theme | Reads as | For | +| --- | --- | --- | +| `soleprint` | dark, rounded, amber | the default — tools and dev chrome | +| `mcrn` | dark, square, monospace, orange glow | the terminal look, matched to mariano.mcrn.ar | +| `lucid` | light, hairline, printable | regulated documents, and sitting beside a real lucid.app export | + +Switch with `?theme=lucid`, or the toggle in the corner. The choice persists. + +## Where it lives + +``` +soleprint/common/theme/ + tokens.css the vocabulary + neutral defaults + themes/*.css one file per theme + theme.js resolve, apply, remember + bake.py inline the defaults into pages +``` + +Served together at `/theme.css` — tokens first, then every theme, each scoped to +`[data-theme="..."]`. Adding a theme is adding a file: `run.py` lists the +directory rather than carrying a hardcoded list. + +## Two naming families, one set of values + +Both are answered, because both were already in use and renaming across a dozen +templates would have been the larger change: + +- `--bg` / `--surface` / `--border` / `--text` / `--muted` / `--accent` — the + station tools and the docs site +- `--surface-0..3` / `--text-primary` / `--panel-radius` — `common/ui`'s Vue + components + +The second family is derived from the first in `tokens.css`, so a page using +either name gets the same colour and a theme author fills in one set. + +## Order matters + +```html + + + +``` + +The theme has to load **before** the page's styles, so its element defaults +underpin the page rather than override it. Get this backwards and +`tokens.css`'s `body { background: var(--bg) }` flattens whatever the page +wanted — which is exactly how artery, atlas and station briefly lost their +coloured content columns. + +## Baked defaults, and why + +`/theme.css` is an absolute path, and soleprint is not always at the root — in a +room's nginx it sits under `/spr/` while `location /` goes to the frontend. A +page that says `background: var(--bg)` and never receives `--bg` does not fall +back to something plainer: the declaration is invalid at computed-value time, so +the background goes transparent and the text goes initial-black on a design that +assumed dark. Unstyled, not merely unbranded. + +So every page carries a generated `:root` block **before** the link. Both are +`:root`, so document order decides: the served stylesheet wins when it loads, +and the baked block is what is left when it does not. + +```bash +python3 common/theme/bake.py # regenerate +python3 common/theme/bake.py --check # fail if a page is stale +``` + +Only the variables a page actually uses are emitted, so the blocks stay small. + +## No webfonts + +The stacks name faces that exist on the target rather than fetching any: + +```css +--font-ui: "Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif; +--font-mono: "Cascadia Mono", "JetBrains Mono", Consolas, "SF Mono", monospace; +``` + +Segoe UI and Consolas ship with Windows. A regulated network blocks +`fonts.googleapis.com` and `file://` stalls on it, and neither failure looks +like a missing font — they look like a broken page. + +## Diagrams follow + +`docs/graphs/themes/*.gvpr` carry the same palettes for graphviz, so a diagram +and the page around it are one visual language. See +[the graphs README](https://git.mcrn.ar/mariano/soleprint/src/branch/main/docs/graphs/README.md) +and [Export / Compile](#export). + +```bash +make docs graphs # every graph, every theme +``` + +Diagrams are baked per theme rather than styled by CSS, because the docs embed +them with `` — which makes the SVG a separate document the page's +stylesheet cannot reach. A plexus that **inlines** the SVG can style it live, +and the bundle plexus does exactly that. + +## Contrast + +`lucid` is the first light theme, and the palette was measured rather than +guessed — against both `#ffffff` and the `#f5f7fa` panel, at the sizes actually +used. `--dim` drives 11px notes and `--status-warn` drives 10px labels, so both +need 4.5:1 rather than the 3:1 large text gets away with. The obvious lighter +greys came in at 3.4–3.8 and were dropped for that reason. diff --git a/docs/data/topics.json b/docs/data/topics.json index ddeaf32..2db00c5 100644 --- a/docs/data/topics.json +++ b/docs/data/topics.json @@ -1,32 +1,188 @@ [ - {"id": "intro", "title": {"en": "Introduction"}}, - {"id": "quickstart", "title": {"en": "Quick Start"}}, - {"id": "concepts", "title": {"en": "Concepts"}}, - {"id": "room-setup", "title": {"en": "↳ Room Setup"}, "sub": true}, - {"id": "standalone", "title": {"en": "↳ Standalone"}, "sub": true}, - {"id": "managed", "title": {"en": "↳ Managed"}, "sub": true}, - - {"id": "artery", "title": {"en": "Artery"}}, - {"id": "artery-jira", "title": {"en": "↳ Jira"}, "sub": true}, - {"id": "artery-google", "title": {"en": "↳ Google"}, "sub": true}, - {"id": "artery-slack", "title": {"en": "↳ Slack"}, "sub": true}, - {"id": "artery-ia", "title": {"en": "↳ IA"}, "sub": true}, - {"id": "artery-shunts", "title": {"en": "↳ Shunts"}, "sub": true}, - - {"id": "atlas", "title": {"en": "Atlas"}}, - {"id": "atlas-books", "title": {"en": "↳ Books"}, "sub": true}, - {"id": "atlas-templates", "title": {"en": "↳ Templates"}, "sub": true}, - - {"id": "station", "title": {"en": "Station"}}, - {"id": "station-tester", "title": {"en": "↳ Tester"}, "sub": true}, - {"id": "station-datagen", "title": {"en": "↳ Datagen"}, "sub": true}, - {"id": "station-modelgen", "title": {"en": "↳ Modelgen"}, "sub": true}, - {"id": "station-graphgen", "title": {"en": "↳ Graphgen"}, "sub": true}, - {"id": "station-shuntgen", "title": {"en": "↳ Shuntgen"}, "sub": true}, - {"id": "station-databrowse", "title": {"en": "↳ Databrowse"}, "sub": true}, - {"id": "station-cabinets", "title": {"en": "↳ Cabinets"}, "sub": true}, - - {"id": "components", "title": {"en": "Shared Components"}}, - {"id": "export", "title": {"en": "Export / Compile"}}, - {"id": "deployment", "title": {"en": "Deployment"}} + { + "id": "intro", + "title": { + "en": "Introduction" + } + }, + { + "id": "quickstart", + "title": { + "en": "Quick Start" + } + }, + { + "id": "concepts", + "title": { + "en": "Concepts" + } + }, + { + "id": "room-setup", + "title": { + "en": "↳ Room Setup" + }, + "sub": true + }, + { + "id": "standalone", + "title": { + "en": "↳ Standalone" + }, + "sub": true + }, + { + "id": "managed", + "title": { + "en": "↳ Managed" + }, + "sub": true + }, + { + "id": "artery", + "title": { + "en": "Artery" + } + }, + { + "id": "artery-jira", + "title": { + "en": "↳ Jira" + }, + "sub": true + }, + { + "id": "artery-google", + "title": { + "en": "↳ Google" + }, + "sub": true + }, + { + "id": "artery-slack", + "title": { + "en": "↳ Slack" + }, + "sub": true + }, + { + "id": "artery-ia", + "title": { + "en": "↳ IA" + }, + "sub": true + }, + { + "id": "artery-shunts", + "title": { + "en": "↳ Shunts" + }, + "sub": true + }, + { + "id": "artery-plexuses", + "title": { + "en": "↳ Plexuses" + }, + "sub": true + }, + { + "id": "atlas", + "title": { + "en": "Atlas" + } + }, + { + "id": "atlas-books", + "title": { + "en": "↳ Books" + }, + "sub": true + }, + { + "id": "atlas-templates", + "title": { + "en": "↳ Templates" + }, + "sub": true + }, + { + "id": "station", + "title": { + "en": "Station" + } + }, + { + "id": "station-tester", + "title": { + "en": "↳ Tester" + }, + "sub": true + }, + { + "id": "station-datagen", + "title": { + "en": "↳ Datagen" + }, + "sub": true + }, + { + "id": "station-modelgen", + "title": { + "en": "↳ Modelgen" + }, + "sub": true + }, + { + "id": "station-graphgen", + "title": { + "en": "↳ Graphgen" + }, + "sub": true + }, + { + "id": "station-shuntgen", + "title": { + "en": "↳ Shuntgen" + }, + "sub": true + }, + { + "id": "station-databrowse", + "title": { + "en": "↳ Databrowse" + }, + "sub": true + }, + { + "id": "station-cabinets", + "title": { + "en": "↳ Cabinets" + }, + "sub": true + }, + { + "id": "components", + "title": { + "en": "Shared Components" + } + }, + { + "id": "export", + "title": { + "en": "Export / Compile" + } + }, + { + "id": "themes", + "title": { + "en": "Themes" + } + }, + { + "id": "deployment", + "title": { + "en": "Deployment" + } + } ] diff --git a/soleprint/artery/index.html b/soleprint/artery/index.html index ac52373..0184c18 100644 --- a/soleprint/artery/index.html +++ b/soleprint/artery/index.html @@ -9,6 +9,26 @@ type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none' stroke='%23b91c1c' stroke-width='2.5'%3E%3Cpath d='M24 4 L24 20 M24 20 L8 40 M24 20 L40 40'/%3E%3Ccircle cx='24' cy='4' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='8' cy='40' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='40' cy='40' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='24' cy='20' r='5' fill='none'/%3E%3Ccircle cx='24' cy='20' r='2' fill='%23b91c1c'/%3E%3C/svg%3E" /> + + + + + - - - - - - +
diff --git a/soleprint/artery/plexuses/README.md b/soleprint/artery/plexuses/README.md new file mode 100644 index 0000000..4636ad3 --- /dev/null +++ b/soleprint/artery/plexuses/README.md @@ -0,0 +1,65 @@ +# Plexuses + +A plexus is a full app — and, unlike everything else here, one that is +**exported rather than served**. It compiles to a distributable file the way +vite builds for production. + +```bash +make dist # standalone +make dist sample # a named room +``` + +Output: `gen//plexuses//index.html`, one self-contained file with its +theme, its data and its diagrams inlined. + +## The constraint + +It has to open from a double-clicked file on a machine with no egress — the +state a regulated Windows box is usually in. That rules out three things a +normal web app does, and every design decision here follows from it: + +| Ruled out | Because | +| --- | --- | +| `fetch("bundle.json")` | `file://` treats every sibling file as cross-origin | +| `` | an absolute path assumes a server at the root | +| a webfont `@import` | a blocked stylesheet is a stall, not a fallback | + +The acceptance test is opening the output with the network off and seeing zero +failed requests. Everything else is cosmetic. + +## Layout + +``` +/ + plexus.json identity, theme, the data the page renders + app/index.html the template +``` + +`build.py::build_plexuses()` fills the placeholders and writes one file: + +| Placeholder | Becomes | +| --- | --- | +| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has somewhere to go | +| `%%BUNDLE%%` | `plexus.json` as a JS object literal | +| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, prolog stripped, inlined | +| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest | + +Anything else in `app/` is copied alongside, for a plexus that outgrows one file. + +A room declares which ones it wants in `cfg//data/plexuses.json`, and may +override anything the manifest sets — most usefully `theme`. + +## Why the diagram is inlined + +`` makes the SVG a separate document that the page's stylesheet +cannot reach. Inlined, it is part of the DOM — and graphviz writes +`class="node accent"` into the markup, so the page's CSS recolours it when the +theme switches. One diagram, three themes, no re-render. + +That is also the demonstration: switch to `lucid` and both the page and the +diagram become something you could put in a document. + +## Adding one + +Drop a directory in beside `bundle/`. Nothing dispatches on the name — the room's +`plexuses.json` is the only place it has to be mentioned. diff --git a/soleprint/artery/plexuses/bundle/app/index.html b/soleprint/artery/plexuses/bundle/app/index.html new file mode 100644 index 0000000..1dbf70f --- /dev/null +++ b/soleprint/artery/plexuses/bundle/app/index.html @@ -0,0 +1,269 @@ + + + + + + +%%TITLE%% + + + +
+
+
+

IT WORKS — %%TITLE%%

+

%%DESCRIPTION%%

+

%%BUILT%%

+
+
+
+ +
+ +

Architecture

+

Rendered from docs/graphs/*.dot by the same palette + as this page. Inlined rather than linked, so it follows the theme switch.

+
%%GRAPH%%
+
+ + + + diff --git a/soleprint/artery/plexuses/bundle/showcase.py b/soleprint/artery/plexuses/bundle/showcase.py new file mode 100644 index 0000000..d0c900b --- /dev/null +++ b/soleprint/artery/plexuses/bundle/showcase.py @@ -0,0 +1,181 @@ +""" +Collect what the tools actually produce, at build time. + +A page that lists tool names ages badly and proves nothing. This runs the real +tools over the real fixtures and hands the output to the template, so the +showcase cannot drift from the thing it is showcasing — the same reason rig's +`make up` applies the artifact it just generated rather than a parallel path. + +Everything here is best-effort: a plexus that fails to build because a demo +panel could not be produced would be a bad trade, so each collector returns +None on failure and the template renders what it got. + +Called by build.py::build_plexuses when a plexus ships a showcase.py exposing +collect(). +""" + +import importlib.util +import json +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +SOLEPRINT = HERE.parents[2] +FIXTURES = SOLEPRINT / "station" / "tools" / "shuntgen" / "fixtures" + + +def _first_lines(path: Path, count: int) -> str: + return "\n".join(path.read_text().splitlines()[:count]) + + +def _extract(name: str, block: str, source: str) -> str: + """Pull one class out of generated source, for showing a representative slice.""" + lines = source.splitlines() + try: + start = next(i for i, l in enumerate(lines) if l.startswith(f"{block} {name}")) + except StopIteration: + return source[:400] + out = [lines[start]] + for line in lines[start + 1:]: + if line and not line.startswith((" ", "\t")): + break + out.append(line) + return "\n".join(out).rstrip() + + +def _tabular(tmp: Path) -> dict | None: + """modelgen + datagen over the sheet fixtures — the headline demo. + + Three spreadsheets become typed models, a graph schema and a generator that + samples the real rows. Showing the input beside the output is the whole + argument for the tool. + """ + sheets = FIXTURES / "sheets" + if not sheets.is_dir(): + return None + + sys.path.insert(0, str(SOLEPRINT)) + try: + from station.tools.modelgen.loader.extract.tabular import TabularExtractor + from station.tools.modelgen.generator import GENERATORS + except ImportError: + return None + + extractor = TabularExtractor(sheets) + models, enums = extractor.extract() + datasets = extractor.datasets() + + GENERATORS["pydantic"]().generate((models, enums), tmp / "models.py") + GENERATORS["schema"]().generate((models, enums), tmp / "schema.json") + GENERATORS["datagen"]().generate((models, enums, datasets), tmp / "gen.py") + + # Import the generated generator and let it produce records, so the JSON on + # the page is genuinely what a caller would receive. + records = [] + try: + spec = importlib.util.spec_from_file_location("showcase_gen", tmp / "gen.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + cls = next( + v for k, v in vars(module).items() + if isinstance(v, type) and k.endswith("Generator") and k != "BaseDataGenerator" + ) + records = cls().generate("Customers", 2) + except Exception: + pass + + schema = json.loads((tmp / "schema.json").read_text()) + + return { + "inputs": [ + {"name": f.name, "rows": len(next((d.rows for d in datasets + if d.source.startswith(f.name)), [])), + "sample": _first_lines(f, 4) if f.suffix != ".ods" else None} + for f in sorted(sheets.iterdir()) if f.is_file() + ], + "models": [ + { + "name": name, + "fields": [ + {"name": fname, "type": fdef.get("type", "?"), + "pk": bool(fdef.get("pk")), "nullable": bool(fdef.get("nullable"))} + for fname, fdef in body.get("fields", {}).items() + ], + } + for name, body in schema.get("models", {}).items() + ], + "pydantic": _extract("Customers", "class", (tmp / "models.py").read_text()), + "records": records, + "counts": { + "files": sum(1 for f in sheets.iterdir() if f.is_file()), + "models": len(models), + "rows": sum(len(d.rows) for d in datasets), + }, + } + + +def _shunt(tmp: Path) -> dict | None: + """shuntgen: the same sheets, turned into routes that answer.""" + sheets = FIXTURES / "sheets" + if not sheets.is_dir(): + return None + + sys.path.insert(0, str(SOLEPRINT)) + try: + from station.tools.modelgen.loader.extract.tabular import TabularExtractor + from station.tools.shuntgen.emit import ShuntEmitter + except ImportError: + return None + + extractor = TabularExtractor(sheets) + models, enums = extractor.extract() + datasets = extractor.datasets() + + emitter = ShuntEmitter( + name="books", output=tmp / "shunt", models=models, enums=enums, + datasets=datasets, source="sheets", kind="tabular", + ) + collections = emitter._collections() + routes = emitter._routes(collections) + + seeded = next((d.rows for d in datasets if d.model == "Customers"), []) + return { + "routes": [ + {"method": r["method"], "path": r["path"], "operation": r["operation"]} + for r in routes + ], + "count": len(routes), + # What GET /customers actually answers with — the imported rows. + "response": seeded[:2], + } + + +def _cabinet() -> dict | None: + """A cabinet's compose fragment, as it gets merged into a room.""" + path = SOLEPRINT / "station" / "cabinets" / "postgres" / "service.yml" + if not path.exists(): + return None + body = "\n".join( + l for l in path.read_text().splitlines() if not l.strip().startswith("#") + ).strip() + return {"name": "postgres", "fragment": body} + + +def collect() -> dict: + """Everything the showcase renders. Never raises.""" + import tempfile + + out: dict = {} + with tempfile.TemporaryDirectory(prefix="spr-showcase-") as raw: + tmp = Path(raw) + for key, fn in (("tabular", lambda: _tabular(tmp)), + ("shunt", lambda: _shunt(tmp)), + ("cabinet", lambda: _cabinet())): + try: + value = fn() + except Exception as e: # a demo panel is never worth failing a build + print(f" showcase: {key} unavailable ({type(e).__name__}: {e})") + value = None + if value: + out[key] = value + return out diff --git a/soleprint/atlas/index.html b/soleprint/atlas/index.html index ddbff46..1e4226a 100644 --- a/soleprint/atlas/index.html +++ b/soleprint/atlas/index.html @@ -9,6 +9,21 @@ type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='%2315803d'%3E%3Cpath d='M4 8 C4 8 12 6 24 10 L24 42 C12 38 4 40 4 40 Z' opacity='0.3'/%3E%3Cpath d='M44 8 C44 8 36 6 24 10 L24 42 C36 38 44 40 44 40 Z' opacity='0.5'/%3E%3Cpath d='M4 8 C4 8 12 6 24 10 M44 8 C44 8 36 6 24 10' fill='none' stroke='%2315803d' stroke-width='2'/%3E%3Cpath d='M4 40 C4 40 12 38 24 42 M44 40 C44 40 36 38 24 42' fill='none' stroke='%2315803d' stroke-width='2'/%3E%3Cline x1='24' y1='10' x2='24' y2='42' stroke='%2315803d' stroke-width='2'/%3E%3C/svg%3E" /> + + + + + - - - - - - +
diff --git a/soleprint/index.html b/soleprint/index.html index 2f197bd..4edbbd9 100644 --- a/soleprint/index.html +++ b/soleprint/index.html @@ -9,6 +9,21 @@ type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' fill='%23e5e5e5'%3E%3Cg transform='rotate(-15 18 38)'%3E%3Cellipse cx='18' cy='32' rx='7' ry='13'/%3E%3Cellipse cx='18' cy='48' rx='6' ry='7'/%3E%3C/g%3E%3Cg transform='rotate(15 46 28)'%3E%3Cellipse cx='46' cy='22' rx='7' ry='13'/%3E%3Cellipse cx='46' cy='38' rx='6' ry='7'/%3E%3C/g%3E%3C/svg%3E" /> + + + + - - - +
diff --git a/soleprint/station/index.html b/soleprint/station/index.html index 58c797a..817b683 100644 --- a/soleprint/station/index.html +++ b/soleprint/station/index.html @@ -9,6 +9,25 @@ type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='%231d4ed8'%3E%3Crect x='4' y='8' width='40' height='28' rx='3' fill='%231d4ed8'/%3E%3Crect x='8' y='12' width='32' height='20' rx='2' fill='%230a0a0a'/%3E%3Crect x='16' y='36' width='16' height='4' fill='%231d4ed8'/%3E%3Crect x='12' y='40' width='24' height='3' rx='1' fill='%231d4ed8'/%3E%3C/svg%3E" /> + + + + + - - - - - - +