MaCH repo
13
00_hello_text/main.c
Normal file
@@ -0,0 +1,13 @@
|
||||
#include <mach.h>
|
||||
|
||||
config mach(){
|
||||
return (config) {
|
||||
.resources = {{
|
||||
{"home", "/", mime_txt,
|
||||
.get = {{
|
||||
render((r){"hello", .mime = mime_txt})
|
||||
}}
|
||||
}
|
||||
}}
|
||||
};
|
||||
}
|
||||
139
01-multi-reactor-architecture.svg
Normal file
@@ -0,0 +1,139 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 920 500" font-family="system-ui, -apple-system, sans-serif">
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<path d="M0,0 L8,3 L0,6" fill="#64748b"/>
|
||||
</marker>
|
||||
<marker id="arrow-amber" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<path d="M0,0 L8,3 L0,6" fill="#f59e0b"/>
|
||||
</marker>
|
||||
<marker id="arrow-emerald" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<path d="M0,0 L8,3 L0,6" fill="#10b981"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="920" height="500" rx="12" fill="#0f172a"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="460" y="38" text-anchor="middle" fill="#f8fafc" font-size="18" font-weight="600">Multi-Reactor Architecture</text>
|
||||
|
||||
<!-- HTTP Clients -->
|
||||
<rect x="30" y="105" width="120" height="44" rx="8" fill="#1e293b" stroke="#334155" stroke-width="1.5"/>
|
||||
<text x="90" y="132" text-anchor="middle" fill="#94a3b8" font-size="13" font-weight="500">HTTP Clients</text>
|
||||
|
||||
<!-- Arrows from clients to request reactors -->
|
||||
<line x1="150" y1="117" x2="200" y2="117" stroke="#64748b" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<line x1="150" y1="127" x2="200" y2="200" stroke="#64748b" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<line x1="150" y1="137" x2="200" y2="285" stroke="#64748b" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- Request Reactors Group -->
|
||||
<rect x="195" y="72" width="280" height="264" rx="10" fill="none" stroke="#334155" stroke-width="1" stroke-dasharray="6,4"/>
|
||||
<text x="335" y="92" text-anchor="middle" fill="#60a5fa" font-size="12" font-weight="600" letter-spacing="0.5">REQUEST REACTORS</text>
|
||||
|
||||
<!-- Request Reactor 0 -->
|
||||
<rect x="210" y="104" width="250" height="44" rx="8" fill="#1e3a5f" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
<text x="225" y="131" fill="#93c5fd" font-size="11" font-weight="600">Core 0</text>
|
||||
<text x="280" y="131" fill="#cbd5e1" font-size="11">event loop</text>
|
||||
<rect x="370" y="113" width="80" height="26" rx="5" fill="#1e293b" stroke="#475569" stroke-width="1"/>
|
||||
<text x="410" y="131" text-anchor="middle" fill="#94a3b8" font-size="10">pipeline</text>
|
||||
|
||||
<!-- Request Reactor 1 -->
|
||||
<rect x="210" y="162" width="250" height="44" rx="8" fill="#1e3a5f" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
<text x="225" y="189" fill="#93c5fd" font-size="11" font-weight="600">Core 1</text>
|
||||
<text x="280" y="189" fill="#cbd5e1" font-size="11">event loop</text>
|
||||
<rect x="370" y="171" width="80" height="26" rx="5" fill="#1e293b" stroke="#475569" stroke-width="1"/>
|
||||
<text x="410" y="189" text-anchor="middle" fill="#94a3b8" font-size="10">pipeline</text>
|
||||
|
||||
<!-- Ellipsis dots -->
|
||||
<circle cx="335" cy="224" r="2" fill="#475569"/>
|
||||
<circle cx="335" cy="234" r="2" fill="#475569"/>
|
||||
<circle cx="335" cy="244" r="2" fill="#475569"/>
|
||||
|
||||
<!-- Request Reactor N -->
|
||||
<rect x="210" y="258" width="250" height="44" rx="8" fill="#1e3a5f" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
<text x="225" y="285" fill="#93c5fd" font-size="11" font-weight="600">Core N</text>
|
||||
<text x="280" y="285" fill="#cbd5e1" font-size="11">event loop</text>
|
||||
<rect x="370" y="267" width="80" height="26" rx="5" fill="#1e293b" stroke="#475569" stroke-width="1"/>
|
||||
<text x="410" y="285" text-anchor="middle" fill="#94a3b8" font-size="10">pipeline</text>
|
||||
|
||||
<!-- Task Reactor Group -->
|
||||
<rect x="195" y="365" width="280" height="90" rx="10" fill="none" stroke="#334155" stroke-width="1" stroke-dasharray="6,4"/>
|
||||
<text x="335" y="385" text-anchor="middle" fill="#fbbf24" font-size="12" font-weight="600" letter-spacing="0.5">TASK REACTOR</text>
|
||||
|
||||
<rect x="210" y="395" width="250" height="44" rx="8" fill="#422006" stroke="#f59e0b" stroke-width="1.5"/>
|
||||
<text x="225" y="422" fill="#fcd34d" font-size="11" font-weight="600">Task</text>
|
||||
<text x="260" y="422" fill="#cbd5e1" font-size="11">event loop</text>
|
||||
<rect x="370" y="404" width="80" height="26" rx="5" fill="#1e293b" stroke="#475569" stroke-width="1"/>
|
||||
<text x="410" y="422" text-anchor="middle" fill="#94a3b8" font-size="10">cron / jobs</text>
|
||||
|
||||
<!-- Task DB -->
|
||||
<rect x="30" y="395" width="120" height="44" rx="8" fill="#1e293b" stroke="#f59e0b" stroke-width="1.5"/>
|
||||
<text x="90" y="414" text-anchor="middle" fill="#fcd34d" font-size="11" font-weight="600">mach_tasks</text>
|
||||
<text x="90" y="429" text-anchor="middle" fill="#94a3b8" font-size="10">database</text>
|
||||
|
||||
<!-- Arrows: task DB to/from task reactor -->
|
||||
<line x1="150" y1="410" x2="205" y2="412" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
|
||||
<line x1="205" y1="424" x2="150" y2="424" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
|
||||
|
||||
<!-- ==================== SHARED THREAD POOL ==================== -->
|
||||
<rect x="560" y="72" width="330" height="264" rx="10" fill="none" stroke="#334155" stroke-width="1" stroke-dasharray="6,4"/>
|
||||
<text x="725" y="96" text-anchor="middle" fill="#34d399" font-size="12" font-weight="600" letter-spacing="0.5">SHARED THREAD POOL</text>
|
||||
|
||||
<!-- Work queue label above queue image -->
|
||||
<text x="725" y="118" text-anchor="middle" fill="#6b7280" font-size="10">work queue</text>
|
||||
|
||||
<!-- Queue visualization -->
|
||||
<rect x="588" y="126" width="274" height="36" rx="6" fill="#1e293b" stroke="#475569" stroke-width="1"/>
|
||||
<!-- Filled queue slots -->
|
||||
<rect x="596" y="132" width="24" height="24" rx="4" fill="#064e3b" stroke="#10b981" stroke-width="1"/>
|
||||
<rect x="626" y="132" width="24" height="24" rx="4" fill="#064e3b" stroke="#10b981" stroke-width="1"/>
|
||||
<rect x="656" y="132" width="24" height="24" rx="4" fill="#064e3b" stroke="#10b981" stroke-width="1"/>
|
||||
<rect x="686" y="132" width="24" height="24" rx="4" fill="#064e3b" stroke="#10b981" stroke-width="1"/>
|
||||
<!-- Empty queue slots -->
|
||||
<rect x="716" y="132" width="24" height="24" rx="4" fill="#0f172a" stroke="#334155" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<rect x="746" y="132" width="24" height="24" rx="4" fill="#0f172a" stroke="#334155" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<rect x="776" y="132" width="24" height="24" rx="4" fill="#0f172a" stroke="#334155" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<rect x="806" y="132" width="24" height="24" rx="4" fill="#0f172a" stroke="#334155" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<rect x="836" y="132" width="18" height="24" rx="4" fill="#0f172a" stroke="#334155" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
|
||||
<!-- Arrows from queue down to threads -->
|
||||
<line x1="640" y1="162" x2="640" y2="198" stroke="#475569" stroke-width="1" marker-end="url(#arrow)"/>
|
||||
<line x1="740" y1="162" x2="740" y2="198" stroke="#475569" stroke-width="1" marker-end="url(#arrow)"/>
|
||||
<line x1="840" y1="162" x2="840" y2="198" stroke="#475569" stroke-width="1" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- Thread boxes -->
|
||||
<rect x="580" y="202" width="110" height="48" rx="6" fill="#064e3b" stroke="#10b981" stroke-width="1.5"/>
|
||||
<text x="635" y="231" text-anchor="middle" fill="#6ee7b7" font-size="11" font-weight="500">Thread 1</text>
|
||||
|
||||
<rect x="700" y="202" width="80" height="48" rx="6" fill="#064e3b" stroke="#10b981" stroke-width="1.5"/>
|
||||
<text x="740" y="231" text-anchor="middle" fill="#6ee7b7" font-size="11" font-weight="500">Thread 2</text>
|
||||
|
||||
<rect x="790" y="202" width="86" height="48" rx="6" fill="#064e3b" stroke="#10b981" stroke-width="1.5"/>
|
||||
<text x="833" y="231" text-anchor="middle" fill="#6ee7b7" font-size="11" font-weight="500">Thread N</text>
|
||||
|
||||
<!-- Result return -->
|
||||
<text x="725" y="284" text-anchor="middle" fill="#6b7280" font-size="10">on complete, resumes pipeline on reactor</text>
|
||||
|
||||
<!-- ==================== INVOKE ARROWS ==================== -->
|
||||
<!-- Arrow from request reactors to pool -->
|
||||
<path d="M 460,184 L 555,150" fill="none" stroke="#10b981" stroke-width="1.5" marker-end="url(#arrow-emerald)" stroke-dasharray="5,3"/>
|
||||
<!-- Label above arrow -->
|
||||
<text x="492" y="155" fill="#34d399" font-size="10" font-weight="500">invoke()</text>
|
||||
|
||||
<!-- Arrow from task reactor to pool -->
|
||||
<path d="M 460,410 Q 530,380 555,300" fill="none" stroke="#10b981" stroke-width="1.5" marker-end="url(#arrow-emerald)" stroke-dasharray="5,3"/>
|
||||
<!-- Label right of curve -->
|
||||
<text x="538" y="370" fill="#34d399" font-size="10" font-weight="500">invoke()</text>
|
||||
|
||||
<!-- ==================== TASK ARROWS ==================== -->
|
||||
<!-- task() from request reactors to task DB -->
|
||||
<path d="M 210,316 Q 140,350 100,390" fill="none" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#arrow-amber)" stroke-dasharray="5,3"/>
|
||||
<!-- Label left of curve -->
|
||||
<text x="120" y="348" fill="#fbbf24" font-size="10" font-weight="500">task()</text>
|
||||
|
||||
<!-- task() from task reactor to task DB (self-enqueue) -->
|
||||
<path d="M 210,445 Q 160,470 105,443" fill="none" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#arrow-amber)" stroke-dasharray="5,3"/>
|
||||
<!-- Label below curve -->
|
||||
<text x="160" y="478" fill="#fbbf24" font-size="10" font-weight="500">task()</text>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.9 KiB |
19
01_hello_world_text/main.c
Normal file
@@ -0,0 +1,19 @@
|
||||
#include <mach.h>
|
||||
|
||||
config mach(){
|
||||
return (config) {
|
||||
.resources = {{
|
||||
{"home", "/", mime_txt,
|
||||
.get = {{
|
||||
validate((v){
|
||||
.name = "name",
|
||||
.validation = "^\\S{1,16}$",
|
||||
.fallback = "world",
|
||||
.message = "must be 1-16 characters, no spaces"
|
||||
}),
|
||||
render((r){"Hello {{name}}", .mime = mime_txt})
|
||||
}}
|
||||
}
|
||||
}}
|
||||
};
|
||||
}
|
||||
95
02-request-pipeline-flow.svg
Normal file
@@ -0,0 +1,95 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 300" width="760" height="300">
|
||||
<rect width="760" height="300" fill="#09080f"/>
|
||||
<defs>
|
||||
<linearGradient id="sg1" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#2563eb"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sg2" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#8b5cf6"/>
|
||||
<stop offset="100%" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sg3" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#f59e0b"/>
|
||||
<stop offset="100%" stop-color="#d97706"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sg4" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#10b981"/>
|
||||
<stop offset="100%" stop-color="#059669"/>
|
||||
</linearGradient>
|
||||
<style>text{font-family:'JetBrains Mono','Fira Code',monospace}</style>
|
||||
</defs>
|
||||
|
||||
<!-- Outer container -->
|
||||
<rect x="20" y="16" width="720" height="268" rx="14" fill="#0f0d1a" stroke="#1e1b2e" stroke-width="1.5"/>
|
||||
|
||||
<!-- Route label -->
|
||||
<text x="44" y="46" fill="#94a3b8" font-size="12" font-weight="600">POST /todos</text>
|
||||
|
||||
<!-- .before label -->
|
||||
<text x="44" y="72" fill="#475569" font-size="9">.before</text>
|
||||
|
||||
<!-- session() box: x=44 w=108 → right edge 152 -->
|
||||
<rect x="44" y="80" width="108" height="40" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="1"/>
|
||||
<text x="98" y="105" text-anchor="middle" fill="#93c5fd" font-size="11" font-weight="600">session()</text>
|
||||
|
||||
<!-- Arrow 1: line 152→170, triangle tip at 180 -->
|
||||
<line x1="152" y1="100" x2="170" y2="100" stroke="#64748b" stroke-width="1.5"/>
|
||||
<polygon points="180,100 170,95 170,105" fill="#64748b"/>
|
||||
|
||||
<!-- .post pipeline label -->
|
||||
<text x="188" y="72" fill="#475569" font-size="9">.post pipeline</text>
|
||||
|
||||
<!-- param() box: x=188 w=108 → right edge 296 -->
|
||||
<rect x="188" y="80" width="108" height="40" rx="8" fill="url(#sg1)"/>
|
||||
<text x="242" y="98" text-anchor="middle" fill="#fff" font-size="11" font-weight="700">param()</text>
|
||||
<text x="242" y="113" text-anchor="middle" fill="rgba(255,255,255,0.65)" font-size="8">validate title</text>
|
||||
|
||||
<!-- Arrow 2: line 296→314, triangle tip at 324 -->
|
||||
<line x1="296" y1="100" x2="314" y2="100" stroke="#64748b" stroke-width="1.5"/>
|
||||
<polygon points="324,100 314,95 314,105" fill="#64748b"/>
|
||||
|
||||
<!-- db() box: x=324 w=108 → right edge 432 -->
|
||||
<rect x="324" y="80" width="108" height="40" rx="8" fill="url(#sg2)"/>
|
||||
<text x="378" y="98" text-anchor="middle" fill="#fff" font-size="11" font-weight="700">db()</text>
|
||||
<text x="378" y="113" text-anchor="middle" fill="rgba(255,255,255,0.65)" font-size="8">INSERT todo</text>
|
||||
|
||||
<!-- Arrow 3: line 432→450, triangle tip at 460 -->
|
||||
<line x1="432" y1="100" x2="450" y2="100" stroke="#64748b" stroke-width="1.5"/>
|
||||
<polygon points="460,100 450,95 450,105" fill="#64748b"/>
|
||||
|
||||
<!-- emit() box: x=460 w=108 → right edge 568 -->
|
||||
<rect x="460" y="80" width="108" height="40" rx="8" fill="url(#sg3)"/>
|
||||
<text x="514" y="98" text-anchor="middle" fill="#fff" font-size="11" font-weight="700">emit()</text>
|
||||
<text x="514" y="113" text-anchor="middle" fill="rgba(255,255,255,0.65)" font-size="8">todo_created</text>
|
||||
|
||||
<!-- Arrow 4: line 568→586, triangle tip at 596 -->
|
||||
<line x1="568" y1="100" x2="586" y2="100" stroke="#64748b" stroke-width="1.5"/>
|
||||
<polygon points="596,100 586,95 586,105" fill="#64748b"/>
|
||||
|
||||
<!-- redirect() box: x=596 w=108 → right edge 704 -->
|
||||
<rect x="596" y="80" width="108" height="40" rx="8" fill="url(#sg4)"/>
|
||||
<text x="650" y="98" text-anchor="middle" fill="#fff" font-size="11" font-weight="700">redirect()</text>
|
||||
<text x="650" y="113" text-anchor="middle" fill="rgba(255,255,255,0.65)" font-size="8">302 → /todos</text>
|
||||
|
||||
<!-- ===== Pipeline Context ===== -->
|
||||
<rect x="44" y="146" width="672" height="120" rx="10" fill="#111827" stroke="#1f2937" stroke-width="1"/>
|
||||
<text x="64" y="170" fill="#6b7280" font-size="10" font-weight="600">PIPELINE CONTEXT</text>
|
||||
|
||||
<!-- Context cards: each 148w x 52h, gap 12 -->
|
||||
<rect x="60" y="182" width="148" height="52" rx="6" fill="#1e1b2e" stroke="#3b82f6" stroke-width="0.75"/>
|
||||
<text x="134" y="206" text-anchor="middle" fill="#e2e8f0" font-size="11" font-weight="600">user_id</text>
|
||||
<text x="134" y="222" text-anchor="middle" fill="#6b7280" font-size="9">← session</text>
|
||||
|
||||
<rect x="220" y="182" width="148" height="52" rx="6" fill="#1e1b2e" stroke="#3b82f6" stroke-width="0.75"/>
|
||||
<text x="294" y="206" text-anchor="middle" fill="#e2e8f0" font-size="11" font-weight="600">title</text>
|
||||
<text x="294" y="222" text-anchor="middle" fill="#6b7280" font-size="9">← param</text>
|
||||
|
||||
<rect x="380" y="182" width="148" height="52" rx="6" fill="#1e1b2e" stroke="#8b5cf6" stroke-width="0.75"/>
|
||||
<text x="454" y="206" text-anchor="middle" fill="#e2e8f0" font-size="11" font-weight="600">todo</text>
|
||||
<text x="454" y="222" text-anchor="middle" fill="#6b7280" font-size="9">← db .set</text>
|
||||
|
||||
<rect x="540" y="182" width="148" height="52" rx="6" fill="#1e1b2e" stroke="#374151" stroke-width="0.75"/>
|
||||
<text x="614" y="206" text-anchor="middle" fill="#e2e8f0" font-size="11" font-weight="600">base_layout</text>
|
||||
<text x="614" y="222" text-anchor="middle" fill="#6b7280" font-size="9">← .context</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
26
02_hello_world_html/main.c
Normal file
@@ -0,0 +1,26 @@
|
||||
#include <mach.h>
|
||||
|
||||
config mach(){
|
||||
return (config) {
|
||||
.resources = {{
|
||||
{"home", "/",
|
||||
.get = {{
|
||||
validate((v){
|
||||
.name = "name",
|
||||
.validation = "^\\S{1,16}$",
|
||||
.fallback = "world",
|
||||
.message = "must be 1-16 characters, no spaces"
|
||||
}),
|
||||
render((r){
|
||||
"<html>"
|
||||
"<head></head>"
|
||||
"<body>"
|
||||
"<p>Hello {{name}}</p>"
|
||||
"</body>"
|
||||
"</html>"
|
||||
})
|
||||
}}
|
||||
}
|
||||
}}
|
||||
};
|
||||
}
|
||||
66
03-event-pub-sub.svg
Normal file
@@ -0,0 +1,66 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 300" width="900" height="300">
|
||||
<rect width="900" height="300" fill="#09080f"/>
|
||||
<defs>
|
||||
<linearGradient id="pubG" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f59e0b"/>
|
||||
<stop offset="100%" stop-color="#d97706"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="subG" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#06b6d4"/>
|
||||
<stop offset="100%" stop-color="#0891b2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="busG" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#7c3aed"/>
|
||||
<stop offset="50%" stop-color="#a855f7"/>
|
||||
<stop offset="100%" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
<style>text{font-family:'JetBrains Mono','Fira Code',monospace}</style>
|
||||
</defs>
|
||||
|
||||
<!-- ===== Publisher: todos module ===== -->
|
||||
<rect x="30" y="30" width="220" height="130" rx="12" fill="#1e1b2e" stroke="#f59e0b" stroke-width="1.5"/>
|
||||
<rect x="30" y="30" width="220" height="36" rx="12" fill="url(#pubG)"/>
|
||||
<text x="140" y="53" text-anchor="middle" fill="#fff" font-size="12" font-weight="700">todos module</text>
|
||||
<text x="50" y="86" fill="#fcd34d" font-size="10">.post pipeline:</text>
|
||||
<text x="58" y="102" fill="#e2e8f0" font-size="10">param() → db()</text>
|
||||
<text x="58" y="118" fill="#e2e8f0" font-size="10">→ emit("todo_created")</text>
|
||||
<text x="58" y="134" fill="#e2e8f0" font-size="10">→ redirect("/todos")</text>
|
||||
|
||||
<!-- .publishes contract -->
|
||||
<text x="140" y="175" text-anchor="middle" fill="#92400e" font-size="9" font-weight="600">.publishes</text>
|
||||
<rect x="40" y="182" width="200" height="28" rx="6" fill="#451a03" stroke="#f59e0b" stroke-width="0.75"/>
|
||||
<text x="140" y="201" text-anchor="middle" fill="#fcd34d" font-size="9">todo_created → user_id, title</text>
|
||||
|
||||
<!-- ===== Event Bus pill ===== -->
|
||||
<rect x="330" y="118" width="140" height="44" rx="22" fill="url(#busG)"/>
|
||||
<text x="400" y="145" text-anchor="middle" fill="#fff" font-size="11" font-weight="700">Event Bus</text>
|
||||
|
||||
<!-- Arrow: publisher → bus -->
|
||||
<line x1="250" y1="140" x2="320" y2="140" stroke="#f59e0b" stroke-width="2"/>
|
||||
<polygon points="330,140 320,135 320,145" fill="#f59e0b"/>
|
||||
|
||||
<!-- ===== Subscriber 1: activity module ===== -->
|
||||
<rect x="570" y="20" width="300" height="100" rx="12" fill="#1e1b2e" stroke="#06b6d4" stroke-width="1.5"/>
|
||||
<rect x="570" y="20" width="300" height="36" rx="12" fill="url(#subG)"/>
|
||||
<text x="720" y="43" text-anchor="middle" fill="#fff" font-size="12" font-weight="700">activity module</text>
|
||||
<text x="595" y="74" fill="#67e8f9" font-size="10">.events:</text>
|
||||
<text x="603" y="92" fill="#e2e8f0" font-size="9">"todo_created" → db(log.sql)</text>
|
||||
|
||||
<!-- Elbow: bus right → subscriber 1 -->
|
||||
<path d="M470,140 L525,140 L525,70 L560,70" stroke="#06b6d4" stroke-width="1.5" fill="none"/>
|
||||
<polygon points="570,70 560,65 560,75" fill="#06b6d4"/>
|
||||
|
||||
<!-- ===== Subscriber 2: notifications module ===== -->
|
||||
<rect x="570" y="160" width="300" height="100" rx="12" fill="#1e1b2e" stroke="#06b6d4" stroke-width="1.5"/>
|
||||
<rect x="570" y="160" width="300" height="36" rx="12" fill="url(#subG)"/>
|
||||
<text x="720" y="183" text-anchor="middle" fill="#fff" font-size="12" font-weight="700">notifications module</text>
|
||||
<text x="595" y="214" fill="#67e8f9" font-size="10">.events:</text>
|
||||
<text x="603" y="232" fill="#e2e8f0" font-size="9">"todo_created" → call(notify)</text>
|
||||
|
||||
<!-- Elbow: bus right → subscriber 2 -->
|
||||
<path d="M470,140 L525,140 L525,210 L560,210" stroke="#06b6d4" stroke-width="1.5" fill="none"/>
|
||||
<polygon points="570,210 560,205 560,215" fill="#06b6d4"/>
|
||||
|
||||
<!-- Footer -->
|
||||
<text x="450" y="290" text-anchor="middle" fill="#64748b" font-size="10">Modules are decoupled — publishers don't know about subscribers</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
47
03_hello_world_html_db/main.c
Normal file
@@ -0,0 +1,47 @@
|
||||
#include <mach.h>
|
||||
#include <sqlite.h>
|
||||
|
||||
config mach(){
|
||||
return (config) {
|
||||
.resources = {{
|
||||
{"home", "/",
|
||||
.get = {{
|
||||
query((d){
|
||||
.set = "greeting",
|
||||
.db = "hello_db",
|
||||
"select name "
|
||||
"from greetings "
|
||||
"limit 1;"
|
||||
}),
|
||||
render((r){
|
||||
"<html>"
|
||||
"<body>"
|
||||
"{{#greeting}}"
|
||||
"<p>Hello {{name}}</p>"
|
||||
"{{/greeting}}"
|
||||
"</body>"
|
||||
"</html>"
|
||||
})
|
||||
}}
|
||||
}
|
||||
}},
|
||||
|
||||
.databases = {{
|
||||
.engine = sqlite_db,
|
||||
.name = "hello_db",
|
||||
.connect = "file:hello.db?mode=rwc",
|
||||
.migrations = {
|
||||
"CREATE TABLE greetings ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
"name TEXT NOT NULL"
|
||||
");"
|
||||
},
|
||||
.seeds = {
|
||||
"INSERT INTO greetings(name)"
|
||||
"VALUES('World');"
|
||||
}
|
||||
}},
|
||||
|
||||
.modules = {sqlite}
|
||||
};
|
||||
}
|
||||
46
04-error-resolution.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 320" width="760" height="320">
|
||||
<rect width="760" height="320" fill="#09080f"/>
|
||||
<defs>
|
||||
<style>text{font-family:'JetBrains Mono','Fira Code',monospace}</style>
|
||||
</defs>
|
||||
|
||||
<!-- Level 1: Root Config — y=24 h=60 → bottom 84 -->
|
||||
<rect x="40" y="24" width="680" height="60" rx="10" fill="#2d2640" stroke="#7c3aed" stroke-width="1.5"/>
|
||||
<text x="380" y="50" text-anchor="middle" fill="#c084fc" font-size="12" font-weight="700">Root Config</text>
|
||||
<text x="380" y="70" text-anchor="middle" fill="#94a3b8" font-size="10">.errors = { 404 → render 404.html }</text>
|
||||
|
||||
<!-- Gap: 84 → 118 (34px gap) -->
|
||||
|
||||
<!-- Level 2: Module Config — y=118 h=60 → bottom 178 -->
|
||||
<rect x="120" y="118" width="520" height="60" rx="10" fill="#1e293b" stroke="#3b82f6" stroke-width="1.5" stroke-dasharray="5,3"/>
|
||||
<text x="380" y="144" text-anchor="middle" fill="#60a5fa" font-size="12" font-weight="700">Module Config</text>
|
||||
<text x="380" y="164" text-anchor="middle" fill="#94a3b8" font-size="10">.errors = { ... }</text>
|
||||
|
||||
<!-- Gap: 178 → 212 (34px gap) -->
|
||||
|
||||
<!-- Level 3: Route — y=212 h=60 → bottom 272 -->
|
||||
<rect x="200" y="212" width="360" height="60" rx="10" fill="#0c2d3e" stroke="#06b6d4" stroke-width="1.5" stroke-dasharray="5,3"/>
|
||||
<text x="380" y="238" text-anchor="middle" fill="#22d3ee" font-size="12" font-weight="700">Route /todos/:id</text>
|
||||
<text x="380" y="258" text-anchor="middle" fill="#94a3b8" font-size="10">.errors = { ... }</text>
|
||||
|
||||
<!-- Error origin dot: y=296 -->
|
||||
<circle cx="380" cy="296" r="10" fill="#ef4444" opacity="0.85"/>
|
||||
<text x="380" y="300" text-anchor="middle" fill="#fff" font-size="10" font-weight="700">!</text>
|
||||
<text x="400" y="300" fill="#fca5a5" font-size="10"> 404 raised in pipeline step</text>
|
||||
|
||||
<!-- Arrow 1: error dot (286) → route bottom (272). Line + up triangle -->
|
||||
<line x1="380" y1="286" x2="380" y2="275" stroke="#ef4444" stroke-width="2"/>
|
||||
<polygon points="380,272 375,279 385,279" fill="#ef4444"/>
|
||||
|
||||
<!-- Arrow 2: route top (212) → module bottom (178). Line in gap + up triangle -->
|
||||
<line x1="380" y1="212" x2="380" y2="181" stroke="#ef4444" stroke-width="2"/>
|
||||
<polygon points="380,178 375,185 385,185" fill="#ef4444"/>
|
||||
<!-- Label -->
|
||||
<text x="400" y="200" fill="#6b7280" font-size="9">no 404 handler → bubble up</text>
|
||||
|
||||
<!-- Arrow 3: module top (118) → root bottom (84). Line in gap + up triangle -->
|
||||
<line x1="380" y1="118" x2="380" y2="87" stroke="#ef4444" stroke-width="2"/>
|
||||
<polygon points="380,84 375,91 385,91" fill="#ef4444"/>
|
||||
<!-- Label -->
|
||||
<text x="400" y="106" fill="#22c55e" font-size="9" font-weight="600">✓ matched → render 404.html</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
224
04_todo_single_file/main.c
Normal file
@@ -0,0 +1,224 @@
|
||||
#include <mach.h>
|
||||
#include <sqlite.h>
|
||||
#include <session_auth.h>
|
||||
|
||||
config mach(){
|
||||
return (config) {
|
||||
.resources = {{
|
||||
{"home", "/",
|
||||
.get = {{
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"<p>home</p>"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}}
|
||||
},
|
||||
|
||||
{"about", "/about",
|
||||
.get = {{
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"<p>about us</p>"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}}
|
||||
},
|
||||
|
||||
{"contact", "/contact",
|
||||
.get = {{
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"<p>contact us</p>"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}}
|
||||
},
|
||||
|
||||
{"todos", "/todos",
|
||||
.get = {{
|
||||
query((d){
|
||||
.set = "todos",
|
||||
.db = "todos_db",
|
||||
.query =
|
||||
"select id, title, finished "
|
||||
"from todos "
|
||||
"where user_id = {{user_id}};"
|
||||
}),
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"<form action='{{url:todos}}' method='post'>"
|
||||
"<input type='text' name='title' placeholder='new todo' required>"
|
||||
"<button type='submit'>add</button>"
|
||||
"</form>"
|
||||
"{{^todos}}"
|
||||
"<p>no todos</p>"
|
||||
"{{/todos}}"
|
||||
"{{#todos}}"
|
||||
"{{#.}}"
|
||||
"{{> todo}}"
|
||||
"{{/.}}"
|
||||
"{{/todos}}"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}},
|
||||
|
||||
.post = {{
|
||||
validate((v){
|
||||
.name = "title",
|
||||
.validation = "^\\S{1,16}$",
|
||||
.message = "must be 1-16 characters, no spaces"
|
||||
}),
|
||||
query((d){
|
||||
.db = "todos_db",
|
||||
.query =
|
||||
"insert into todos(user_id, title) "
|
||||
"values({{user_id}}, {{title}});"
|
||||
}),
|
||||
redirect((u){"todos"})
|
||||
}},
|
||||
|
||||
.before = {logged_in()},
|
||||
|
||||
.context = {
|
||||
{"todo",
|
||||
"<div>"
|
||||
"<form action='{{url:todo:id}}' method='post' style='display:inline'>"
|
||||
"<input type='hidden' name='http_method' value='patch'>"
|
||||
"{{^finished}}"
|
||||
"<input type='checkbox' name='finished' value='1'>"
|
||||
"{{/finished}}"
|
||||
"{{#finished}}"
|
||||
"<input type='checkbox' name='finished' value='1' checked>"
|
||||
"{{/finished}}"
|
||||
"{{title}}"
|
||||
"<button type='submit'>save</button>"
|
||||
"</form>"
|
||||
"<form action='{{url:todo:id}}' method='post' style='display:inline'>"
|
||||
"<input type='hidden' name='http_method' value='delete'>"
|
||||
"<button type='submit'>delete</button>"
|
||||
"</form>"
|
||||
"</div>"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{"todo", "/todos/:id",
|
||||
.patch = {{
|
||||
validate((v){
|
||||
.name = "finished",
|
||||
.optional = true,
|
||||
.validation = "1",
|
||||
.message = "must be 1"
|
||||
}),
|
||||
query((d){
|
||||
.db = "todos_db",
|
||||
.query =
|
||||
"update todos "
|
||||
"set finished = {{finished}} "
|
||||
"where user_id = {{user_id}} "
|
||||
"and id = {{id}};"
|
||||
}),
|
||||
redirect((u){"todos"})
|
||||
}},
|
||||
|
||||
.delete = {{
|
||||
query((d){
|
||||
.db = "todos_db",
|
||||
.query =
|
||||
"delete from todos "
|
||||
"where user_id = {{user_id}} "
|
||||
"and id = {{id}};"
|
||||
}),
|
||||
redirect((u){"todos"})
|
||||
}},
|
||||
|
||||
.before = {
|
||||
logged_in(),
|
||||
validate((v){
|
||||
.name = "id",
|
||||
.validation = "^\\d{1,10}$",
|
||||
.message = "must be between 1-9999999999"
|
||||
})
|
||||
}
|
||||
}},
|
||||
|
||||
.before = {session()},
|
||||
|
||||
.context = {
|
||||
{"layout",
|
||||
"<html>"
|
||||
"<head>"
|
||||
"<link rel='icon' href='{{asset:favicon.png}}'>"
|
||||
"</head>"
|
||||
"<body>"
|
||||
"<p>"
|
||||
"{{^user}}"
|
||||
"<a href='{{url:login}}'>sign in</a>"
|
||||
"{{/user}}"
|
||||
"{{#user}}"
|
||||
"welcome, {{short_name}}"
|
||||
"{{/user}}"
|
||||
"</p>"
|
||||
"<nav>"
|
||||
"<a href='{{url:home}}'>home</a>"
|
||||
"<a href='{{url:about}}'>about us</a>"
|
||||
"<a href='{{url:contact}}'>contact us</a>"
|
||||
"<a href='{{url:todos}}'>todos</a>"
|
||||
"</nav>"
|
||||
"{{$body}}"
|
||||
"{{/body}}"
|
||||
"</body>"
|
||||
"</html>"
|
||||
}
|
||||
},
|
||||
|
||||
.errors = {
|
||||
{http_error, {
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"<p>error</p>"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}},
|
||||
|
||||
{http_not_found, {
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"<p>not found</p>"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}}
|
||||
}
|
||||
},
|
||||
|
||||
.databases = {{
|
||||
.engine = sqlite_db,
|
||||
.name = "todos_db",
|
||||
.connect = "file:todo.db?mode=rwc",
|
||||
.migrations = {
|
||||
"CREATE TABLE IF NOT EXISTS todos ( "
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"user_id INTEGER NOT NULL, "
|
||||
"title TEXT NOT NULL, "
|
||||
"finished INTEGER CHECK(finished IN (1))"
|
||||
"); "
|
||||
"CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id);"
|
||||
}
|
||||
}},
|
||||
|
||||
.modules = {sqlite, session_auth}
|
||||
};
|
||||
}
|
||||
0
04_todo_single_file/public/favicon.png
Normal file
58
05-app-composition-tree.svg
Normal file
@@ -0,0 +1,58 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 270" width="760" height="270">
|
||||
<rect width="760" height="270" fill="#09080f"/>
|
||||
<defs>
|
||||
<linearGradient id="rG" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#a855f7"/>
|
||||
<stop offset="100%" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="mG" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#2563eb"/>
|
||||
</linearGradient>
|
||||
<style>text{font-family:'JetBrains Mono','Fira Code',monospace}</style>
|
||||
</defs>
|
||||
|
||||
<!-- Root node: x=290 y=20 w=180 h=44 → center 380, bottom 64 -->
|
||||
<rect x="290" y="20" width="180" height="44" rx="10" fill="url(#rG)"/>
|
||||
<text x="380" y="47" text-anchor="middle" fill="#fff" font-size="12" font-weight="700">mach() — root</text>
|
||||
|
||||
<!-- Elbow connectors: root bottom center → down to 82 → horizontal → down to child tops at 108 -->
|
||||
<!-- Vertical trunk from root -->
|
||||
<line x1="380" y1="64" x2="380" y2="82" stroke="#64748b" stroke-width="1.5"/>
|
||||
<!-- Horizontal bar -->
|
||||
<line x1="130" y1="82" x2="630" y2="82" stroke="#64748b" stroke-width="1.5"/>
|
||||
<!-- Vertical drops to each child -->
|
||||
<line x1="130" y1="82" x2="130" y2="108" stroke="#64748b" stroke-width="1.5"/>
|
||||
<line x1="380" y1="82" x2="380" y2="108" stroke="#64748b" stroke-width="1.5"/>
|
||||
<line x1="630" y1="82" x2="630" y2="108" stroke="#64748b" stroke-width="1.5"/>
|
||||
|
||||
<!-- Level 1: each w=170 h=40, top at y=108 → bottom at 148 -->
|
||||
<rect x="45" y="108" width="170" height="40" rx="8" fill="url(#mG)"/>
|
||||
<text x="130" y="133" text-anchor="middle" fill="#fff" font-size="11" font-weight="600">sqlite_config()</text>
|
||||
|
||||
<rect x="295" y="108" width="170" height="40" rx="8" fill="url(#mG)"/>
|
||||
<text x="380" y="133" text-anchor="middle" fill="#fff" font-size="11" font-weight="600">todos_config()</text>
|
||||
|
||||
<rect x="545" y="108" width="170" height="40" rx="8" fill="url(#mG)"/>
|
||||
<text x="630" y="133" text-anchor="middle" fill="#fff" font-size="11" font-weight="600">auth_config()</text>
|
||||
|
||||
<!-- Elbow connectors: todos bottom center → down to 162 → horizontal → down to leaf tops at 178 -->
|
||||
<line x1="380" y1="148" x2="380" y2="162" stroke="#64748b" stroke-width="1"/>
|
||||
<line x1="260" y1="162" x2="500" y2="162" stroke="#64748b" stroke-width="1"/>
|
||||
<line x1="260" y1="162" x2="260" y2="178" stroke="#64748b" stroke-width="1"/>
|
||||
<line x1="500" y1="162" x2="500" y2="178" stroke="#64748b" stroke-width="1"/>
|
||||
|
||||
<!-- Level 2 leaf nodes: w=170 h=64, top at y=178 -->
|
||||
<rect x="175" y="178" width="170" height="64" rx="8" fill="#0f0d1a" stroke="#10b981" stroke-width="1"/>
|
||||
<text x="260" y="200" text-anchor="middle" fill="#6ee7b7" font-size="10">.databases</text>
|
||||
<text x="260" y="216" text-anchor="middle" fill="#6ee7b7" font-size="10">.resources</text>
|
||||
<text x="260" y="232" text-anchor="middle" fill="#6ee7b7" font-size="10">.events</text>
|
||||
|
||||
<rect x="415" y="178" width="170" height="64" rx="8" fill="#0f0d1a" stroke="#10b981" stroke-width="1"/>
|
||||
<text x="500" y="200" text-anchor="middle" fill="#6ee7b7" font-size="10">.publishes</text>
|
||||
<text x="500" y="216" text-anchor="middle" fill="#6ee7b7" font-size="10">.context</text>
|
||||
<text x="500" y="232" text-anchor="middle" fill="#6ee7b7" font-size="10">.errors</text>
|
||||
|
||||
<!-- Footer -->
|
||||
<text x="380" y="262" text-anchor="middle" fill="#64748b" font-size="10">First registration wins — root can override module defaults</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
5
05_todo/404.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>not found</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
5
05_todo/5xx.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>error</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
5
05_todo/about.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>about us</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
5
05_todo/contact.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>contact us</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
2
05_todo/create_todo.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
insert into todos(user_id, title)
|
||||
values({{user_id}}, {{title}});
|
||||
7
05_todo/create_todos_table.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
finished INTEGER CHECK(finished IN (1))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id);
|
||||
3
05_todo/delete_todo.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
delete from todos
|
||||
where user_id = {{user_id}}
|
||||
and id = {{id}};
|
||||
3
05_todo/get_todos.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
select id, title, finished
|
||||
from todos
|
||||
where user_id = {{user_id}};
|
||||
5
05_todo/home.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>home</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
23
05_todo/layout.mustache.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<html>
|
||||
<head>
|
||||
<link rel='icon' href='{{asset:favicon.png}}'>
|
||||
</head>
|
||||
<body>
|
||||
<p>
|
||||
{{^user}}
|
||||
<a href='{{url:login}}'>sign in</a>
|
||||
{{/user}}
|
||||
{{#user}}
|
||||
welcome, {{short_name}}
|
||||
{{/user}}
|
||||
</p>
|
||||
<nav>
|
||||
<a href='{{url:home}}'>home</a>
|
||||
<a href='{{url:about}}'>about us</a>
|
||||
<a href='{{url:contact}}'>contact us</a>
|
||||
<a href='{{url:todos}}'>todos</a>
|
||||
</nav>
|
||||
{{$body}}
|
||||
{{/body}}
|
||||
</body>
|
||||
</html>
|
||||
141
05_todo/main.c
Normal file
@@ -0,0 +1,141 @@
|
||||
#include <mach.h>
|
||||
#include <sqlite.h>
|
||||
#include <session_auth.h>
|
||||
|
||||
config mach(){
|
||||
return (config) {
|
||||
.resources = {{
|
||||
{"home", "/",
|
||||
.get = {{
|
||||
render((r){(asset){
|
||||
#embed "home.mustache.html"
|
||||
}})
|
||||
}}
|
||||
},
|
||||
|
||||
{"about", "/about",
|
||||
.get = {{
|
||||
render((r){(asset){
|
||||
#embed "about.mustache.html"
|
||||
}})
|
||||
}}
|
||||
},
|
||||
|
||||
{"contact", "/contact",
|
||||
.get = {{
|
||||
render((r){(asset){
|
||||
#embed "contact.mustache.html"
|
||||
}})
|
||||
}}
|
||||
},
|
||||
|
||||
{"todos", "/todos",
|
||||
.get = {{
|
||||
query((d){
|
||||
.set = "todos",
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "get_todos.sql"
|
||||
}
|
||||
}),
|
||||
render((r){(asset){
|
||||
#embed "todos.mustache.html"
|
||||
}})
|
||||
}},
|
||||
|
||||
.post = {{
|
||||
validate((v){
|
||||
.name = "title",
|
||||
.validation = "^\\S{1,16}$",
|
||||
.message = "must be 1-16 characters, no spaces"
|
||||
}),
|
||||
query((d){
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "create_todo.sql"
|
||||
}
|
||||
}),
|
||||
redirect((u){"todos"})
|
||||
}},
|
||||
|
||||
.before = {logged_in()},
|
||||
|
||||
.context = {
|
||||
{"todo", (asset){
|
||||
#embed "todo.mustache.html"
|
||||
}}
|
||||
}
|
||||
},
|
||||
|
||||
{"todo", "/todos/:id",
|
||||
.patch = {{
|
||||
validate((v){
|
||||
.name = "finished",
|
||||
.optional = true,
|
||||
.validation = "1",
|
||||
.message = "must be 1"
|
||||
}),
|
||||
find((d){
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "update_todo.sql"
|
||||
}
|
||||
}),
|
||||
redirect((u){"todos"})
|
||||
}},
|
||||
|
||||
.delete = {{
|
||||
find((d){
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "delete_todo.sql"
|
||||
}
|
||||
}),
|
||||
redirect((u){"todos"})
|
||||
}},
|
||||
|
||||
.before = {
|
||||
logged_in(),
|
||||
validate((v){
|
||||
.name = "id",
|
||||
.validation = "^\\d{1,10}$",
|
||||
.message = "must be between 1-9999999999"
|
||||
})
|
||||
}
|
||||
}},
|
||||
|
||||
.before = {session()},
|
||||
|
||||
.context = {
|
||||
{"layout", (asset){
|
||||
#embed "layout.mustache.html"
|
||||
}}
|
||||
},
|
||||
|
||||
.errors = {
|
||||
{http_error, {
|
||||
render((r){(asset){
|
||||
#embed "5xx.mustache.html"
|
||||
}})
|
||||
}},
|
||||
|
||||
{http_not_found, {
|
||||
render((r){(asset){
|
||||
#embed "404.mustache.html"
|
||||
}})
|
||||
}}
|
||||
}
|
||||
},
|
||||
|
||||
.databases = {{
|
||||
.engine = sqlite_db,
|
||||
.name = "todos_db",
|
||||
.connect = "file:todo.db?mode=rwc",
|
||||
.migrations = {(asset){
|
||||
#embed "create_todos_table.sql"
|
||||
}}
|
||||
}},
|
||||
|
||||
.modules = {sqlite, session_auth}
|
||||
};
|
||||
}
|
||||
0
05_todo/public/favicon.png
Normal file
17
05_todo/todo.mustache.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<div>
|
||||
<form action="{{url:todo:id}}" method="post" style="display:inline">
|
||||
<input type="hidden" name="http_method" value="patch">
|
||||
{{^finished}}
|
||||
<input type="checkbox" name="finished" value="1">
|
||||
{{/finished}}
|
||||
{{#finished}}
|
||||
<input type="checkbox" name="finished" value="1" checked>
|
||||
{{/finished}}
|
||||
{{title}}
|
||||
<button type="submit">save</button>
|
||||
</form>
|
||||
<form action="{{url:todo:id}}" method="post" style="display:inline">
|
||||
<input type="hidden" name="http_method" value="delete">
|
||||
<button type="submit">delete</button>
|
||||
</form>
|
||||
</div>
|
||||
16
05_todo/todos.mustache.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<form action="{{url:todos}}" method="post">
|
||||
<input type="text" name="title" placeholder="new todo" required>
|
||||
<button type="submit">add</button>
|
||||
</form>
|
||||
{{^todos}}
|
||||
<p>no todos</p>
|
||||
{{/todos}}
|
||||
{{#todos}}
|
||||
{{#.}}
|
||||
{{> todo}}
|
||||
{{/.}}
|
||||
{{/todos}}
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
4
05_todo/update_todo.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
update todos
|
||||
set finished = {{finished}}
|
||||
where user_id = {{user_id}}
|
||||
and id = {{id}};
|
||||
52
06-middleware-scoping.svg
Normal file
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 360" width="760" height="360">
|
||||
<rect width="760" height="360" fill="#09080f"/>
|
||||
<defs>
|
||||
<style>text{font-family:'JetBrains Mono','Fira Code',monospace}</style>
|
||||
</defs>
|
||||
|
||||
<!-- Outermost: Global .before / .after -->
|
||||
<rect x="30" y="20" width="700" height="310" rx="14" fill="#1a1328" stroke="#7c3aed" stroke-width="1.5"/>
|
||||
<text x="50" y="46" fill="#c084fc" font-size="11" font-weight="700">Global .before</text>
|
||||
<text x="50" y="62" fill="#9580b8" font-size="9">session()</text>
|
||||
<text x="610" y="46" fill="#c084fc" font-size="11" font-weight="700">Global .after</text>
|
||||
<text x="610" y="62" fill="#9580b8" font-size="9">call(log_request)</text>
|
||||
|
||||
<!-- Middle: Resource .before / .after -->
|
||||
<rect x="60" y="78" width="640" height="230" rx="12" fill="#111827" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
<text x="80" y="102" fill="#60a5fa" font-size="11" font-weight="700">Resource .before</text>
|
||||
<text x="80" y="118" fill="#7b9ec9" font-size="9">logged_in()</text>
|
||||
<text x="570" y="102" fill="#60a5fa" font-size="11" font-weight="700">Resource .after</text>
|
||||
|
||||
<!-- Inner: Route pipeline -->
|
||||
<rect x="90" y="134" width="580" height="152" rx="10" fill="#0f0d1a" stroke="#06b6d4" stroke-width="1.5"/>
|
||||
<text x="110" y="158" fill="#22d3ee" font-size="11" font-weight="700">Route Pipeline — POST /todos</text>
|
||||
|
||||
<!-- Steps inside route -->
|
||||
<rect x="110" y="172" width="100" height="36" rx="6" fill="#1e293b" stroke="#3b82f6" stroke-width="0.75"/>
|
||||
<text x="160" y="194" text-anchor="middle" fill="#93c5fd" font-size="10" font-weight="600">param()</text>
|
||||
|
||||
<line x1="210" y1="190" x2="228" y2="190" stroke="#64748b" stroke-width="1.5"/>
|
||||
<polygon points="238,190 228,185 228,195" fill="#64748b"/>
|
||||
|
||||
<rect x="238" y="172" width="100" height="36" rx="6" fill="#1e293b" stroke="#8b5cf6" stroke-width="0.75"/>
|
||||
<text x="288" y="194" text-anchor="middle" fill="#c4b5fd" font-size="10" font-weight="600">db()</text>
|
||||
|
||||
<line x1="338" y1="190" x2="356" y2="190" stroke="#64748b" stroke-width="1.5"/>
|
||||
<polygon points="366,190 356,185 356,195" fill="#64748b"/>
|
||||
|
||||
<rect x="366" y="172" width="100" height="36" rx="6" fill="#1e293b" stroke="#f59e0b" stroke-width="0.75"/>
|
||||
<text x="416" y="194" text-anchor="middle" fill="#fcd34d" font-size="10" font-weight="600">emit()</text>
|
||||
|
||||
<line x1="466" y1="190" x2="484" y2="190" stroke="#64748b" stroke-width="1.5"/>
|
||||
<polygon points="494,190 484,185 484,195" fill="#64748b"/>
|
||||
|
||||
<rect x="494" y="172" width="100" height="36" rx="6" fill="#1e293b" stroke="#10b981" stroke-width="0.75"/>
|
||||
<text x="544" y="194" text-anchor="middle" fill="#6ee7b7" font-size="10" font-weight="600">redirect()</text>
|
||||
|
||||
<!-- Route .before / .after labels -->
|
||||
<text x="110" y="244" fill="#22d3ee" font-size="9">Route .before runs first inside this scope</text>
|
||||
<text x="110" y="260" fill="#22d3ee" font-size="9">Route .after runs last inside this scope</text>
|
||||
|
||||
<!-- Execution order arrows at bottom -->
|
||||
<text x="380" y="348" text-anchor="middle" fill="#64748b" font-size="10">Execution order: global .before → resource .before → route pipeline → resource .after → global .after</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
5
06_todo_sse_datastar/404.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>not found</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
5
06_todo_sse_datastar/5xx.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>error</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
5
06_todo_sse_datastar/about.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>about us</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
5
06_todo_sse_datastar/contact.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>contact us</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
3
06_todo_sse_datastar/create_todo.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
insert into todos(user_id, title)
|
||||
values({{user_id}}, {{title}})
|
||||
returning id, title, finished;
|
||||
7
06_todo_sse_datastar/create_todos_table.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
finished INTEGER CHECK(finished IN (1))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id);
|
||||
4
06_todo_sse_datastar/delete_todo.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
delete from todos
|
||||
where user_id = {{user_id}}
|
||||
and id = {{id}}
|
||||
returning id;
|
||||
3
06_todo_sse_datastar/get_todos.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
select id, title, finished
|
||||
from todos
|
||||
where user_id = {{user_id}};
|
||||
5
06_todo_sse_datastar/home.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>home</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
25
06_todo_sse_datastar/layout.mustache.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<html>
|
||||
<head>
|
||||
<link rel='icon' href='{{asset:favicon.png}}'>
|
||||
{{$head}}
|
||||
{{/head}}
|
||||
</head>
|
||||
<body>
|
||||
<p>
|
||||
{{^user}}
|
||||
<a href='{{url:login}}'>sign in</a>
|
||||
{{/user}}
|
||||
{{#user}}
|
||||
welcome, {{short_name}}
|
||||
{{/user}}
|
||||
</p>
|
||||
<nav>
|
||||
<a href='{{url:home}}'>home</a>
|
||||
<a href='{{url:about}}'>about us</a>
|
||||
<a href='{{url:contact}}'>contact us</a>
|
||||
<a href='{{url:todos}}'>todos</a>
|
||||
</nav>
|
||||
{{$body}}
|
||||
{{/body}}
|
||||
</body>
|
||||
</html>
|
||||
157
06_todo_sse_datastar/main.c
Normal file
@@ -0,0 +1,157 @@
|
||||
#include <mach.h>
|
||||
#include <sqlite.h>
|
||||
#include <datastar.h>
|
||||
#include <session_auth.h>
|
||||
|
||||
config mach(){
|
||||
return (config) {
|
||||
.resources = {{
|
||||
{"home", "/",
|
||||
.get = {{
|
||||
render((r){(asset){
|
||||
#embed "home.mustache.html"
|
||||
}})
|
||||
}}
|
||||
},
|
||||
|
||||
{"about", "/about",
|
||||
.get = {{
|
||||
render((r){(asset){
|
||||
#embed "about.mustache.html"
|
||||
}})
|
||||
}}
|
||||
},
|
||||
|
||||
{"contact", "/contact",
|
||||
.get = {{
|
||||
render((r){(asset){
|
||||
#embed "contact.mustache.html"
|
||||
}})
|
||||
}}
|
||||
},
|
||||
|
||||
{"todos", "/todos",
|
||||
.sse = {.channel = "todos:{{user_id}}"},
|
||||
|
||||
.get = {{
|
||||
query((d){
|
||||
.set = "todos",
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "get_todos.sql"
|
||||
}
|
||||
}),
|
||||
render((r){(asset){
|
||||
#embed "todos.mustache.html"
|
||||
}})
|
||||
}},
|
||||
|
||||
.post = {{
|
||||
validate((v){
|
||||
.name = "title",
|
||||
.validation = "^\\S{1,16}$",
|
||||
.message = "must be 1-16 characters, no spaces"
|
||||
}),
|
||||
query((d){
|
||||
.set = "todo",
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "create_todo.sql"
|
||||
}
|
||||
}),
|
||||
ds_sse((ds){
|
||||
.target = "todos",
|
||||
.mode = mode_prepend,
|
||||
.channel = "todos:{{user_id}}",
|
||||
.elements = {.asset = "todo"}
|
||||
})
|
||||
}},
|
||||
|
||||
.before = {logged_in()}
|
||||
},
|
||||
|
||||
{"todo", "/todos/:id",
|
||||
.patch = {{
|
||||
validate((v){
|
||||
.name = "finished",
|
||||
.optional = true,
|
||||
.validation = "1",
|
||||
.message = "must be 1"
|
||||
}),
|
||||
find((d){
|
||||
.set = "todo",
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "update_todo.sql"
|
||||
}
|
||||
}),
|
||||
ds_sse((ds){
|
||||
.mode = mode_replace,
|
||||
.target = "todo_{{id}}",
|
||||
.channel = "todos:{{user_id}}",
|
||||
.elements = {.asset = "todo"}
|
||||
})
|
||||
}},
|
||||
|
||||
.delete = {{
|
||||
find((d){
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "delete_todo.sql"
|
||||
}
|
||||
}),
|
||||
ds_sse((ds){
|
||||
.mode = mode_remove,
|
||||
.target = "todo_{{id}}",
|
||||
.channel = "todos:{{user_id}}"
|
||||
})
|
||||
}},
|
||||
|
||||
.before = {
|
||||
logged_in(),
|
||||
validate((v){
|
||||
.name = "id",
|
||||
.validation = "^\\d{1,10}$",
|
||||
.message = "must be between 1-9999999999"
|
||||
})
|
||||
}
|
||||
}},
|
||||
|
||||
.before = {session()},
|
||||
|
||||
.context = {
|
||||
{"layout", (asset){
|
||||
#embed "layout.mustache.html"
|
||||
}},
|
||||
{"todo", (asset){
|
||||
#embed "todo.mustache.html"
|
||||
}}
|
||||
},
|
||||
|
||||
.errors = {
|
||||
{http_error, {
|
||||
render((r){(asset){
|
||||
#embed "5xx.mustache.html"
|
||||
}})
|
||||
}},
|
||||
|
||||
{http_not_found, {
|
||||
render((r){(asset){
|
||||
#embed "404.mustache.html"
|
||||
}})
|
||||
}}
|
||||
}
|
||||
},
|
||||
|
||||
.databases = {{
|
||||
.engine = sqlite_db,
|
||||
.name = "todos_db",
|
||||
.connect = "file:todo.db?mode=rwc",
|
||||
.migrations = {(asset){
|
||||
#embed "create_todos_table.sql"
|
||||
}}
|
||||
}},
|
||||
|
||||
.modules = {sqlite, datastar, session_auth}
|
||||
};
|
||||
}
|
||||
0
06_todo_sse_datastar/public/favicon.png
Normal file
10
06_todo_sse_datastar/todo.mustache.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<div id="todo_{{id}}">
|
||||
{{^finished}}
|
||||
<input type="checkbox" data-on:click__prevent="@patch('{{url:todo:id}}?finished=1')">
|
||||
{{/finished}}
|
||||
{{#finished}}
|
||||
<input type="checkbox" checked data-on:click__prevent="@patch('{{url:todo:id}}')">
|
||||
{{/finished}}
|
||||
{{title}}
|
||||
<button data-on:click="@delete('{{url:todo:id}}')">delete</button>
|
||||
</div>
|
||||
21
06_todo_sse_datastar/todos.mustache.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{{< layout}}
|
||||
{{$head}}
|
||||
{{> datastar_script }}
|
||||
{{/head}}
|
||||
{{$body}}
|
||||
<input type="text" placeholder="new todo" data-bind:title
|
||||
data-on:keydown="evt.key === 'Enter' && @post('{{utl:todos}}') && ($title = '');"
|
||||
>
|
||||
<button data-on:click="@post('{{url:todos}}') && ($title = '')">add</button>
|
||||
<div id="todos" data-init="@get('{{url:todos}}?http_method=sse')">
|
||||
{{^todos}}
|
||||
<p>no todos</p>
|
||||
{{/todos}}
|
||||
{{#todos}}
|
||||
{{#.}}
|
||||
{{> todo}}
|
||||
{{/.}}
|
||||
{{/todos}}
|
||||
</div>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
5
06_todo_sse_datastar/update_todo.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
update todos
|
||||
set finished = {{finished}}
|
||||
where user_id = {{user_id}}
|
||||
and id = {{id}}
|
||||
returning id, title, finished;
|
||||
50
07-context-scoping.svg
Normal file
@@ -0,0 +1,50 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 360" width="760" height="360">
|
||||
<rect width="760" height="360" fill="#09080f"/>
|
||||
<defs>
|
||||
<style>text{font-family:'JetBrains Mono','Fira Code',monospace}</style>
|
||||
</defs>
|
||||
|
||||
<!-- Outermost: Global .context -->
|
||||
<rect x="30" y="20" width="700" height="310" rx="14" fill="#1a1328" stroke="#7c3aed" stroke-width="1.5"/>
|
||||
<text x="50" y="46" fill="#c084fc" font-size="11" font-weight="700">Global .context</text>
|
||||
<text x="50" y="62" fill="#9580b8" font-size="9">base_layout, site_name</text>
|
||||
|
||||
<!-- Middle: Resource .context -->
|
||||
<rect x="60" y="78" width="640" height="230" rx="12" fill="#111827" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
<text x="80" y="102" fill="#60a5fa" font-size="11" font-weight="700">Resource .context</text>
|
||||
<text x="80" y="118" fill="#7b9ec9" font-size="9">layout, sidebar</text>
|
||||
|
||||
<!-- Inner: Route pipeline -->
|
||||
<rect x="90" y="134" width="580" height="152" rx="10" fill="#0f0d1a" stroke="#06b6d4" stroke-width="1.5"/>
|
||||
<text x="110" y="158" fill="#22d3ee" font-size="11" font-weight="700">Route Pipeline — GET /todos</text>
|
||||
|
||||
<!-- Steps inside route -->
|
||||
<rect x="110" y="172" width="120" height="36" rx="6" fill="#1e293b" stroke="#8b5cf6" stroke-width="0.75"/>
|
||||
<text x="170" y="194" text-anchor="middle" fill="#c4b5fd" font-size="10" font-weight="600">db() → todos</text>
|
||||
|
||||
<line x1="230" y1="190" x2="258" y2="190" stroke="#64748b" stroke-width="1.5"/>
|
||||
<polygon points="268,190 258,185 258,195" fill="#64748b"/>
|
||||
|
||||
<rect x="268" y="172" width="120" height="36" rx="6" fill="#1e293b" stroke="#f59e0b" stroke-width="0.75"/>
|
||||
<text x="328" y="194" text-anchor="middle" fill="#fcd34d" font-size="10" font-weight="600">call() → count</text>
|
||||
|
||||
<line x1="388" y1="190" x2="416" y2="190" stroke="#64748b" stroke-width="1.5"/>
|
||||
<polygon points="426,190 416,185 416,195" fill="#64748b"/>
|
||||
|
||||
<rect x="426" y="172" width="120" height="36" rx="6" fill="#1e293b" stroke="#10b981" stroke-width="0.75"/>
|
||||
<text x="486" y="194" text-anchor="middle" fill="#6ee7b7" font-size="10" font-weight="600">render()</text>
|
||||
|
||||
<!-- Available context note -->
|
||||
<text x="110" y="237" fill="#22d3ee" font-size="9">render() sees: base_layout, site_name, layout, sidebar, todos, count</text>
|
||||
<text x="110" y="253" fill="#22d3ee" font-size="9">Context merges top-down; first named registration wins</text>
|
||||
|
||||
<!-- Cascade arrows on the left side -->
|
||||
<line x1="42" y1="68" x2="42" y2="78" stroke="#7c3aed" stroke-width="1.5" stroke-dasharray="3,2"/>
|
||||
<polygon points="42,84 38,76 46,76" fill="#7c3aed"/>
|
||||
|
||||
<line x1="72" y1="124" x2="72" y2="134" stroke="#3b82f6" stroke-width="1.5" stroke-dasharray="3,2"/>
|
||||
<polygon points="72,140 68,132 76,132" fill="#3b82f6"/>
|
||||
|
||||
<!-- Bottom summary -->
|
||||
<text x="380" y="348" text-anchor="middle" fill="#64748b" font-size="10">Context cascades: global .context → resource .context → pipeline steps add to context → render() merges all</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
5
07_todo_db_per_user/404.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>not found</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
5
07_todo_db_per_user/5xx.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>error</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
5
07_todo_db_per_user/about.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>about us</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
5
07_todo_db_per_user/contact.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>contact us</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
2
07_todo_db_per_user/create_todo.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
insert into todos(title)
|
||||
values({{title}});
|
||||
5
07_todo_db_per_user/create_todos_table.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
finished INTEGER CHECK(finished IN (1))
|
||||
);
|
||||
2
07_todo_db_per_user/delete_todo.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
delete from todos
|
||||
where id = {{id}};
|
||||
2
07_todo_db_per_user/get_todos.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
select id, title, finished
|
||||
from todos;
|
||||
5
07_todo_db_per_user/home.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>home</p>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
23
07_todo_db_per_user/layout.mustache.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<html>
|
||||
<head>
|
||||
<link rel='icon' href='{{asset:favicon.png}}'>
|
||||
</head>
|
||||
<body>
|
||||
<p>
|
||||
{{^user}}
|
||||
<a href='{{url:login}}'>sign in</a>
|
||||
{{/user}}
|
||||
{{#user}}
|
||||
welcome, {{short_name}}
|
||||
{{/user}}
|
||||
</p>
|
||||
<nav>
|
||||
<a href='{{url:home}}'>home</a>
|
||||
<a href='{{url:about}}'>about us</a>
|
||||
<a href='{{url:contact}}'>contact us</a>
|
||||
<a href='{{url:todos}}'>todos</a>
|
||||
</nav>
|
||||
{{$body}}
|
||||
{{/body}}
|
||||
</body>
|
||||
</html>
|
||||
141
07_todo_db_per_user/main.c
Normal file
@@ -0,0 +1,141 @@
|
||||
#include <mach.h>
|
||||
#include <sqlite.h>
|
||||
#include <session_auth.h>
|
||||
|
||||
config mach(){
|
||||
return (config) {
|
||||
.resources = {{
|
||||
{"home","/",
|
||||
.get = {{
|
||||
render((r){(asset){
|
||||
#embed "home.mustache.html"
|
||||
}})
|
||||
}}
|
||||
},
|
||||
|
||||
{"about", "/about",
|
||||
.get = {{
|
||||
render((r){(asset){
|
||||
#embed "about.mustache.html"
|
||||
}})
|
||||
}}
|
||||
},
|
||||
|
||||
{"contact", "/contact",
|
||||
.get = {{
|
||||
render((r){(asset){
|
||||
#embed "contact.mustache.html"
|
||||
}})
|
||||
}}
|
||||
},
|
||||
|
||||
{"todos", "/todos",
|
||||
.get = {{
|
||||
query((d){
|
||||
.set = "todos",
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "get_todos.sql"
|
||||
}
|
||||
}),
|
||||
render((r){(asset){
|
||||
#embed "todos.mustache.html"
|
||||
}})
|
||||
}},
|
||||
|
||||
.post = {{
|
||||
validate((v){
|
||||
.name = "title",
|
||||
.validation = "^\\S{1,16}$",
|
||||
.message = "must be 1-16 characters, no spaces"
|
||||
}),
|
||||
query((d){
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "create_todo.sql"
|
||||
}
|
||||
}),
|
||||
redirect((u){"todos"})
|
||||
}},
|
||||
|
||||
.before = {logged_in()},
|
||||
|
||||
.context = {
|
||||
{"todo", (asset){
|
||||
#embed "todo.mustache.html"
|
||||
}}
|
||||
}
|
||||
},
|
||||
|
||||
{"todo", "/todos/:id",
|
||||
.patch = {{
|
||||
validate((v){
|
||||
.name = "finished",
|
||||
.optional = true,
|
||||
.validation = "1",
|
||||
.message = "must be 1"
|
||||
}),
|
||||
find((d){
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "update_todo.sql"
|
||||
}
|
||||
}),
|
||||
redirect((u){"todos"})
|
||||
}},
|
||||
|
||||
.delete = {{
|
||||
find((d){
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "delete_todo.sql"
|
||||
}
|
||||
}),
|
||||
redirect((u){"todos"})
|
||||
}},
|
||||
|
||||
.before = {
|
||||
logged_in(),
|
||||
validate((v){
|
||||
.name = "id",
|
||||
.validation = "^\\d{1,10}$",
|
||||
.message = "must be between 1-9999999999"
|
||||
})
|
||||
}
|
||||
}},
|
||||
|
||||
.before = {session()},
|
||||
|
||||
.context = {
|
||||
{"layout", (asset){
|
||||
#embed "layout.mustache.html"
|
||||
}}
|
||||
},
|
||||
|
||||
.errors = {
|
||||
{http_error, {
|
||||
render((r){(asset){
|
||||
#embed "5xx.mustache.html"
|
||||
}})
|
||||
}},
|
||||
|
||||
{http_not_found, {
|
||||
render((r){(asset){
|
||||
#embed "404.mustache.html"
|
||||
}})
|
||||
}}
|
||||
}
|
||||
},
|
||||
|
||||
.databases = {{
|
||||
.engine = sqlite_db,
|
||||
.name = "todos_db",
|
||||
.connect = "file:{{user_id}}_todo.db?mode=rwc",
|
||||
.migrations = {(asset){
|
||||
#embed "create_todos_table.sql"
|
||||
}}
|
||||
}},
|
||||
|
||||
.modules = {sqlite, session_auth}
|
||||
};
|
||||
}
|
||||
0
07_todo_db_per_user/public/favicon.png
Normal file
17
07_todo_db_per_user/todo.mustache.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<div>
|
||||
<form action="{{url:todo:id}}" method="post" style="display:inline">
|
||||
<input type="hidden" name="http_method" value="patch">
|
||||
{{^finished}}
|
||||
<input type="checkbox" name="finished" value="1">
|
||||
{{/finished}}
|
||||
{{#finished}}
|
||||
<input type="checkbox" name="finished" value="1" checked>
|
||||
{{/finished}}
|
||||
{{title}}
|
||||
<button type="submit">save</button>
|
||||
</form>
|
||||
<form action="{{url:todo:id}}" method="post" style="display:inline">
|
||||
<input type="hidden" name="http_method" value="delete">
|
||||
<button type="submit">delete</button>
|
||||
</form>
|
||||
</div>
|
||||
16
07_todo_db_per_user/todos.mustache.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<form action="{{url:todos}}" method="post">
|
||||
<input type="text" name="title" placeholder="new todo" required>
|
||||
<button type="submit">add</button>
|
||||
</form>
|
||||
{{^todos}}
|
||||
<p>no todos</p>
|
||||
{{/todos}}
|
||||
{{#todos}}
|
||||
{{#.}}
|
||||
{{> todo}}
|
||||
{{/.}}
|
||||
{{/todos}}
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
3
07_todo_db_per_user/update_todo.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
update todos
|
||||
set finished = {{finished}}
|
||||
where id = {{id}};
|
||||
96
08-sse-datastar-flow.svg
Normal file
@@ -0,0 +1,96 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 400" width="760" height="400">
|
||||
<rect width="760" height="400" fill="#09080f"/>
|
||||
<defs>
|
||||
<linearGradient id="clientG" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#06b6d4"/>
|
||||
<stop offset="100%" stop-color="#0891b2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="serverG" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#8b5cf6"/>
|
||||
<stop offset="100%" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="chanG" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#f59e0b"/>
|
||||
<stop offset="50%" stop-color="#fbbf24"/>
|
||||
<stop offset="100%" stop-color="#f59e0b"/>
|
||||
</linearGradient>
|
||||
<style>text{font-family:'JetBrains Mono','Fira Code',monospace}</style>
|
||||
</defs>
|
||||
|
||||
<text x="380" y="28" text-anchor="middle" fill="#e2e8f0" font-size="13" font-weight="700">SSE / Datastar — Real-Time Push</text>
|
||||
|
||||
<!-- ===== Left: Client A (subscriber) ===== -->
|
||||
<rect x="30" y="50" width="180" height="80" rx="10" fill="#1e1b2e" stroke="#06b6d4" stroke-width="1.5"/>
|
||||
<rect x="30" y="50" width="180" height="32" rx="10" fill="url(#clientG)"/>
|
||||
<text x="120" y="71" text-anchor="middle" fill="#fff" font-size="11" font-weight="700">Client A</text>
|
||||
<text x="50" y="100" fill="#67e8f9" font-size="9">GET /todos</text>
|
||||
<text x="50" y="114" fill="#67e8f9" font-size="9">.sse channel connected</text>
|
||||
|
||||
<!-- ===== Left: Client B (subscriber) ===== -->
|
||||
<rect x="30" y="150" width="180" height="80" rx="10" fill="#1e1b2e" stroke="#06b6d4" stroke-width="1.5"/>
|
||||
<rect x="30" y="150" width="180" height="32" rx="10" fill="url(#clientG)"/>
|
||||
<text x="120" y="171" text-anchor="middle" fill="#fff" font-size="11" font-weight="700">Client B</text>
|
||||
<text x="50" y="200" fill="#67e8f9" font-size="9">GET /todos</text>
|
||||
<text x="50" y="214" fill="#67e8f9" font-size="9">.sse channel connected</text>
|
||||
|
||||
<!-- ===== Channel pill ===== -->
|
||||
<rect x="280" y="110" width="200" height="44" rx="22" fill="url(#chanG)"/>
|
||||
<text x="380" y="128" text-anchor="middle" fill="#1e1b2e" font-size="10" font-weight="700">Channel</text>
|
||||
<text x="380" y="143" text-anchor="middle" fill="#78350f" font-size="9">todos/{{user_id}}</text>
|
||||
|
||||
<!-- Arrows: clients → channel (subscribe) -->
|
||||
<line x1="210" y1="90" x2="270" y2="132" stroke="#06b6d4" stroke-width="1.5"/>
|
||||
<polygon points="280,132 270,127 268,137" fill="#06b6d4"/>
|
||||
<line x1="210" y1="190" x2="270" y2="132" stroke="#06b6d4" stroke-width="1.5"/>
|
||||
<polygon points="280,132 270,127 268,137" fill="#06b6d4"/>
|
||||
|
||||
<text x="220" y="86" fill="#475569" font-size="8">subscribe</text>
|
||||
<text x="220" y="198" fill="#475569" font-size="8">subscribe</text>
|
||||
|
||||
<!-- ===== Right: Server pipeline (POST) ===== -->
|
||||
<rect x="30" y="270" width="700" height="110" rx="12" fill="#0f0d1a" stroke="#7c3aed" stroke-width="1.5"/>
|
||||
<text x="50" y="296" fill="#c084fc" font-size="11" font-weight="700">POST /todos — another client submits a form</text>
|
||||
|
||||
<!-- Pipeline steps -->
|
||||
<rect x="50" y="310" width="90" height="32" rx="6" fill="#1e293b" stroke="#3b82f6" stroke-width="0.75"/>
|
||||
<text x="95" y="330" text-anchor="middle" fill="#93c5fd" font-size="9">param()</text>
|
||||
|
||||
<line x1="140" y1="326" x2="155" y2="326" stroke="#64748b" stroke-width="1"/>
|
||||
<polygon points="163,326 155,322 155,330" fill="#64748b"/>
|
||||
|
||||
<rect x="163" y="310" width="90" height="32" rx="6" fill="#1e293b" stroke="#8b5cf6" stroke-width="0.75"/>
|
||||
<text x="208" y="330" text-anchor="middle" fill="#c4b5fd" font-size="9">db()</text>
|
||||
|
||||
<line x1="253" y1="326" x2="268" y2="326" stroke="#64748b" stroke-width="1"/>
|
||||
<polygon points="276,326 268,322 268,330" fill="#64748b"/>
|
||||
|
||||
<!-- datastar / sse step highlighted -->
|
||||
<rect x="276" y="306" width="180" height="40" rx="6" fill="#451a03" stroke="#f59e0b" stroke-width="1.5"/>
|
||||
<text x="366" y="324" text-anchor="middle" fill="#fcd34d" font-size="10" font-weight="700">datastar() / sse()</text>
|
||||
<text x="366" y="339" text-anchor="middle" fill="#fbbf24" font-size="8">.channel = "todos/{{user_id}}"</text>
|
||||
|
||||
<!-- Arrow from datastar step up to channel -->
|
||||
<path d="M366,306 L366,280 L380,280 L380,162" stroke="#f59e0b" stroke-width="2" fill="none"/>
|
||||
<polygon points="380,154 375,162 385,162" fill="#f59e0b"/>
|
||||
|
||||
<text x="394" y="230" fill="#f59e0b" font-size="9" font-weight="600">push to channel</text>
|
||||
|
||||
<!-- Arrows: channel → clients (broadcast) -->
|
||||
<line x1="280" y1="125" x2="218" y2="94" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<polygon points="210,90 218,89 216,99" fill="#f59e0b"/>
|
||||
|
||||
<line x1="280" y1="140" x2="218" y2="186" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<polygon points="210,190 218,181 216,191" fill="#f59e0b"/>
|
||||
|
||||
<text x="222" y="110" fill="#92400e" font-size="8">broadcast</text>
|
||||
<text x="222" y="180" fill="#92400e" font-size="8">broadcast</text>
|
||||
|
||||
<!-- Datastar DOM update note -->
|
||||
<rect x="530" y="100" width="200" height="64" rx="8" fill="#111827" stroke="#1f2937" stroke-width="1"/>
|
||||
<text x="550" y="120" fill="#6b7280" font-size="9" font-weight="600">DATASTAR MODE</text>
|
||||
<text x="550" y="136" fill="#94a3b8" font-size="9">.target = "todos"</text>
|
||||
<text x="550" y="152" fill="#94a3b8" font-size="9">.mode = mode_prepend</text>
|
||||
|
||||
<!-- Footer -->
|
||||
<text x="380" y="396" text-anchor="middle" fill="#64748b" font-size="10">Without .channel, events push directly to the requesting client</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
53
08_todo_events/activity/activity.c
Normal file
@@ -0,0 +1,53 @@
|
||||
#include <mach.h>
|
||||
#include <sqlite.h>
|
||||
#include <session_auth.h>
|
||||
|
||||
config activity(){
|
||||
return (config) {
|
||||
.name = "activity",
|
||||
|
||||
.resources = {{
|
||||
{"activity", "/activity",
|
||||
.get = {{
|
||||
query((d){
|
||||
.set = "activity",
|
||||
.db = "activity_db",
|
||||
.query = (asset){
|
||||
#embed "get_activities.sql"
|
||||
}
|
||||
}),
|
||||
render((r){(asset){
|
||||
#embed "activity.mustache.html"
|
||||
}})
|
||||
}}
|
||||
}},
|
||||
|
||||
.before = {
|
||||
session(),
|
||||
logged_in()
|
||||
}
|
||||
},
|
||||
|
||||
.events = {
|
||||
{"todo_created", {
|
||||
query((d){
|
||||
.db = "activity_db",
|
||||
.query = (asset){
|
||||
#embed "insert_activity.sql"
|
||||
}
|
||||
})
|
||||
}}
|
||||
},
|
||||
|
||||
.databases = {{
|
||||
.name = "activity_db",
|
||||
.engine = sqlite_db,
|
||||
.connect = "file:activity.db?mode=rwc",
|
||||
.migrations = {(asset){
|
||||
#embed "create_activity_table.sql"
|
||||
}}
|
||||
}},
|
||||
|
||||
.modules = {sqlite, session_auth}
|
||||
};
|
||||
}
|
||||
13
08_todo_events/activity/activity.mustache.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
{{^activity}}
|
||||
<p>no activity</p>
|
||||
{{/activity}}
|
||||
{{#activity}}
|
||||
<p>activity</p>
|
||||
{{#.}}
|
||||
<p>{{action}}: {{title}} ({{created_at}})</p>
|
||||
{{/.}}
|
||||
{{/activity}}
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
8
08_todo_events/activity/create_activity_table.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_user_id ON activity(user_id);
|
||||
5
08_todo_events/activity/get_activities.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
select action, title, created_at
|
||||
from activity
|
||||
where user_id = {{user_id}}
|
||||
order by created_at desc
|
||||
limit 50;
|
||||
2
08_todo_events/activity/insert_activity.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
insert into activity(user_id, action, title)
|
||||
values({{user_id}}, 'created', {{title}});
|
||||
42
08_todo_events/main.c
Normal file
@@ -0,0 +1,42 @@
|
||||
#include <mach.h>
|
||||
#include <session_auth.h>
|
||||
#include "todos/todos.c"
|
||||
#include "activity/activity.c"
|
||||
|
||||
config mach(){
|
||||
return (config) {
|
||||
.resources = {{
|
||||
{"home", "/",
|
||||
.get = {{
|
||||
render((r){(asset){
|
||||
#embed "static/home.mustache.html"
|
||||
}})
|
||||
}}
|
||||
}},
|
||||
|
||||
.before = {session()},
|
||||
|
||||
.context = {
|
||||
{"layout", (asset){
|
||||
#embed "static/layout.mustache.html"
|
||||
}}
|
||||
},
|
||||
|
||||
.errors = {
|
||||
{http_error, {
|
||||
render((r){(asset){
|
||||
#embed "static/5xx.mustache.html"
|
||||
}})
|
||||
}},
|
||||
|
||||
{http_not_found, {
|
||||
render((r){(asset){
|
||||
#embed "static/404.mustache.html"
|
||||
}})
|
||||
}}
|
||||
}
|
||||
},
|
||||
|
||||
.modules = {todos, activity, session_auth}
|
||||
};
|
||||
}
|
||||
0
08_todo_events/public/favicon.png
Normal file
5
08_todo_events/static/404.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>not found</p>
|
||||
{{/body}}
|
||||
{{/layout}};
|
||||
5
08_todo_events/static/5xx.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>error</p>
|
||||
{{/body}}
|
||||
{{/layout}};
|
||||
5
08_todo_events/static/home.mustache.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<p>home</p>
|
||||
{{/body}}
|
||||
{{/layout}};
|
||||
20
08_todo_events/static/layout.mustache.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<html>
|
||||
<head>
|
||||
<link rel='icon' href='/favicon.png'>
|
||||
</head>
|
||||
<body>
|
||||
<p>
|
||||
{{^user}}
|
||||
<a href='/login'>sign in</a>
|
||||
{{/user}}
|
||||
{{#user}}
|
||||
welcome, {{short_name}}
|
||||
{{/user}}
|
||||
</p>
|
||||
<nav>
|
||||
<a href='/'>home</a>
|
||||
</nav>
|
||||
{{$body}}
|
||||
{{/body}}
|
||||
</body>
|
||||
</html>
|
||||
2
08_todo_events/todos/create_todo.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
insert into todos(user_id, title)
|
||||
values({{user_id}}, {{title}});
|
||||
7
08_todo_events/todos/create_todos_table.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
finished INTEGER CHECK(finished IN (1))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id);
|
||||
3
08_todo_events/todos/delete_todo.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
delete from todos
|
||||
where user_id = {{user_id}}
|
||||
and id = {{id}};
|
||||
3
08_todo_events/todos/get_todos.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
select id, title, finished
|
||||
from todos
|
||||
where user_id = {{user_id}};
|
||||
17
08_todo_events/todos/todo.mustache.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<div>
|
||||
<form action="/todos/{{id}}" method="post" style="display:inline">
|
||||
<input type="hidden" name="http_method" value="PATCH">
|
||||
{{^finished}}
|
||||
<input type="checkbox" name="finished" value="1">
|
||||
{{/finished}}
|
||||
{{#finished}}
|
||||
<input type="checkbox" name="finished" value="1" checked>
|
||||
{{/finished}}
|
||||
{{title}}
|
||||
<button type="submit">save</button>
|
||||
</form>
|
||||
<form action="/todos/{{id}}" method="post" style="display:inline">
|
||||
<input type="hidden" name="http_method" value="delete">
|
||||
<button type="submit">delete</button>
|
||||
</form>
|
||||
</div>
|
||||
108
08_todo_events/todos/todos.c
Normal file
@@ -0,0 +1,108 @@
|
||||
#include <mach.h>
|
||||
#include <sqlite.h>
|
||||
#include <session_auth.h>
|
||||
|
||||
config todos(){
|
||||
return (config) {
|
||||
.name = "todos",
|
||||
|
||||
.resources = {{
|
||||
{"todos", "/todos",
|
||||
.get = {{
|
||||
query((d){
|
||||
.set = "todos",
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "get_todos.sql"
|
||||
}
|
||||
}),
|
||||
render((r){(asset){
|
||||
#embed "todos.mustache.html"
|
||||
}})
|
||||
}},
|
||||
|
||||
.post = {{
|
||||
validate((v){
|
||||
.name = "title",
|
||||
.validation = "^\\S{1,16}$",
|
||||
.message = "must be 1-16 characters, no spaces"
|
||||
}),
|
||||
query((d){
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "create_todo.sql"
|
||||
}
|
||||
}),
|
||||
emit((e){"todo_created"}),
|
||||
redirect((u){"todos"})
|
||||
}},
|
||||
|
||||
.context = {
|
||||
{"todo", (asset){
|
||||
#embed "todo.mustache.html"
|
||||
}}
|
||||
}
|
||||
},
|
||||
|
||||
{"todo", "/todos/:id",
|
||||
.patch = {{
|
||||
validate((v){
|
||||
.name = "finished",
|
||||
.optional = true,
|
||||
.validation = "1",
|
||||
.message = "must be 1"
|
||||
}),
|
||||
find((d){
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "update_todo.sql"
|
||||
}
|
||||
}),
|
||||
redirect((u){"todos"})
|
||||
}},
|
||||
.delete = {{
|
||||
find((d){
|
||||
.db = "todos_db",
|
||||
.query = (asset){
|
||||
#embed "delete_todo.sql"
|
||||
}
|
||||
}),
|
||||
redirect((u){"todos"})
|
||||
}},
|
||||
|
||||
.before = {
|
||||
validate((v){
|
||||
.name = "id",
|
||||
.validation = "^\\d{1,10}$",
|
||||
.message = "must be between 1-9999999999"
|
||||
})
|
||||
}
|
||||
}},
|
||||
|
||||
.before = {
|
||||
session(),
|
||||
logged_in()
|
||||
}
|
||||
},
|
||||
|
||||
.databases = {{
|
||||
.engine = sqlite_db,
|
||||
.name = "todos_db",
|
||||
.connect = "file:todo.db?mode=rwc",
|
||||
.migrations = {(asset){
|
||||
#embed "create_todos_table.sql"
|
||||
}}
|
||||
}},
|
||||
|
||||
.publishes = {
|
||||
{"todo_created",
|
||||
.with = {
|
||||
"user_id",
|
||||
"title"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
.modules = {sqlite, session_auth}
|
||||
};
|
||||
}
|
||||
16
08_todo_events/todos/todos.mustache.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<form action="/todos" method="post">
|
||||
<input type="text" name="title" placeholder="new todo" required>
|
||||
<button type="submit">add</button>
|
||||
</form>
|
||||
{{^todos}}
|
||||
<p>no todos</p>
|
||||
{{/todos}}
|
||||
{{#todos}}
|
||||
{{#.}}
|
||||
{{> todo}}
|
||||
{{/.}}
|
||||
{{/todos}}
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
4
08_todo_events/todos/update_todo.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
update todos
|
||||
set finished = {{finished}}
|
||||
where user_id = {{user_id}}
|
||||
and id = {{id}};
|
||||
89
09-database-multi-tenancy.svg
Normal file
@@ -0,0 +1,89 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 340" width="760" height="340">
|
||||
<rect width="760" height="340" fill="#09080f"/>
|
||||
<defs>
|
||||
<linearGradient id="dbG" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#8b5cf6"/>
|
||||
<stop offset="100%" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
<style>text{font-family:'JetBrains Mono','Fira Code',monospace}</style>
|
||||
</defs>
|
||||
|
||||
<text x="380" y="28" text-anchor="middle" fill="#e2e8f0" font-size="13" font-weight="700">Database Multi-Tenancy</text>
|
||||
|
||||
<!-- Config block -->
|
||||
<rect x="40" y="50" width="320" height="86" rx="10" fill="#111827" stroke="#1f2937" stroke-width="1"/>
|
||||
<text x="60" y="74" fill="#6b7280" font-size="9" font-weight="600">.databases config</text>
|
||||
<text x="60" y="96" fill="#c4b5fd" font-size="10">.connect =</text>
|
||||
<text x="60" y="114" fill="#fcd34d" font-size="10">"file:<tspan fill="#f59e0b" font-weight="700">{{user_id}}</tspan>_todo.db?mode=rwc"</text>
|
||||
|
||||
<!-- Arrow right -->
|
||||
<line x1="360" y1="93" x2="398" y2="93" stroke="#64748b" stroke-width="1.5"/>
|
||||
<polygon points="408,93 398,88 398,98" fill="#64748b"/>
|
||||
|
||||
<!-- Resolution box -->
|
||||
<rect x="408" y="50" width="180" height="86" rx="10" fill="#111827" stroke="#f59e0b" stroke-width="1"/>
|
||||
<text x="428" y="74" fill="#f59e0b" font-size="9" font-weight="600">RUNTIME RESOLUTION</text>
|
||||
<text x="428" y="96" fill="#94a3b8" font-size="10">user_id from</text>
|
||||
<text x="428" y="114" fill="#94a3b8" font-size="10">pipeline context</text>
|
||||
|
||||
<!-- Three request arrows going down -->
|
||||
<!-- User alice -->
|
||||
<rect x="40" y="170" width="180" height="36" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="1"/>
|
||||
<text x="130" y="192" text-anchor="middle" fill="#93c5fd" font-size="10">Request: user_id = alice</text>
|
||||
|
||||
<!-- User bob -->
|
||||
<rect x="40" y="224" width="180" height="36" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="1"/>
|
||||
<text x="130" y="246" text-anchor="middle" fill="#93c5fd" font-size="10">Request: user_id = bob</text>
|
||||
|
||||
<!-- User carol -->
|
||||
<rect x="40" y="278" width="180" height="36" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="1"/>
|
||||
<text x="130" y="300" text-anchor="middle" fill="#93c5fd" font-size="10">Request: user_id = carol</text>
|
||||
|
||||
<!-- Arrows to databases -->
|
||||
<line x1="220" y1="188" x2="398" y2="188" stroke="#64748b" stroke-width="1"/>
|
||||
<polygon points="408,188 398,183 398,193" fill="#64748b"/>
|
||||
|
||||
<line x1="220" y1="242" x2="398" y2="242" stroke="#64748b" stroke-width="1"/>
|
||||
<polygon points="408,242 398,237 398,247" fill="#64748b"/>
|
||||
|
||||
<line x1="220" y1="296" x2="398" y2="296" stroke="#64748b" stroke-width="1"/>
|
||||
<polygon points="408,296 398,291 398,301" fill="#64748b"/>
|
||||
|
||||
<!-- Database: alice -->
|
||||
<rect x="408" y="170" width="220" height="36" rx="8" fill="#2d2640" stroke="#8b5cf6" stroke-width="1.5"/>
|
||||
<text x="440" y="192" fill="#c4b5fd" font-size="10">file:</text>
|
||||
<text x="472" y="192" fill="#fcd34d" font-size="10" font-weight="700">alice</text>
|
||||
<text x="505" y="192" fill="#c4b5fd" font-size="10">_todo.db</text>
|
||||
|
||||
<!-- Database: bob -->
|
||||
<rect x="408" y="224" width="220" height="36" rx="8" fill="#2d2640" stroke="#8b5cf6" stroke-width="1.5"/>
|
||||
<text x="440" y="246" fill="#c4b5fd" font-size="10">file:</text>
|
||||
<text x="472" y="246" fill="#fcd34d" font-size="10" font-weight="700">bob</text>
|
||||
<text x="495" y="246" fill="#c4b5fd" font-size="10">_todo.db</text>
|
||||
|
||||
<!-- Database: carol -->
|
||||
<rect x="408" y="278" width="220" height="36" rx="8" fill="#2d2640" stroke="#8b5cf6" stroke-width="1.5"/>
|
||||
<text x="440" y="300" fill="#c4b5fd" font-size="10">file:</text>
|
||||
<text x="472" y="300" fill="#fcd34d" font-size="10" font-weight="700">carol</text>
|
||||
<text x="505" y="300" fill="#c4b5fd" font-size="10">_todo.db</text>
|
||||
|
||||
<!-- DB icons (simple cylinder shapes) -->
|
||||
<g transform="translate(648, 170)">
|
||||
<ellipse cx="18" cy="6" rx="18" ry="6" fill="#7c3aed" opacity="0.5"/>
|
||||
<rect x="0" y="6" width="36" height="24" fill="#7c3aed" opacity="0.3"/>
|
||||
<ellipse cx="18" cy="30" rx="18" ry="6" fill="#7c3aed" opacity="0.5"/>
|
||||
</g>
|
||||
<g transform="translate(648, 224)">
|
||||
<ellipse cx="18" cy="6" rx="18" ry="6" fill="#7c3aed" opacity="0.5"/>
|
||||
<rect x="0" y="6" width="36" height="24" fill="#7c3aed" opacity="0.3"/>
|
||||
<ellipse cx="18" cy="30" rx="18" ry="6" fill="#7c3aed" opacity="0.5"/>
|
||||
</g>
|
||||
<g transform="translate(648, 278)">
|
||||
<ellipse cx="18" cy="6" rx="18" ry="6" fill="#7c3aed" opacity="0.5"/>
|
||||
<rect x="0" y="6" width="36" height="24" fill="#7c3aed" opacity="0.3"/>
|
||||
<ellipse cx="18" cy="30" rx="18" ry="6" fill="#7c3aed" opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- Footer -->
|
||||
<text x="380" y="336" text-anchor="middle" fill="#64748b" font-size="10">One config, per-user databases — Mustache interpolation in .connect at runtime</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
1
09_roundest_pokemon_htmx/README.md
Normal file
@@ -0,0 +1 @@
|
||||
compare to https://github.com/t3dotgg/1app5stacks
|
||||
180
09_roundest_pokemon_htmx/main.c
Normal file
@@ -0,0 +1,180 @@
|
||||
#include <mach.h>
|
||||
#include <htmx.h>
|
||||
#include <sqlite.h>
|
||||
#include <tailwind.h>
|
||||
|
||||
void assign_opponents(){
|
||||
auto const t = get("challengers");
|
||||
auto const p0 = table_get(t, 0);
|
||||
auto const p1 = table_get(t, 1);
|
||||
record_set(p0, "opponent_id", record_get(p1, "id"));
|
||||
record_set(p1, "opponent_id", record_get(p0, "id"));
|
||||
}
|
||||
|
||||
config mach(){
|
||||
return (config) {
|
||||
.resources = {{
|
||||
{"home", "/",
|
||||
.get = {{
|
||||
query((d){
|
||||
.set = "challengers",
|
||||
.db = "pokemon_db",
|
||||
.query =
|
||||
"select id, name, sprite "
|
||||
"from pokemons "
|
||||
"order by random() "
|
||||
"limit 2;"
|
||||
}),
|
||||
invoke((i){assign_opponents}),
|
||||
render((r){.asset = "home"})
|
||||
}},
|
||||
|
||||
.post = {{
|
||||
validate((v){
|
||||
.name = "winner",
|
||||
.validation = "^\\d{1,8}$",
|
||||
.message = "must be 1-99999999"
|
||||
}),
|
||||
validate((v){
|
||||
.name = "loser",
|
||||
.validation = "^\\d{1,8}$",
|
||||
.message = "must be 1-99999999"
|
||||
}),
|
||||
find((d){
|
||||
.db = "pokemon_db",
|
||||
.query =
|
||||
"begin transaction; "
|
||||
"update pokemons "
|
||||
"set wins = wins + 1 "
|
||||
"where id = {{winner}}; "
|
||||
"update pokemons "
|
||||
"set loses = loses + 1 "
|
||||
"where id = {{loser}}; "
|
||||
"commit;"
|
||||
}),
|
||||
reroute((u){"home"})
|
||||
}}
|
||||
},
|
||||
|
||||
{"result", "/results",
|
||||
.get = {{
|
||||
query((d){
|
||||
.set = "results",
|
||||
.db = "pokemon_db",
|
||||
.query =
|
||||
"select id, name, sprite, wins, loses, "
|
||||
"cast(wins as real) / nullif(loses, 0) as ratio_of_wins_to_loses, "
|
||||
"row_number() over (order by (cast(wins as real) / nullif(loses, 0)) desc) as rank "
|
||||
"from pokemons "
|
||||
"order by ratio_of_wins_to_loses desc;"
|
||||
}),
|
||||
render((r){
|
||||
"{{< home}}"
|
||||
"{{$body}}"
|
||||
"<div class='container mx-auto px-4 py-8 text-white'>"
|
||||
"<div class='grid gap-4'>"
|
||||
"{{#results}}"
|
||||
"<div class='flex items-center gap-6 p-6 bg-gray-800/40 rounded-lg shadow hover:shadow-md transition-shadow'>"
|
||||
"<div class='text-2xl font-bold text-gray-400 w-8'>"
|
||||
"{{rank}}"
|
||||
"</div>"
|
||||
"<img src='{{sprite}}' alt='{{name}}' loading='lazy' class='w-20 h-20'>"
|
||||
"<div class='flex-grow'>"
|
||||
"<div class='text-gray-400 text-sm'>#{{id}}</div>"
|
||||
"<h2 class='text-xl font-semibold capitalize'>{{name}}</h2>"
|
||||
"</div>"
|
||||
"<div class='text-right'>"
|
||||
"<div class='text-2xl font-bold text-blue-400'>"
|
||||
"{{ratio_of_wins_to_loses}}"
|
||||
"</div>"
|
||||
"<div class='text-sm text-gray-400'>"
|
||||
"{{wins}}W - {{loses}}L"
|
||||
"</div>"
|
||||
"</div>"
|
||||
"</div>"
|
||||
"{{/results}}"
|
||||
"</div>"
|
||||
"</div>"
|
||||
"{{/body}}"
|
||||
"{{/home}}"
|
||||
})
|
||||
}}
|
||||
}},
|
||||
|
||||
.context = {
|
||||
{"home",
|
||||
"<!DOCTYPE html>"
|
||||
"<html>"
|
||||
"<head>"
|
||||
"<title>Roundest (MaCH Version)</title>"
|
||||
"{{> htmx_script}}"
|
||||
"{{> tailwind_script}}"
|
||||
"</head>"
|
||||
"<body hx-boost='true'>"
|
||||
"<div class='bg-gray-950 text-white flex flex-col justify-between font-geist min-h-screen border-t-2 border-red-300'>"
|
||||
"<nav class='flex flex-row justify-between items-center py-4 px-8'>"
|
||||
"<div class='flex items-baseline'>"
|
||||
"<a href='{{url:home}}' class='font-bold text-3xl'>"
|
||||
"Round<span class='text-red-300'>est</span>"
|
||||
"</a>"
|
||||
"<p class='text-gray-400 font-extralight pl-2 text-2xl'>"
|
||||
"(MaCH Version)"
|
||||
"</p>"
|
||||
"</div>"
|
||||
"<div class='flex flex-row items-center gap-8'>"
|
||||
"<a href='{{url:results}}' class='hover:underline text-lg'>Results</a>"
|
||||
"</div>"
|
||||
"</nav>"
|
||||
"<main>"
|
||||
"{{$body}}"
|
||||
"<div class='container mx-auto px-4'>"
|
||||
"<h1 class='text-3xl font-bold text-center mb-8'>Vote for which is roundest</h1>"
|
||||
"<div class='flex justify-center gap-16 items-center min-h-[80vh]'>"
|
||||
"{{#challengers}}"
|
||||
"<form action='{{url:home}}' method='post'>"
|
||||
"<div class='flex flex-col items-center gap-4'>"
|
||||
"<img src='{{sprite}}' alt='{{name}}' class='w-64 h-64 sprite'>"
|
||||
"<div class='text-center'>"
|
||||
"<span class='text-gray-500 text-lg'>#{{id}}</span>"
|
||||
"<h2 class='text-2xl font-bold capitalize'>{{name}}</h2>"
|
||||
"<input type='hidden' name='winner' value='{{id}}'>"
|
||||
"<input type='hidden' name='loser' value='{{opponent_id}}'>"
|
||||
"<button class='mt-4 px-8 py-3 bg-blue-500 text-white rounded-lg text-lg font-semibold hover:bg-blue-600 transition-colors'>"
|
||||
"Vote"
|
||||
"</button>"
|
||||
"</div>"
|
||||
"</div>"
|
||||
"</form>"
|
||||
"{{/challengers}}"
|
||||
"</div>"
|
||||
"</div>"
|
||||
"{{/body}}"
|
||||
"</main>"
|
||||
"<footer class='font-light text-center py-3 text-gray-500'>"
|
||||
"<a href='https://code.nightshadecoder.dev/nightshade/mach_examples' target='_blank'>GitHub</a>"
|
||||
"</footer>"
|
||||
"</div>"
|
||||
"</body>"
|
||||
"</html>"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
.databases = {{
|
||||
.name = "pokemon_db",
|
||||
.engine = sqlite_db,
|
||||
.connect = "file::memory:?cache=shared",
|
||||
.migrations = {
|
||||
"CREATE TABLE IF NOT EXISTS pokemons ( "
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"name TEXT NOT NULL, "
|
||||
"sprite TEXT NOT NULL, "
|
||||
"wins INTEGER NOT NULL DEFAULT 0, "
|
||||
"loses INTEGER NOT NULL DEFAULT 0 "
|
||||
");"
|
||||
}
|
||||
}},
|
||||
|
||||
.modules = {htmx, sqlite, tailwind}
|
||||
};
|
||||
}
|
||||
82
10-boot-time-compilation.svg
Normal file
@@ -0,0 +1,82 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 300" width="760" height="300">
|
||||
<rect width="760" height="300" fill="#09080f"/>
|
||||
<defs>
|
||||
<linearGradient id="bootG" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#7c3aed"/>
|
||||
<stop offset="100%" stop-color="#a855f7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="warmG" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#f59e0b"/>
|
||||
<stop offset="100%" stop-color="#fbbf24"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="runG" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#10b981"/>
|
||||
<stop offset="100%" stop-color="#34d399"/>
|
||||
</linearGradient>
|
||||
<style>text{font-family:'JetBrains Mono','Fira Code',monospace}</style>
|
||||
</defs>
|
||||
|
||||
<text x="380" y="28" text-anchor="middle" fill="#e2e8f0" font-size="13" font-weight="700">Boot-Time Compilation</text>
|
||||
|
||||
<!-- Phase 1: config() -->
|
||||
<rect x="30" y="50" width="200" height="110" rx="10" fill="#1e1b2e" stroke="#7c3aed" stroke-width="1.5"/>
|
||||
<rect x="30" y="50" width="200" height="32" rx="10" fill="url(#bootG)"/>
|
||||
<text x="130" y="71" text-anchor="middle" fill="#fff" font-size="11" font-weight="700">config mach()</text>
|
||||
<text x="50" y="96" fill="#c4b5fd" font-size="9">.includes</text>
|
||||
<text x="50" y="112" fill="#c4b5fd" font-size="9">.databases</text>
|
||||
<text x="50" y="128" fill="#c4b5fd" font-size="9">.resources</text>
|
||||
<text x="50" y="144" fill="#c4b5fd" font-size="9">.events / .errors</text>
|
||||
|
||||
<!-- Arrow 1 -->
|
||||
<line x1="230" y1="105" x2="268" y2="105" stroke="#64748b" stroke-width="1.5"/>
|
||||
<polygon points="278,105 268,100 268,110" fill="#64748b"/>
|
||||
|
||||
<!-- Phase 2: Precompile -->
|
||||
<rect x="278" y="50" width="200" height="110" rx="10" fill="#1e1b2e" stroke="#f59e0b" stroke-width="1.5"/>
|
||||
<rect x="278" y="50" width="200" height="32" rx="10" fill="url(#warmG)"/>
|
||||
<text x="378" y="71" text-anchor="middle" fill="#1e1b2e" font-size="11" font-weight="700">Precompile</text>
|
||||
<text x="298" y="96" fill="#fcd34d" font-size="9">Compile pipelines</text>
|
||||
<text x="298" y="112" fill="#fcd34d" font-size="9">Compile templates</text>
|
||||
<text x="298" y="128" fill="#fcd34d" font-size="9">Prepare SQL statements</text>
|
||||
<text x="298" y="144" fill="#fcd34d" font-size="9">Build route table</text>
|
||||
|
||||
<!-- Arrow 2 -->
|
||||
<line x1="478" y1="105" x2="516" y2="105" stroke="#64748b" stroke-width="1.5"/>
|
||||
<polygon points="526,105 516,100 516,110" fill="#64748b"/>
|
||||
|
||||
<!-- Phase 3: Execution Graph -->
|
||||
<rect x="526" y="50" width="200" height="110" rx="10" fill="#1e1b2e" stroke="#10b981" stroke-width="1.5"/>
|
||||
<rect x="526" y="50" width="200" height="32" rx="10" fill="url(#runG)"/>
|
||||
<text x="626" y="71" text-anchor="middle" fill="#fff" font-size="11" font-weight="700">Execution Graph</text>
|
||||
<text x="546" y="96" fill="#6ee7b7" font-size="9">Pre-warmed pipelines</text>
|
||||
<text x="546" y="112" fill="#6ee7b7" font-size="9">Optimized query plans</text>
|
||||
<text x="546" y="128" fill="#6ee7b7" font-size="9">Compiled templates</text>
|
||||
<text x="546" y="144" fill="#6ee7b7" font-size="9">Ready to serve</text>
|
||||
|
||||
<!-- Phase labels -->
|
||||
<text x="130" y="180" text-anchor="middle" fill="#7c3aed" font-size="9" font-weight="600">RUNS ONCE AT BOOT</text>
|
||||
<text x="378" y="180" text-anchor="middle" fill="#f59e0b" font-size="9" font-weight="600">RUNS ONCE AT BOOT</text>
|
||||
<text x="626" y="180" text-anchor="middle" fill="#10b981" font-size="9" font-weight="600">SERVES ALL REQUESTS</text>
|
||||
|
||||
<!-- Runtime section -->
|
||||
<rect x="30" y="200" width="696" height="76" rx="10" fill="#0f0d1a" stroke="#10b981" stroke-width="1" stroke-dasharray="5,3"/>
|
||||
<text x="50" y="224" fill="#6ee7b7" font-size="10" font-weight="600">Runtime: each request executes a pre-warmed pipeline</text>
|
||||
|
||||
<!-- Request arrows -->
|
||||
<text x="50" y="248" fill="#475569" font-size="9">req →</text>
|
||||
<rect x="90" y="236" width="80" height="24" rx="4" fill="#1e293b" stroke="#10b981" stroke-width="0.75"/>
|
||||
<text x="130" y="252" text-anchor="middle" fill="#6ee7b7" font-size="9">pipeline</text>
|
||||
<line x1="170" y1="248" x2="184" y2="248" stroke="#10b981" stroke-width="1"/>
|
||||
<polygon points="192,248 184,244 184,252" fill="#10b981"/>
|
||||
<text x="200" y="252" fill="#475569" font-size="9">→ response</text>
|
||||
|
||||
<text x="320" y="248" fill="#475569" font-size="9">req →</text>
|
||||
<rect x="360" y="236" width="80" height="24" rx="4" fill="#1e293b" stroke="#10b981" stroke-width="0.75"/>
|
||||
<text x="400" y="252" text-anchor="middle" fill="#6ee7b7" font-size="9">pipeline</text>
|
||||
<line x1="440" y1="248" x2="454" y2="248" stroke="#10b981" stroke-width="1"/>
|
||||
<polygon points="462,248 454,244 454,252" fill="#10b981"/>
|
||||
<text x="470" y="252" fill="#475569" font-size="9">→ response</text>
|
||||
|
||||
<!-- Footer -->
|
||||
<text x="380" y="296" text-anchor="middle" fill="#64748b" font-size="10">Zero per-request compilation — config builds the graph, requests just execute it</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
189
10_fetch_weather/main.c
Normal file
@@ -0,0 +1,189 @@
|
||||
#include <mach.h>
|
||||
|
||||
config mach() {
|
||||
return (config) {
|
||||
.resources = {{
|
||||
{"home", "/",
|
||||
.get = {{
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"<div class='card'>"
|
||||
"<h1>Weather Lookup</h1>"
|
||||
"<p class='sub'>Enter a US zip code to get the current forecast</p>"
|
||||
"<form action='{{url:weather}}' method='get'>"
|
||||
"<div class='form-row'>"
|
||||
"<input type='text' name='zipcode' placeholder='e.g. 90210' "
|
||||
"maxlength='5' pattern='[0-9]{5}' required />"
|
||||
"<button type='submit'>Search</button>"
|
||||
"</div>"
|
||||
"</form>"
|
||||
"</div>"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}}
|
||||
},
|
||||
|
||||
{"weather", "/weather",
|
||||
.get = {{
|
||||
validate((v){
|
||||
.name = "zipcode",
|
||||
.validation = "^[0-9]{5}$",
|
||||
.message = "Zip code must be exactly 5 digits"
|
||||
}),
|
||||
fetch((f){
|
||||
.set = "weather",
|
||||
.url = "https://wttr.in/{{zipcode}}?format=j1",
|
||||
}),
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"<div class='card'>"
|
||||
"<h1>Current Weather</h1>"
|
||||
"{{#nearest_area}}"
|
||||
"<p class='location'>{{areaName}} — {{region}}, {{country}}</p>"
|
||||
"{{/nearest_area}}"
|
||||
"{{#current_condition}}"
|
||||
"<div class='temp-main'>{{temp_F}}°F</div>"
|
||||
"<div class='desc'>{{weatherDesc}}</div>"
|
||||
"<div class='details'>"
|
||||
"<div class='detail'>"
|
||||
"<div class='label'>Feels Like</div>"
|
||||
"<div class='value'>{{FeelsLikeF}}°F</div>"
|
||||
"</div>"
|
||||
"<div class='detail'>"
|
||||
"<div class='label'>Humidity</div>"
|
||||
"<div class='value'>{{humidity}}%</div>"
|
||||
"</div>"
|
||||
"<div class='detail'>"
|
||||
"<div class='label'>Wind</div>"
|
||||
"<div class='value'>{{windspeedMiles}} mph</div>"
|
||||
"</div>"
|
||||
"<div class='detail'>"
|
||||
"<div class='label'>UV Index</div>"
|
||||
"<div class='value'>{{uvIndex}}</div>"
|
||||
"</div>"
|
||||
"</div>"
|
||||
"{{/current_condition}}"
|
||||
"<a class='back' href='{{url:home}}'>← Search another zip code</a>"
|
||||
"</div>"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}},
|
||||
|
||||
.errors = {
|
||||
{http_bad_request, {
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"<div class='card center'>"
|
||||
"<h1 class='error-icon'>Oops</h1>"
|
||||
"<p class='error-msg'>Invalid zip code. Please enter exactly 5 digits.</p>"
|
||||
"<a class='back' href='{{url:home}}'>← Try again</a>"
|
||||
"</div>"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}}
|
||||
}
|
||||
}
|
||||
}},
|
||||
|
||||
.context = {
|
||||
{"layout",
|
||||
"<html>"
|
||||
"<head>"
|
||||
"<title>Weather App</title>"
|
||||
"<style>"
|
||||
"* { margin: 0; padding: 0; box-sizing: border-box; }"
|
||||
"body {"
|
||||
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;"
|
||||
"background: linear-gradient(135deg, #0f2027, #203a43, #2c5364);"
|
||||
"min-height: 100vh;"
|
||||
"display: flex;"
|
||||
"justify-content: center;"
|
||||
"align-items: center;"
|
||||
"color: #e0e0e0;"
|
||||
"}"
|
||||
".card {"
|
||||
"background: rgba(255,255,255,0.08);"
|
||||
"backdrop-filter: blur(12px);"
|
||||
"border-radius: 16px;"
|
||||
"padding: 48px;"
|
||||
"width: 500px;"
|
||||
"box-shadow: 0 8px 32px rgba(0,0,0,0.3);"
|
||||
"border: 1px solid rgba(255,255,255,0.1);"
|
||||
"}"
|
||||
".card.center { text-align: center; }"
|
||||
"h1 { font-size: 24px; margin-bottom: 4px; }"
|
||||
".sub { color: #9ab; margin-bottom: 32px; font-size: 14px; }"
|
||||
".location { color: #9ab; font-size: 14px; margin-bottom: 32px; }"
|
||||
".form-row { display: flex; gap: 12px; }"
|
||||
"input {"
|
||||
"flex: 1;"
|
||||
"padding: 14px 18px;"
|
||||
"border-radius: 10px;"
|
||||
"border: 1px solid rgba(255,255,255,0.15);"
|
||||
"background: rgba(255,255,255,0.06);"
|
||||
"color: #fff;"
|
||||
"font-size: 16px;"
|
||||
"outline: none;"
|
||||
"}"
|
||||
"input::placeholder { color: #789; }"
|
||||
"input:focus { border-color: #5ba3d9; }"
|
||||
"button {"
|
||||
"padding: 14px 28px;"
|
||||
"border-radius: 10px;"
|
||||
"border: none;"
|
||||
"background: #5ba3d9;"
|
||||
"color: #fff;"
|
||||
"font-size: 16px;"
|
||||
"font-weight: 600;"
|
||||
"cursor: pointer;"
|
||||
"}"
|
||||
"button:hover { background: #4a8ec4; }"
|
||||
".temp-main {"
|
||||
"font-size: 72px;"
|
||||
"font-weight: 700;"
|
||||
"line-height: 1;"
|
||||
"margin-bottom: 4px;"
|
||||
"}"
|
||||
".desc {"
|
||||
"font-size: 18px;"
|
||||
"color: #acd;"
|
||||
"margin-bottom: 32px;"
|
||||
"}"
|
||||
".details {"
|
||||
"display: grid;"
|
||||
"grid-template-columns: 1fr 1fr;"
|
||||
"gap: 16px;"
|
||||
"}"
|
||||
".detail {"
|
||||
"background: rgba(255,255,255,0.05);"
|
||||
"border-radius: 10px;"
|
||||
"padding: 16px;"
|
||||
"}"
|
||||
".detail .label { font-size: 12px; color: #789; text-transform: uppercase; }"
|
||||
".detail .value { font-size: 20px; font-weight: 600; margin-top: 4px; }"
|
||||
".back {"
|
||||
"display: inline-block;"
|
||||
"margin-top: 32px;"
|
||||
"color: #5ba3d9;"
|
||||
"text-decoration: none;"
|
||||
"font-size: 14px;"
|
||||
"}"
|
||||
".back:hover { text-decoration: underline; }"
|
||||
".error-icon { font-size: 48px; margin-bottom: 16px; }"
|
||||
".error-msg { color: #e88; font-size: 16px; margin-bottom: 24px; }"
|
||||
"</style>"
|
||||
"</head>"
|
||||
"<body>"
|
||||
"{{$body}}{{/body}}"
|
||||
"</body>"
|
||||
"</html>"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
437
11_rails_15_min_blog/main.c
Normal file
@@ -0,0 +1,437 @@
|
||||
#include <mach.h>
|
||||
#include <sqlite.h>
|
||||
#include <tailwind.h>
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 15 Minute Blog in MACH
|
||||
// ─────────────────────────────────────────────
|
||||
// Resources:
|
||||
// GET / → redirect to posts
|
||||
// GET /posts → list all posts
|
||||
// POST /posts → create a post
|
||||
// GET /posts/new → new post form
|
||||
// GET /posts/:id → show a post
|
||||
// PATCH /posts/:id → update a post
|
||||
// DEL /posts/:id → delete a post
|
||||
// GET /posts/:id/edit→ edit post form
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
config mach() {
|
||||
return (config) {
|
||||
|
||||
// ── Resources ──────────────────────────────
|
||||
|
||||
.resources = {{
|
||||
|
||||
// Home: just redirect to the posts index
|
||||
{"home", "/",
|
||||
.get = {{
|
||||
redirect((u){"posts"})
|
||||
}}
|
||||
},
|
||||
|
||||
// Posts index + create
|
||||
{"posts", "/posts",
|
||||
.get = {{
|
||||
query((d){
|
||||
.set = "posts",
|
||||
.db = "blog_db",
|
||||
"select id, title, body, created_at "
|
||||
"from posts "
|
||||
"order by created_at desc;"
|
||||
}),
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
|
||||
"<div class=\"flex items-center justify-between mb-8\">"
|
||||
"<h1 class=\"text-3xl font-bold text-gray-900\">"
|
||||
"All Posts"
|
||||
"</h1>"
|
||||
"<a href=\"{{url:new_post}}\" "
|
||||
"class=\"bg-blue-600 text-white px-4 py-2 rounded-lg "
|
||||
"hover:bg-blue-700 transition\">"
|
||||
"New Post"
|
||||
"</a>"
|
||||
"</div>"
|
||||
|
||||
"{{#posts}}"
|
||||
"{{> post_card}}"
|
||||
"{{/posts}}"
|
||||
|
||||
"{{^posts}}"
|
||||
"<div class=\"text-center py-16 text-gray-400\">"
|
||||
"<p class=\"text-xl\">No posts yet.</p>"
|
||||
"<a href=\"{{url:new_post}}\" "
|
||||
"class=\"text-blue-600 hover:underline mt-2 inline-block\">"
|
||||
"Write your first post"
|
||||
"</a>"
|
||||
"</div>"
|
||||
"{{/posts}}"
|
||||
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}},
|
||||
|
||||
.post = {{
|
||||
validate((v){
|
||||
.name = "title",
|
||||
.validation = "^.{1,200}$",
|
||||
.message = "Title is required (max 200 characters)"
|
||||
}),
|
||||
validate((v){
|
||||
.name = "body",
|
||||
.validation = "^[\\s\\S]{1,50000}$",
|
||||
.message = "Body is required"
|
||||
}),
|
||||
query((d){
|
||||
.db = "blog_db",
|
||||
"insert into posts (title, body) "
|
||||
"values ({{title}}, {{body}});"
|
||||
}),
|
||||
redirect((u){"posts"})
|
||||
}}
|
||||
},
|
||||
|
||||
// New post form
|
||||
{"new_post", "/posts/new",
|
||||
.get = {{
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"<h1 class=\"text-3xl font-bold text-gray-900 mb-8\">"
|
||||
"New Post"
|
||||
"</h1>"
|
||||
|
||||
"<form method=\"post\" action=\"{{url:posts}}\">"
|
||||
"{{> post_form}}"
|
||||
"</form>"
|
||||
|
||||
"<a href=\"{{url:posts}}\" "
|
||||
"class=\"text-gray-500 hover:text-gray-700 mt-4 inline-block\">"
|
||||
"← Back to posts"
|
||||
"</a>"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}}
|
||||
},
|
||||
|
||||
// Single post: show, update, delete
|
||||
{"post", "/posts/:id",
|
||||
.get = {{
|
||||
find((d){
|
||||
.set = "post",
|
||||
.db = "blog_db",
|
||||
"select id, title, body, created_at "
|
||||
"from posts "
|
||||
"where id = {{id}};"
|
||||
}),
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"{{#post}}"
|
||||
|
||||
"<article class=\"max-w-none\">"
|
||||
"<h1 class=\"text-4xl font-bold text-gray-900 mb-2\">"
|
||||
"{{title}}"
|
||||
"</h1>"
|
||||
"<p class=\"text-sm text-gray-400 mb-8\">"
|
||||
"Published {{created_at}}"
|
||||
"</p>"
|
||||
"<div class=\"prose prose-lg text-gray-700 "
|
||||
"whitespace-pre-line leading-relaxed\">"
|
||||
"{{body}}"
|
||||
"</div>"
|
||||
"</article>"
|
||||
|
||||
"<div class=\"flex gap-4 mt-10 pt-6 border-t border-gray-200\">"
|
||||
"<a href=\"{{url:edit_post:id}}\" "
|
||||
"class=\"bg-gray-100 text-gray-700 px-4 py-2 "
|
||||
"rounded-lg hover:bg-gray-200 transition\">"
|
||||
"Edit"
|
||||
"</a>"
|
||||
"<form method=\"post\" action=\"{{url:post:id}}\" "
|
||||
"class=\"inline\">"
|
||||
"<input type=\"hidden\" name=\"http_method\" value=\"delete\">"
|
||||
"<button type=\"submit\" "
|
||||
"class=\"bg-red-50 text-red-600 px-4 py-2 "
|
||||
"rounded-lg hover:bg-red-100 transition\" "
|
||||
"onclick=\"return confirm('Delete this post?')\">"
|
||||
"Delete"
|
||||
"</button>"
|
||||
"</form>"
|
||||
"<a href=\"{{url:posts}}\" "
|
||||
"class=\"text-gray-500 hover:text-gray-700 px-4 py-2\">"
|
||||
"← Back"
|
||||
"</a>"
|
||||
"</div>"
|
||||
|
||||
"{{/post}}"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}},
|
||||
|
||||
.patch = {{
|
||||
validate((v){
|
||||
.name = "title",
|
||||
.validation = "^.{1,200}$",
|
||||
.message = "Title is required (max 200 characters)"
|
||||
}),
|
||||
validate((v){
|
||||
.name = "body",
|
||||
.validation = "^[\\s\\S]{1,50000}$",
|
||||
.message = "Body is required"
|
||||
}),
|
||||
query((d){
|
||||
.db = "blog_db",
|
||||
"update posts "
|
||||
"set title = {{title}}, body = {{body}} "
|
||||
"where id = {{id}};"
|
||||
}),
|
||||
redirect((u){"post"})
|
||||
}},
|
||||
|
||||
.delete = {{
|
||||
query((d){
|
||||
.db = "blog_db",
|
||||
"delete from posts "
|
||||
"where id = {{id}};"
|
||||
}),
|
||||
redirect((u){"posts"})
|
||||
}},
|
||||
|
||||
.before = {
|
||||
validate((v){
|
||||
.name = "id",
|
||||
.validation = "^\\d{1,10}$",
|
||||
.message = "Invalid post ID"
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Edit post form
|
||||
{"edit_post", "/posts/:id/edit",
|
||||
.get = {{
|
||||
find((d){
|
||||
.set = "post",
|
||||
.db = "blog_db",
|
||||
"select id, title, body "
|
||||
"from posts "
|
||||
"where id = {{id}};"
|
||||
}),
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"{{#post}}"
|
||||
|
||||
"<h1 class=\"text-3xl font-bold text-gray-900 mb-8\">"
|
||||
"Edit Post"
|
||||
"</h1>"
|
||||
|
||||
"<form method=\"post\" action=\"{{url:post:id}}\">"
|
||||
"<input type=\"hidden\" name=\"http_method\" value=\"patch\">"
|
||||
"{{> post_form}}"
|
||||
"</form>"
|
||||
|
||||
"<a href=\"{{url:post:id}}\" "
|
||||
"class=\"text-gray-500 hover:text-gray-700 mt-4 "
|
||||
"inline-block\">"
|
||||
"← Back to post"
|
||||
"</a>"
|
||||
|
||||
"{{/post}}"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}},
|
||||
|
||||
.before = {
|
||||
validate((v){
|
||||
.name = "id",
|
||||
.validation = "^\\d{1,10}$",
|
||||
.message = "Invalid post ID"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
// ── Shared Layout & Partials ───────────────
|
||||
|
||||
.context = {
|
||||
// Layout — the HTML shell every page inherits from
|
||||
{"layout",
|
||||
"<!DOCTYPE html>"
|
||||
"<html lang=\"en\">"
|
||||
"<head>"
|
||||
"<meta charset=\"utf-8\">"
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
|
||||
"<title>MACH Blog</title>"
|
||||
"{{> tailwind_script}}"
|
||||
"</head>"
|
||||
"<body class=\"bg-gray-50 min-h-screen\">"
|
||||
"<nav class=\"bg-white border-b border-gray-200\">"
|
||||
"<div class=\"max-w-3xl mx-auto px-6 py-4 "
|
||||
"flex items-center justify-between\">"
|
||||
"<a href=\"{{url:posts}}\" "
|
||||
"class=\"text-xl font-bold text-gray-900 "
|
||||
"hover:text-blue-600 transition\">"
|
||||
"MACH Blog"
|
||||
"</a>"
|
||||
"<span class=\"text-xs text-gray-400\">"
|
||||
"built in 15 minutes"
|
||||
"</span>"
|
||||
"</div>"
|
||||
"</nav>"
|
||||
"<main class=\"max-w-3xl mx-auto px-6 py-10\">"
|
||||
"{{$body}}{{/body}}"
|
||||
"</main>"
|
||||
"<footer class=\"max-w-3xl mx-auto px-6 py-6 text-center "
|
||||
"text-xs text-gray-300\">"
|
||||
"Powered by MACH"
|
||||
"</footer>"
|
||||
"</body>"
|
||||
"</html>"
|
||||
},
|
||||
|
||||
// Partial: post card for the index listing
|
||||
{"post_card",
|
||||
"<article class=\"bg-white rounded-xl shadow-sm border border-gray-100 "
|
||||
"p-6 mb-4 hover:shadow-md transition\">"
|
||||
"<a href=\"{{url:post:id}}\" class=\"block\">"
|
||||
"<h2 class=\"text-xl font-semibold text-gray-900 "
|
||||
"hover:text-blue-600 transition\">"
|
||||
"{{title}}"
|
||||
"</h2>"
|
||||
"<p class=\"text-sm text-gray-400 mt-1\">"
|
||||
"{{created_at}}"
|
||||
"</p>"
|
||||
"<p class=\"text-gray-600 mt-3 line-clamp-2\">"
|
||||
"{{body}}"
|
||||
"</p>"
|
||||
"</a>"
|
||||
"</article>"
|
||||
},
|
||||
|
||||
// Partial: reusable form fields for new + edit
|
||||
{"post_form",
|
||||
"<div class=\"space-y-6\">"
|
||||
"<div>"
|
||||
"<label for=\"title\" "
|
||||
"class=\"block text-sm font-medium text-gray-700 mb-1\">"
|
||||
"Title"
|
||||
"</label>"
|
||||
"<input type=\"text\" id=\"title\" name=\"title\" "
|
||||
"value=\"{{title}}\" "
|
||||
"placeholder=\"Your post title\" "
|
||||
"class=\"w-full px-4 py-2 border border-gray-300 rounded-lg "
|
||||
"focus:ring-2 focus:ring-blue-500 focus:border-blue-500 "
|
||||
"outline-none transition\">"
|
||||
"</div>"
|
||||
"<div>"
|
||||
"<label for=\"body\" "
|
||||
"class=\"block text-sm font-medium text-gray-700 mb-1\">"
|
||||
"Body"
|
||||
"</label>"
|
||||
"<textarea id=\"body\" name=\"body\" rows=\"12\" "
|
||||
"placeholder=\"Write your post...\" "
|
||||
"class=\"w-full px-4 py-2 border border-gray-300 rounded-lg "
|
||||
"focus:ring-2 focus:ring-blue-500 "
|
||||
"focus:border-blue-500 outline-none transition "
|
||||
"resize-y\">"
|
||||
"{{body}}"
|
||||
"</textarea>"
|
||||
"</div>"
|
||||
"<button type=\"submit\" "
|
||||
"class=\"bg-blue-600 text-white px-6 py-2 rounded-lg "
|
||||
"hover:bg-blue-700 transition font-medium\">"
|
||||
"Save Post"
|
||||
"</button>"
|
||||
"</div>"
|
||||
}
|
||||
},
|
||||
|
||||
// ── Error Handling ─────────────────────────
|
||||
|
||||
.errors = {
|
||||
{http_not_found, {
|
||||
render((r){
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"<div class=\"text-center py-20\">"
|
||||
"<h1 class=\"text-6xl font-bold text-gray-200\">404</h1>"
|
||||
"<p class=\"text-gray-500 mt-4\">Post not found.</p>"
|
||||
"<a href=\"{{url:posts}}\" "
|
||||
"class=\"text-blue-600 hover:underline mt-4 inline-block\">"
|
||||
"← Back to all posts"
|
||||
"</a>"
|
||||
"</div>"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}},
|
||||
{http_bad_request, {
|
||||
render((r){
|
||||
.status = http_bad_request,
|
||||
"{{< layout}}"
|
||||
"{{$body}}"
|
||||
"<div class=\"text-center py-20\">"
|
||||
"<h1 class=\"text-4xl font-bold text-gray-200\">"
|
||||
"Validation Error"
|
||||
"</h1>"
|
||||
"<p class=\"text-red-500 mt-4\">"
|
||||
"Please check your input and try again."
|
||||
"</p>"
|
||||
"<a href=\"{{url:posts}}\" "
|
||||
"class=\"text-blue-600 hover:underline mt-4 inline-block\">"
|
||||
"← Back to all posts"
|
||||
"</a>"
|
||||
"</div>"
|
||||
"{{/body}}"
|
||||
"{{/layout}}"
|
||||
})
|
||||
}}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
// ── Database ───────────────────────────────
|
||||
|
||||
.databases = {{
|
||||
.engine = sqlite_db,
|
||||
.name = "blog_db",
|
||||
.connect = "file:blog.db?mode=rwc",
|
||||
.migrations = {
|
||||
"CREATE TABLE posts ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
"title TEXT NOT NULL,"
|
||||
"body TEXT NOT NULL,"
|
||||
"created_at TEXT NOT NULL "
|
||||
"DEFAULT (strftime('%Y-%m-%d %H:%M', 'now'))"
|
||||
");"
|
||||
},
|
||||
.seeds = {
|
||||
"INSERT INTO posts (title, body) VALUES"
|
||||
"('Hello World', "
|
||||
" 'Welcome to the MACH blog! This post was created by the "
|
||||
"database seed. MACH is a high-performance, declarative web "
|
||||
"framework for C that lets you build full CRUD apps in a "
|
||||
"single file with zero boilerplate.'),"
|
||||
"('Why Declarative C?', "
|
||||
" 'Traditional C web development means manual memory management, "
|
||||
"string wrangling, and security pitfalls at every turn. MACH "
|
||||
"flips the script: you declare resources, pipelines, and "
|
||||
"templates. The runtime handles allocation, prepared statements, "
|
||||
"XSS escaping, and async I/O. You get the speed of C with the "
|
||||
"ergonomics of a modern framework.');"
|
||||
}
|
||||
}},
|
||||
|
||||
// ── Modules ────────────────────────────────
|
||||
|
||||
.modules = {sqlite, tailwind}
|
||||
};
|
||||
}
|
||||
11
12_rails_15_min_blog_external/400.mustache.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<div class="text-center py-20">
|
||||
<h1 class="text-4xl font-bold text-gray-200">Validation Error</h1>
|
||||
<p class="text-red-500 mt-4">Please check your input and try again.</p>
|
||||
<a href="{{url:posts}}" class="text-blue-600 hover:underline mt-4 inline-block">
|
||||
← Back to all posts
|
||||
</a>
|
||||
</div>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
11
12_rails_15_min_blog_external/404.mustache.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<div class="text-center py-20">
|
||||
<h1 class="text-6xl font-bold text-gray-200">404</h1>
|
||||
<p class="text-gray-500 mt-4">Post not found.</p>
|
||||
<a href="{{url:posts}}" class="text-blue-600 hover:underline mt-4 inline-block">
|
||||
← Back to all posts
|
||||
</a>
|
||||
</div>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
6
12_rails_15_min_blog_external/create_posts.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M', 'now'))
|
||||
);
|
||||
2
12_rails_15_min_blog_external/delete_post.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DELETE FROM posts
|
||||
WHERE id = {{id}};
|
||||
16
12_rails_15_min_blog_external/edit_post.mustache.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
{{#post}}
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Edit Post</h1>
|
||||
|
||||
<form method="post" action="{{url:post:id}}">
|
||||
<input type="hidden" name="http_method" value="patch">
|
||||
{{> post_form}}
|
||||
</form>
|
||||
|
||||
<a href="{{url:post:id}}" class="text-gray-500 hover:text-gray-700 mt-4 inline-block">
|
||||
← Back to post
|
||||
</a>
|
||||
{{/post}}
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
3
12_rails_15_min_blog_external/get_post.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
SELECT id, title, body, created_at
|
||||
FROM posts
|
||||
WHERE id = {{id}};
|
||||
3
12_rails_15_min_blog_external/get_posts.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
SELECT id, title, body, created_at
|
||||
FROM posts
|
||||
ORDER BY created_at DESC;
|
||||
2
12_rails_15_min_blog_external/insert_post.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO posts (title, body)
|
||||
VALUES ({{title}}, {{body}});
|
||||
25
12_rails_15_min_blog_external/layout.mustache.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MACH Blog</title>
|
||||
{{> tailwind_script}}
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<nav class="bg-white border-b border-gray-200">
|
||||
<div class="max-w-3xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="{{url:posts}}" class="text-xl font-bold text-gray-900 hover:text-blue-600 transition">
|
||||
MACH Blog
|
||||
</a>
|
||||
<span class="text-xs text-gray-400">built in 15 minutes</span>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="max-w-3xl mx-auto px-6 py-10">
|
||||
{{$body}}{{/body}}
|
||||
</main>
|
||||
<footer class="max-w-3xl mx-auto px-6 py-6 text-center text-xs text-gray-300">
|
||||
Powered by MACH
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
205
12_rails_15_min_blog_external/main.c
Normal file
@@ -0,0 +1,205 @@
|
||||
#include <mach.h>
|
||||
#include <sqlite.h>
|
||||
#include <tailwind.h>
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 15 Minute Blog in MACH
|
||||
// ─────────────────────────────────────────────
|
||||
// Resources:
|
||||
// GET / → redirect to posts
|
||||
// GET /posts → list all posts
|
||||
// POST /posts → create a post
|
||||
// GET /posts/new → new post form
|
||||
// GET /posts/:id → show a post
|
||||
// PATCH /posts/:id → update a post
|
||||
// DEL /posts/:id → delete a post
|
||||
// GET /posts/:id/edit→ edit post form
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
config mach() {
|
||||
return (config) {
|
||||
|
||||
// ── Resources ──────────────────────────────
|
||||
|
||||
.resources = {{
|
||||
|
||||
{"home", "/",
|
||||
.get = {{
|
||||
redirect((u){"posts"})
|
||||
}}
|
||||
},
|
||||
|
||||
{"posts", "/posts",
|
||||
.get = {{
|
||||
query((d){
|
||||
.set = "posts",
|
||||
.db = "blog_db",
|
||||
(asset){
|
||||
#embed "get_posts.sql"
|
||||
}
|
||||
}),
|
||||
render((r){(asset){
|
||||
#embed "posts.mustache.html"
|
||||
}})
|
||||
}},
|
||||
|
||||
.post = {{
|
||||
validate((v){
|
||||
.name = "title",
|
||||
.validation = "^.{1,200}$",
|
||||
.message = "Title is required (max 200 characters)"
|
||||
}),
|
||||
validate((v){
|
||||
.name = "body",
|
||||
.validation = "^[\\s\\S]{1,50000}$",
|
||||
.message = "Body is required"
|
||||
}),
|
||||
query((d){
|
||||
.db = "blog_db",
|
||||
(asset){
|
||||
#embed "insert_post.sql"
|
||||
}
|
||||
}),
|
||||
redirect((u){"posts"})
|
||||
}}
|
||||
},
|
||||
|
||||
{"new_post", "/posts/new",
|
||||
.get = {{
|
||||
render((r){(asset){
|
||||
#embed "new_post.mustache.html"
|
||||
}})
|
||||
}}
|
||||
},
|
||||
|
||||
{"edit_post", "/posts/:id/edit",
|
||||
.get = {{
|
||||
find((d){
|
||||
.set = "post",
|
||||
.db = "blog_db",
|
||||
(asset){
|
||||
#embed "get_post.sql"
|
||||
}
|
||||
}),
|
||||
render((r){(asset){
|
||||
#embed "edit_post.mustache.html"
|
||||
}})
|
||||
}},
|
||||
|
||||
.before = {
|
||||
validate((v){
|
||||
.name = "id",
|
||||
.validation = "^\\d{1,10}$",
|
||||
.message = "Invalid post ID"
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
{"post", "/posts/:id",
|
||||
.get = {{
|
||||
find((d){
|
||||
.set = "post",
|
||||
.db = "blog_db",
|
||||
(asset){
|
||||
#embed "get_post.sql"
|
||||
}
|
||||
}),
|
||||
render((r){(asset){
|
||||
#embed "post_show.mustache.html"
|
||||
}})
|
||||
}},
|
||||
|
||||
.patch = {{
|
||||
validate((v){
|
||||
.name = "title",
|
||||
.validation = "^.{1,200}$",
|
||||
.message = "Title is required (max 200 characters)"
|
||||
}),
|
||||
validate((v){
|
||||
.name = "body",
|
||||
.validation = "^[\\s\\S]{1,50000}$",
|
||||
.message = "Body is required"
|
||||
}),
|
||||
query((d){
|
||||
.db = "blog_db",
|
||||
(asset){
|
||||
#embed "update_post.sql"
|
||||
}
|
||||
}),
|
||||
redirect((u){"post"})
|
||||
}},
|
||||
|
||||
.delete = {{
|
||||
query((d){
|
||||
.db = "blog_db",
|
||||
(asset){
|
||||
#embed "delete_post.sql"
|
||||
}
|
||||
}),
|
||||
redirect((u){"posts"})
|
||||
}},
|
||||
|
||||
.before = {
|
||||
validate((v){
|
||||
.name = "id",
|
||||
.validation = "^\\d{1,10}$",
|
||||
.message = "Invalid post ID"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
// ── Shared Layout & Partials ───────────────
|
||||
|
||||
.context = {
|
||||
{"layout", (asset){
|
||||
#embed "layout.mustache.html"
|
||||
}},
|
||||
{"post_card", (asset){
|
||||
#embed "post_card.mustache.html"
|
||||
}},
|
||||
{"post_form", (asset){
|
||||
#embed "post_form.mustache.html"
|
||||
}}
|
||||
},
|
||||
|
||||
// ── Error Handling ─────────────────────────
|
||||
|
||||
.errors = {
|
||||
{http_not_found, {
|
||||
render((r){(asset){
|
||||
#embed "404.mustache.html"
|
||||
}})
|
||||
}},
|
||||
{http_bad_request, {
|
||||
render((r){
|
||||
.status = http_bad_request,
|
||||
(asset){
|
||||
#embed "400.mustache.html"
|
||||
}
|
||||
})
|
||||
}}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
// ── Database ───────────────────────────────
|
||||
|
||||
.databases = {{
|
||||
.engine = sqlite_db,
|
||||
.name = "blog_db",
|
||||
.connect = "file:blog.db?mode=rwc",
|
||||
.migrations = {(asset){
|
||||
#embed "create_posts.sql"
|
||||
}},
|
||||
.seeds = {(asset){
|
||||
#embed "seed_posts.sql"
|
||||
}}
|
||||
}},
|
||||
|
||||
// ── Modules ────────────────────────────────
|
||||
|
||||
.modules = {sqlite, tailwind}
|
||||
};
|
||||
}
|
||||
13
12_rails_15_min_blog_external/new_post.mustache.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">New Post</h1>
|
||||
|
||||
<form method="post" action="{{url:posts}}">
|
||||
{{> post_form}}
|
||||
</form>
|
||||
|
||||
<a href="{{url:posts}}" class="text-gray-500 hover:text-gray-700 mt-4 inline-block">
|
||||
← Back to posts
|
||||
</a>
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
9
12_rails_15_min_blog_external/post_card.mustache.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<article class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 mb-4 hover:shadow-md transition">
|
||||
<a href="{{url:post:id}}" class="block">
|
||||
<h2 class="text-xl font-semibold text-gray-900 hover:text-blue-600 transition">
|
||||
{{title}}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-400 mt-1">{{created_at}}</p>
|
||||
<p class="text-gray-600 mt-3 line-clamp-2">{{body}}</p>
|
||||
</a>
|
||||
</article>
|
||||
19
12_rails_15_min_blog_external/post_form.mustache.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
|
||||
<input type="text" id="title" name="title"
|
||||
value="{{title}}"
|
||||
placeholder="Your post title"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition">
|
||||
</div>
|
||||
<div>
|
||||
<label for="body" class="block text-sm font-medium text-gray-700 mb-1">Body</label>
|
||||
<textarea id="body" name="body" rows="12"
|
||||
placeholder="Write your post..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition resize-y">{{body}}</textarea>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition font-medium">
|
||||
Save Post
|
||||
</button>
|
||||
</div>
|
||||
31
12_rails_15_min_blog_external/post_show.mustache.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
{{#post}}
|
||||
<article class="max-w-none">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-2">{{title}}</h1>
|
||||
<p class="text-sm text-gray-400 mb-8">Published {{created_at}}</p>
|
||||
<div class="prose prose-lg text-gray-700 whitespace-pre-line leading-relaxed">
|
||||
{{body}}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="flex gap-4 mt-10 pt-6 border-t border-gray-200">
|
||||
<a href="{{url:edit_post:id}}"
|
||||
class="bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200 transition">
|
||||
Edit
|
||||
</a>
|
||||
<form method="post" action="{{url:post:id}}" class="inline">
|
||||
<input type="hidden" name="http_method" value="delete">
|
||||
<button type="submit"
|
||||
class="bg-red-50 text-red-600 px-4 py-2 rounded-lg hover:bg-red-100 transition"
|
||||
onclick="return confirm('Delete this post?')">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{url:posts}}" class="text-gray-500 hover:text-gray-700 px-4 py-2">
|
||||
← Back
|
||||
</a>
|
||||
</div>
|
||||
{{/post}}
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
24
12_rails_15_min_blog_external/posts.mustache.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{{< layout}}
|
||||
{{$body}}
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">All Posts</h1>
|
||||
<a href="{{url:new_post}}"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition">
|
||||
New Post
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{#posts}}
|
||||
{{> post_card}}
|
||||
{{/posts}}
|
||||
|
||||
{{^posts}}
|
||||
<div class="text-center py-16 text-gray-400">
|
||||
<p class="text-xl">No posts yet.</p>
|
||||
<a href="{{url:new_post}}" class="text-blue-600 hover:underline mt-2 inline-block">
|
||||
Write your first post
|
||||
</a>
|
||||
</div>
|
||||
{{/posts}}
|
||||
{{/body}}
|
||||
{{/layout}}
|
||||
5
12_rails_15_min_blog_external/seed_posts.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
INSERT INTO posts (title, body) VALUES
|
||||
('Hello World',
|
||||
'Welcome to the MACH blog! This post was created by the database seed. MACH is a high-performance, declarative web framework for C that lets you build full CRUD apps in a single file with zero boilerplate.'),
|
||||
('Why Declarative C?',
|
||||
'Traditional C web development means manual memory management, string wrangling, and security pitfalls at every turn. MACH flips the script: you declare resources, pipelines, and templates. The runtime handles allocation, prepared statements, XSS escaping, and async I/O. You get the speed of C with the ergonomics of a modern framework.');
|
||||