updates 33.2 112
This commit is contained in:
@@ -9,6 +9,26 @@
|
||||
type="image/svg+xml"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none' stroke='%23b91c1c' stroke-width='2.5'%3E%3Cpath d='M24 4 L24 20 M24 20 L8 40 M24 20 L40 40'/%3E%3Ccircle cx='24' cy='4' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='8' cy='40' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='40' cy='40' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='24' cy='20' r='5' fill='none'/%3E%3Ccircle cx='24' cy='20' r='2' fill='%23b91c1c'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0d0f;
|
||||
--border: #2e2e38;
|
||||
--border-strong: #3d3d4a;
|
||||
--dim: #555568;
|
||||
--muted: #8888a0;
|
||||
--surface: #16161a;
|
||||
--system-accent: #d4a574;
|
||||
--system-accent-text: #e0b98d;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Artery keeps its own colour under every theme. */
|
||||
:root { --system-accent: #b91c1c; --system-accent-text: #fca5a5; }
|
||||
</style>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -374,27 +394,7 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0d0f;
|
||||
--border: #2e2e38;
|
||||
--border-strong: #3d3d4a;
|
||||
--dim: #555568;
|
||||
--muted: #8888a0;
|
||||
--surface: #16161a;
|
||||
--system-accent: #d4a574;
|
||||
--system-accent-text: #e0b98d;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Artery keeps its own colour under every theme. */
|
||||
:root { --system-accent: #b91c1c; --system-accent-text: #fca5a5; }
|
||||
</style>
|
||||
</head>
|
||||
</head>
|
||||
<body>
|
||||
<header style="position: relative">
|
||||
<!-- Flux capacitor -->
|
||||
|
||||
65
soleprint/artery/plexuses/README.md
Normal file
65
soleprint/artery/plexuses/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Plexuses
|
||||
|
||||
A plexus is a full app — and, unlike everything else here, one that is
|
||||
**exported rather than served**. It compiles to a distributable file the way
|
||||
vite builds for production.
|
||||
|
||||
```bash
|
||||
make dist # standalone
|
||||
make dist sample # a named room
|
||||
```
|
||||
|
||||
Output: `gen/<room>/plexuses/<name>/index.html`, one self-contained file with its
|
||||
theme, its data and its diagrams inlined.
|
||||
|
||||
## The constraint
|
||||
|
||||
It has to open from a double-clicked file on a machine with no egress — the
|
||||
state a regulated Windows box is usually in. That rules out three things a
|
||||
normal web app does, and every design decision here follows from it:
|
||||
|
||||
| Ruled out | Because |
|
||||
| --- | --- |
|
||||
| `fetch("bundle.json")` | `file://` treats every sibling file as cross-origin |
|
||||
| `<link href="/theme.css">` | an absolute path assumes a server at the root |
|
||||
| a webfont `@import` | a blocked stylesheet is a stall, not a fallback |
|
||||
|
||||
The acceptance test is opening the output with the network off and seeing zero
|
||||
failed requests. Everything else is cosmetic.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
<name>/
|
||||
plexus.json identity, theme, the data the page renders
|
||||
app/index.html the template
|
||||
```
|
||||
|
||||
`build.py::build_plexuses()` fills the placeholders and writes one file:
|
||||
|
||||
| Placeholder | Becomes |
|
||||
| --- | --- |
|
||||
| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has somewhere to go |
|
||||
| `%%BUNDLE%%` | `plexus.json` as a JS object literal |
|
||||
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, prolog stripped, inlined |
|
||||
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
|
||||
|
||||
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
|
||||
|
||||
A room declares which ones it wants in `cfg/<room>/data/plexuses.json`, and may
|
||||
override anything the manifest sets — most usefully `theme`.
|
||||
|
||||
## Why the diagram is inlined
|
||||
|
||||
`<img src="…svg">` makes the SVG a separate document that the page's stylesheet
|
||||
cannot reach. Inlined, it is part of the DOM — and graphviz writes
|
||||
`class="node accent"` into the markup, so the page's CSS recolours it when the
|
||||
theme switches. One diagram, three themes, no re-render.
|
||||
|
||||
That is also the demonstration: switch to `lucid` and both the page and the
|
||||
diagram become something you could put in a document.
|
||||
|
||||
## Adding one
|
||||
|
||||
Drop a directory in beside `bundle/`. Nothing dispatches on the name — the room's
|
||||
`plexuses.json` is the only place it has to be mentioned.
|
||||
269
soleprint/artery/plexuses/bundle/app/index.html
Normal file
269
soleprint/artery/plexuses/bundle/app/index.html
Normal file
@@ -0,0 +1,269 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Bundle plexus — the template. build.py inlines everything marked %%…%% and
|
||||
writes one self-contained file to gen/<room>/plexuses/bundle/index.html.
|
||||
|
||||
What this page is FOR: showing the three themes doing real work. Every panel
|
||||
renders genuine output — showcase.py runs modelgen, datagen and shuntgen over
|
||||
the fixtures at build time — so the page is simultaneously a demo of the tools
|
||||
and a specimen of the theme. A list of tool names would prove neither.
|
||||
|
||||
Three rules, all consequences of "must open from a double-clicked file on a
|
||||
machine with no egress":
|
||||
no fetch() file:// treats every sibling file as cross-origin
|
||||
no /theme.css an absolute path assumes a server at the root
|
||||
no webfont a blocked stylesheet is a stall, not a fallback
|
||||
-->
|
||||
<html lang="en" data-theme="%%DEFAULT_THEME%%">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>%%TITLE%%</title>
|
||||
<style>
|
||||
%%THEME_CSS%%
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: var(--space-6); }
|
||||
main { max-width: 64rem; margin: 0 auto; }
|
||||
|
||||
header { display: flex; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap;
|
||||
padding-bottom: var(--space-4); border-bottom: var(--hairline) solid var(--border); }
|
||||
h1 { margin: 0; font-size: 1.5rem; }
|
||||
h1 .ok { color: var(--status-ok); }
|
||||
.sub { color: var(--muted); margin: .35rem 0 0; max-width: 46rem; }
|
||||
.built { color: var(--dim); font-family: var(--font-mono); font-size: 11px; margin: .5rem 0 0; }
|
||||
|
||||
h2 { font-size: .75rem; text-transform: uppercase; letter-spacing: .1em;
|
||||
color: var(--muted); margin: var(--space-8) 0 var(--space-2); font-weight: 600; }
|
||||
h2 .n { color: var(--dim); font-weight: 400; letter-spacing: 0; text-transform: none; }
|
||||
.lede { color: var(--muted); margin: 0 0 var(--space-3); font-size: .9rem; max-width: 46rem; }
|
||||
|
||||
.panel { background: var(--surface); border: var(--hairline) solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: var(--space-4); }
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); align-items: start; }
|
||||
@media (max-width: 820px) { .split { grid-template-columns: 1fr; } }
|
||||
|
||||
pre { margin: 0; background: var(--bg); border: var(--hairline) solid var(--border);
|
||||
border-radius: var(--radius); padding: var(--space-3); overflow-x: auto;
|
||||
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55; color: var(--text); }
|
||||
.cap { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: var(--dim); margin-bottom: 6px; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th { text-align: left; padding: 5px 8px; color: var(--muted); font-weight: 600; font-size: 10px;
|
||||
text-transform: uppercase; letter-spacing: .05em; border-bottom: var(--hairline) solid var(--border); }
|
||||
td { padding: 5px 8px; border-bottom: var(--hairline) solid var(--border); font-family: var(--font-mono); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: var(--bg-2); }
|
||||
|
||||
.verb { font-weight: 600; font-size: 10px; padding: 1px 6px; border-radius: var(--radius-sm);
|
||||
border: 1px solid currentColor; }
|
||||
.GET { color: var(--status-info); } .POST { color: var(--status-ok); }
|
||||
.PUT { color: var(--status-warn); } .DELETE { color: var(--status-error); }
|
||||
|
||||
.chip { display: inline-block; font-family: var(--font-mono); font-size: 9.5px;
|
||||
padding: 0 5px; border-radius: var(--radius-sm);
|
||||
border: var(--hairline) solid var(--border); color: var(--dim); }
|
||||
.chip.pk { border-color: var(--accent); color: var(--accent-text); }
|
||||
.chip.fk { border-color: var(--status-ok); color: var(--status-ok); }
|
||||
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: var(--space-3); }
|
||||
.card { background: var(--bg); border: var(--hairline) solid var(--border);
|
||||
border-radius: var(--radius); overflow: hidden; }
|
||||
.card h3 { margin: 0; padding: 6px 10px; font-size: 12px; background: var(--surface-raised);
|
||||
border-bottom: var(--hairline) solid var(--border); font-family: var(--font-mono); }
|
||||
.card .row { display: flex; gap: 6px; align-items: baseline; padding: 3px 10px; font-size: 11px;
|
||||
font-family: var(--font-mono); }
|
||||
.card .row .f { flex: 1; }
|
||||
.card .row .t { color: var(--dim); font-size: 10px; }
|
||||
|
||||
/* Swatches: the palette, read back from the live page rather than written out,
|
||||
so what you see is what the theme actually resolved to. */
|
||||
.swatches { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 8px; }
|
||||
.sw { border: var(--hairline) solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||
.sw .chipc { height: 34px; }
|
||||
.sw .meta { padding: 4px 7px; font-family: var(--font-mono); font-size: 9.5px; }
|
||||
.sw .meta b { display: block; color: var(--text); font-weight: 600; }
|
||||
.sw .meta span { color: var(--dim); }
|
||||
|
||||
.themes { display: flex; margin-left: auto; }
|
||||
.themes button { font-family: var(--font-mono); font-size: 10px; padding: 5px 11px;
|
||||
text-transform: uppercase; letter-spacing: .06em; border-radius: 0; }
|
||||
.themes button:first-child { border-radius: var(--radius) 0 0 var(--radius); }
|
||||
.themes button:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
|
||||
.themes button + button { border-left: none; }
|
||||
.themes button[aria-pressed="true"] { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
||||
|
||||
.flow { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
|
||||
font-family: var(--font-mono); font-size: 12px; margin-bottom: var(--space-3); }
|
||||
.flow .box { border: var(--hairline) solid var(--border); border-radius: var(--radius);
|
||||
padding: 5px 10px; background: var(--bg); }
|
||||
.flow .arrow { color: var(--accent); }
|
||||
.flow .box b { color: var(--accent-text); }
|
||||
|
||||
.diagram { overflow-x: auto; border: var(--hairline) solid var(--border);
|
||||
border-radius: var(--radius); background: var(--bg); padding: var(--space-3); }
|
||||
.diagram svg { max-width: 100%; height: auto; display: block; margin: 0 auto; }
|
||||
.diagram .graph > polygon { fill: var(--bg); }
|
||||
.diagram .node ellipse, .diagram .node polygon, .diagram .node path { fill: var(--surface); stroke: var(--border); }
|
||||
.diagram .node text { fill: var(--text); }
|
||||
.diagram .edge path { stroke: var(--dim); }
|
||||
.diagram .edge polygon { fill: var(--dim); stroke: var(--dim); }
|
||||
.diagram .edge text { fill: var(--muted); }
|
||||
.diagram .cluster polygon, .diagram .cluster path { fill: var(--bg-2); stroke: var(--border); }
|
||||
.diagram .cluster text { fill: var(--muted); }
|
||||
.diagram .node.accent ellipse, .diagram .node.accent polygon, .diagram .node.accent path,
|
||||
.diagram .node.ok ellipse, .diagram .node.ok polygon, .diagram .node.ok path { stroke: var(--accent); stroke-width: 1.5; }
|
||||
.diagram .node.accent-text text { fill: var(--accent-text); }
|
||||
.diagram .node.ok text { fill: var(--status-ok); }
|
||||
.diagram .cluster.artery polygon, .diagram .cluster.artery path { stroke: var(--sys-artery); }
|
||||
.diagram .cluster.atlas polygon, .diagram .cluster.atlas path { stroke: var(--sys-atlas); }
|
||||
.diagram .cluster.station polygon, .diagram .cluster.station path { stroke: var(--sys-station); }
|
||||
.diagram .cluster.artery text { fill: var(--sys-artery); }
|
||||
.diagram .cluster.atlas text { fill: var(--sys-atlas); }
|
||||
.diagram .cluster.station text { fill: var(--sys-station); }
|
||||
.diagram .edge.artery path, .diagram .edge.artery polygon { stroke: var(--sys-artery); fill: var(--sys-artery); }
|
||||
.diagram .edge.atlas path, .diagram .edge.atlas polygon { stroke: var(--sys-atlas); fill: var(--sys-atlas); }
|
||||
.diagram .edge.station path, .diagram .edge.station polygon { stroke: var(--sys-station);fill: var(--sys-station); }
|
||||
|
||||
:root { --sys-artery: #b91c1c; --sys-atlas: #15803d; --sys-station: #1d4ed8; }
|
||||
[data-theme="lucid"] { --sys-artery: #c0392b; --sys-atlas: #1a7f45; --sys-station: #2b5fd9; }
|
||||
[data-theme="mcrn"] { --sys-artery: #c0392b; --sys-atlas: #2ecc71; --sys-station: #5dade2; }
|
||||
|
||||
@media print { .themes { display: none; } .panel, .card { break-inside: avoid; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div>
|
||||
<h1><span class="ok">IT WORKS</span> — %%TITLE%%</h1>
|
||||
<p class="sub">%%DESCRIPTION%%</p>
|
||||
<p class="built">%%BUILT%%</p>
|
||||
</div>
|
||||
<div class="themes" id="themes" role="group" aria-label="Theme"></div>
|
||||
</header>
|
||||
|
||||
<div id="body"></div>
|
||||
|
||||
<h2>Architecture</h2>
|
||||
<p class="lede">Rendered from <code>docs/graphs/*.dot</code> by the same palette
|
||||
as this page. Inlined rather than linked, so it follows the theme switch.</p>
|
||||
<div class="diagram">%%GRAPH%%</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
var BUNDLE = %%BUNDLE%%;
|
||||
var THEMES = ["lucid", "soleprint", "mcrn"];
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
function el(html) { return html; }
|
||||
|
||||
var S = BUNDLE.showcase || {};
|
||||
var out = [];
|
||||
|
||||
/* ── the headline: spreadsheets in, a working service out ───────────────── */
|
||||
if (S.tabular) {
|
||||
var t = S.tabular;
|
||||
out.push('<h2>Spreadsheets to a typed API <span class="n">— modelgen · datagen · shuntgen</span></h2>');
|
||||
out.push('<p class="lede">Nobody wrote a model. These are the real files in ' +
|
||||
'<code>fixtures/sheets/</code>, and everything below was generated from them at build time.</p>');
|
||||
out.push('<div class="flow">' +
|
||||
'<span class="box"><b>' + t.counts.files + '</b> files · <b>' + t.counts.rows + '</b> rows</span>' +
|
||||
'<span class="arrow">→</span><span class="box"><b>' + t.counts.models + '</b> models</span>' +
|
||||
'<span class="arrow">→</span><span class="box"><b>' +
|
||||
(S.shunt ? S.shunt.count : 0) + '</b> routes</span></div>');
|
||||
|
||||
var sample = (t.inputs.filter(function (i) { return i.sample; })[0]) || null;
|
||||
out.push('<div class="panel"><div class="split">' +
|
||||
'<div><div class="cap">in — ' + esc(sample ? sample.name : "sheet") + '</div><pre>' +
|
||||
esc(sample ? sample.sample : "") + '</pre></div>' +
|
||||
'<div><div class="cap">out — models.py</div><pre>' + esc(t.pydantic) + '</pre></div>' +
|
||||
'</div></div>');
|
||||
|
||||
if (t.models && t.models.length) {
|
||||
out.push('<h2>Inferred schema <span class="n">— types, keys and relations, from the data</span></h2>');
|
||||
out.push('<div class="cards">' + t.models.map(function (m) {
|
||||
return '<div class="card"><h3>' + esc(m.name) + '</h3>' + m.fields.map(function (f) {
|
||||
var kind = f.pk ? '<span class="chip pk">pk</span>'
|
||||
: (String(f.type).indexOf("FK:") === 0 ? '<span class="chip fk">fk</span>' : "");
|
||||
return '<div class="row"><span class="f">' + esc(f.name) + "</span>" +
|
||||
'<span class="t">' + esc(f.type) + "</span>" + kind + "</div>";
|
||||
}).join("") + "</div>";
|
||||
}).join("") + "</div>");
|
||||
}
|
||||
|
||||
if (t.records && t.records.length) {
|
||||
out.push('<h2>Generated records <span class="n">— datagen, sampling the imported rows</span></h2>');
|
||||
out.push('<div class="panel"><pre>' + esc(JSON.stringify(t.records, null, 2)) + "</pre></div>");
|
||||
}
|
||||
}
|
||||
|
||||
if (S.shunt) {
|
||||
out.push('<h2>The service it becomes <span class="n">— shuntgen</span></h2>');
|
||||
out.push('<p class="lede">Five routes per table, backed by the real rows. ' +
|
||||
'<code>python run.py</code> and it answers.</p>');
|
||||
out.push('<div class="panel"><div class="split">' +
|
||||
'<div><div class="cap">routes</div><table><tbody>' +
|
||||
S.shunt.routes.slice(0, 10).map(function (r) {
|
||||
return "<tr><td><span class=\"verb " + r.method + '">' + r.method + "</span></td>" +
|
||||
"<td>" + esc(r.path) + "</td><td>" + esc(r.operation) + "</td></tr>";
|
||||
}).join("") + "</tbody></table></div>" +
|
||||
'<div><div class="cap">GET /customers</div><pre>' +
|
||||
esc(JSON.stringify(S.shunt.response, null, 2)) + "</pre></div></div></div>");
|
||||
}
|
||||
|
||||
if (S.cabinet) {
|
||||
out.push('<h2>Dependencies it declares <span class="n">— cabinets</span></h2>');
|
||||
out.push('<p class="lede">A room asks for <code>' + esc(S.cabinet.name) +
|
||||
'</code> once. This is merged into its compose file on a laptop; the same name ' +
|
||||
'installs as a rig addon in a cluster.</p>');
|
||||
out.push('<div class="panel"><pre>' + esc(S.cabinet.fragment) + "</pre></div>");
|
||||
}
|
||||
|
||||
/* ── the palette, read back from the live page ──────────────────────────── */
|
||||
var TOKENS = ["--bg", "--bg-2", "--surface", "--surface-raised", "--border",
|
||||
"--text", "--muted", "--dim",
|
||||
"--accent", "--accent-text", "--status-ok", "--status-info",
|
||||
"--status-warn", "--status-error"];
|
||||
|
||||
out.push('<h2>The theme itself <span class="n">— read back from this page, live</span></h2>');
|
||||
out.push('<p class="lede">Switch above and every panel, table, badge and the diagram follow. ' +
|
||||
'Nothing here is fetched: the whole thing is one file.</p>');
|
||||
out.push('<div class="panel"><div class="swatches" id="swatches"></div></div>');
|
||||
|
||||
document.getElementById("body").innerHTML = out.join("");
|
||||
|
||||
function paintSwatches() {
|
||||
var cs = getComputedStyle(document.documentElement);
|
||||
document.getElementById("swatches").innerHTML = TOKENS.map(function (name) {
|
||||
var v = cs.getPropertyValue(name).trim();
|
||||
return '<div class="sw"><div class="chipc" style="background:' + v + '"></div>' +
|
||||
'<div class="meta"><b>' + name + "</b><span>" + (v || "—") + "</span></div></div>";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
var root = document.documentElement;
|
||||
function apply(theme) {
|
||||
root.setAttribute("data-theme", theme);
|
||||
try { localStorage.setItem("spr-bundle-theme", theme); } catch (e) { /* file:// */ }
|
||||
var bs = document.querySelectorAll("#themes button");
|
||||
for (var i = 0; i < bs.length; i++) {
|
||||
bs[i].setAttribute("aria-pressed", bs[i].dataset.theme === theme ? "true" : "false");
|
||||
}
|
||||
paintSwatches();
|
||||
}
|
||||
document.getElementById("themes").innerHTML = THEMES.map(function (t) {
|
||||
return '<button type="button" data-theme="' + t + '">' + t + "</button>";
|
||||
}).join("");
|
||||
document.getElementById("themes").addEventListener("click", function (e) {
|
||||
if (e.target.dataset.theme) apply(e.target.dataset.theme);
|
||||
});
|
||||
var saved = null;
|
||||
try { saved = localStorage.getItem("spr-bundle-theme"); } catch (e) { /* file:// */ }
|
||||
apply(THEMES.indexOf(saved) !== -1 ? saved : root.getAttribute("data-theme"));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
181
soleprint/artery/plexuses/bundle/showcase.py
Normal file
181
soleprint/artery/plexuses/bundle/showcase.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Collect what the tools actually produce, at build time.
|
||||
|
||||
A page that lists tool names ages badly and proves nothing. This runs the real
|
||||
tools over the real fixtures and hands the output to the template, so the
|
||||
showcase cannot drift from the thing it is showcasing — the same reason rig's
|
||||
`make up` applies the artifact it just generated rather than a parallel path.
|
||||
|
||||
Everything here is best-effort: a plexus that fails to build because a demo
|
||||
panel could not be produced would be a bad trade, so each collector returns
|
||||
None on failure and the template renders what it got.
|
||||
|
||||
Called by build.py::build_plexuses when a plexus ships a showcase.py exposing
|
||||
collect().
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
SOLEPRINT = HERE.parents[2]
|
||||
FIXTURES = SOLEPRINT / "station" / "tools" / "shuntgen" / "fixtures"
|
||||
|
||||
|
||||
def _first_lines(path: Path, count: int) -> str:
|
||||
return "\n".join(path.read_text().splitlines()[:count])
|
||||
|
||||
|
||||
def _extract(name: str, block: str, source: str) -> str:
|
||||
"""Pull one class out of generated source, for showing a representative slice."""
|
||||
lines = source.splitlines()
|
||||
try:
|
||||
start = next(i for i, l in enumerate(lines) if l.startswith(f"{block} {name}"))
|
||||
except StopIteration:
|
||||
return source[:400]
|
||||
out = [lines[start]]
|
||||
for line in lines[start + 1:]:
|
||||
if line and not line.startswith((" ", "\t")):
|
||||
break
|
||||
out.append(line)
|
||||
return "\n".join(out).rstrip()
|
||||
|
||||
|
||||
def _tabular(tmp: Path) -> dict | None:
|
||||
"""modelgen + datagen over the sheet fixtures — the headline demo.
|
||||
|
||||
Three spreadsheets become typed models, a graph schema and a generator that
|
||||
samples the real rows. Showing the input beside the output is the whole
|
||||
argument for the tool.
|
||||
"""
|
||||
sheets = FIXTURES / "sheets"
|
||||
if not sheets.is_dir():
|
||||
return None
|
||||
|
||||
sys.path.insert(0, str(SOLEPRINT))
|
||||
try:
|
||||
from station.tools.modelgen.loader.extract.tabular import TabularExtractor
|
||||
from station.tools.modelgen.generator import GENERATORS
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
extractor = TabularExtractor(sheets)
|
||||
models, enums = extractor.extract()
|
||||
datasets = extractor.datasets()
|
||||
|
||||
GENERATORS["pydantic"]().generate((models, enums), tmp / "models.py")
|
||||
GENERATORS["schema"]().generate((models, enums), tmp / "schema.json")
|
||||
GENERATORS["datagen"]().generate((models, enums, datasets), tmp / "gen.py")
|
||||
|
||||
# Import the generated generator and let it produce records, so the JSON on
|
||||
# the page is genuinely what a caller would receive.
|
||||
records = []
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("showcase_gen", tmp / "gen.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
cls = next(
|
||||
v for k, v in vars(module).items()
|
||||
if isinstance(v, type) and k.endswith("Generator") and k != "BaseDataGenerator"
|
||||
)
|
||||
records = cls().generate("Customers", 2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
schema = json.loads((tmp / "schema.json").read_text())
|
||||
|
||||
return {
|
||||
"inputs": [
|
||||
{"name": f.name, "rows": len(next((d.rows for d in datasets
|
||||
if d.source.startswith(f.name)), [])),
|
||||
"sample": _first_lines(f, 4) if f.suffix != ".ods" else None}
|
||||
for f in sorted(sheets.iterdir()) if f.is_file()
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"name": name,
|
||||
"fields": [
|
||||
{"name": fname, "type": fdef.get("type", "?"),
|
||||
"pk": bool(fdef.get("pk")), "nullable": bool(fdef.get("nullable"))}
|
||||
for fname, fdef in body.get("fields", {}).items()
|
||||
],
|
||||
}
|
||||
for name, body in schema.get("models", {}).items()
|
||||
],
|
||||
"pydantic": _extract("Customers", "class", (tmp / "models.py").read_text()),
|
||||
"records": records,
|
||||
"counts": {
|
||||
"files": sum(1 for f in sheets.iterdir() if f.is_file()),
|
||||
"models": len(models),
|
||||
"rows": sum(len(d.rows) for d in datasets),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _shunt(tmp: Path) -> dict | None:
|
||||
"""shuntgen: the same sheets, turned into routes that answer."""
|
||||
sheets = FIXTURES / "sheets"
|
||||
if not sheets.is_dir():
|
||||
return None
|
||||
|
||||
sys.path.insert(0, str(SOLEPRINT))
|
||||
try:
|
||||
from station.tools.modelgen.loader.extract.tabular import TabularExtractor
|
||||
from station.tools.shuntgen.emit import ShuntEmitter
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
extractor = TabularExtractor(sheets)
|
||||
models, enums = extractor.extract()
|
||||
datasets = extractor.datasets()
|
||||
|
||||
emitter = ShuntEmitter(
|
||||
name="books", output=tmp / "shunt", models=models, enums=enums,
|
||||
datasets=datasets, source="sheets", kind="tabular",
|
||||
)
|
||||
collections = emitter._collections()
|
||||
routes = emitter._routes(collections)
|
||||
|
||||
seeded = next((d.rows for d in datasets if d.model == "Customers"), [])
|
||||
return {
|
||||
"routes": [
|
||||
{"method": r["method"], "path": r["path"], "operation": r["operation"]}
|
||||
for r in routes
|
||||
],
|
||||
"count": len(routes),
|
||||
# What GET /customers actually answers with — the imported rows.
|
||||
"response": seeded[:2],
|
||||
}
|
||||
|
||||
|
||||
def _cabinet() -> dict | None:
|
||||
"""A cabinet's compose fragment, as it gets merged into a room."""
|
||||
path = SOLEPRINT / "station" / "cabinets" / "postgres" / "service.yml"
|
||||
if not path.exists():
|
||||
return None
|
||||
body = "\n".join(
|
||||
l for l in path.read_text().splitlines() if not l.strip().startswith("#")
|
||||
).strip()
|
||||
return {"name": "postgres", "fragment": body}
|
||||
|
||||
|
||||
def collect() -> dict:
|
||||
"""Everything the showcase renders. Never raises."""
|
||||
import tempfile
|
||||
|
||||
out: dict = {}
|
||||
with tempfile.TemporaryDirectory(prefix="spr-showcase-") as raw:
|
||||
tmp = Path(raw)
|
||||
for key, fn in (("tabular", lambda: _tabular(tmp)),
|
||||
("shunt", lambda: _shunt(tmp)),
|
||||
("cabinet", lambda: _cabinet())):
|
||||
try:
|
||||
value = fn()
|
||||
except Exception as e: # a demo panel is never worth failing a build
|
||||
print(f" showcase: {key} unavailable ({type(e).__name__}: {e})")
|
||||
value = None
|
||||
if value:
|
||||
out[key] = value
|
||||
return out
|
||||
@@ -9,6 +9,21 @@
|
||||
type="image/svg+xml"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='%2315803d'%3E%3Cpath d='M4 8 C4 8 12 6 24 10 L24 42 C12 38 4 40 4 40 Z' opacity='0.3'/%3E%3Cpath d='M44 8 C44 8 36 6 24 10 L24 42 C36 38 44 40 44 40 Z' opacity='0.5'/%3E%3Cpath d='M4 8 C4 8 12 6 24 10 M44 8 C44 8 36 6 24 10' fill='none' stroke='%2315803d' stroke-width='2'/%3E%3Cpath d='M4 40 C4 40 12 38 24 42 M44 40 C44 40 36 38 24 42' fill='none' stroke='%2315803d' stroke-width='2'/%3E%3Cline x1='24' y1='10' x2='24' y2='42' stroke='%2315803d' stroke-width='2'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0d0f;
|
||||
--muted: #8888a0;
|
||||
--system-accent-text: #e0b98d;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Atlas keeps its own colour under every theme. */
|
||||
:root { --system-accent: #15803d; --system-accent-text: #86efac; }
|
||||
</style>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -138,22 +153,7 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0d0f;
|
||||
--muted: #8888a0;
|
||||
--system-accent-text: #e0b98d;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Atlas keeps its own colour under every theme. */
|
||||
:root { --system-accent: #15803d; --system-accent-text: #86efac; }
|
||||
</style>
|
||||
</head>
|
||||
</head>
|
||||
<body>
|
||||
<header style="position: relative">
|
||||
<!-- Open book -->
|
||||
|
||||
@@ -9,6 +9,21 @@
|
||||
type="image/svg+xml"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' fill='%23e5e5e5'%3E%3Cg transform='rotate(-15 18 38)'%3E%3Cellipse cx='18' cy='32' rx='7' ry='13'/%3E%3Cellipse cx='18' cy='48' rx='6' ry='7'/%3E%3C/g%3E%3Cg transform='rotate(15 46 28)'%3E%3Cellipse cx='46' cy='22' rx='7' ry='13'/%3E%3Cellipse cx='46' cy='38' rx='6' ry='7'/%3E%3C/g%3E%3C/svg%3E"
|
||||
/>
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--accent: #d4a574;
|
||||
--accent-dim: #b8956a;
|
||||
--bg: #0d0d0f;
|
||||
--border: #2e2e38;
|
||||
--dim: #555568;
|
||||
--muted: #8888a0;
|
||||
--surface: #16161a;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -249,22 +264,7 @@
|
||||
<link rel="stylesheet" href="/sidebar.css">
|
||||
<script src="/sidebar.js"></script>
|
||||
{% endif %}
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--accent: #d4a574;
|
||||
--accent-dim: #b8956a;
|
||||
--bg: #0d0d0f;
|
||||
--border: #2e2e38;
|
||||
--dim: #555568;
|
||||
--muted: #8888a0;
|
||||
--surface: #16161a;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
</head>
|
||||
</head>
|
||||
<body{% if managed %} class="has-sidebar"{% endif %}>
|
||||
|
||||
<header>
|
||||
|
||||
@@ -9,6 +9,25 @@
|
||||
type="image/svg+xml"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='%231d4ed8'%3E%3Crect x='4' y='8' width='40' height='28' rx='3' fill='%231d4ed8'/%3E%3Crect x='8' y='12' width='32' height='20' rx='2' fill='%230a0a0a'/%3E%3Crect x='16' y='36' width='16' height='4' fill='%231d4ed8'/%3E%3Crect x='12' y='40' width='24' height='3' rx='1' fill='%231d4ed8'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0d0f;
|
||||
--border: #2e2e38;
|
||||
--border-strong: #3d3d4a;
|
||||
--muted: #8888a0;
|
||||
--surface: #16161a;
|
||||
--system-accent: #d4a574;
|
||||
--system-accent-text: #e0b98d;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Station keeps its own colour under every theme. */
|
||||
:root { --system-accent: #1d4ed8; --system-accent-text: #93c5fd; }
|
||||
</style>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -157,26 +176,7 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0d0f;
|
||||
--border: #2e2e38;
|
||||
--border-strong: #3d3d4a;
|
||||
--muted: #8888a0;
|
||||
--surface: #16161a;
|
||||
--system-accent: #d4a574;
|
||||
--system-accent-text: #e0b98d;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Station keeps its own colour under every theme. */
|
||||
:root { --system-accent: #1d4ed8; --system-accent-text: #93c5fd; }
|
||||
</style>
|
||||
</head>
|
||||
</head>
|
||||
<body>
|
||||
<header style="position: relative">
|
||||
<!-- Control station / monitor -->
|
||||
|
||||
Reference in New Issue
Block a user