ui work
This commit is contained in:
4
Makefile
4
Makefile
@@ -58,8 +58,8 @@ start: ## run a built room [<room>] [-d] [--build]
|
|||||||
stop: ## stop a running room [<room>]
|
stop: ## stop a running room [<room>]
|
||||||
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
|
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
|
||||||
|
|
||||||
dist: ## compile the plexus UIs to single files [<room>]
|
dist: ## compile the plexus UIs to single files [NAME|FILE]
|
||||||
bash ctrl/dist.sh $(or $(ARGS),$(ROOM))
|
bash ctrl/dist.sh $(ARGS)
|
||||||
|
|
||||||
# ── theme ──────────────────────────────────────────────────────────────────
|
# ── theme ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
184
build.py
184
build.py
@@ -23,7 +23,6 @@ Generated structure for managed rooms:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import importlib.util
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
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")
|
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 <img>-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("<svg")
|
|
||||||
return svg[start:] if start >= 0 else svg
|
|
||||||
log.warning(f" no rendered graph '{name}' — run docs/graphs/render.sh")
|
|
||||||
return "<p>diagram not rendered</p>"
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
def build_soleprint(output_dir: Path, room: str):
|
||||||
"""Build soleprint folder with core + room config merged."""
|
"""Build soleprint folder with core + room config merged."""
|
||||||
soleprint = SPR_ROOT / "soleprint"
|
soleprint = SPR_ROOT / "soleprint"
|
||||||
@@ -738,11 +595,6 @@ def build_soleprint(output_dir: Path, room: str):
|
|||||||
log.info("Composing cabinets...")
|
log.info("Composing cabinets...")
|
||||||
compose_cabinets(output_dir, room)
|
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
|
# Generate models
|
||||||
log.info("Generating models...")
|
log.info("Generating models...")
|
||||||
if not generate_models(output_dir, room):
|
if not generate_models(output_dir, room):
|
||||||
@@ -799,33 +651,6 @@ def build_models_only():
|
|||||||
sys.exit(1)
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Soleprint Build Tool")
|
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("--cfg", "-c", type=str, help="Room config name")
|
||||||
parser.add_argument("--all", action="store_true", help="Build all rooms")
|
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("--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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.plexuses:
|
if args.models:
|
||||||
build_plexuses_only(args.cfg or "standalone")
|
|
||||||
elif args.models:
|
|
||||||
build_models_only()
|
build_models_only()
|
||||||
elif args.all:
|
elif args.all:
|
||||||
build(SPR_ROOT / "gen" / "standalone", None)
|
build(SPR_ROOT / "gen" / "standalone", None)
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"name": "bundle"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
32
ctrl/dist.sh
32
ctrl/dist.sh
@@ -2,30 +2,36 @@
|
|||||||
# Compile the plexus UIs to distributable files — this repo's `vite build`.
|
# Compile the plexus UIs to distributable files — this repo's `vite build`.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./ctrl/dist.sh # standalone
|
# ./ctrl/dist.sh # every plexus -> gen/plexuses/<name>/index.html
|
||||||
# ./ctrl/dist.sh sample # a named room
|
# ./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
|
# 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
|
# 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
|
# from a double-click on a machine with no server, no node and no network — the
|
||||||
# state a regulated Windows box is usually in.
|
# state a regulated Windows box is usually in.
|
||||||
#
|
#
|
||||||
# `make build` runs this as one of its steps. This exists for the loop where the
|
# It needs nothing built first: a plexus is its own directory under
|
||||||
# UI is what you are working on: rebuilding a whole room to see a CSS change is
|
# soleprint/artery/plexuses/ (or wherever a run file points), and the theme it
|
||||||
# a slow way to iterate.
|
# carries is cut to what the page reads. See soleprint/common/slice/plexus.py.
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
CALLER_DIR="$PWD"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
cd "$ROOT_DIR"
|
|
||||||
|
|
||||||
PYTHON="${PYTHON:-python3}"
|
PYTHON="${PYTHON:-python3}"
|
||||||
ROOM="${1:-standalone}"
|
|
||||||
|
|
||||||
if [[ ! -d "cfg/$ROOM" ]]; then
|
args=()
|
||||||
echo "No such room: cfg/$ROOM" >&2
|
for a in "$@"; do
|
||||||
echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2
|
# A bare word that names an existing path is a path the caller typed.
|
||||||
exit 1
|
if [[ "$a" != -* && -e "$CALLER_DIR/$a" ]]; then
|
||||||
fi
|
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[@]}"
|
||||||
|
|||||||
@@ -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.
|
builds for production, and that compile is the whole point of the format.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dist # compile the plexus UIs for standalone
|
make dist # every plexus
|
||||||
make dist sample # for a named room
|
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/<room>/plexuses/<name>/`,
|
Output is one `index.html` per plexus under `gen/plexuses/<name>/` (or `--dest`),
|
||||||
carrying its theme, its data and its diagrams inline. No server, no node, no
|
carrying its theme, its data and its diagrams inline. No server, no node, no
|
||||||
network. Zip it, mail it, double-click it.
|
network. Zip it, mail it, double-click it.
|
||||||
|
|
||||||
`make build` runs the same step as part of a room build. `make dist` exists for
|
A plexus is built on its own: nothing else has to be built first, and no other
|
||||||
the loop where the UI is what you are working on — rebuilding a whole room to
|
configuration has to name it.
|
||||||
see a CSS change is a slow way to iterate.
|
|
||||||
|
|
||||||
## The constraint that shapes 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
|
are stacks. The test that matters is opening the output with the network off and
|
||||||
seeing zero failed requests — everything else is cosmetic.
|
seeing zero failed requests — everything else is cosmetic.
|
||||||
|
|
||||||
## Declaring one
|
## Choosing what gets built
|
||||||
|
|
||||||
A room opts in through `cfg/<room>/data/plexuses.json`, the same shape and the
|
`make dist` builds every directory under `soleprint/artery/plexuses/` that holds a
|
||||||
same place as its sibling `data/*.json` files:
|
`plexus.json`. To build with other settings — another theme, a plexus kept
|
||||||
|
outside spr, a different destination — list it in a run file:
|
||||||
|
|
||||||
```json
|
```toml
|
||||||
{ "items": [ { "name": "bundle" } ] }
|
[[plexus]]
|
||||||
|
name = "bundle"
|
||||||
|
theme = "mcrn"
|
||||||
|
dest = "out/bundle.html"
|
||||||
```
|
```
|
||||||
|
|
||||||
A room may override anything the plexus declares — most usefully the theme:
|
`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
|
||||||
```json
|
vendored slice. See [Export / Compile](#export).
|
||||||
{ "items": [ { "name": "bundle", "theme": "mcrn" } ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Writing one
|
## Writing one
|
||||||
|
|
||||||
@@ -57,17 +59,22 @@ soleprint/artery/plexuses/<name>/
|
|||||||
app/index.html the template
|
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 |
|
| 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 |
|
| `%%BUNDLE%%` | `plexus.json` as a JS object |
|
||||||
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, inlined |
|
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, inlined |
|
||||||
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
|
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
|
||||||
|
|
||||||
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
|
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 bundle plexus
|
||||||
|
|
||||||
The one that ships. It answers "what does a rig installation have at its
|
The one that ships. It answers "what does a rig installation have at its
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ make start # run it
|
|||||||
| Command | Runs | Does |
|
| Command | Runs | Does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `make build [<room>\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` |
|
| `make build [<room>\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` |
|
||||||
| `make dist [<room>]` | `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 docs [serve\|graphs]` | `ctrl/docs.sh` | serve the docs, or re-render the diagrams |
|
||||||
| `make start [<room>] [-d]` | `ctrl/start.sh` | run a built room's compose stack |
|
| `make start [<room>] [-d]` | `ctrl/start.sh` | run a built room's compose stack |
|
||||||
| `make stop [<room>]` | `ctrl/stop.sh` | stop it |
|
| `make stop [<room>]` | `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
|
4. **Compose cabinets.** The dependency containers the room declared in
|
||||||
`data/cabinets.json` are merged into its `docker-compose.yml`. See
|
`data/cabinets.json` are merged into its `docker-compose.yml`. See
|
||||||
[Cabinets](#station-cabinets).
|
[Cabinets](#station-cabinets).
|
||||||
5. **Export plexuses.** Each plexus the room declared in `data/plexuses.json` is
|
5. **Generate models.** modelgen reads the room's `config.json` and writes
|
||||||
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`.
|
`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.
|
`soleprint/ctrl/k8s/` writes manifests and lifecycle scripts.
|
||||||
|
|
||||||
## What comes out
|
## What comes out
|
||||||
@@ -71,7 +68,6 @@ gen/standalone/
|
|||||||
cfg/config.json
|
cfg/config.json
|
||||||
data/*.json
|
data/*.json
|
||||||
models/pydantic/
|
models/pydantic/
|
||||||
plexuses/<name>/index.html # one file each, opens with no server
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A **managed** room — one that wraps an existing application — is three folders
|
A **managed** room — one that wraps an existing application — is three folders
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ A plexus is a full app — and, unlike everything else here, one that is
|
|||||||
vite builds for production.
|
vite builds for production.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dist # standalone
|
make dist # every plexus here
|
||||||
make dist sample # a named room
|
make dist bundle # one, by name
|
||||||
|
make dist path/to/ui.toml # the [[plexus]] tables of a run file
|
||||||
```
|
```
|
||||||
|
|
||||||
Output: `gen/<room>/plexuses/<name>/index.html`, one self-contained file with its
|
Output: `gen/plexuses/<name>/index.html` (or `--dest`), one self-contained file
|
||||||
theme, its data and its diagrams inlined.
|
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
|
## The constraint
|
||||||
|
|
||||||
@@ -39,15 +41,22 @@ failed requests. Everything else is cosmetic.
|
|||||||
|
|
||||||
| Placeholder | Becomes |
|
| 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 |
|
| `%%BUNDLE%%` | `plexus.json` as a JS object literal |
|
||||||
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, prolog stripped, inlined |
|
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, prolog stripped, inlined |
|
||||||
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
|
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
|
||||||
|
|
||||||
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
|
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
|
||||||
|
|
||||||
A room declares which ones it wants in `cfg/<room>/data/plexuses.json`, and may
|
A run file's `[[plexus]]` table can override `theme`, `inline_themes`, `title`
|
||||||
override anything the manifest sets — most usefully `theme`.
|
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
|
## Why the diagram is inlined
|
||||||
|
|
||||||
@@ -61,5 +70,5 @@ diagram become something you could put in a document.
|
|||||||
|
|
||||||
## Adding one
|
## Adding one
|
||||||
|
|
||||||
Drop a directory in beside `bundle/`. Nothing dispatches on the name — the room's
|
Drop a directory in beside `bundle/`. Nothing dispatches on the name and nothing
|
||||||
`plexuses.json` is the only place it has to be mentioned.
|
lists it: `make dist` builds every directory that holds a `plexus.json`.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<!--
|
<!--
|
||||||
Bundle plexus — the template. build.py inlines everything marked %%…%% and
|
Bundle plexus — the template. `make dist bundle` inlines everything marked
|
||||||
writes one self-contained file to gen/<room>/plexuses/bundle/index.html.
|
%%…%% and writes one self-contained file to gen/plexuses/bundle/index.html.
|
||||||
|
|
||||||
What this page is FOR: showing the three themes doing real work. Every panel
|
What this page is FOR: showing the three themes doing real work. Every panel
|
||||||
renders genuine output — showcase.py runs modelgen, datagen and shuntgen over
|
renders genuine output — showcase.py runs modelgen, datagen and shuntgen over
|
||||||
@@ -217,7 +217,7 @@ if (S.shunt) {
|
|||||||
|
|
||||||
if (S.cabinet) {
|
if (S.cabinet) {
|
||||||
out.push('<h2>Dependencies it declares <span class="n">— cabinets</span></h2>');
|
out.push('<h2>Dependencies it declares <span class="n">— cabinets</span></h2>');
|
||||||
out.push('<p class="lede">A room asks for <code>' + esc(S.cabinet.name) +
|
out.push('<p class="lede">An environment asks for <code>' + esc(S.cabinet.name) +
|
||||||
'</code> once. This is merged into its compose file on a laptop; the same name ' +
|
'</code> once. This is merged into its compose file on a laptop; the same name ' +
|
||||||
'installs as a rig addon in a cluster.</p>');
|
'installs as a rig addon in a cluster.</p>');
|
||||||
out.push('<div class="panel"><pre>' + esc(S.cabinet.fragment) + "</pre></div>");
|
out.push('<div class="panel"><pre>' + esc(S.cabinet.fragment) + "</pre></div>");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"title": "Soleprint Bundle",
|
"title": "Soleprint Bundle",
|
||||||
"description": "What a rig installation has at its disposal. Exports to a single file that opens with no server.",
|
"description": "What a rig installation has at its disposal. Exports to a single file that opens with no server.",
|
||||||
"theme": "lucid",
|
"theme": "lucid",
|
||||||
|
"inline_themes": ["lucid", "soleprint", "mcrn"],
|
||||||
"graph": "system_overview",
|
"graph": "system_overview",
|
||||||
"_comment": "A plexus is exported, not served. build.py inlines the theme, the data and the diagram into one index.html so it survives a locked-down Windows box, a zip attachment and a double-click. Nothing here is fetched at runtime.",
|
"_comment": "A plexus is exported, not served. build.py inlines the theme, the data and the diagram into one index.html so it survives a locked-down Windows box, a zip attachment and a double-click. Nothing here is fetched at runtime.",
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ def _shunt(tmp: Path) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def _cabinet() -> dict | None:
|
def _cabinet() -> dict | None:
|
||||||
"""A cabinet's compose fragment, as it gets merged into a room."""
|
"""A cabinet's compose fragment, as it gets merged into a compose file."""
|
||||||
path = SOLEPRINT / "station" / "cabinets" / "postgres" / "service.yml"
|
path = SOLEPRINT / "station" / "cabinets" / "postgres" / "service.yml"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
|
|||||||
3
soleprint/artery/plexuses/live/app/.gitignore
vendored
Normal file
3
soleprint/artery/plexuses/live/app/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
ui/
|
||||||
12
soleprint/artery/plexuses/live/app/index.html
Normal file
12
soleprint/artery/plexuses/live/app/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Live panels</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
soleprint/artery/plexuses/live/app/package.json
Normal file
12
soleprint/artery/plexuses/live/app/package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "live-panels",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5",
|
||||||
|
"vite": "^6"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
soleprint/artery/plexuses/live/app/pnpm-workspace.yaml
Normal file
4
soleprint/artery/plexuses/live/app/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# pnpm 10+ refuses dependency build scripts unless they are allowed here.
|
||||||
|
# esbuild (vite's bundler) needs its postinstall to link a platform binary.
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
74
soleprint/artery/plexuses/live/app/src/App.vue
Normal file
74
soleprint/artery/plexuses/live/app/src/App.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/*
|
||||||
|
* A live page with no server: a local generator stands in for a stream, so
|
||||||
|
* the file behaves the same from a double-click as it would behind an API.
|
||||||
|
* Everything drawn comes from the slice: Panel, SplitPane, LogRenderer.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { LogRenderer, Panel, SplitPane, type LogEntry } from '@ui'
|
||||||
|
|
||||||
|
const STAGES = ['ingest', 'validate', 'transform', 'load']
|
||||||
|
const LEVELS = ['INFO', 'INFO', 'INFO', 'DEBUG', 'WARN', 'ERROR']
|
||||||
|
const MAX_LINES = 2000
|
||||||
|
|
||||||
|
const entries = ref<LogEntry[]>([])
|
||||||
|
const counts = ref<Record<string, number>>({})
|
||||||
|
const running = ref(true)
|
||||||
|
let timer: number | undefined
|
||||||
|
|
||||||
|
function tick(): void {
|
||||||
|
const level = LEVELS[Math.floor(Math.random() * LEVELS.length)]
|
||||||
|
const stage = STAGES[Math.floor(Math.random() * STAGES.length)]
|
||||||
|
const n = entries.value.length + 1
|
||||||
|
entries.value.push({ level, stage, msg: `record ${n} ${level === 'ERROR' ? 'rejected' : 'ok'}`,
|
||||||
|
ts: new Date().toISOString().slice(11, 23) })
|
||||||
|
if (entries.value.length > MAX_LINES) entries.value.splice(0, entries.value.length - MAX_LINES)
|
||||||
|
counts.value[level] = (counts.value[level] ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(): void {
|
||||||
|
running.value = !running.value
|
||||||
|
if (running.value) start()
|
||||||
|
else stop()
|
||||||
|
}
|
||||||
|
function start(): void { timer = window.setInterval(tick, 50) }
|
||||||
|
function stop(): void { window.clearInterval(timer) }
|
||||||
|
|
||||||
|
const status = computed(() => (running.value ? 'live' : 'idle'))
|
||||||
|
onMounted(start)
|
||||||
|
onUnmounted(stop)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<SplitPane direction="horizontal" :initial-size="2">
|
||||||
|
<template #first>
|
||||||
|
<Panel title="Events" :status="status">
|
||||||
|
<template #actions>
|
||||||
|
<button @click="toggle">{{ running ? 'pause' : 'resume' }}</button>
|
||||||
|
</template>
|
||||||
|
<LogRenderer :entries="entries" />
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
<template #second>
|
||||||
|
<Panel title="Counts" :maximizable="false">
|
||||||
|
<dl class="counts">
|
||||||
|
<template v-for="level in ['INFO', 'DEBUG', 'WARN', 'ERROR']" :key="level">
|
||||||
|
<dt>{{ level }}</dt>
|
||||||
|
<dd>{{ counts[level] ?? 0 }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
</SplitPane>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body, #app { height: 100%; margin: 0; }
|
||||||
|
.page { height: 100%; padding: var(--space-3); box-sizing: border-box; }
|
||||||
|
.counts { display: grid; grid-template-columns: auto 1fr; gap: var(--space-2) var(--space-4);
|
||||||
|
font-family: var(--font-mono); color: var(--text-primary); margin: 0; }
|
||||||
|
.counts dt { color: var(--text-secondary); }
|
||||||
|
.counts dd { margin: 0; text-align: right; }
|
||||||
|
</style>
|
||||||
6
soleprint/artery/plexuses/live/app/src/env.d.ts
vendored
Normal file
6
soleprint/artery/plexuses/live/app/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<object, object, unknown>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
4
soleprint/artery/plexuses/live/app/src/main.ts
Normal file
4
soleprint/artery/plexuses/live/app/src/main.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
6
soleprint/artery/plexuses/live/plexus.json
Normal file
6
soleprint/artery/plexuses/live/plexus.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "live",
|
||||||
|
"title": "Live panels",
|
||||||
|
"description": "A panel layout fed by a local generator, built from a slice of the Vue library into one file that opens with no server.",
|
||||||
|
"_comment": "A Vue plexus: app/ is an ordinary Vite + Vue app whose `@ui` imports are sliced from common/ui at build time. `make dist live` builds it in a temporary directory and inlines the script and stylesheet, so the output is one file. Nothing here is fetched at runtime; the generator stands in for a stream."
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ The command line — every command slices have, and nothing else.
|
|||||||
scan what a consumer uses, and how its framework copy compares with spr
|
scan what a consumer uses, and how its framework copy compares with spr
|
||||||
slice write every slice a run file lists (only what is stale)
|
slice write every slice a run file lists (only what is stale)
|
||||||
check the same, writing nothing: exit 1 if anything is stale
|
check the same, writing nothing: exit 1 if anything is stale
|
||||||
|
plexus build one self-contained page from a plexus (make dist)
|
||||||
selftest this package's own suite
|
selftest this package's own suite
|
||||||
|
|
||||||
Library modules beside this package hold no command-line code; selftest asserts
|
Library modules beside this package hold no command-line code; selftest asserts
|
||||||
@@ -23,6 +24,7 @@ COMMANDS = {
|
|||||||
"scan": ("scan", "what a consumer uses, and its copy against spr"),
|
"scan": ("scan", "what a consumer uses, and its copy against spr"),
|
||||||
"slice": ("slice", "write every slice a run file lists"),
|
"slice": ("slice", "write every slice a run file lists"),
|
||||||
"check": ("slice", "the same, writing nothing — exit 1 if stale"),
|
"check": ("slice", "the same, writing nothing — exit 1 if stale"),
|
||||||
|
"plexus": ("plexus", "build a plexus: one self-contained page"),
|
||||||
"selftest": ("selftest", "this package's own suite"),
|
"selftest": ("selftest", "this package's own suite"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
71
soleprint/common/slice/cli/plexus.py
Normal file
71
soleprint/common/slice/cli/plexus.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
python3 -m common.slice plexus every plexus -> gen/plexuses/<name>/
|
||||||
|
python3 -m common.slice plexus NAME [--dest PATH] one, by name
|
||||||
|
python3 -m common.slice plexus FILE [--only NAME] every [[plexus]] in a run file
|
||||||
|
… --delivery vendored --notice none|FILE renamed, scrubbed, notice stated
|
||||||
|
|
||||||
|
No instance to build first, nothing keyed on configuration elsewhere: a plexus
|
||||||
|
is its own directory, and this writes one self-contained file from it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .. import plexus as px
|
||||||
|
from .common import PROG, Abort, kb, load_runfile
|
||||||
|
|
||||||
|
EXTERNAL = re.compile(r"""(?:src|href)=["'](?!#|data:|javascript:)([^"']+)["']""")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv, command=None) -> int:
|
||||||
|
p = argparse.ArgumentParser(prog=f"{PROG} plexus")
|
||||||
|
p.add_argument("target", nargs="?", help="A plexus name, or a run file. Omit for every plexus.")
|
||||||
|
p.add_argument("--only", action="append", default=[], metavar="NAME",
|
||||||
|
help="With a run file: just this plexus. Repeatable.")
|
||||||
|
p.add_argument("--dest", help="Where the .html goes (a file, or a directory for index.html).")
|
||||||
|
p.add_argument("--delivery", choices=("dependency", "vendored"), default="dependency")
|
||||||
|
p.add_argument("--notice", help='With --delivery vendored: "none", or a file to head the page.')
|
||||||
|
p.add_argument("--minify", action="store_true", help="Vue plexus: minify the inlined script.")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.target and args.target.endswith(".toml"):
|
||||||
|
rf = load_runfile(Path(args.target))
|
||||||
|
chosen = px.from_runfile(rf.plexuses, rf.path.resolve().parent)
|
||||||
|
if args.only:
|
||||||
|
chosen = [x for x in chosen if x.name in set(args.only)]
|
||||||
|
if not chosen:
|
||||||
|
raise Abort(f"{args.target} lists no [[plexus]] to build")
|
||||||
|
else:
|
||||||
|
names = [args.target] if args.target else px.discover()
|
||||||
|
chosen = [px.resolve(n, dest=args.dest if args.target else None,
|
||||||
|
delivery=args.delivery, notice=args.notice,
|
||||||
|
minify=args.minify) for n in names]
|
||||||
|
except px.PlexusError as e:
|
||||||
|
raise Abort(str(e)) from None
|
||||||
|
|
||||||
|
failed = 0
|
||||||
|
for x in chosen:
|
||||||
|
kind = "vue" if x.is_vue else "template"
|
||||||
|
try:
|
||||||
|
built = px.build(x)
|
||||||
|
path = px.write(built)
|
||||||
|
except px.PlexusError as e:
|
||||||
|
failed += 1
|
||||||
|
print(f" FAIL {x.name:<12} {e}")
|
||||||
|
for line, term, text in (built.hits if "built" in locals() else [])[:10]:
|
||||||
|
print(f" line {line} {term!r} {text[:90]}")
|
||||||
|
continue
|
||||||
|
external = sorted(set(EXTERNAL.findall(built.html)))
|
||||||
|
print(f" ok {x.name:<12} {kind:<8} {x.delivery:<10} {kb(len(built.html.encode())):>9} -> {path}")
|
||||||
|
for note in built.notes:
|
||||||
|
print(f" {note}")
|
||||||
|
if external:
|
||||||
|
print(f" WARNING references outside the file: {', '.join(external[:5])}")
|
||||||
|
print()
|
||||||
|
if failed:
|
||||||
|
print(f"{failed} of {len(chosen)} plexus(es) failed")
|
||||||
|
return 1
|
||||||
|
print(f"{len(chosen)} plexus(es) built")
|
||||||
|
return 0
|
||||||
147
soleprint/common/slice/css.py
Normal file
147
soleprint/common/slice/css.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
Top-level CSS rules, and which of them a page can use.
|
||||||
|
|
||||||
|
A plexus used to inline the whole theme contract and every theme — 20 KB of
|
||||||
|
rules for a page that uses a fraction of them, including component styling for
|
||||||
|
screens it does not have. This cuts that down, conservatively:
|
||||||
|
|
||||||
|
custom properties only the names the page reads, plus what they reach
|
||||||
|
a rule kept unless the page provably cannot use it: every
|
||||||
|
class and id in one of its selectors has to appear
|
||||||
|
somewhere in the page — markup *or* script, since a
|
||||||
|
page that builds its DOM in JS names its classes there
|
||||||
|
element selectors kept (h2, button, *): cheap, and a page has elements
|
||||||
|
@keyframes kept when a kept rule or the page names it
|
||||||
|
@media / @supports kept when anything inside is
|
||||||
|
|
||||||
|
When unsure it keeps. A rule too many costs bytes; a rule too few is a page that
|
||||||
|
renders wrong, and nothing would say so.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
COMMENT = re.compile(r"/\*.*?\*/", re.S)
|
||||||
|
DECL = re.compile(r"(--[\w-]+)\s*:\s*([^;]+);")
|
||||||
|
VAR = re.compile(r"var\(\s*(--[\w-]+)")
|
||||||
|
THEME_ATTR = re.compile(r"""\[data-theme=["']?[\w-]+["']?\]""")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Rule:
|
||||||
|
prelude: str # selector list, or an at-rule's head
|
||||||
|
body: str # what is between the braces
|
||||||
|
|
||||||
|
@property
|
||||||
|
def at(self) -> str:
|
||||||
|
return self.prelude.split()[0] if self.prelude.startswith("@") else ""
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
return f"{self.prelude} {{{self.body}}}"
|
||||||
|
|
||||||
|
|
||||||
|
def rules(css: str) -> list[Rule]:
|
||||||
|
"""Top-level rules, comments removed. Nested blocks stay inside their body."""
|
||||||
|
css = COMMENT.sub("", css)
|
||||||
|
out, i, n = [], 0, len(css)
|
||||||
|
while i < n:
|
||||||
|
start = css.find("{", i)
|
||||||
|
if start < 0:
|
||||||
|
break
|
||||||
|
prelude = css[i:start].strip()
|
||||||
|
depth, j = 1, start + 1
|
||||||
|
while j < n and depth:
|
||||||
|
if css[j] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif css[j] == "}":
|
||||||
|
depth -= 1
|
||||||
|
j += 1
|
||||||
|
out.append(Rule(prelude=prelude, body=css[start + 1:j - 1]))
|
||||||
|
i = j
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def only_properties(rule: Rule) -> bool:
|
||||||
|
"""A block that sets custom properties and nothing else."""
|
||||||
|
body = rule.body.strip()
|
||||||
|
return bool(body) and not DECL.sub("", body).strip(" \n\t;")
|
||||||
|
|
||||||
|
|
||||||
|
def declarations(rule: Rule) -> dict[str, str]:
|
||||||
|
return {name: value.strip() for name, value in DECL.findall(rule.body)}
|
||||||
|
|
||||||
|
|
||||||
|
def _split_selectors(prelude: str) -> list[str]:
|
||||||
|
parts, depth, cur = [], 0, ""
|
||||||
|
for ch in prelude:
|
||||||
|
if ch in "([":
|
||||||
|
depth += 1
|
||||||
|
elif ch in ")]":
|
||||||
|
depth -= 1
|
||||||
|
if ch == "," and depth == 0:
|
||||||
|
parts.append(cur)
|
||||||
|
cur = ""
|
||||||
|
else:
|
||||||
|
cur += ch
|
||||||
|
parts.append(cur)
|
||||||
|
return [p.strip() for p in parts if p.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _names(selector: str) -> list[str]:
|
||||||
|
"""Classes and ids a selector requires, outside :not(…) and attribute brackets."""
|
||||||
|
s = THEME_ATTR.sub("", selector)
|
||||||
|
s = re.sub(r":not\([^)]*\)", "", s)
|
||||||
|
s = re.sub(r"\[[^\]]*\]", "", s)
|
||||||
|
return re.findall(r"[.#](-?[A-Za-z_][\w-]*)", s)
|
||||||
|
|
||||||
|
|
||||||
|
def matches(prelude: str, page: str) -> bool:
|
||||||
|
for selector in _split_selectors(prelude):
|
||||||
|
names = _names(selector)
|
||||||
|
if all(re.search(rf"(?<![\w-]){re.escape(n)}(?![\w-])", page) for n in names):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def keyframes_name(rule: Rule) -> str:
|
||||||
|
parts = rule.prelude.split()
|
||||||
|
return parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def shake(css: str, page: str) -> tuple[list[Rule], set]:
|
||||||
|
"""Rules of `css` the page can use, and the custom properties they read.
|
||||||
|
|
||||||
|
Custom-property-only blocks are not decided here — the caller cuts those
|
||||||
|
by name — so they are returned as they are.
|
||||||
|
"""
|
||||||
|
kept, reads = [], set()
|
||||||
|
candidates = rules(css)
|
||||||
|
keyframes = [r for r in candidates if r.at == "@keyframes"]
|
||||||
|
for r in candidates:
|
||||||
|
if r.at == "@keyframes":
|
||||||
|
continue
|
||||||
|
if r.at in ("@media", "@supports"):
|
||||||
|
inner, inner_reads = shake(r.body, page)
|
||||||
|
if inner:
|
||||||
|
kept.append(Rule(prelude=r.prelude, body="\n" + "\n".join(x.text() for x in inner) + "\n"))
|
||||||
|
reads |= inner_reads
|
||||||
|
continue
|
||||||
|
if r.at:
|
||||||
|
kept.append(r) # @font-face and friends: rare, and never guessed at
|
||||||
|
continue
|
||||||
|
if only_properties(r) or matches(r.prelude, page):
|
||||||
|
kept.append(r)
|
||||||
|
reads |= set(VAR.findall(r.body))
|
||||||
|
text = page + "\n".join(r.body for r in kept)
|
||||||
|
for k in keyframes:
|
||||||
|
name = keyframes_name(k)
|
||||||
|
if name and re.search(rf"(?<![\w-]){re.escape(name)}(?![\w-])", text):
|
||||||
|
kept.append(k)
|
||||||
|
reads |= set(VAR.findall(k.body))
|
||||||
|
return kept, reads
|
||||||
|
|
||||||
|
|
||||||
|
def pretty(rule: Rule) -> str:
|
||||||
|
"""One rule, re-indented: readable, never minified."""
|
||||||
|
body = "\n".join(f" {line.strip()}" for line in rule.body.strip().splitlines() if line.strip())
|
||||||
|
return f"{rule.prelude} {{\n{body}\n}}"
|
||||||
414
soleprint/common/slice/plexus.py
Normal file
414
soleprint/common/slice/plexus.py
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
"""
|
||||||
|
A plexus: one page that carries everything it needs, built with nothing behind it.
|
||||||
|
|
||||||
|
make dist every plexus under artery/plexuses -> gen/plexuses/
|
||||||
|
make dist bundle one, by name
|
||||||
|
make dist path/to/ui.toml every [[plexus]] a run file lists
|
||||||
|
|
||||||
|
A plexus is exported, not served. The output is one index.html with its theme,
|
||||||
|
its data and its diagram inlined, so it survives a locked-down machine, a zip
|
||||||
|
attachment and a double-click. It is the output-only / demo-only form of the UI:
|
||||||
|
the rendered page ships, the source that made it never does.
|
||||||
|
|
||||||
|
## What changed when it stopped belonging to a build
|
||||||
|
|
||||||
|
It used to be one step of building a whole instance, keyed on which plexuses an
|
||||||
|
instance's config asked for, and it inlined the entire theme contract and every
|
||||||
|
theme — 20 KB of rules for a page that reads a fraction of them. Now:
|
||||||
|
|
||||||
|
- it is built on its own, from its directory or from a run file;
|
||||||
|
- the theme it carries is cut to what the page reads (see css.py): the custom
|
||||||
|
properties it uses and what they reach, per theme it can switch to, and the
|
||||||
|
rules that can match something on the page;
|
||||||
|
- parts arrive when the template carries `<!-- theme:here -->`, 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("<svg")
|
||||||
|
return svg[start:] if start >= 0 else svg
|
||||||
|
return "<p>diagram not rendered — run docs/graphs/render.sh</p>"
|
||||||
|
|
||||||
|
|
||||||
|
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{p.notice.read_text().rstrip()}\n-->\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'<script type="module"[^>]*\ssrc="([^"]+)"[^>]*></script>')
|
||||||
|
STYLE_TAG = re.compile(r'<link rel="stylesheet"[^>]*\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("</script", "<\\/script")
|
||||||
|
return f'<script type="module">\n{code}\n</script>'
|
||||||
|
|
||||||
|
def style(m):
|
||||||
|
return f"<style>\n{(dist / m.group(1)).read_text()}\n</style>"
|
||||||
|
|
||||||
|
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"<title>.*?</title>", f"<title>{title}</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("</head>", f"<script>window.PAGE_DATA = {json.dumps(data)}</script>\n</head>", 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
|
||||||
@@ -19,7 +19,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
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
|
HERE = Path(__file__).resolve().parent
|
||||||
PASS, FAIL = [], []
|
PASS, FAIL = [], []
|
||||||
@@ -218,6 +218,34 @@ def main() -> int:
|
|||||||
"turns the next one into a call)",
|
"turns the next one into a call)",
|
||||||
chunks and all(c.endswith(";") for c in chunks), [c[-30:] for c in chunks])
|
chunks and all(c.endswith(";") for c in chunks), [c[-30:] for c in chunks])
|
||||||
|
|
||||||
|
section("the plexus")
|
||||||
|
page = '<div class="card"><h2>x</h2></div><script>el.className = "dyn"</script>'
|
||||||
|
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)")
|
section("consumers (read-only, skipped when absent)")
|
||||||
meetus = Path.home() / "wdir/semester/mts/ui/meetus-app"
|
meetus = Path.home() / "wdir/semester/mts/ui/meetus-app"
|
||||||
if meetus.exists():
|
if meetus.exists():
|
||||||
|
|||||||
@@ -355,7 +355,11 @@ def artery_index(request: Request):
|
|||||||
veins = load_data("veins.json")
|
veins = load_data("veins.json")
|
||||||
pulses = load_data("pulses.json")
|
pulses = load_data("pulses.json")
|
||||||
shunts_data = load_data("shunts.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
|
# Scan directories for shunts not in data files
|
||||||
shunts = (
|
shunts = (
|
||||||
|
|||||||
Reference in New Issue
Block a user