Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e8c9854bf | ||
|
|
3d2c18cb29 |
+23
-23
@@ -31,7 +31,7 @@ Every non-`.c` file is an asset, loaded into context under its **basename** (the
|
|||||||
|
|
||||||
## Templates (Mustache + MDM)
|
## Templates (Mustache + MDM)
|
||||||
|
|
||||||
Full Mustache base spec EXCEPT dot notation. Steps: `mustache(template_key, set_key)` (Mustache), `mdm(template_key, set_key)` (Markdown+Mustache), `json(template_key, set_key)` (JSON). All auto-escape (XSS-safe) except explicit unescape.
|
Full Mustache base spec EXCEPT dot notation. Steps: `mustache(template_ctx_key, ctx_key)` (Mustache), `mdm(template_ctx_key, ctx_key)` (Markdown+Mustache), `json(template_ctx_key, ctx_key)` (JSON). All auto-escape (XSS-safe) except explicit unescape.
|
||||||
|
|
||||||
- Interpolation: `{{name}}` (HTML-escaped), `{{{name}}}` or `{{&name}}` (raw).
|
- Interpolation: `{{name}}` (HTML-escaped), `{{{name}}}` or `{{&name}}` (raw).
|
||||||
- Sections: `{{#name}}...{{/name}}` (truthy; iterates arrays). Inverted: `{{^name}}...{{/name}}` (falsy/empty).
|
- Sections: `{{#name}}...{{/name}}` (truthy; iterates arrays). Inverted: `{{^name}}...{{/name}}` (falsy/empty).
|
||||||
@@ -98,13 +98,13 @@ Every step accepts `.if_ctx` / `.not_ctx` (conditionals) and `.map` / `.map_key`
|
|||||||
|
|
||||||
### input — validate request params
|
### input — validate request params
|
||||||
On success promotes `input:name` → app scope. On failure writes `error:name` and raises `n_bad_request` (400) to the nearest error/repair pipeline. All validations run before the error fires, so all field errors are available together.
|
On success promotes `input:name` → app scope. On failure writes `error:name` and raises `n_bad_request` (400) to the nearest error/repair pipeline. All validations run before the error fires, so all field errors are available together.
|
||||||
By order: `{param_key, matches, message}`. Named: `.opt` (skip if absent), `.def` (default when absent). `matches` is a regex string or a built-in macro. Define custom macros: `#define n_zipcode "^\\d{5}$"`.
|
By order: `{ctx_key, regex, err_msg}`. Named: `.opt` (skip if absent), `.def` (default when absent). `regex` is a regex string or a built-in macro. Define custom macros: `#define n_zipcode "^\\d{5}$"`.
|
||||||
```c
|
```c
|
||||||
input(
|
input(
|
||||||
{"email", n_email, "must be a valid email"},
|
{.ctx_key = "email", .regex = n_email, .err_msg = "must be a valid email"},
|
||||||
{"title", n_not_empty, "cannot be empty"},
|
{.ctx_key = "title", .regex = n_not_empty, .err_msg = "cannot be empty"},
|
||||||
{"page", n_int, "must be a number", .def = "1"},
|
{.ctx_key = "page", .regex = n_int, .err_msg = "must be a number", .def = "1"},
|
||||||
{"filter", "^(active|done)$", "must be 'active' or 'done'", .opt = true}
|
{.ctx_key = "filter", .regex = "^(active|done)$", .err_msg = "must be 'active' or 'done'", .opt = true}
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
Built-in validators: `n_not_empty n_alpha n_alphanum n_slug n_no_html` · `n_int n_positive n_float n_percent` · `n_email n_uuid n_user` · `n_date n_time n_datetime` · `n_url n_ipv4 n_hex_color` · `n_zip n_phone n_cron` · `n_token n_base64` · `n_bool n_yes_no n_on_off`.
|
Built-in validators: `n_not_empty n_alpha n_alphanum n_slug n_no_html` · `n_int n_positive n_float n_percent` · `n_email n_uuid n_user` · `n_date n_time n_datetime` · `n_url n_ipv4 n_hex_color` · `n_zip n_phone n_cron` · `n_token n_base64` · `n_bool n_yes_no n_on_off`.
|
||||||
@@ -112,33 +112,33 @@ For non-regex checks (uniqueness, cross-field), pair with a query + `run()` call
|
|||||||
|
|
||||||
### query — `<engine>_query(...)`
|
### query — `<engine>_query(...)`
|
||||||
`sqlite_query`, `postgres_query`, `mysql_query`, `redis_query`, `duckdb_query`. Multiple items in one call run CONCURRENTLY. Prepared statements: interpolated `{{values}}` are bound, not spliced (SQL-injection-safe). Transactions: put `BEGIN`/`COMMIT`/`ROLLBACK` in SQL. Results are always tables (even single rows).
|
`sqlite_query`, `postgres_query`, `mysql_query`, `redis_query`, `duckdb_query`. Multiple items in one call run CONCURRENTLY. Prepared statements: interpolated `{{values}}` are bound, not spliced (SQL-injection-safe). Transactions: put `BEGIN`/`COMMIT`/`ROLLBACK` in SQL. Results are always tables (even single rows).
|
||||||
By order per item: `{db_name, sql_key, set_key}`. `set_key` optional (omit for inserts without `RETURNING`). Named: `.err_on_empty = true` (404 if zero rows), `.if_ctx`/`.not_ctx` (per item).
|
By order per item: `{db, q, ctx_key}`. `ctx_key` optional (omit for inserts without `RETURNING`). Named: `.err_on_empty = true` (404 if zero rows), `.if_ctx`/`.not_ctx` (per item).
|
||||||
```c
|
```c
|
||||||
sqlite_query(
|
sqlite_query(
|
||||||
{"todos_db", "get_todos", "todos_data"},
|
{.db = "todos_db", .q = "get_todos", .ctx_key = "todos_data"},
|
||||||
{"todos_db", "get_todo", "todo", .err_on_empty = true},
|
{.db = "todos_db", .q = "get_todo", .ctx_key = "todo", .err_on_empty = true},
|
||||||
{"todos_db", "get_urgent","urgent", .if_ctx = "show_urgent"}
|
{.db = "todos_db", .q = "get_urgent", .ctx_key = "urgent", .if_ctx = "show_urgent"}
|
||||||
)
|
)
|
||||||
sqlite_query({"todos_db", "create_todo"}) // no result captured
|
sqlite_query({.db = "todos_db", .q = "create_todo"}) // no result captured
|
||||||
```
|
```
|
||||||
SQL file uses bound params: `select id, title from todos where id = {{id}};` and `insert into todos(title) values({{title}});`.
|
SQL file uses bound params: `select id, title from todos where id = {{id}};` and `insert into todos(title) values({{title}});`.
|
||||||
|
|
||||||
### join — nest one table's records into another (in-memory)
|
### join — nest one table's records into another (in-memory)
|
||||||
By order: `join(parent_key, parent_field, child_key, child_field)`. Optional `.parent_join_key` (new field name on parent records; defaults to the child table name). After it, each parent record gains a field holding its matched child records.
|
By order: `join(parent_ctx_key, parent_field_key, child_ctx_key, child_field_key)`. Optional `.parent_join_key` (new field name on parent records; defaults to the child table name). After it, each parent record gains a field holding its matched child records.
|
||||||
```c
|
```c
|
||||||
// before: { blog:[{id,...}], comments:[{id,blog_id,...}] }
|
// before: { blog:[{id,...}], comments:[{id,blog_id,...}] }
|
||||||
join("blog", "id", "comments", "blog_id")
|
join(.parent_ctx_key = "blog", .parent_field_key = "id", .child_ctx_key = "comments", .child_field_key = "blog_id")
|
||||||
// after: { blog:[{id,..., comments:[{...}]}] } -> template: {{#blog}}...{{#comments}}{{body}}{{/comments}}{{/blog}}
|
// after: { blog:[{id,..., comments:[{...}]}] } -> template: {{#blog}}...{{#comments}}{{body}}{{/comments}}{{/blog}}
|
||||||
```
|
```
|
||||||
|
|
||||||
### fetch — outbound HTTP; JSON parses into tables/records
|
### fetch — outbound HTTP; JSON parses into tables/records
|
||||||
Multiple items run CONCURRENTLY. Fields (all named): `.url` (interpolation ok), `.ctx_key` (response key), `.meth` (`n_get` default, `n_post n_put n_patch n_delete n_sse`), `.headers` (array of `{name,value}`), `.json_ctx_key` (context key serialized as JSON body), `.json` (literal JSON body string, interpolation ok), `.txt` (context key as plain-text body), `.if_ctx`/`.not_ctx`.
|
Multiple items run CONCURRENTLY. Fields (all named): `.url` (interpolation ok), `.ctx_key` (response key), `.method` (`n_get` default, `n_post n_put n_patch n_delete n_sse`), `.headers` (array of `{name,value}`), `.json_ctx_key` (context key serialized as JSON body), `.json` (literal JSON body string, interpolation ok), `.txt` (context key as plain-text body), `.if_ctx`/`.not_ctx`.
|
||||||
```c
|
```c
|
||||||
fetch(
|
fetch(
|
||||||
{.url = "https://api.weather.dev/now?city={{city}}", .ctx_key = "weather"},
|
{.url = "https://api.weather.dev/now?city={{city}}", .ctx_key = "weather"},
|
||||||
{.url = "https://api.news.dev/headlines?topic={{topic}}", .ctx_key = "news"}
|
{.url = "https://api.news.dev/headlines?topic={{topic}}", .ctx_key = "news"}
|
||||||
)
|
)
|
||||||
fetch({.url = "https://api.payments.dev/charge", .ctx_key = "receipt", .meth = n_post, .json_ctx_key = "order",
|
fetch({.url = "https://api.payments.dev/charge", .ctx_key = "receipt", .method = n_post, .json_ctx_key = "order",
|
||||||
.headers = {{"Authorization","Bearer {{api_key}}"}, {"Idempotency-Key","{{order_id}}"}}})
|
.headers = {{"Authorization","Bearer {{api_key}}"}, {"Idempotency-Key","{{order_id}}"}}})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -165,18 +165,18 @@ run(.call = assign_opponents)
|
|||||||
`dispatch("task_name")`. Enqueues a named task as a durable background job and returns immediately; task reactors pick it up. Checkpointed after each step, so a crash mid-task resumes where it stopped. Requires `#include <dispatch.h>`. Task must be defined with `task(...)`.
|
`dispatch("task_name")`. Enqueues a named task as a durable background job and returns immediately; task reactors pick it up. Checkpointed after each step, so a crash mid-task resumes where it stopped. Requires `#include <dispatch.h>`. Task must be defined with `task(...)`.
|
||||||
|
|
||||||
### sse — push a Server-Sent Event
|
### sse — push a Server-Sent Event
|
||||||
With `.chan` (by order, first value, supports interpolation) broadcasts to all clients on the channel; without it, returns to the requester. Named: `.evt` (event line), `.d` (array of strings, one per data line), `.cmt` (comment/keep-alive line).
|
With `.chan` (by order, first value, supports interpolation) broadcasts to all clients on the channel; without it, returns to the requester. Named: `.event` (event line), `.data` (array of strings, one per data line), `.comment` (comment/keep-alive line).
|
||||||
```c
|
```c
|
||||||
sse("todos:{{user_id}}", .evt = "todo_updated", .d = {"id: {{todo_id}}", "title: {{title}}"})
|
sse(.chan = "todos:{{user_id}}", .event = "todo_updated", .data = {"id: {{todo_id}}", "title: {{title}}"})
|
||||||
```
|
```
|
||||||
|
|
||||||
### render — mustache / mdm / json
|
### render — mustache / mdm / json
|
||||||
`mustache(template_key, set_key)`, `mdm(...)`, `json(...)`. By order: template context key, then set_key for output. (See Templates.)
|
`mustache(template_ctx_key, ctx_key)`, `mdm(...)`, `json(...)`. By order: template context key, then `ctx_key` for output. (See Templates.)
|
||||||
|
|
||||||
### respond — send the response
|
### respond — send the response
|
||||||
By order: `respond(context_key)`. Named: `.status` (default `n_ok`; values `n_ok` 200, `n_created` 201, `n_redirect` 302, `n_bad_request` 400, `n_not_authorized` 401, `n_not_found` 404, `n_error` 500), `.mime` (override; same values as resource `.mime`).
|
By order: `respond(ctx_key)`. Named: `.status` (default `n_ok`; values `n_ok` 200, `n_created` 201, `n_redirect` 302, `n_bad_request` 400, `n_not_authorized` 401, `n_not_found` 404, `n_error` 500), `.mime` (override; same values as resource `.mime`).
|
||||||
```c
|
```c
|
||||||
mustache("404","not_found_s"), respond("not_found_s", .status = n_not_found)
|
mustache("404","not_found_s"), respond(.ctx_key = "not_found_s", .status = n_not_found)
|
||||||
```
|
```
|
||||||
|
|
||||||
### headers / cookies — set response headers/cookies
|
### headers / cookies — set response headers/cookies
|
||||||
@@ -222,9 +222,9 @@ respond("page_s", .not_ctx = "is_htmx")
|
|||||||
|
|
||||||
## Iteration
|
## Iteration
|
||||||
|
|
||||||
`.map = "table"` runs a step once per row, ALL ROWS CONCURRENTLY; the row's fields land in scope as bare `{{interpolations}}`. `.map_key = "key"` (pairs with `.map`) exposes the current row as a single-row table under `key` (useful for `mustache`). With a `set_key`, results collect into a table aligned with the input (one entry per row).
|
`.map = "table"` runs a step once per row, ALL ROWS CONCURRENTLY; the row's fields land in scope as bare `{{interpolations}}`. `.map_key = "key"` (pairs with `.map`) exposes the current row as a single-row table under `key` (useful for `mustache`). With a `ctx_key`, results collect into a table aligned with the input (one entry per row).
|
||||||
```c
|
```c
|
||||||
fetch({"https://api.users.dev/{{id}}", "profiles", .map = "users"}) // per-row fan-out
|
fetch({n_get, "https://api.users.dev/{{id}}", "profiles", .map = "users"}) // per-row fan-out
|
||||||
mustache("todo","todo_s", .map = "todos", .map_key = "todo_d") // render per row, collect into todo_s
|
mustache("todo","todo_s", .map = "todos", .map_key = "todo_d") // render per row, collect into todo_s
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ A task is a named, reusable pipeline defined inside a module with `task("name",
|
|||||||
// reusable, run inline via run_task("recount_todos"):
|
// reusable, run inline via run_task("recount_todos"):
|
||||||
task("recount_todos", { sqlite_query({"todos_db","recount"}) }, .accepts = {"user_id"});
|
task("recount_todos", { sqlite_query({"todos_db","recount"}) }, .accepts = {"user_id"});
|
||||||
// durable background job via dispatch("notify_new_todo") (needs #include <dispatch.h>):
|
// durable background job via dispatch("notify_new_todo") (needs #include <dispatch.h>):
|
||||||
task("notify_new_todo", { fetch({.url = "https://api.push.dev/notify", .meth = n_post, .json = "{\"text\":\"New todo: {{title}}\"}"}) }, .accepts = {"title"});
|
task("notify_new_todo", { fetch({n_post, "https://api.push.dev/notify", .json = "{\"text\":\"New todo: {{title}}\"}"}) }, .accepts = {"title"});
|
||||||
// recurring on a schedule, no caller:
|
// recurring on a schedule, no caller:
|
||||||
task("daily_digest", { sqlite_query({"todos_db","digest"}), emit("digest_ready") }, .cron = "0 8 * * *");
|
task("daily_digest", { sqlite_query({"todos_db","digest"}), emit("digest_ready") }, .cron = "0 8 * * *");
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user