Merge branch 'ui'
This commit is contained in:
2
Makefile
2
Makefile
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
7
soleprint/common/theme/.gitignore
vendored
Normal 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
|
||||
@@ -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,10 +534,13 @@ 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.",
|
||||
"",
|
||||
]
|
||||
if rebuild is None:
|
||||
out += [
|
||||
"## How a page is built",
|
||||
"",
|
||||
"Scaffold first, features after. `./ctrl/theme.sh new \"Title\"` gives a page with",
|
||||
@@ -522,9 +548,11 @@ def export(parts: dict, values: dict[str, str], wanted: list[str]) -> int:
|
||||
"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.",
|
||||
"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)
|
||||
|
||||
|
||||
print("\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",
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
259
soleprint/common/theme/runfile.py
Normal file
259
soleprint/common/theme/runfile.py
Normal 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)
|
||||
45
soleprint/common/theme/theme.example.toml
Normal file
45
soleprint/common/theme/theme.example.toml
Normal 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"
|
||||
Reference in New Issue
Block a user