Compare commits

..

2 Commits

Author SHA1 Message Date
5219bd5edb Merge branch 'ui' 2026-09-16 09:38:08 -03:00
f7910bf42b ui framework extraction updates 2026-09-16 09:38:04 -03:00
7 changed files with 613 additions and 31 deletions

View File

@@ -63,7 +63,7 @@ dist: ## compile the plexus UIs to single files [<room
# ── theme ──────────────────────────────────────────────────────────────────
theme: ## ad-hoc pages: scaffold, add parts, bake [new|parts|bake|check|export]
theme: ## ad-hoc pages [new|parts|bake|check|export|run FILE]
bash ctrl/theme.sh $(or $(ARGS),bake)
# ── docs ───────────────────────────────────────────────────────────────────

View File

@@ -5,6 +5,8 @@
# ./ctrl/theme.sh # bake — rewrite every generated block
# ./ctrl/theme.sh check # fail if any page is stale; changes nothing
# ./ctrl/theme.sh new [title] # a scaffold page to start from
# ./ctrl/theme.sh run FILE [--list|--check] [--only NAME]
# # every page a run file lists, each with a contract
# ./ctrl/theme.sh parts # what can be added, and the markup that adds it
# ./ctrl/theme.sh export [name...] # the contract for a subset, as one doc
#
@@ -30,6 +32,10 @@
# hardcoded page list, and so was never baked once.
set -e
# Where the caller stood. A run file is named relative to there, and the cd below
# would otherwise make `run ./theme.toml` mean a different file.
CALLER_DIR="$PWD"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$ROOT_DIR/soleprint"
@@ -42,6 +48,22 @@ case "${1:-bake}" in
parts)
exec "$PYTHON" common/theme/bake.py --parts
;;
run)
# A run file: every page a project has, its context, and a contract per
# page for the LLM. See soleprint/common/theme/theme.example.toml.
shift
args=() file="" prev=""
for a in "$@"; do
if [[ -z "$file" && "$a" != -* && "$prev" != "--only" ]]; then
file="$(cd "$CALLER_DIR" && realpath -m -- "$a")"
args+=("$file")
else
args+=("$a")
fi
prev="$a"
done
exec "$PYTHON" common/theme/bake.py --run "${args[@]}"
;;
new)
shift
exec "$PYTHON" common/theme/bake.py --new "$@"
@@ -52,7 +74,7 @@ case "${1:-bake}" in
;;
*)
echo "Unknown: $1" >&2
echo "Usage: ./ctrl/theme.sh [new [title]|parts|bake|check|export [name...]]" >&2
echo "Usage: ./ctrl/theme.sh [new [title]|parts|bake|check|export [name...]|run FILE]" >&2
exit 1
;;
esac

7
soleprint/common/theme/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Everything a run writes — contracts, and pages the example scaffolds.
out/
__pycache__/
# A real run file names a client's paths. It belongs beside the client's code;
# if one is kept here anyway, it is not committed. theme.example.toml is.
theme.toml

View File

@@ -52,7 +52,9 @@ LINK = '<link rel="stylesheet" href="/theme.css">'
# Directories with nothing bakeable in them. `gen/` is build output — baking
# there would be editing an artifact, and the next build overwrites it anyway.
SKIP_DIRS = {"node_modules", ".venv", "__pycache__", ".git", "gen", "dist", "def"}
# `out/` is the same thing for run files: a page a run scaffolded there belongs
# to that run, and `--run --check` is what checks it.
SKIP_DIRS = {"node_modules", ".venv", "__pycache__", ".git", "gen", "dist", "def", "out"}
def declarations(css: str, selector: str) -> dict[str, str]:
@@ -299,12 +301,10 @@ SCAFFOLD = """<!DOCTYPE html>
%(title)s — an ad-hoc page.
HOW THIS GROWS. You never pick parts and you never edit the generated blocks.
You write the markup for the feature you want, run `make theme bake`, and the
part arrives. Remove the markup, bake again, and it leaves.
You write the markup for the feature you want, rebuild, and the part arrives.
Remove the markup, rebuild again, and it leaves.
`./ctrl/theme.sh parts` what can be added, and the markup for each
`make theme bake` put it in
`make theme check` says when this page has gone stale
%(how)s
It must open from a double-click as well as be served, so: no /theme.css, no
CDN, no webfont, no build step. Everything is in this one file.
@@ -350,6 +350,18 @@ SCAFFOLD = """<!DOCTYPE html>
"""
# How a scaffold says to rebuild itself. Two wordings because there are two
# places a page can live, and each is wrong in the other: `make theme bake`
# only walks spr's own tree, so it never reaches a page in a client repo.
HOW_IN_SPR = """ `./ctrl/theme.sh parts` what can be added, and the markup for each
`make theme bake` put it in
`make theme check` says when this page has gone stale"""
HOW_IN_RUN = """ This page is listed in a run file. Its contract (written on every run) has
the exact rebuild command, the parts to add and the markup for each.
Rebuild with that command; add --check to it to see whether this page is stale."""
def scaffold(title: str) -> int:
"""The simple page you start from, before any feature is on it.
@@ -357,7 +369,7 @@ def scaffold(title: str) -> int:
scaffold first, features after -- so the starting point has to be the
smallest thing that already works, not a gallery to delete from.
"""
print(SCAFFOLD % {"title": title or "page"}, end="")
print(SCAFFOLD % {"title": title or "page", "how": HOW_IN_SPR}, end="")
return 0
@@ -460,7 +472,24 @@ def audit(parts: dict, values: dict[str, str]) -> int:
def export(parts: dict, values: dict[str, str], wanted: list[str]) -> int:
"""Write the plain-HTML contract for a chosen subset, as one document.
"""Print the contract for the named parts -- every part when none are named."""
unknown = [n for n in wanted if n not in parts]
if unknown:
print(f"unknown part(s): {', '.join(unknown)}", file=sys.stderr)
print(f"available: {', '.join(sorted(parts))}", file=sys.stderr)
return 1
print(contract(parts, values, wanted or sorted(parts)))
return 0
def contract(parts: dict, values: dict[str, str], wanted, rebuild: str | None = None) -> str:
"""The plain-HTML contract for a chosen subset, as one document.
`rebuild` is set when the contract is for one page of a run file. The
generic wording ("run make theme bake", "start from theme.sh new") is then
dropped: a document carrying two different rebuild instructions is a
document that makes the reader pick one, which is the guess it exists to
remove.
For handing to a vetted LLM when an ad-hoc page is what you want back. The
selection is the point: asking for a page and pasting the whole framework
@@ -475,13 +504,7 @@ def export(parts: dict, values: dict[str, str], wanted: list[str]) -> int:
the code are the same file and cannot drift apart. A separate guide would
be a second thing to keep true.
"""
unknown = [n for n in wanted if n not in parts]
if unknown:
print(f"unknown part(s): {', '.join(unknown)}", file=sys.stderr)
print(f"available: {', '.join(sorted(parts))}", file=sys.stderr)
return 1
names = sorted(wanted) if wanted else sorted(parts)
names = sorted(wanted)
# An always-part is the element layer; a page with panels and OS-default
# buttons is not what anyone is asking for.
names += [n for n, (_, _, _, always) in parts.items() if always and n not in names]
@@ -511,20 +534,25 @@ def export(parts: dict, values: dict[str, str], wanted: list[str]) -> int:
" of the same box is the problem, not the fix.",
"4. **Do not paste the CSS below into the page.** Write the markup and the",
" page's own styles only, leave `<!-- theme:here -->` in `<head>`, and run",
" `make theme bake` — it inserts exactly the parts the markup uses.",
f" {rebuild or '`make theme bake`'} — it inserts exactly the parts the markup uses.",
"5. **Style with the variables, never with literals.** The values below are",
" resolved for reference; a hex typed into the page cannot follow a theme.",
"",
"## How a page is built",
"",
"Scaffold first, features after. `./ctrl/theme.sh new \"Title\"` gives a page with",
"one panel and the `<!-- theme:here -->` anchor. To add a feature you add its",
"markup and run `make theme bake`; the part appears. Remove the markup, bake",
"again, and it leaves. Nothing is selected by hand.",
"",
"Full workflow, rules and the markup for every part: `common/theme/parts/README.md`,",
"and `./ctrl/theme.sh parts` for the catalogue.",
"",
]
if rebuild is None:
out += [
"## How a page is built",
"",
"Scaffold first, features after. `./ctrl/theme.sh new \"Title\"` gives a page with",
"one panel and the `<!-- theme:here -->` anchor. To add a feature you add its",
"markup and run `make theme bake`; the part appears. Remove the markup, bake",
"again, and it leaves. Nothing is selected by hand.",
"",
"For many pages, each with the code and schema it is about, use a run file:",
"`./ctrl/theme.sh run theme.toml` — see `common/theme/theme.example.toml`.",
"",
]
out += [
"## Tokens these parts use",
"",
"```css",
@@ -538,6 +566,11 @@ def export(parts: dict, values: dict[str, str], wanted: list[str]) -> int:
for name in names:
css = parts[name][0]
header = part_header(name, css)
if rebuild:
# The part headers are written for spr's own tree. Inside a page
# contract they must name the same command as everything else.
header = header.replace("`make theme bake`", rebuild)
css = header + css[len(part_header(name, css)):] if css.strip() else css
out += [f"## part: {name}", ""]
if css.strip():
out += ["```css", header.strip(), "", css[len(header):].strip(), "```", ""]
@@ -545,9 +578,158 @@ def export(parts: dict, values: dict[str, str], wanted: list[str]) -> int:
out += ["```", header.strip(), "```", ""]
js = PARTS / f"{name}.js"
if js.exists():
out += [f"### {name}.js — the behaviour", "", "```js", js.read_text().strip(), "```", ""]
code = js.read_text().strip()
if rebuild:
code = code.replace("`make theme bake`", rebuild)
out += [f"### {name}.js — the behaviour", "", "```js", code, "```", ""]
return "\n".join(out)
RUN_ORDER = (
"1. scaffold — only if the page file does not exist; an existing page is never overwritten",
"2. bake — the parts its markup uses go in; parts it no longer uses come out",
"3. contract — written to the export path: rules, parts, the page, its context",
)
print("\n".join(out))
def page_contract(entry, runfile_path: Path, parts: dict, values: dict[str, str]) -> str:
"""The document an LLM gets for ONE page: nothing to guess, nothing to look up.
The generic contract says how parts work. This adds what is specific to the
page: where it is, the exact command that rebuilds it, which features are
already in and which are asked for, the page as it stands, and the code and
schema it is about -- inlined, because a model shown a path invents the file.
"""
html = entry.page.read_text() if entry.page.exists() else ""
have = parts_used(html, parts)
add = [p for p in entry.parts if p not in have]
bake_py = Path(__file__).resolve()
command = f"python3 {bake_py} --run {runfile_path.resolve()} --only {entry.name}"
out = [
f"# page: {entry.name}",
"",
"This document is everything needed to change one ad-hoc page. Do not look",
"for other files and do not guess paths — every path below is exact.",
"",
"| | |",
"| --- | --- |",
f"| page file | `{entry.page.resolve()}` |",
f"| run file | `{runfile_path.resolve()}` |",
f"| rebuild command | `{command}` |",
f"| parts already in the page | {', '.join(have) or '(none yet)'} |",
f"| parts to ADD | {', '.join(add) or '(none — the page has everything asked for)'} |",
"",
"## What to do, in this order",
"",
"1. Edit **only the page file**, and only outside the two generated blocks",
" (`<!-- theme:baked-defaults -->` and `<!-- theme:parts -->`).",
"2. For each part to ADD, write the markup from its section below. Do not paste",
" any part's CSS or JS — the rebuild inserts it.",
"3. Use the field names from the context files at the end. Do not invent fields.",
"4. Run the rebuild command. It does, per page:",
"",
]
out += [f" {line}" for line in RUN_ORDER]
out += [
"",
"5. Run it again with `--check` appended. Exit 0 means the page is current.",
"",
"---",
"",
contract(parts, values, sorted(set(have) | set(entry.parts)), rebuild="the rebuild command"),
"",
"---",
"",
"## The page as it stands",
"",
"Generated blocks removed — they are rewritten on every rebuild.",
"",
"```html",
strip_blocks(html).strip() if html else "(does not exist yet — the rebuild scaffolds it)",
"```",
]
for ctx in entry.context:
fence = {".py": "python", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
".sql": "sql", ".ts": "ts", ".js": "js", ".html": "html"}.get(ctx.suffix, "")
out += ["", f"## context: `{ctx.resolve()}`", "", f"```{fence}",
ctx.read_text(errors="replace").rstrip(), "```"]
return "\n".join(out) + "\n"
def run_file(path: Path, only: list[str], check: bool, listing: bool,
parts: dict, values: dict[str, str]) -> int:
"""Every page a run file lists, in order. One failing page never stops the rest."""
import runfile # beside this script; kept apart because it only reads config
if not path.exists():
print(f"no run file at {path} — see common/theme/theme.example.toml", file=sys.stderr)
return 1
try:
rf = runfile.load(path, set(parts), (ANCHOR, LINK))
selected = rf.select(only)
except runfile.ConfigError as e:
print(e, file=sys.stderr)
return 1
if listing:
# The resolved form, because the question a run file raises is "relative
# to what" -- and the answer should be visible, not inferred.
print(f"{rf.path}{len(selected)} of {len(rf.pages)} page(s)\n")
print("per page, in order:")
for line in RUN_ORDER:
print(f" {line}")
print()
for p in selected:
html = p.page.read_text() if p.page.exists() else ""
have = parts_used(html, parts)
add = [x for x in p.parts if x not in have]
print(f" {p.line()}")
print(f" {'':16} has: {' '.join(have) or '-'} to add: {' '.join(add) or '-'}")
for c in p.context:
print(f" {'':16} context: {c}")
return 0
print(f"{rf.path}{len(selected)} page(s){' (check: nothing written)' if check else ''}")
failed = 0
for p in selected:
try:
if check:
if not p.page.exists():
status, detail = "FAIL", "page missing — run without --check to scaffold it"
else:
before = p.page.read_text()
changed, note = bake(p.page, values, parts)
if changed:
p.page.write_text(before)
status, detail = "FAIL", "stale — run without --check"
else:
status, detail = "ok ", note
else:
made = ""
if not p.page.exists():
# Parents are created: a page is often the first file in its
# folder. A mistyped path is not silent -- the row says
# "scaffolded", which an existing page never does.
p.page.parent.mkdir(parents=True, exist_ok=True)
p.page.write_text(SCAFFOLD % {"title": p.title, "how": HOW_IN_RUN})
made = "scaffolded, "
_, note = bake(p.page, values, parts)
p.export.parent.mkdir(parents=True, exist_ok=True)
p.export.write_text(page_contract(p, rf.path, parts, values))
status, detail = "ok ", f"{made}{note} -> {p.export}"
except Exception as e: # noqa: BLE001 - one bad page must not cost the run
status, detail = "FAIL", f"{type(e).__name__}: {e}"
if status == "FAIL":
failed += 1
print(f" {status} {p.name:<16} {detail}")
print()
if failed:
print(f"{failed} of {len(selected)} page(s) did not hold")
return 1
print(f"{len(selected)} page(s) " + ("current" if check else "built"))
return 0
@@ -574,6 +756,14 @@ def main() -> int:
values = palette()
parts = load_parts()
if "--run" in sys.argv:
args = sys.argv[sys.argv.index("--run") + 1 :]
only = [args[i + 1] for i, a in enumerate(args) if a == "--only" and i + 1 < len(args)]
flags_with_values = {i + 1 for i, a in enumerate(args) if a == "--only"}
positional = [a for i, a in enumerate(args) if not a.startswith("-") and i not in flags_with_values]
path = Path(positional[0]) if positional else Path("theme.toml")
return run_file(path, only, check, "--list" in args, parts, values)
if "--parts" in sys.argv:
return catalogue(parts)

View File

@@ -1,7 +1,9 @@
# parts — ad-hoc pages that stay standalone
Everything needed to build one of these pages is in this file. You do not need to
read `common/ui`, any Vue source, or any other document.
read `common/ui`, any Vue source, or any other document. For a page built against
real code and a database, the run file below writes a per-page contract that is
itself complete — that document is what an LLM gets.
## What this is for
@@ -44,6 +46,63 @@ verification below.
make theme check # exit 1 and names the stale pages
```
## Many pages, against real code and a database: a run file
When a page is about somebody's actual code and data — route handlers, a schema —
write a **run file** instead of running commands by hand. It fixes the three things
that otherwise get guessed: where each page goes, which files it is about, and the
command that rebuilds it.
```toml
# theme.toml — beside the project it describes; paths relative to THIS file
[defaults]
export = "out/contracts" # each page's contract: <export>/<name>.md
[[page]]
name = "sensors"
title = "Sensors" # used only when scaffolding
page = "ui/sensors/index.html" # scaffolded on the first run if missing
parts = ["feed", "maximize"] # features to ADD, not what the page has
context = ["api/routes.py", "db/schema.json"] # inlined into the contract
```
```bash
./ctrl/theme.sh run path/to/theme.toml --list # everything resolved; writes nothing
./ctrl/theme.sh run path/to/theme.toml # scaffold → bake → contract, per page
./ctrl/theme.sh run path/to/theme.toml --only sensors
./ctrl/theme.sh run path/to/theme.toml --check # writes nothing; exit 1 if missing or stale
```
Per page, always in this order: **scaffold** (only if the file does not exist —
an existing page is never overwritten), **bake**, **contract**.
**The contract is what goes to the LLM.** `<export>/<name>.md` opens with a table:
the page's absolute path, the run file, **the exact rebuild command**, the parts
the page already has, and the parts still to add. Then the numbered steps, the
parts in full, the page as it stands with the generated blocks removed, and every
context file inlined verbatim so the field names come from the real schema. It
names one rebuild command and no other — a document with two is a document that
makes the reader pick.
The loop, as tested outside this repo: run → hand over the contract → the page is
edited → run the command copied from the contract's table → `parts to ADD` reads
`(none)` → the same command with `--check` exits 0.
Rules, the same as docgen's run file:
- **Paths are relative to the run file**, not to where the command runs.
- **A page's value replaces the default**, for every key.
- **Unknown keys are refused**, and every problem is reported in one pass: a
misspelt key, an unknown part, a missing context file, a page without the
anchor, a non-`.html` page, a duplicate name, two pages writing one contract.
- An existing page without `<!-- theme:here -->` is refused, not rewritten. Add
the anchor on purpose.
**Keep the real run file beside the client's code, never in spr** — it names the
client's paths. `common/theme/theme.example.toml` is the committed one; it runs
against pages in this tree and scaffolds one under `common/theme/out/`, which is
gitignored and which `make theme check` does not walk.
## The rules — break these and the page stops being standalone
1. **One file.** No bundler, no npm, no build step, no framework.

View File

@@ -0,0 +1,259 @@
"""
A run file: every ad-hoc page a project has, and what each one is about.
python3 common/theme/bake.py --run theme.toml
python3 common/theme/bake.py --run theme.toml --only orders --check
python3 common/theme/bake.py --run theme.toml --list # resolved, nothing written
This file only loads and validates. The run itself is in bake.py.
## Why it exists
An LLM asked to build a page against real code and a real database has to know
three things that are not in the code: where the page goes, which files it is
about, and the command to rebuild it — in the right order. Left to guess, it
guesses a different path each time. Written down once, every run is the same,
and the contract a run writes states all three, so nothing is left to infer.
Same shape as docgen's run file (atlas2/docgen/book/config.py), on purpose: one
convention for "rebuild these, with these settings", not two.
## The shape
# theme.toml — beside the project it describes; paths relative to THIS file
[defaults]
export = "out/contracts" # each page's contract: <export>/<name>.md
[[page]]
name = "orders"
page = "src/orders/ui/index.html" # made from the scaffold if missing
title = "Orders" # used only when scaffolding
parts = ["feed", "params"] # features to ADD — see below
context = [ # what the page is about, inlined
"src/orders/api/routes.py",
"schema/orders.json",
]
**`parts` names features to add, not what the page uses.** What a page uses is
read from its markup at bake time and never listed. `parts` is the other half:
what the next edit should bring in, so the contract carries those parts in full
and says which are still missing.
**`context` is the code and the data the page is for** — route handlers, a
modelgen schema JSON, an OpenAPI document. They are inlined into the contract
verbatim, so the model sees the real field names instead of inventing some.
## Three rules, the same three as docgen's
**Paths are relative to the run file**, not to where the command runs. The same
file must build the same pages from any directory.
**A page's value replaces the default, for every key.** Including `parts` and
`context`. One rule nobody has to remember.
**Unknown keys are refused, not ignored**, and every problem is listed at once
rather than the first. `contxt = [...]` silently doing nothing is a contract
that quietly lost its schema.
## Where it lives
Beside the client project, never in spr. A run file names a client's paths and
files, and those do not get committed here. `common/theme/.gitignore` ignores
`theme.toml` for the case where one is kept here anyway; `theme.example.toml`
is the committed one, and it runs against pages in this tree.
"""
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
tomllib = None
TOP_KEYS = {"defaults", "page"}
DEFAULT_KEYS = {"export", "parts", "context"}
PAGE_KEYS = {"name", "page", "title", "parts", "context", "export"}
# A page name becomes a file name, so it is held to what is safe as one.
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
class ConfigError(ValueError):
"""A run file that cannot be run. Carries every problem, not the first."""
def __init__(self, path, problems: list[str]):
self.path, self.problems = path, problems
super().__init__(f"{path}: {len(problems)} problem(s)\n " + "\n ".join(problems))
@dataclass
class Page:
"""One page, fully resolved — nothing relative, nothing defaulted later."""
name: str
page: Path
export: Path
title: str
parts: tuple = ()
context: tuple = ()
def line(self) -> str:
state = "exists " if self.page.exists() else "MISSING"
return f"{self.name:<16} {state} {self.page} -> {self.export}"
@dataclass
class RunFile:
path: Path
pages: list[Page] = field(default_factory=list)
def select(self, names) -> list[Page]:
"""The named pages, in run-file order. An unknown name is an error."""
if not names:
return list(self.pages)
known = {p.name for p in self.pages}
unknown = [n for n in names if n not in known]
if unknown:
raise ConfigError(self.path, [
f"no page named {n!r} — have: {', '.join(sorted(known))}" for n in unknown
])
wanted = set(names)
return [p for p in self.pages if p.name in wanted]
def _path(value, base: Path) -> Path:
# Normalised lexically, not resolved: `--list` should show the path the way
# its owner writes it, symlinks included.
p = Path(str(value)).expanduser()
return Path(os.path.normpath(p if p.is_absolute() else base / p))
def _strings(value) -> bool:
return isinstance(value, list) and all(isinstance(x, str) for x in value)
def load(path, known_parts, anchors) -> RunFile:
"""Read and resolve a run file. Raises ConfigError listing every problem.
`known_parts` is the part catalogue and `anchors` the markers that make a
page bakeable. Both are passed in rather than imported so that this module
stays a reader of files, with nothing to know about how a page is baked.
"""
path = Path(path)
if tomllib is None:
raise ConfigError(path, ["run files need Python 3.11+ (tomllib)"])
try:
data = tomllib.loads(path.read_text())
except OSError as e:
raise ConfigError(path, [f"cannot read: {e}"]) from None
except tomllib.TOMLDecodeError as e:
raise ConfigError(path, [f"not valid TOML: {e}"]) from None
base = path.resolve().parent
problems: list[str] = []
for key in sorted(set(data) - TOP_KEYS):
problems.append(f"unknown top-level key {key!r} — have: {', '.join(sorted(TOP_KEYS))}")
defaults = data.get("defaults") or {}
if not isinstance(defaults, dict):
problems.append("[defaults] must be a table")
defaults = {}
for key in sorted(set(defaults) - DEFAULT_KEYS):
problems.append(f"[defaults] has unknown key {key!r} — have: "
f"{', '.join(sorted(DEFAULT_KEYS))}")
raw_pages = data.get("page") or []
if not isinstance(raw_pages, list) or not raw_pages:
problems.append("no pages — add at least one [[page]] table")
raw_pages = []
pages: list[Page] = []
for i, raw in enumerate(raw_pages):
where = f"page[{i}]"
if not isinstance(raw, dict):
problems.append(f"{where} must be a table")
continue
name = raw.get("name")
if isinstance(name, str):
where = f"page {name!r}"
if not isinstance(name, str) or not NAME.match(name):
problems.append(f"{where} needs a name of letters, digits, '.', '_' or '-'")
continue
for key in sorted(set(raw) - PAGE_KEYS):
problems.append(f"{where} has unknown key {key!r} — have: "
f"{', '.join(sorted(PAGE_KEYS))}")
def pick(key, default=None):
# A page's value replaces the default, for every key.
return raw[key] if key in raw else defaults.get(key, default)
if not isinstance(raw.get("page"), str):
problems.append(f"{where} needs `page`, the path of its .html file")
continue
page = _path(raw["page"], base)
if page.suffix != ".html":
problems.append(f"{where}: page {page} is not an .html file")
elif page.exists():
text = page.read_text(errors="replace")
if not any(a in text for a in anchors):
# The page exists but was not made for this. Refused rather than
# rewritten: bake would otherwise have nowhere to put anything,
# and the fix is one comment the owner should add on purpose.
problems.append(f"{where}: {page} has no <!-- theme:here --> anchor "
f"— add it to <head>")
parts = pick("parts", [])
if not _strings(parts):
problems.append(f"{where}: parts must be a list of part names")
parts = []
for p in parts:
if p not in known_parts:
problems.append(f"{where}: unknown part {p!r} — have: "
f"{', '.join(sorted(known_parts))}")
context = pick("context", [])
if not _strings(context):
problems.append(f"{where}: context must be a list of file paths")
context = []
resolved_context = []
for c in context:
cp = _path(c, base)
if not cp.is_file():
problems.append(f"{where}: context {cp} is not a file")
resolved_context.append(cp)
if "export" in raw:
export = _path(raw["export"], base)
else:
export = _path(defaults.get("export", "out/contracts"), base) / f"{name}.md"
title = raw.get("title", name)
if not isinstance(title, str):
problems.append(f"{where}: title must be a string")
title = name
pages.append(Page(name=name, page=page, export=export, title=title,
parts=tuple(parts), context=tuple(resolved_context)))
seen_names, seen_pages, seen_exports = set(), {}, {}
for p in pages:
if p.name in seen_names:
problems.append(f"page {p.name!r} is listed twice")
seen_names.add(p.name)
for key, seen, what in ((p.page, seen_pages, "page"), (p.export, seen_exports, "export")):
k = key.resolve()
if k in seen:
# Two entries writing one file: the second overwrites the first on
# every run, and nothing says so.
problems.append(f"pages {seen[k]!r} and {p.name!r} share one {what}: {key}")
seen[k] = p.name
if problems:
raise ConfigError(path, problems)
return RunFile(path=path, pages=pages)

View File

@@ -0,0 +1,45 @@
# A run file: every ad-hoc page a project has, and what each one is about.
#
# ./ctrl/theme.sh run soleprint/common/theme/theme.example.toml
# ./ctrl/theme.sh run <file> --list # resolved, nothing written
# ./ctrl/theme.sh run <file> --only orders # just one page
# ./ctrl/theme.sh run <file> --check # nothing written; exit 1 if stale
#
# Copy it next to the project it describes and edit the pages — a real one names a
# client's paths, so it lives with the client's code, never committed to spr.
#
# Paths are relative to THIS FILE, not to where the command runs. A page's own
# value replaces the default for every key. Unknown keys are refused.
#
# Per page, always in this order:
# 1. scaffold — only if the page file does not exist
# 2. bake — parts its markup uses go in, parts it dropped come out
# 3. contract — <export>/<name>.md: exact paths, the rebuild command, the parts
# to add, the page as it stands, and every context file inlined.
# That file is what goes to the LLM.
[defaults]
export = "out/contracts" # each page's contract lands at <export>/<name>.md
# An existing page, with the code it is about. `parts` is empty: nothing to add,
# so the contract is for maintaining what is there.
[[page]]
name = "jira"
page = "../../artery/veins/jira/ui/index.html"
context = ["../../artery/veins/jira/api/routes.py"]
# A page that does not exist yet, against a database schema (modelgen's JSON,
# the same file docgen's `schema` book reads). The first run scaffolds it; the
# contract then asks for a live feed and knobs, and inlines the schema so the
# field names come from the real tables.
[[page]]
name = "orders"
title = "Orders"
page = "out/scratch/orders/index.html"
parts = ["feed", "params", "split"]
context = ["../../atlas2/docgen/fixtures/shop.json"]
# A served page with nothing to add and nothing it is about:
# [[page]]
# name = "mercadopago"
# page = "../../artery/shunts/mercadopago/templates/index.html"