diff --git a/Makefile b/Makefile index 472780a..5ea3465 100644 --- a/Makefile +++ b/Makefile @@ -58,8 +58,8 @@ 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)) +dist: ## compile the plexus UIs to single files [NAME|FILE] + bash ctrl/dist.sh $(ARGS) # ── theme ────────────────────────────────────────────────────────────────── diff --git a/build.py b/build.py index e155025..3e4a133 100644 --- a/build.py +++ b/build.py @@ -23,7 +23,6 @@ Generated structure for managed rooms: """ import argparse -import importlib.util import json import logging import shutil @@ -559,148 +558,6 @@ 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" @@ -738,11 +595,6 @@ 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): @@ -799,33 +651,6 @@ 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") @@ -833,17 +658,10 @@ 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.plexuses: - build_plexuses_only(args.cfg or "standalone") - elif args.models: + if args.models: build_models_only() elif args.all: build(SPR_ROOT / "gen" / "standalone", None) diff --git a/cfg/sample/data/plexuses.json b/cfg/sample/data/plexuses.json deleted file mode 100644 index 2feb210..0000000 --- a/cfg/sample/data/plexuses.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "items": [] -} diff --git a/cfg/standalone/data/plexuses.json b/cfg/standalone/data/plexuses.json deleted file mode 100644 index 15b8b45..0000000 --- a/cfg/standalone/data/plexuses.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "items": [ - { - "name": "bundle" - } - ] -} diff --git a/ctrl/dist.sh b/ctrl/dist.sh index 81a9e3c..df12100 100755 --- a/ctrl/dist.sh +++ b/ctrl/dist.sh @@ -2,30 +2,36 @@ # Compile the plexus UIs to distributable files — this repo's `vite build`. # # Usage: -# ./ctrl/dist.sh # standalone -# ./ctrl/dist.sh sample # a named room +# ./ctrl/dist.sh # every plexus -> gen/plexuses//index.html +# ./ctrl/dist.sh bundle # one, by name +# ./ctrl/dist.sh path/to/ui.toml # every [[plexus]] a run file lists +# ./ctrl/dist.sh bundle --dest /tmp/out --delivery vendored --notice none # # 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. +# It needs nothing built first: a plexus is its own directory under +# soleprint/artery/plexuses/ (or wherever a run file points), and the theme it +# carries is cut to what the page reads. See soleprint/common/slice/plexus.py. set -e +CALLER_DIR="$PWD" 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 +args=() +for a in "$@"; do + # A bare word that names an existing path is a path the caller typed. + if [[ "$a" != -* && -e "$CALLER_DIR/$a" ]]; then + args+=("$(cd "$CALLER_DIR" && realpath -m -- "$a")") + else + args+=("$a") + fi +done -exec "$PYTHON" build.py --plexuses --cfg "$ROOM" +cd "$ROOT_DIR/soleprint" +exec "$PYTHON" -m common.slice plexus "${args[@]}" diff --git a/docs/data/en/artery-plexuses.md b/docs/data/en/artery-plexuses.md index 88a1dd5..eb99aa2 100644 --- a/docs/data/en/artery-plexuses.md +++ b/docs/data/en/artery-plexuses.md @@ -6,17 +6,17 @@ 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 +make dist # every plexus +make dist bundle # one, by name +make dist path/to/ui.toml # the [[plexus]] tables of a run file ``` -Output is one `index.html` per plexus under `gen//plexuses//`, +Output is one `index.html` per plexus under `gen/plexuses//` (or `--dest`), 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. +A plexus is built on its own: nothing else has to be built first, and no other +configuration has to name it. ## The constraint that shapes it @@ -34,20 +34,22 @@ 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 +## Choosing what gets built -A room opts in through `cfg//data/plexuses.json`, the same shape and the -same place as its sibling `data/*.json` files: +`make dist` builds every directory under `soleprint/artery/plexuses/` that holds a +`plexus.json`. To build with other settings — another theme, a plexus kept +outside spr, a different destination — list it in a run file: -```json -{ "items": [ { "name": "bundle" } ] } +```toml +[[plexus]] +name = "bundle" +theme = "mcrn" +dest = "out/bundle.html" ``` -A room may override anything the plexus declares — most usefully the theme: - -```json -{ "items": [ { "name": "bundle", "theme": "mcrn" } ] } -``` +`delivery = "vendored"` renames the output through the mapping profile, refuses +to write if any spr word survives, and needs a `notice` — the same rules as a +vendored slice. See [Export / Compile](#export). ## Writing one @@ -57,17 +59,22 @@ soleprint/artery/plexuses// app/index.html the template ``` -`build.py` fills these placeholders and writes one file: +`make dist` fills these placeholders and writes one file: | Placeholder | Becomes | | --- | --- | -| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has something to switch to | +| `%%THEME_CSS%%` | only what the page can use: the custom properties it reads (and what they reach) per theme in `inline_themes`, and the rules that can match something on it | | `%%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. +A plexus whose `app/` holds a `package.json` is built as a **Vue app** instead: +its `@ui` imports are sliced from `common/ui` (the same closure a consumer +would get), `vite build` runs in a temporary directory, and the emitted script +and stylesheet are inlined into one file. `live/` is the example. + ## The bundle plexus The one that ships. It answers "what does a rig installation have at its diff --git a/docs/data/en/export.md b/docs/data/en/export.md index 39f8817..877e458 100644 --- a/docs/data/en/export.md +++ b/docs/data/en/export.md @@ -19,7 +19,7 @@ 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 dist [NAME\|FILE]` | `ctrl/dist.sh` | compile the plexus UIs to single files — nothing built first | | `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 | @@ -52,12 +52,9 @@ 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. **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 +5. **Generate models.** modelgen reads the room's `config.json` and writes `models/pydantic/__init__.py`. -7. **Render k8s** (optional). When the room's config enables it, +6. **Render k8s** (optional). When the room's config enables it, `soleprint/ctrl/k8s/` writes manifests and lifecycle scripts. ## What comes out @@ -71,7 +68,6 @@ 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 diff --git a/soleprint/artery/plexuses/README.md b/soleprint/artery/plexuses/README.md index 4636ad3..9bed217 100644 --- a/soleprint/artery/plexuses/README.md +++ b/soleprint/artery/plexuses/README.md @@ -5,12 +5,14 @@ A plexus is a full app — and, unlike everything else here, one that is vite builds for production. ```bash -make dist # standalone -make dist sample # a named room +make dist # every plexus here +make dist bundle # one, by name +make dist path/to/ui.toml # the [[plexus]] tables of a run file ``` -Output: `gen//plexuses//index.html`, one self-contained file with its -theme, its data and its diagrams inlined. +Output: `gen/plexuses//index.html` (or `--dest`), one self-contained file +with its theme, its data and its diagrams inlined. Nothing has to be built first: +a plexus is its own directory, and `soleprint/common/slice/plexus.py` builds it. ## The constraint @@ -39,15 +41,22 @@ failed requests. Everything else is cosmetic. | Placeholder | Becomes | | --- | --- | -| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has somewhere to go | +| `%%THEME_CSS%%` | the tokens and rules the page can use, for each theme in `inline_themes` (default: `theme` alone) | | `%%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`. +A run file's `[[plexus]]` table can override `theme`, `inline_themes`, `title` +and `description`, point `source` at a plexus kept elsewhere, and set `dest`. +`delivery = "vendored"` renames the output through the mapping profile, fails on +any spr word, and needs a `notice` — the same rules as a vendored slice. + +A plexus whose `app/` holds a `package.json` is a **Vue** plexus: its `@ui` +imports are sliced from `common/ui`, `vite build` runs in a temporary directory, +and the script and stylesheet are inlined into the one file. `live/` is the +example. ## Why the diagram is inlined @@ -61,5 +70,5 @@ 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. +Drop a directory in beside `bundle/`. Nothing dispatches on the name and nothing +lists it: `make dist` builds every directory that holds a `plexus.json`. diff --git a/soleprint/artery/plexuses/bundle/app/index.html b/soleprint/artery/plexuses/bundle/app/index.html index 1dbf70f..7d1254c 100644 --- a/soleprint/artery/plexuses/bundle/app/index.html +++ b/soleprint/artery/plexuses/bundle/app/index.html @@ -1,7 +1,7 @@ `, as in bake; +- `delivery = "vendored"` renames through the mapping profile and scrubs the + result, exactly as a vendored slice is — and needs a `notice`, for the same + reason. + +## Two kinds + +A template plexus (`app/index.html` with `%%…%%` placeholders) is filled here. +A Vue plexus (`app/package.json`) is an app built on a slice: its `@ui` imports +are sliced from common/ui, `vite build` runs in a temporary directory, and the +emitted script and stylesheet are inlined into one file — no bundler plugin, +because the inlining is a few lines of text work. +""" + +import importlib.util +import json +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path + +from ..theme import bake +from . import css, names +from .graph import COMMON +from .manifest import REPO + +PLEXUSES = COMMON.parent / "artery" / "plexuses" +THEME = COMMON / "theme" +GRAPHS = REPO / "docs" / "graphs" +GEN = REPO / "gen" / "plexuses" + +KEYS = {"name", "source", "dest", "theme", "inline_themes", "delivery", "notice", + "rename", "title", "description", "minify"} + + +class PlexusError(ValueError): + pass + + +@dataclass +class Plexus: + name: str + source: Path + dest: Path # the .html written + delivery: str = "dependency" + notice: str | Path | None = None + rename: dict = field(default_factory=dict) + overrides: dict = field(default_factory=dict) # manifest keys a run file replaces + minify: bool = False + + @property + def vendored(self) -> bool: + return self.delivery == "vendored" + + @property + def is_vue(self) -> bool: + return (self.source / "app" / "package.json").exists() + + +@dataclass +class Built: + plexus: Plexus + html: str = "" + hits: list = field(default_factory=list) + notes: list = field(default_factory=list) + + +def discover() -> list[str]: + return sorted(p.parent.name for p in PLEXUSES.glob("*/plexus.json")) + + +def resolve(name: str, *, source=None, dest=None, delivery="dependency", notice=None, + rename=None, overrides=None, minify=False, base: Path | None = None) -> Plexus: + """One plexus, every path absolute. Raises PlexusError saying what is wrong.""" + base = base or Path.cwd() + src = Path(source) if source else PLEXUSES / name + src = src if src.is_absolute() else (base / src).resolve() + if not (src / "plexus.json").exists(): + raise PlexusError(f"no plexus at {src} (have: {', '.join(discover()) or 'none'})") + if dest: + out = Path(dest) if Path(dest).is_absolute() else (base / dest).resolve() + if out.suffix != ".html": + out = out / "index.html" + else: + out = GEN / name / "index.html" + if delivery not in ("dependency", "vendored"): + raise PlexusError(f"{name}: delivery must be dependency or vendored") + if delivery == "vendored": + if notice is None: + raise PlexusError(f"{name}: a vendored plexus needs `notice` — \"none\", or a file " + f"whose text heads the page. The tool never picks the licensing.") + if notice != "none": + notice = Path(notice) if Path(notice).is_absolute() else (base / notice).resolve() + if not notice.is_file(): + raise PlexusError(f"{name}: notice {notice} is not a file") + return Plexus(name=name, source=src, dest=out, delivery=delivery, notice=notice, + rename=dict(rename or {}), overrides=dict(overrides or {}), minify=minify) + + +def from_runfile(tables: list, base: Path) -> list[Plexus]: + """The raw [[plexus]] tables of a run file, resolved; every problem at once.""" + out, problems = [], [] + for i, raw in enumerate(tables): + where = f"plexus[{i}]" + if not isinstance(raw, dict) or not isinstance(raw.get("name"), str): + problems.append(f"{where} needs a name") + continue + where = f"plexus {raw['name']!r}" + unknown = sorted(set(raw) - KEYS) + if unknown: + problems.append(f"{where} has unknown key(s) {', '.join(unknown)} — have: " + f"{', '.join(sorted(KEYS))}") + continue + overrides = {k: raw[k] for k in ("theme", "inline_themes", "title", "description") if k in raw} + try: + out.append(resolve(raw["name"], source=raw.get("source"), dest=raw.get("dest"), + delivery=raw.get("delivery", "dependency"), notice=raw.get("notice"), + rename=raw.get("rename"), overrides=overrides, + minify=bool(raw.get("minify", False)), base=base)) + except PlexusError as e: + problems.append(str(e)) + if problems: + raise PlexusError("; ".join(problems)) + return out + + +def _manifest(p: Plexus) -> dict: + try: + data = json.loads((p.source / "plexus.json").read_text()) + except ValueError as e: + raise PlexusError(f"{p.name}: plexus.json is not valid JSON: {e}") from None + data.update(p.overrides) + return data + + +def _showcase(p: Plexus) -> dict | None: + """A plexus may ship showcase.py exposing collect(): what the page shows, + produced at build time by the real tools, so it cannot drift from them.""" + collector = p.source / "showcase.py" + if not collector.exists(): + return None + try: + spec = importlib.util.spec_from_file_location(f"plexus_{p.name}_showcase", collector) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.collect() + except Exception as e: # noqa: BLE001 - a demo panel must not cost the build + return {"_error": f"{type(e).__name__}: {e}"} + + +def _graph(name: str, theme: str) -> str: + """A rendered graph, inlined so the page's CSS can recolour it per theme.""" + 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 + return "

diagram not rendered — run docs/graphs/render.sh

" + + +def theme_css(page: str, themes: list[str]) -> str: + """The contract and the listed themes, cut to what `page` can use.""" + missing = [t for t in themes if not (THEME / "themes" / f"{t}.css").exists()] + if missing: + raise PlexusError(f"no theme {', '.join(map(repr, missing))}") + contract = (THEME / "tokens.css").read_text() + sheets = {t: (THEME / "themes" / f"{t}.css").read_text() for t in themes} + + kept_contract, reads = css.shake(contract, page) + kept_themes = {} + for t, text in sheets.items(): + kept_themes[t], more = css.shake(text, page) + reads |= more + reads |= set(css.VAR.findall(page)) + + # Every declaration that could answer a name, from the contract and from + # each theme's own block — the closure runs over all of them. + root = {} + for r in kept_contract: + if r.prelude == ":root" and css.only_properties(r): + root.update(css.declarations(r)) + per_theme = {} + for t, kept in kept_themes.items(): + per_theme[t] = {} + for r in kept: + if r.prelude.strip() == f'[data-theme="{t}"]' and css.only_properties(r): + per_theme[t].update(css.declarations(r)) + everything = {**root, **{k: v for d in per_theme.values() for k, v in d.items()}} + wanted, stack = set(), list(reads) + while stack: + n = stack.pop() + if n in wanted: + continue + wanted.add(n) + if n in everything: + stack.extend(css.VAR.findall(everything[n])) + + out = [":root {"] + [f" {k}: {v};" for k, v in root.items() if k in wanted] + ["}"] + for t, decls in per_theme.items(): + chosen = [f" {k}: {v};" for k, v in decls.items() if k in wanted] + if chosen: + out += [f'[data-theme="{t}"] {{'] + chosen + ["}"] + for r in kept_contract: + if not css.only_properties(r): + out.append(css.pretty(r)) + for t, kept in kept_themes.items(): + for r in kept: + if not (r.prelude.strip() == f'[data-theme="{t}"]' and css.only_properties(r)): + out.append(css.pretty(r)) + return "\n".join(out) + "\n" + + +def _finish(built: Built) -> None: + p = built.plexus + if not p.vendored: + return + profile = names.load(p.rename) + built.html = names.rename(built.html, profile) + if isinstance(p.notice, Path): + built.html = f"\n" + built.html + built.hits = names.scrub(built.html, profile) + + +def build(p: Plexus) -> Built: + return build_vue(p) if p.is_vue else build_template(p) + + +def build_template(p: Plexus) -> Built: + built = Built(plexus=p) + manifest = _manifest(p) + template = p.source / "app" / "index.html" + if not template.exists(): + raise PlexusError(f"{p.name}: no app/index.html") + theme = manifest.get("theme", "soleprint") + themes = manifest.get("inline_themes") or [theme] + data = {k: v for k, v in manifest.items() if not k.startswith("_")} + showcase = _showcase(p) + if showcase is not None: + data["showcase"] = showcase + + today = date.today().isoformat() + page = template.read_text() + for token, value in ( + ("%%TITLE%%", manifest.get("title", p.name)), + ("%%DESCRIPTION%%", manifest.get("description", "")), + ("%%DEFAULT_THEME%%", theme), + ("%%BUILT%%", f"built {today}" + ("" if p.vendored else " by soleprint")), + ("%%GRAPH%%", _graph(manifest.get("graph", "system_overview"), theme)), + ("%%BUNDLE%%", json.dumps(data, indent=2)), + ): + page = page.replace(token, value) + + # Parts, when the template asks for them the way a page does. + if bake.ANCHOR in page: + parts = bake.load_parts() + wanted = bake.parts_used(page, parts) + if wanted: + block = bake.parts_block(wanted, parts, "") + page = page.replace(bake.ANCHOR, bake.ANCHOR + "\n" + block, 1) + + built.html = page.replace("%%THEME_CSS%%", theme_css(page, themes)) + + # Anything else in app/ rides along, for a plexus that outgrows one file. + extras = [x.name for x in (p.source / "app").iterdir() if x.name != "index.html"] + if extras: + built.notes.append(f"also copied beside it: {', '.join(extras)}") + _finish(built) + return built + + +# ── Vue ────────────────────────────────────────────────────────────────────── + +SCRIPT_TAG = re.compile(r'') +STYLE_TAG = re.compile(r']*\shref="([^"]+)"[^>]*>') + +VITE_CONFIG = """import vue from '@vitejs/plugin-vue' +export default {{ + root: {root!r}, + base: './', + plugins: [vue()], + cacheDir: {cache!r}, + resolve: {{ alias: {{ '@ui': {ui!r} }} }}, + build: {{ + outDir: {out!r}, + emptyOutDir: true, + assetsInlineLimit: 100000000, + cssCodeSplit: false, + modulePreload: false, + minify: {minify}, + rollupOptions: {{ output: {{ inlineDynamicImports: true }} }}, + }}, +}} +""" + + +def _run(cmd: list, cwd: Path) -> None: + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if result.returncode != 0: + tail = (result.stdout + result.stderr).strip().splitlines()[-12:] + raise PlexusError(f"`{' '.join(cmd)}` failed:\n " + "\n ".join(tail)) + + +def build_vue(p: Plexus) -> Built: + from . import runfile, slices + + built = Built(plexus=p) + manifest = _manifest(p) + app = p.source / "app" + with tempfile.TemporaryDirectory(prefix=f"plexus-{p.name}-") as t: + tmp = Path(t) + work = tmp / "app" + shutil.copytree(app, work, ignore=shutil.ignore_patterns("node_modules", "dist")) + + # The app's @ui imports, sliced from common/ui into the build — the + # same closure a consumer would receive, and nothing else. + # The slice follows the plexus's delivery: a vendored page must not + # carry the provenance headers a dependency slice keeps. Its licensing + # is the page's `notice`, stated once, so the inner slice says none. + entry = runfile.Slice(name=p.name, target="vue", delivery=p.delivery, + dest=work / "ui", manifest=tmp / "ui.slice.json", + layout="package", scan=(work / "src",), alias=("@ui",), + theme=manifest.get("theme"), rename=p.rename, + notice="none" if p.vendored else None) + sliced = slices.build(entry) + if sliced.blocked: + raise PlexusError(f"{p.name}: the slice would not build: {'; '.join(sliced.blocked)}") + slices.apply(entry, sliced, slices.plan(entry, sliced), force=True) + built.notes.append(f"slice: {', '.join(sliced.closure)} ({', '.join(sliced.deps)})") + + # pnpm 10+ exits non-zero when a dependency's build script is not + # allowed, and every Vite build needs esbuild's. An app that says + # nothing gets the one allowance it cannot build without. + if not (work / "pnpm-workspace.yaml").exists(): + (work / "pnpm-workspace.yaml").write_text("allowBuilds:\n esbuild: true\n") + installed = subprocess.run(["pnpm", "install", "--offline"], cwd=work, + capture_output=True, text=True) + if installed.returncode != 0: + _run(["pnpm", "install", "--prefer-offline"], work) + + (work / "vite.plexus.config.mjs").write_text(VITE_CONFIG.format( + root=str(work), cache=str(tmp / ".vite"), ui=str(work / "ui" / "src"), + out=str(tmp / "dist"), minify="true" if p.minify else "false")) + _run([str(work / "node_modules" / ".bin" / "vite"), "build", + "--config", "vite.plexus.config.mjs", "--logLevel", "warn"], work) + + dist = tmp / "dist" + page = (dist / "index.html").read_text() + + def script(m): + code = (dist / m.group(1)).read_text().replace("\n{code}\n' + + def style(m): + return f"" + + page = SCRIPT_TAG.sub(script, page) + page = STYLE_TAG.sub(style, page) + left = re.findall(r'(?:src|href)="\./[^"]+"', page) + if left: + raise PlexusError(f"{p.name}: not inlined: {', '.join(left)}") + + title = manifest.get("title", p.name) + page = re.sub(r".*?", f"{title}", page, count=1, flags=re.S) + data = {k: v for k, v in manifest.items() if not k.startswith("_")} + # The page's own data, for an app that wants it, under a name that says + # what it is to whoever opens the file. + page = page.replace("", f"\n", 1) + built.html = page + _finish(built) + return built + + +def write(built: Built) -> Path: + p = built.plexus + if built.hits: + raise PlexusError(f"{p.name}: scrub found {len(built.hits)} spr word(s) in a vendored " + f"plexus — nothing written") + p.dest.parent.mkdir(parents=True, exist_ok=True) + p.dest.write_text(built.html) + if not p.is_vue: + for extra in (p.source / "app").iterdir(): + if extra.name == "index.html": + continue + target = p.dest.parent / extra.name + if extra.is_dir(): + shutil.copytree(extra, target, dirs_exist_ok=True) + else: + shutil.copy2(extra, target) + return p.dest diff --git a/soleprint/common/slice/selftest.py b/soleprint/common/slice/selftest.py index 2642fd2..69f1660 100644 --- a/soleprint/common/slice/selftest.py +++ b/soleprint/common/slice/selftest.py @@ -19,7 +19,7 @@ import sys import tempfile from pathlib import Path -from . import graph, manifest, names, runfile, scan, slices, tokens +from . import css, graph, manifest, names, plexus, runfile, scan, slices, tokens HERE = Path(__file__).resolve().parent PASS, FAIL = [], [] @@ -218,6 +218,34 @@ def main() -> int: "turns the next one into a call)", chunks and all(c.endswith(";") for c in chunks), [c[-30:] for c in chunks]) + section("the plexus") + page = '

x

' + kept, _ = css.shake('.card{a:1} .gone{b:2} h2{c:3} .dyn{d:4} @keyframes spin{} ' + '.card{animation: spin 1s}', page) + preludes = [r.prelude for r in kept] + check("a rule whose class never appears is dropped", ".gone" not in preludes, preludes) + check("a class named only in script still counts", ".dyn" in preludes, preludes) + check("element rules stay; referenced keyframes stay", + "h2" in preludes and "@keyframes spin" in preludes, preludes) + try: + plexus.resolve("bundle", delivery="vendored") + check("(design) a vendored plexus without notice is refused", False) + except plexus.PlexusError as e: + check("(design) a vendored plexus without notice is refused", "notice" in str(e)) + b = plexus.resolve("bundle") + check("a plexus needs nothing built first: it lands in gen/plexuses/", + b.dest == plexus.GEN / "bundle" / "index.html") + built = plexus.build(b) + check("every placeholder is filled", not re.search(r"%%[A-Z_]+%%", built.html)) + check("each theme it can switch to is carried, and nothing it cannot use", + all(f'[data-theme="{t}"]' in built.html for t in ("lucid", "soleprint", "mcrn")) + and "#spr-theme-toggle" not in built.html and ".prompt-list" not in built.html) + vendored = plexus.build(plexus.resolve("bundle", delivery="vendored", notice="none")) + check("(design) spr's own showcase cannot be vendored — its content is spr", + bool(vendored.hits)) + check("no plexus code is left in build.py", + "plexus" not in (manifest.REPO / "build.py").read_text().lower()) + section("consumers (read-only, skipped when absent)") meetus = Path.home() / "wdir/semester/mts/ui/meetus-app" if meetus.exists(): diff --git a/soleprint/run.py b/soleprint/run.py index d4d3e3a..2fa2d83 100644 --- a/soleprint/run.py +++ b/soleprint/run.py @@ -355,7 +355,11 @@ def artery_index(request: Request): veins = load_data("veins.json") pulses = load_data("pulses.json") shunts_data = load_data("shunts.json") - plexuses = load_data("plexuses.json") + # A plexus is built on its own (`make dist`), so the hub lists what is on + # disk when no data file names them — the same fallback shunts use below. + plexuses = load_data("plexuses.json") or scan_directory(SPR_ROOT / "artery" / "plexuses") + for p in plexuses: + p.setdefault("status", "ready") # Scan directories for shunts not in data files shunts = (