- docs/examples/wo-html: library dep — esc(), element builders,
Tailwind-style utility sheet as one static string, page() shell;
self-contained responses, no CDN/JS/build step
- docs/examples/site: the language tutorial served by the language —
9 seeded chapters in a @table (hello/values+bitwise/containers/
classes/optionals+traps/tables/actors/deps/serving), server-rendered
via wo-html, seed-if-empty so WAL restarts keep admin edits
- routes: / index, /ch/:slug (styled 404), /health, POST /admin/ch/
:slug (bearer handler-side — mechanism framework's, policy app's;
form-encoded title/body update by assignment, 302 back)
- chapter code samples use the lexer's \$ escape to show ${...}
literally; .. never straddles newlines (accumulator style)
- gate: scripts/site-accept.sh + just site — TWO file:// dep remotes,
11 checks incl. authed-edit-survives-restart; /health polling, no
boot-race sleep
- README: run + nginx sketch for writeonce.de; CODE-LOGIC beside code
- full battery 13/13 (site gate included)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
123 lines
5.9 KiB
Text
123 lines
5.9 KiB
Text
-- wo-html — server-rendered HTML as plain Text. Three layers, all pure:
|
|
-- esc() HTML-escape untrusted text (the ONLY defense: use it on
|
|
-- everything that did not come from your own code)
|
|
-- el()/... element builders — `el("h1", "text-3xl font-bold", t)`
|
|
-- tw_css() a Tailwind-style utility stylesheet: the same class
|
|
-- names Tailwind popularized, hand-written as one static
|
|
-- sheet, inlined by page() so a page is one self-contained
|
|
-- response — no CDN, no build step, no JS
|
|
--
|
|
-- The language has no varargs and no closures (doctrine), so builders
|
|
-- take exactly (tag, classes, inner) and pages compose by `..` and by
|
|
-- functions returning Text. That constraint is the demo: an HTML layer
|
|
-- in writeonce is ordinary code, not a template dialect.
|
|
|
|
-- HTML-escape: & < > " (the four that matter in text and attributes).
|
|
pub fn esc(t: Text) -> Text {
|
|
let out = "";
|
|
let i = 0;
|
|
let n = len(t);
|
|
while i < n {
|
|
let b = byte_at(t, i);
|
|
if b == 38 { out = out .. "&"; }
|
|
else {
|
|
if b == 60 { out = out .. "<"; }
|
|
else {
|
|
if b == 62 { out = out .. ">"; }
|
|
else {
|
|
if b == 34 { out = out .. """; }
|
|
else { out = out .. substr(t, i, 1); }
|
|
}
|
|
}
|
|
}
|
|
i = i + 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
-- One element. Empty class list = no attribute. The inner text is the
|
|
-- CALLER's business: pass esc(user_text) for data, raw markup for
|
|
-- fragments you built yourself.
|
|
pub fn el(tag: Text, cls: Text, inner: Text) -> Text {
|
|
if cls == "" { return "<${tag}>${inner}</${tag}>"; }
|
|
return "<${tag} class=\"${cls}\">${inner}</${tag}>";
|
|
}
|
|
|
|
-- An anchor: href is attribute context, so it is escaped here.
|
|
pub fn link(href: Text, cls: Text, label: Text) -> Text {
|
|
if cls == "" { return "<a href=\"${esc(href)}\">${label}</a>"; }
|
|
return "<a href=\"${esc(href)}\" class=\"${cls}\">${label}</a>";
|
|
}
|
|
|
|
-- A code block: content is ALWAYS escaped — code samples are exactly the
|
|
-- text that breaks HTML otherwise.
|
|
pub fn code_block(src: Text) -> Text {
|
|
return el("pre", "code-block", el("code", "", esc(src)));
|
|
}
|
|
|
|
pub fn text_input(name: Text, value: Text) -> Text {
|
|
return "<input type=\"text\" name=\"${esc(name)}\" value=\"${esc(value)}\" class=\"field\">";
|
|
}
|
|
|
|
pub fn text_area(name: Text, value: Text, rows: Int) -> Text {
|
|
return "<textarea name=\"${esc(name)}\" rows=\"${rows}\" class=\"field\">${esc(value)}</textarea>";
|
|
}
|
|
|
|
pub fn submit_btn(label: Text) -> Text {
|
|
return "<button type=\"submit\" class=\"btn\">${esc(label)}</button>";
|
|
}
|
|
|
|
pub fn form_post(action: Text, inner: Text) -> Text {
|
|
return "<form method=\"POST\" action=\"${esc(action)}\" class=\"flex flex-col gap-2\">${inner}</form>";
|
|
}
|
|
|
|
-- The utility sheet. Tailwind's names, one hand-written static sheet —
|
|
-- only the utilities this ecosystem's pages actually use; growing it is
|
|
-- adding a line, not adopting a toolchain. `.code-block`, `.field` and
|
|
-- `.btn` are the three composites the builders above rely on.
|
|
pub fn tw_css() -> Text {
|
|
let c = "*{box-sizing:border-box;margin:0;padding:0}";
|
|
c = c .. "body{font-family:system-ui,sans-serif;background:#f9fafb;color:#111827;line-height:1.6}";
|
|
c = c .. ".mx-auto{margin-left:auto;margin-right:auto}";
|
|
c = c .. ".max-w-3xl{max-width:48rem}";
|
|
c = c .. ".p-4{padding:1rem}.p-6{padding:1.5rem}.px-4{padding-left:1rem;padding-right:1rem}";
|
|
c = c .. ".py-2{padding-top:.5rem;padding-bottom:.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}";
|
|
c = c .. ".mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}";
|
|
c = c .. ".mt-4{margin-top:1rem}.mt-8{margin-top:2rem}";
|
|
c = c .. ".flex{display:flex}.flex-col{flex-direction:column}";
|
|
c = c .. ".items-center{align-items:center}.justify-between{justify-content:space-between}";
|
|
c = c .. ".gap-2{gap:.5rem}.gap-4{gap:1rem}";
|
|
c = c .. ".text-sm{font-size:.875rem}.text-lg{font-size:1.125rem}";
|
|
c = c .. ".text-xl{font-size:1.25rem}.text-2xl{font-size:1.5rem}.text-3xl{font-size:1.875rem}";
|
|
c = c .. ".font-bold{font-weight:700}.font-mono{font-family:ui-monospace,monospace}";
|
|
c = c .. ".text-gray-500{color:#6b7280}.text-gray-700{color:#374151}.text-gray-900{color:#111827}";
|
|
c = c .. ".text-blue-600{color:#2563eb}.text-white{color:#fff}";
|
|
c = c .. ".bg-white{background:#fff}.bg-gray-50{background:#f9fafb}";
|
|
c = c .. ".bg-gray-900{background:#111827}.bg-blue-600{background:#2563eb}";
|
|
c = c .. ".rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}";
|
|
c = c .. ".border{border:1px solid #e5e7eb}.shadow-sm{box-shadow:0 1px 2px rgba(0,0,0,.05)}";
|
|
c = c .. ".block{display:block}.w-full{width:100%}.leading-relaxed{line-height:1.75}";
|
|
c = c .. ".underline{text-decoration:underline}.no-underline{text-decoration:none}";
|
|
c = c .. ".list-disc{list-style:disc}.pl-6{padding-left:1.5rem}";
|
|
c = c .. "a{color:#2563eb;text-decoration:none}a:hover{text-decoration:underline}";
|
|
c = c .. ".code-block{background:#111827;color:#e5e7eb;padding:1rem;border-radius:.5rem;";
|
|
c = c .. "overflow-x:auto;font-size:.875rem;line-height:1.6;margin:1rem 0}";
|
|
c = c .. ".code-block code{font-family:ui-monospace,monospace;white-space:pre}";
|
|
c = c .. ".field{display:block;width:100%;border:1px solid #e5e7eb;border-radius:.25rem;";
|
|
c = c .. "padding:.5rem;font-family:ui-monospace,monospace;font-size:.875rem}";
|
|
c = c .. ".btn{background:#2563eb;color:#fff;border:0;border-radius:.25rem;";
|
|
c = c .. "padding:.5rem 1rem;font-weight:700;cursor:pointer}";
|
|
return c;
|
|
}
|
|
|
|
-- One full document: the sheet inlined, viewport set, body handed in.
|
|
-- Self-contained by construction — view-source shows everything.
|
|
pub fn page(title: Text, body: Text) -> Text {
|
|
let d = "<!doctype html><html><head><meta charset=\"utf-8\">";
|
|
d = d .. "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">";
|
|
d = d .. "<title>${esc(title)}</title>";
|
|
d = d .. "<style>${tw_css()}</style></head><body>";
|
|
d = d .. body;
|
|
d = d .. "</body></html>";
|
|
return d;
|
|
}
|