No results

    Search failed

    Forms & the poops bridge

    Forms

    A build.forms block emits an HTML <form> per resource, from the same field DSL that made the table, wired to the resource's /api endpoint.

    "build": {
      "forms": {
        "messages": {
          "into": "src/markup/_partials",
          "success": "/thanks",
          "hints": { "body": { "widget": "textarea" }, "email": { "help": "We'll only reply here" } }
        }
      }
    }

    Field types map to inputs — slug→pattern, enum→<select>, ref:→<select> built from the referenced table (only rows the viewer may read, per that resource's access.read, and bounded), email→type=email, image→file input. id is omitted, and so is any field defaulting to now — = now and = now! alike, since both are server-owned. A hints.<field>.include puts one back; hints.<field>.exclude takes any other field out.

    They work. The create/update routes content-negotiate:

    Client Success Error
    HTMX HX-Redirect, or a "Saved" fragment 422 + the form re-rendered with errors + values
    Browser (no JS) 303 redirect (Post/Redirect/Get) 422 + the re-rendered form
    API (JSON) 201 + JSON 422 + JSON

    The HTMX error row needs assets/septic-forms.js (see Progressive validation): stock htmx swaps nothing on a 4xx, so the re-rendered form shows only with the script loaded.

    Editing

    GET /api/:resource/:id as a writer wanting HTML returns a prefilled edit form (PUT via HTMX hx-put, or POST + _method=PUT for no-JS). Editing never re-applies defaults, so created survives.

    The form posts every field it drew, so emptying an input clears that column β€” "" for a string or text, NULL for every other type. Clearing a required field still errors, and a file/image keeps its stored upload, because an untouched file input can post an empty part. A JSON PUT/PATCH is the other contract: only the keys it carries, cleared with an explicit null. The data layer has both contracts side by side.

    The negotiated admin

    One level up, the same trade: GET /api/:resource as a writer wanting HTML is a table β€” each id linking its edit form, prev/next when a page fills, the create form underneath. An API client on the same URL keeps its JSON array. No separate admin app, no extra route.

    The door into it: an anonymous browser on a denied route is redirected to GET /api/_auth/login β€” a login form that sets the session cookie and returns the browser to where it was going (next accepts only same-origin relative paths). JSON clients keep their 401. No signup page beside it: public registration is an application decision, not a backend default.

    Notify

    "notify": { "url": "https://ntfy.sh/my-topic", "events": ["create"], "resources": ["messages"] }

    A successful HTTP write POSTs {event, resource, row} to the URL β€” "email me when the form lands" through any webhook, without septic owning an SMTP client. events defaults to ["create"]; resources absent means all; timeout (ms, default 5000). Fire-and-forget: a failed call warns and never fails the write.

    Progressive validation

    Native HTML5 validation fires from the emitted attributes with no JS. Include assets/septic-forms.js and those same native checks drive styled inline messages β€” no rules duplicated. It also opts the server's errors back into HTMX: stock htmx swaps nothing on a 4xx, so the 422/409 re-rendered form (the HTMX error column above) is shown only with the script loaded. The server (validate.js) stays the authority.

    The poops bridge

    septic build turns DB rows into markup and runs poops:

    "build": {
      "resources": {
        "posts": {
          "into": "src/markup/posts", "slug": "slug", "body": "body", "layout": "post",
          "where": { "status": "published" }
        }
      }
    }

    Each row β†’ src/markup/posts/<slug>.md (front matter + body), kept in step β€” only changed files are rewritten, deleted rows are swept β€” then poops compiles the site. where is optional β€” an equality filter (multiple keys ANDed, values bound) so drafts stay out of the static site while the API still serves them. One config, one dataset β€” a live API and a static site. poops is an optional peer: markup is always written; if poops isn't installed, septic build emits the markup and says so.

    Where they meet: one origin

    An emitted form posts to a relative /api/<resource>, so it works only when the page and the API answer on the same host. septic serves /api and /uploads; the compiled site is poops output, and nothing in septic serves it. Two processes on two ports means the form posts into the void.

    laxative is the piece that closes that: it boots septic's app, serves the built site behind it, and rebuilds on change β€” laxative dev in development, laxative serve in production. Same host, same port, no CORS to configure. Prefer your own server? Mount createServer(config).app beside your static handler β€” see the exports.