From f7910bf42b749a22788c06a833e86aa462e477a9 Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Wed, 16 Sep 2026 09:38:04 -0300 Subject: [PATCH] ui framework extraction updates --- Makefile | 2 +- ctrl/theme.sh | 24 +- soleprint/common/theme/.gitignore | 7 + soleprint/common/theme/bake.py | 246 +++++++++++++++++--- soleprint/common/theme/parts/README.md | 61 ++++- soleprint/common/theme/runfile.py | 259 ++++++++++++++++++++++ soleprint/common/theme/theme.example.toml | 45 ++++ 7 files changed, 613 insertions(+), 31 deletions(-) create mode 100644 soleprint/common/theme/.gitignore create mode 100644 soleprint/common/theme/runfile.py create mode 100644 soleprint/common/theme/theme.example.toml diff --git a/Makefile b/Makefile index 4f72c58..566fa29 100644 --- a/Makefile +++ b/Makefile @@ -63,7 +63,7 @@ dist: ## compile the plexus UIs to single files [&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 diff --git a/soleprint/common/theme/.gitignore b/soleprint/common/theme/.gitignore new file mode 100644 index 0000000..d5a699e --- /dev/null +++ b/soleprint/common/theme/.gitignore @@ -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 diff --git a/soleprint/common/theme/bake.py b/soleprint/common/theme/bake.py index c1be7cb..453f6f7 100644 --- a/soleprint/common/theme/bake.py +++ b/soleprint/common/theme/bake.py @@ -52,7 +52,9 @@ LINK = '' # 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 = """ %(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 = """ """ +# 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 `` in ``, 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 `` 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 `` 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", + " (`` and ``).", + "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) diff --git a/soleprint/common/theme/parts/README.md b/soleprint/common/theme/parts/README.md index dc04022..24d17d2 100644 --- a/soleprint/common/theme/parts/README.md +++ b/soleprint/common/theme/parts/README.md @@ -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: /.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.** `/.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 `` 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. diff --git a/soleprint/common/theme/runfile.py b/soleprint/common/theme/runfile.py new file mode 100644 index 0000000..4e520c2 --- /dev/null +++ b/soleprint/common/theme/runfile.py @@ -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: /.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 anchor " + f"— add it to ") + + 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) diff --git a/soleprint/common/theme/theme.example.toml b/soleprint/common/theme/theme.example.toml new file mode 100644 index 0000000..638dc24 --- /dev/null +++ b/soleprint/common/theme/theme.example.toml @@ -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 --list # resolved, nothing written +# ./ctrl/theme.sh run --only orders # just one page +# ./ctrl/theme.sh run --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 — /.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 /.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"