fresco 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/exe/fresco +3 -0
  3. data/lib/fresco/application.rb +12 -0
  4. data/lib/fresco/cli/build.rb +682 -0
  5. data/lib/fresco/cli/dev.rb +17 -0
  6. data/lib/fresco/cli/dev_loop.rb +815 -0
  7. data/lib/fresco/cli/new.rb +120 -0
  8. data/lib/fresco/cli/release.rb +76 -0
  9. data/lib/fresco/cli.rb +56 -0
  10. data/lib/fresco/database_config.rb +34 -0
  11. data/lib/fresco/generators/app/Gemfile.tt +18 -0
  12. data/lib/fresco/generators/app/README.md.tt +32 -0
  13. data/lib/fresco/generators/app/app/action.rb.tt +20 -0
  14. data/lib/fresco/generators/app/app/actions/root_path.rb.tt +5 -0
  15. data/lib/fresco/generators/app/app/views/layouts/application.html.erb +29 -0
  16. data/lib/fresco/generators/app/app/views/root_path.html.erb +8 -0
  17. data/lib/fresco/generators/app/app.rb.tt +15 -0
  18. data/lib/fresco/generators/app/bin/build +2 -0
  19. data/lib/fresco/generators/app/bin/dev +2 -0
  20. data/lib/fresco/generators/app/bin/release +2 -0
  21. data/lib/fresco/generators/app/config/app.rb.tt +26 -0
  22. data/lib/fresco/generators/app/config/database.rb +17 -0
  23. data/lib/fresco/generators/app/config/routes.rb +11 -0
  24. data/lib/fresco/generators/app/db/schema.rb +14 -0
  25. data/lib/fresco/generators/app/public/404.html +87 -0
  26. data/lib/fresco/generators/app/public/500.html +84 -0
  27. data/lib/fresco/migration_builder.rb +55 -0
  28. data/lib/fresco/model_builder.rb +54 -0
  29. data/lib/fresco/paths.rb +20 -0
  30. data/lib/fresco/router.rb +67 -0
  31. data/lib/fresco/runtime/boot.rb +34 -0
  32. data/lib/fresco/runtime/db_postgres.rb +403 -0
  33. data/lib/fresco/runtime/db_sqlite.rb +495 -0
  34. data/lib/fresco/runtime/http.c +456 -0
  35. data/lib/fresco/runtime/postgres.c +339 -0
  36. data/lib/fresco/runtime/runtime.rb +1810 -0
  37. data/lib/fresco/runtime/sqlite.c +220 -0
  38. data/lib/fresco/runtime/welcome.rb +152 -0
  39. data/lib/fresco/schema_builder.rb +71 -0
  40. data/lib/fresco/templates/dispatch.rb.erb +32 -0
  41. data/lib/fresco/templates/layout_dispatch.rb.erb +16 -0
  42. data/lib/fresco/templates/manifest.rb.erb +5 -0
  43. data/lib/fresco/templates/migrations.rb.erb +152 -0
  44. data/lib/fresco/templates/model.rb.erb +223 -0
  45. data/lib/fresco/templates/view.rb.erb +5 -0
  46. data/lib/fresco/version.rb +3 -0
  47. data/lib/fresco.rb +61 -0
  48. metadata +115 -0
@@ -0,0 +1,220 @@
1
+ /*
2
+ * Fresco SQLite shim. Wraps libsqlite3 behind a stable str/int FFI
3
+ * surface so Spinel-AOT'd apps can talk to a database without going
4
+ * through CRuby's `sqlite3` gem (which ships native code via the MRI
5
+ * ABI — not loadable by Spinel).
6
+ *
7
+ * Pattern mirrors lib/http.c: spinel-friendly types only (:int, :str),
8
+ * static buffers for return strings, slot tables for opaque handles
9
+ * so the Ruby side never sees a raw pointer.
10
+ *
11
+ * Lifted from tep's lib/tep/tep_sqlite.c. Two intentional divergences
12
+ * from the original:
13
+ *
14
+ * 1. Multi-cursor. Tep keeps a single global `sqlite3_stmt *`, which
15
+ * means one in-flight query per process — fine for hand-written
16
+ * route handlers, too tight once the codegen tier (M3+) starts
17
+ * stacking finders. We track up to FRESCO_SQLITE_MAX_STMTS
18
+ * cursors in a slot table and return an int cursor id from
19
+ * fresco_sqlite_prepare; subsequent bind/step/col/finalize/reset
20
+ * calls take the cursor id as their first argument.
21
+ *
22
+ * 2. Naming. `fresco_sqlite_*` rather than `tep_sqlite_*`.
23
+ *
24
+ * Errors
25
+ * ------
26
+ * Most functions return 0 on success and -1 on error. We don't
27
+ * surface sqlite3_errmsg through the FFI in v1 — the :str return
28
+ * lifetime would have to be threaded through another rotating buffer
29
+ * and callers haven't asked for it yet.
30
+ */
31
+
32
+ #include <stdlib.h>
33
+ #include <string.h>
34
+ #include <sqlite3.h>
35
+
36
+ #define FRESCO_SQLITE_MAX_HANDLES 16
37
+ #define FRESCO_SQLITE_MAX_STMTS 64
38
+ #define FRESCO_SQLITE_COL_BUFSIZE 65536
39
+ #define FRESCO_SQLITE_COL_BUF_SLOTS 16
40
+
41
+ static sqlite3 *fresco_sqlite_handles[FRESCO_SQLITE_MAX_HANDLES] = {0};
42
+ static sqlite3_stmt *fresco_sqlite_stmts[FRESCO_SQLITE_MAX_STMTS] = {0};
43
+
44
+ /* Rotating return buffers for col_str. Spinel's `:str` return type
45
+ * wants a pointer that stays valid until "the caller is done with
46
+ * it", but in practice callers stash multiple col_str results into
47
+ * variables / hashes / array entries before the buffer would
48
+ * otherwise rotate. A single static buf would alias all those
49
+ * entries to whatever the most recent call wrote.
50
+ *
51
+ * We rotate across SLOTS buffers; each call lands in the next
52
+ * slot. With 16 slots a typical handler doing
53
+ *
54
+ * a = first_str(...); b = first_str(...); c = first_str(...)
55
+ *
56
+ * sees three independent strings. Callers that hold > SLOTS live
57
+ * col_str references concurrently must copy each result before the
58
+ * next call. */
59
+ static char fresco_sqlite_col_buf[FRESCO_SQLITE_COL_BUF_SLOTS][FRESCO_SQLITE_COL_BUFSIZE];
60
+ static int fresco_sqlite_col_slot = 0;
61
+
62
+ /* Open a database. Returns a 1-indexed handle on success (0 reserved
63
+ * for "uninitialised"); -1 if the slot table is full or sqlite3_open
64
+ * rejects the path. `:memory:` works as a path for anonymous in-mem
65
+ * databases. Closing frees the slot for re-use. */
66
+ int fresco_sqlite_open(const char *path) {
67
+ int i;
68
+ for (i = 0; i < FRESCO_SQLITE_MAX_HANDLES; i++) {
69
+ if (fresco_sqlite_handles[i] == NULL) {
70
+ sqlite3 *db = NULL;
71
+ if (sqlite3_open(path, &db) != SQLITE_OK) {
72
+ if (db) sqlite3_close(db);
73
+ return -1;
74
+ }
75
+ fresco_sqlite_handles[i] = db;
76
+ return i + 1;
77
+ }
78
+ }
79
+ return -1;
80
+ }
81
+
82
+ int fresco_sqlite_close(int h) {
83
+ int i;
84
+ if (h < 1 || h > FRESCO_SQLITE_MAX_HANDLES) return -1;
85
+ sqlite3 *db = fresco_sqlite_handles[h - 1];
86
+ if (db == NULL) return -1;
87
+ /* Finalize any cursor still attached to this handle to avoid
88
+ * sqlite3_close returning SQLITE_BUSY. */
89
+ for (i = 0; i < FRESCO_SQLITE_MAX_STMTS; i++) {
90
+ if (fresco_sqlite_stmts[i] != NULL &&
91
+ sqlite3_db_handle(fresco_sqlite_stmts[i]) == db) {
92
+ sqlite3_finalize(fresco_sqlite_stmts[i]);
93
+ fresco_sqlite_stmts[i] = NULL;
94
+ }
95
+ }
96
+ sqlite3_close(db);
97
+ fresco_sqlite_handles[h - 1] = NULL;
98
+ return 0;
99
+ }
100
+
101
+ /* No-bind statement runner. Use for DDL, BEGIN/COMMIT, and queries
102
+ * whose values are constants. For anything user-supplied always go
103
+ * through prepare + bind + step + finalize. */
104
+ int fresco_sqlite_exec(int h, const char *sql) {
105
+ if (h < 1 || h > FRESCO_SQLITE_MAX_HANDLES) return -1;
106
+ sqlite3 *db = fresco_sqlite_handles[h - 1];
107
+ if (db == NULL) return -1;
108
+ char *err = NULL;
109
+ int rc = sqlite3_exec(db, sql, NULL, NULL, &err);
110
+ if (err) sqlite3_free(err);
111
+ return rc == SQLITE_OK ? 0 : -1;
112
+ }
113
+
114
+ /* Prepare a parameterised statement on handle `h`. Returns a 1-indexed
115
+ * cursor id on success, -1 on failure or if the slot table is full.
116
+ * The cursor id is the first argument to subsequent bind/step/col/
117
+ * finalize/reset calls — callers can hold several cursors at once. */
118
+ int fresco_sqlite_prepare(int h, const char *sql) {
119
+ int i;
120
+ if (h < 1 || h > FRESCO_SQLITE_MAX_HANDLES) return -1;
121
+ sqlite3 *db = fresco_sqlite_handles[h - 1];
122
+ if (db == NULL) return -1;
123
+ for (i = 0; i < FRESCO_SQLITE_MAX_STMTS; i++) {
124
+ if (fresco_sqlite_stmts[i] == NULL) {
125
+ sqlite3_stmt *stmt = NULL;
126
+ if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) {
127
+ return -1;
128
+ }
129
+ fresco_sqlite_stmts[i] = stmt;
130
+ return i + 1;
131
+ }
132
+ }
133
+ return -1;
134
+ }
135
+
136
+ int fresco_sqlite_bind_str(int cid, int idx, const char *value) {
137
+ if (cid < 1 || cid > FRESCO_SQLITE_MAX_STMTS) return -1;
138
+ sqlite3_stmt *stmt = fresco_sqlite_stmts[cid - 1];
139
+ if (stmt == NULL) return -1;
140
+ /* SQLITE_TRANSIENT — sqlite copies the string before returning,
141
+ * so the caller's pointer doesn't need to outlive the bind. */
142
+ return sqlite3_bind_text(stmt, idx, value, -1, SQLITE_TRANSIENT)
143
+ == SQLITE_OK ? 0 : -1;
144
+ }
145
+
146
+ int fresco_sqlite_bind_int(int cid, int idx, int value) {
147
+ if (cid < 1 || cid > FRESCO_SQLITE_MAX_STMTS) return -1;
148
+ sqlite3_stmt *stmt = fresco_sqlite_stmts[cid - 1];
149
+ if (stmt == NULL) return -1;
150
+ return sqlite3_bind_int(stmt, idx, value) == SQLITE_OK ? 0 : -1;
151
+ }
152
+
153
+ /* 1 -> row available, 0 -> done (no more rows), -1 -> error or invalid
154
+ * cursor. Iteration pattern: `while step(cid) == 1; ...; end`. */
155
+ int fresco_sqlite_step(int cid) {
156
+ if (cid < 1 || cid > FRESCO_SQLITE_MAX_STMTS) return -1;
157
+ sqlite3_stmt *stmt = fresco_sqlite_stmts[cid - 1];
158
+ if (stmt == NULL) return -1;
159
+ int rc = sqlite3_step(stmt);
160
+ if (rc == SQLITE_ROW) return 1;
161
+ if (rc == SQLITE_DONE) return 0;
162
+ return -1;
163
+ }
164
+
165
+ const char *fresco_sqlite_col_str(int cid, int idx) {
166
+ if (cid < 1 || cid > FRESCO_SQLITE_MAX_STMTS) return "";
167
+ sqlite3_stmt *stmt = fresco_sqlite_stmts[cid - 1];
168
+ if (stmt == NULL) return "";
169
+ const unsigned char *t = sqlite3_column_text(stmt, idx);
170
+ if (!t) return "";
171
+ size_t n = strlen((const char *)t);
172
+ if (n >= FRESCO_SQLITE_COL_BUFSIZE) n = FRESCO_SQLITE_COL_BUFSIZE - 1;
173
+ char *buf = fresco_sqlite_col_buf[fresco_sqlite_col_slot];
174
+ fresco_sqlite_col_slot = (fresco_sqlite_col_slot + 1) % FRESCO_SQLITE_COL_BUF_SLOTS;
175
+ memcpy(buf, t, n);
176
+ buf[n] = '\0';
177
+ return buf;
178
+ }
179
+
180
+ int fresco_sqlite_col_int(int cid, int idx) {
181
+ if (cid < 1 || cid > FRESCO_SQLITE_MAX_STMTS) return 0;
182
+ sqlite3_stmt *stmt = fresco_sqlite_stmts[cid - 1];
183
+ if (stmt == NULL) return 0;
184
+ return sqlite3_column_int(stmt, idx);
185
+ }
186
+
187
+ int fresco_sqlite_col_count(int cid) {
188
+ if (cid < 1 || cid > FRESCO_SQLITE_MAX_STMTS) return 0;
189
+ sqlite3_stmt *stmt = fresco_sqlite_stmts[cid - 1];
190
+ if (stmt == NULL) return 0;
191
+ return sqlite3_column_count(stmt);
192
+ }
193
+
194
+ int fresco_sqlite_finalize(int cid) {
195
+ if (cid < 1 || cid > FRESCO_SQLITE_MAX_STMTS) return 0;
196
+ sqlite3_stmt *stmt = fresco_sqlite_stmts[cid - 1];
197
+ if (stmt == NULL) return 0;
198
+ sqlite3_finalize(stmt);
199
+ fresco_sqlite_stmts[cid - 1] = NULL;
200
+ return 0;
201
+ }
202
+
203
+ /* Reset a statement so it can be re-stepped (e.g. inside a loop
204
+ * where bound params change between iterations). Bindings survive
205
+ * the reset; sqlite3_clear_bindings is intentionally NOT called so
206
+ * callers can re-step with the same values cheaply. Use a fresh
207
+ * prepare if you need cleared bindings. */
208
+ int fresco_sqlite_reset(int cid) {
209
+ if (cid < 1 || cid > FRESCO_SQLITE_MAX_STMTS) return -1;
210
+ sqlite3_stmt *stmt = fresco_sqlite_stmts[cid - 1];
211
+ if (stmt == NULL) return -1;
212
+ return sqlite3_reset(stmt) == SQLITE_OK ? 0 : -1;
213
+ }
214
+
215
+ int fresco_sqlite_last_insert_rowid(int h) {
216
+ if (h < 1 || h > FRESCO_SQLITE_MAX_HANDLES) return -1;
217
+ sqlite3 *db = fresco_sqlite_handles[h - 1];
218
+ if (db == NULL) return -1;
219
+ return (int)sqlite3_last_insert_rowid(db);
220
+ }
@@ -0,0 +1,152 @@
1
+ # Fresco: default welcome action. Auto-generated into
2
+ # generated/welcome.rb by `fresco build` when config/routes.rb doesn't
3
+ # define a `root to: ...` route. Source of truth is
4
+ # lib/fresco/runtime/welcome.rb in the fresco gem — `fresco build`
5
+ # copies this file verbatim (same handling as runtime.rb / boot.rb).
6
+ #
7
+ # Inherits Fresco::Action directly (not App::Action) so the welcome
8
+ # page doesn't depend on the user defining a :application layout. The
9
+ # body is built inline via Response.html, bypassing #render and
10
+ # dispatch_layout entirely — no view template, no layout, no coupling
11
+ # with user code.
12
+ #
13
+ # Style matches public/404.html / public/500.html: IBM Plex Mono,
14
+ # 40rem column, CSS-variable light/dark theming with the same toggle
15
+ # button. Pre-paint script runs from localStorage so the page doesn't
16
+ # flash light before JS executes. Stop seeing this page by adding
17
+ # `root to: SomeAction` to config/routes.rb.
18
+
19
+ module Fresco
20
+ class Welcome < Fresco::Action
21
+ def call(req = Request.new("", "", ""))
22
+ Response.html(200, welcome_body)
23
+ end
24
+
25
+ # Manual string concat (matches build_headers / Json.encode_string
26
+ # in runtime.rb) keeps each intermediate narrowly typed String for
27
+ # Spinel — heredocs and `<<~`-style literals don't appear elsewhere
28
+ # in the Spinel-compiled surface, so we stick to the established
29
+ # `out += ...` pattern.
30
+ def welcome_body
31
+ out = "<!doctype html>"
32
+ out += "<html>"
33
+ out += "<head>"
34
+ out += "<meta charset=\"utf-8\">"
35
+ out += "<title>Welcome to Fresco</title>"
36
+ out += "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">"
37
+ out += "<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>"
38
+ out += "<link href=\"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap\" rel=\"stylesheet\">"
39
+ out += "<script>"
40
+ out += "(function () {"
41
+ out += " var t = localStorage.getItem(\"theme\");"
42
+ out += " if (!t) {"
43
+ out += " t = (window.matchMedia && window.matchMedia(\"(prefers-color-scheme: dark)\").matches) ? \"dark\" : \"light\";"
44
+ out += " localStorage.setItem(\"theme\", t);"
45
+ out += " }"
46
+ out += " document.documentElement.setAttribute(\"data-theme\", t);"
47
+ out += "})();"
48
+ out += "</script>"
49
+ out += "<style>"
50
+ out += ":root { --bg: #f5f5f4; --fg: #1c1917; --muted: #78716c; --border: #e7e5e4; --code-bg: #e7e5e4; --accent: #0066cc; --status: #78716c; }"
51
+ out += ":root[data-theme=\"dark\"] { --bg: #1c1917; --fg: #e7e5e4; --muted: #a8a29e; --border: #292524; --code-bg: #292524; --accent: #6ab0ff; --status: #a8a29e; }"
52
+ out += "body { font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace; max-width: 40rem; margin: 4rem auto; padding: 0 1.5rem; background: var(--bg); color: var(--fg); transition: background 0.15s, color 0.15s; }"
53
+ out += "h1 { font-weight: 700; font-size: 2.2rem; margin: 0 0 0.5rem; line-height: 1.2; }"
54
+ out += ".status { color: var(--status); font-size: 0.9rem; letter-spacing: 0.05em; text-transform: uppercase; }"
55
+ out += ".lede { color: var(--muted); margin: 0 0 1.75rem; }"
56
+ out += "p { line-height: 1.5; }"
57
+ out += "code { background: var(--code-bg); padding: 0.1rem 0.3rem; border-radius: 3px; }"
58
+ out += "a { color: var(--accent); }"
59
+ out += "a:hover { text-decoration: underline; }"
60
+ out += "hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }"
61
+ out += "footer { color: var(--muted); font-size: 0.85rem; }"
62
+ out += "#theme-toggle { position: fixed; top: 1rem; right: 1rem; background: transparent; border: 1px solid var(--border); color: var(--fg); border-radius: 6px; padding: 0.4rem; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }"
63
+ out += "#theme-toggle:hover { background: var(--code-bg); }"
64
+ out += "#theme-toggle svg { width: 18px; height: 18px; display: none; }"
65
+ out += ":root[data-theme=\"light\"] #theme-toggle .icon-moon { display: inline-block; }"
66
+ out += ":root[data-theme=\"dark\"] #theme-toggle .icon-sun { display: inline-block; }"
67
+ # Trans pride coloring on the "Fresco" wordmark — 6 letters laid
68
+ # out as a mirror of the 5-stripe flag (blue / pink / white /
69
+ # pink / blue). The two middle letters take --fg as a stand-in
70
+ # for "white" so they stay readable on both stone backgrounds
71
+ # (renders as dark stone in light mode, near-white in dark mode).
72
+ # Light mode uses deeper blue/pink variants so they don't wash
73
+ # out against #f5f5f4; dark mode uses the canonical pastels which
74
+ # pop on #1c1917.
75
+ out += ".pride { font-weight: 700; letter-spacing: 0.04em; margin-left: 0.1em; }"
76
+ out += ".pride span { display: inline-block; }"
77
+ out += ".pride .p1 { color: #2dbfe0; }"
78
+ out += ".pride .p2 { color: #ed7b95; }"
79
+ out += ".pride .p3 { color: var(--fg); }"
80
+ out += ".pride .p4 { color: var(--fg); }"
81
+ out += ".pride .p5 { color: #ed7b95; }"
82
+ out += ".pride .p6 { color: #2dbfe0; }"
83
+ out += ":root[data-theme=\"dark\"] .pride .p1 { color: #5bcefa; }"
84
+ out += ":root[data-theme=\"dark\"] .pride .p2 { color: #f5a9b8; }"
85
+ out += ":root[data-theme=\"dark\"] .pride .p5 { color: #f5a9b8; }"
86
+ out += ":root[data-theme=\"dark\"] .pride .p6 { color: #5bcefa; }"
87
+ # Two CTA cards (Docs / GitHub). Whole card is the clickable <a>;
88
+ # the icon, title, and "→" sit stacked inside. Flex layout, equal
89
+ # width, collapses to a single column on narrow viewports so the
90
+ # cards don't squeeze on mobile.
91
+ out += ".cards { display: flex; gap: 0.875rem; margin: 0 0 1.75rem; }"
92
+ out += ".cta { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 1.25rem; border: 2px solid var(--border); border-radius: 8px; background: transparent; color: var(--fg); text-decoration: none; transition: transform 0.15s, border-color 0.15s; }"
93
+ # Trans pride hover border: two-layer background trick. The inner
94
+ # solid layer is clipped to padding-box and covers the card body;
95
+ # the gradient layer fills border-box and only shows through where
96
+ # the (now-transparent) border sits. The gradient encodes the
97
+ # 5-stripe flag (blue / pink / white / pink / blue) doubled
98
+ # end-to-end so panning 200% bg-size from 0 to -100% loops
99
+ # seamlessly — the second half is identical to the first.
100
+ out += ".cta:hover { border-color: transparent; transform: translateY(-1px); background-image: linear-gradient(var(--code-bg), var(--code-bg)), linear-gradient(90deg, #5bcefa, #f5a9b8, #ffffff, #f5a9b8, #5bcefa, #f5a9b8, #ffffff, #f5a9b8, #5bcefa); background-size: auto, 200% 100%; background-position: 0 0, 0 0; background-origin: border-box; background-clip: padding-box, border-box; animation: prideShift 4s linear infinite; }"
101
+ out += "@keyframes prideShift { to { background-position: 0 0, -100% 0; } }"
102
+ out += ".cta svg { width: 28px; height: 28px; stroke: var(--accent); }"
103
+ out += ".cta-title { font-weight: 700; font-size: 1.05rem; }"
104
+ out += ".cta-sub { color: var(--muted); font-size: 0.88rem; }"
105
+ out += ".cta:hover .cta-sub { color: var(--accent); }"
106
+ out += "@media (max-width: 30rem) { .cards { flex-direction: column; } }"
107
+ # Respect the OS reduce-motion preference — keep the rainbow
108
+ # border, just stop panning it for folks who opted out.
109
+ out += "@media (prefers-reduced-motion: reduce) { .cta:hover { animation: none; } }"
110
+ out += "</style>"
111
+ out += "</head>"
112
+ out += "<body>"
113
+ out += "<button id=\"theme-toggle\" aria-label=\"Toggle color theme\" title=\"Toggle color theme\">"
114
+ out += "<svg class=\"icon-sun\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2\"/><path d=\"M12 20v2\"/><path d=\"m4.93 4.93 1.41 1.41\"/><path d=\"m17.66 17.66 1.41 1.41\"/><path d=\"M2 12h2\"/><path d=\"M20 12h2\"/><path d=\"m6.34 17.66-1.41 1.41\"/><path d=\"m19.07 4.93-1.41 1.41\"/></svg>"
115
+ out += "<svg class=\"icon-moon\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"/></svg>"
116
+ out += "</button>"
117
+ out += "<div class=\"status\">200 &middot; Welcome</div>"
118
+ out += "<h1>You're on <span class=\"pride\"><span class=\"p1\">F</span><span class=\"p2\">r</span><span class=\"p3\">e</span><span class=\"p4\">s</span><span class=\"p5\">c</span><span class=\"p6\">o</span></span>.</h1>"
119
+ out += "<p class=\"lede\">Spinel-compiled Ruby. Single binary. No runtime.</p>"
120
+ # Lucide icons (MIT) inlined as SVG. book-open → Docs;
121
+ # github → repo. Update href="#" with real URLs when ready.
122
+ out += "<div class=\"cards\">"
123
+ out += "<a class=\"cta\" href=\"https://github.com/afomera/fresco\">"
124
+ out += "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 7v14\"/><path d=\"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z\"/></svg>"
125
+ out += "<div class=\"cta-title\">Documentation</div>"
126
+ out += "<div class=\"cta-sub\">Get started &rarr;</div>"
127
+ out += "</a>"
128
+ out += "<a class=\"cta\" href=\"https://github.com/afomera/fresco\">"
129
+ out += "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4\"/><path d=\"M9 18c-4.51 2-5-2-7-2\"/></svg>"
130
+ out += "<div class=\"cta-title\">GitHub</div>"
131
+ out += "<div class=\"cta-sub\">View source &rarr;</div>"
132
+ out += "</a>"
133
+ out += "</div>"
134
+ out += "<p>Next step: add <code>root to: SomeAction</code> to <code>config/routes.rb</code>, then rebuild and reload.</p>"
135
+ out += "<hr>"
136
+ out += "<footer>Powered by Spinel + Fresco. Served from the fresco gem's <code>welcome</code> action.</footer>"
137
+ out += "<script>"
138
+ out += "(function () {"
139
+ out += " document.getElementById(\"theme-toggle\").addEventListener(\"click\", function () {"
140
+ out += " var cur = document.documentElement.getAttribute(\"data-theme\");"
141
+ out += " var nxt = cur === \"dark\" ? \"light\" : \"dark\";"
142
+ out += " document.documentElement.setAttribute(\"data-theme\", nxt);"
143
+ out += " localStorage.setItem(\"theme\", nxt);"
144
+ out += " });"
145
+ out += "})();"
146
+ out += "</script>"
147
+ out += "</body>"
148
+ out += "</html>"
149
+ out
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,71 @@
1
+ # Build-time schema DSL. Captured by db/schema.rb's `Fresco.schema do
2
+ # ... end` block; `fresco build` reads `Fresco.schema_tables` back out
3
+ # to drive the model codegen and (M5+) the embedded migration runner.
4
+ # Runtime never loads db/schema.rb — generated model files are
5
+ # self-contained.
6
+ #
7
+ # Shape captured per table:
8
+ # { name: :users,
9
+ # columns: [
10
+ # { name: :id, type: :int, primary_key: true },
11
+ # { name: :email, type: :str, null: false, index: :unique },
12
+ # ...
13
+ # ] }
14
+ #
15
+ # Columns are an ordered Array of Hashes so iteration order matches
16
+ # the SELECT clause order, which matches the column index the model
17
+ # initializer expects.
18
+ module Fresco
19
+ @schema_tables = []
20
+
21
+ def self.schema_tables
22
+ @schema_tables
23
+ end
24
+
25
+ def self.schema(&blk)
26
+ builder = Fresco::SchemaBuilder.new
27
+ builder.instance_eval(&blk)
28
+ @schema_tables = builder.tables
29
+ end
30
+ end
31
+
32
+ class Fresco::SchemaBuilder
33
+ attr_reader :tables
34
+
35
+ def initialize
36
+ @tables = []
37
+ end
38
+
39
+ def table(name, &blk)
40
+ tb = Fresco::TableBuilder.new(name)
41
+ tb.instance_eval(&blk) if blk
42
+ @tables << { name: name, columns: tb.columns, foreign_keys: tb.foreign_keys }
43
+ end
44
+ end
45
+
46
+ class Fresco::TableBuilder
47
+ attr_reader :columns, :foreign_keys
48
+
49
+ def initialize(name)
50
+ @name = name
51
+ @columns = []
52
+ @foreign_keys = []
53
+ end
54
+
55
+ # `type:` is one of :int, :str, :bool. `opts` is open — common
56
+ # keys: primary_key (Bool), null (Bool), index (:unique or any
57
+ # truthy), default (literal or Proc — M5+).
58
+ def column(name, type, **opts)
59
+ @columns << { name: name, type: type }.merge(opts)
60
+ end
61
+
62
+ # Foreign-key metadata for the generated migration runner (M5+) and
63
+ # for M6+ join-helper codegen (`post.user`, `user.posts`). M4 only
64
+ # captures the entry — no SQL is emitted from it yet, and no methods
65
+ # on the model class reflect it. Convention: the column itself is
66
+ # declared with `column :user_id, :int` separately; this entry
67
+ # records the FK relationship only.
68
+ def foreign_key(column_name, references:)
69
+ @foreign_keys << { column: column_name, references: references }
70
+ end
71
+ end
@@ -0,0 +1,32 @@
1
+ # Fresco: dispatcher. Auto-generated by bin/build; do not edit.
2
+ #
3
+ # Per-route filter chain is inlined here rather than going through
4
+ # Fresco::Action#handle. Spinel-shape constraint:
5
+ # `<klass>.new.handle(req)` lowers to the generic
6
+ # sp_Fresco_Action_handle, whose internal call(req) is a static
7
+ # dispatch to the base 500 fallback — the subclass's #call never
8
+ # runs. By keeping every method call on `_action` here at the
9
+ # concrete subclass type (sp_<Klass>_*), Spinel statically dispatches
10
+ # each one. Filter hooks (before_action / after_action / halt!) are
11
+ # inherited from Fresco::Action; subclasses override only the ones
12
+ # they need.
13
+
14
+ def dispatch_request(method, path, req)
15
+ <% routes.each_with_index do |r, i| -%>
16
+ <% if i > 0 -%>
17
+
18
+ <% end -%>
19
+ <% if r[:captures].empty? -%>
20
+ if method == <%= r[:verb].inspect %> && path == <%= r[:pattern].inspect %>
21
+ <% else -%>
22
+ if method == <%= r[:verb].inspect %> && path =~ /<%= r[:regex_src] %>/
23
+ <% r[:captures].each_with_index do |c, j| -%>
24
+ req.params.set_sym(:<%= c %>, $<%= j + 1 %> || "")
25
+ <% end -%>
26
+ <% end -%>
27
+ return <%= r[:klass] %>.new.handle(req)
28
+ end
29
+ <% end -%>
30
+
31
+ error_response(404, "Not Found")
32
+ end
@@ -0,0 +1,16 @@
1
+ # Fresco: layout dispatcher. Auto-generated by bin/build; do not edit.
2
+ #
3
+ # Maps the layout symbol declared on an action (via `layout :foo`) to
4
+ # the matching `render_layouts_<name>(flash: flash, content: body)` call.
5
+ # Symbol dispatch via static if/elsif so Spinel can lower each branch
6
+ # concretely — no send/public_send. Unknown / :none returns the body
7
+ # unchanged (flash is dropped since there's no layout to consume it).
8
+
9
+ def dispatch_layout(name, body, flash)
10
+ <% layout_names.each do |layout_name| -%>
11
+ if name == :<%= layout_name %>
12
+ return render_layouts_<%= layout_name %>(flash: flash, content: body)
13
+ end
14
+ <% end -%>
15
+ body
16
+ end
@@ -0,0 +1,5 @@
1
+ # Fresco: <%= kind %> manifest. Auto-generated by bin/build; do not edit.
2
+
3
+ <% paths.each do |p| -%>
4
+ require_relative "<%= p %>"
5
+ <% end -%>
@@ -0,0 +1,152 @@
1
+ # Fresco migrations runner. Auto-generated by bin/build from
2
+ # db/migrations/*.rb. Do not edit.
3
+ #
4
+ # Each migration's up + down SQL is embedded as a flat method
5
+ # returning Array<String>. Spinel can pin those arrays at codegen
6
+ # time; the runner just iterates and execs them.
7
+ #
8
+ # Schema-version tracking lives in the `schema_migrations` table
9
+ # (one row per applied migration, keyed by name). migrate! runs
10
+ # pending up migrations in declaration order; rollback! reverses
11
+ # the most recently applied one. Both commands print one line per
12
+ # migration to stdout so the operator can see progress when running
13
+ # `./app db:migrate` over an SSH session.
14
+
15
+ module Fresco
16
+ module DbMigrations
17
+ # Applied-migrations sentinel table. Built lazily on the first
18
+ # migrate! / rollback! call so a fresh DB doesn't require manual
19
+ # setup. Idempotent — `CREATE TABLE IF NOT EXISTS` is portable
20
+ # enough across SQLite and Postgres.
21
+ def self.ensure_schema_table!
22
+ Fresco::Db.exec("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY)")
23
+ end
24
+
25
+ # Has `name` been applied already? Used to skip migrations that
26
+ # already ran. We read via a COUNT(*) rather than a SELECT-then-
27
+ # step pattern to keep the cursor lifecycle short.
28
+ def self.applied?(name = "")
29
+ cid = Fresco::Db.prepare("<%= applied_sql %>")
30
+ return false if cid < 0
31
+ Fresco::Db.bind_str(cid, 1, name)
32
+ n = 0
33
+ if Fresco::Db.step(cid) == 1
34
+ n = Fresco::Db.col_int(cid, 0)
35
+ end
36
+ Fresco::Db.finalize(cid)
37
+ n > 0
38
+ end
39
+
40
+ def self.mark_applied!(name = "")
41
+ cid = Fresco::Db.prepare("<%= insert_applied_sql %>")
42
+ return if cid < 0
43
+ Fresco::Db.bind_str(cid, 1, name)
44
+ Fresco::Db.step(cid)
45
+ Fresco::Db.finalize(cid)
46
+ end
47
+
48
+ def self.unmark!(name = "")
49
+ cid = Fresco::Db.prepare("<%= delete_applied_sql %>")
50
+ return if cid < 0
51
+ Fresco::Db.bind_str(cid, 1, name)
52
+ Fresco::Db.step(cid)
53
+ Fresco::Db.finalize(cid)
54
+ end
55
+
56
+ # Declaration order. Migrations are filename-sorted at build
57
+ # time so "0001..." comes before "0002...". Earlier entries
58
+ # also need to run first because later migrations may reference
59
+ # tables earlier ones created.
60
+ def self.names
61
+ [<%= migrations.map { |m| m[:name].inspect }.join(", ") %>]
62
+ end
63
+
64
+ <% migrations.each do |m| -%>
65
+ def self.up_<%= m[:method_suffix] %>
66
+ [<%= m[:up_sql].map { |s| s.inspect }.join(", ") %>]
67
+ end
68
+
69
+ def self.down_<%= m[:method_suffix] %>
70
+ [<%= m[:down_sql].map { |s| s.inspect }.join(", ") %>]
71
+ end
72
+
73
+ <% end -%>
74
+
75
+ # Dispatch on the migration name. Each branch is a typed,
76
+ # monomorphic Array<String> body — no dynamic method-name lookup
77
+ # (Spinel can't `send(:up_<name>)` portably).
78
+ def self.run_up(name = "")
79
+ <% migrations.each_with_index do |m, i| -%>
80
+ <%= i == 0 ? "if" : "elsif" %> name == "<%= m[:name] %>"
81
+ sqls = up_<%= m[:method_suffix] %>
82
+ j = 0
83
+ while j < sqls.length
84
+ Fresco::Db.exec(sqls[j])
85
+ j += 1
86
+ end
87
+ <% end -%>
88
+ <% if migrations.any? -%>
89
+ end
90
+ <% end -%>
91
+ end
92
+
93
+ def self.run_down(name = "")
94
+ <% migrations.each_with_index do |m, i| -%>
95
+ <%= i == 0 ? "if" : "elsif" %> name == "<%= m[:name] %>"
96
+ sqls = down_<%= m[:method_suffix] %>
97
+ j = 0
98
+ while j < sqls.length
99
+ Fresco::Db.exec(sqls[j])
100
+ j += 1
101
+ end
102
+ <% end -%>
103
+ <% if migrations.any? -%>
104
+ end
105
+ <% end -%>
106
+ end
107
+
108
+ # Apply every pending migration in declaration order. Idempotent
109
+ # — already-applied names are skipped. Caller prints a header;
110
+ # this method prints one line per migration it actually ran.
111
+ def self.migrate!
112
+ ensure_schema_table!
113
+ <% if migrations.any? -%>
114
+ ns = names
115
+ i = 0
116
+ while i < ns.length
117
+ n = ns[i]
118
+ unless applied?(n)
119
+ run_up(n)
120
+ mark_applied!(n)
121
+ puts "[migrate] up " + n
122
+ end
123
+ i += 1
124
+ end
125
+ <% end -%>
126
+ end
127
+
128
+ # Roll back the most recently applied migration (the one at the
129
+ # end of the declaration list whose name appears in
130
+ # schema_migrations). Down-runs in declaration-reverse so the
131
+ # call from `./app db:rollback` undoes one step at a time —
132
+ # rerun the command to peel further back.
133
+ def self.rollback!
134
+ ensure_schema_table!
135
+ <% if migrations.any? -%>
136
+ ns = names
137
+ i = ns.length - 1
138
+ while i >= 0
139
+ n = ns[i]
140
+ if applied?(n)
141
+ run_down(n)
142
+ unmark!(n)
143
+ puts "[migrate] down " + n
144
+ return
145
+ end
146
+ i -= 1
147
+ end
148
+ <% end -%>
149
+ puts "[migrate] no migrations to roll back"
150
+ end
151
+ end
152
+ end