Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cheatsheet-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@marko/run": patch
---

Bring the shipped cheatsheet up to date with the runtime: context properties, body limits and error codes, options-only middleware, HEAD/QUERY handling and `Run.href` options.
63 changes: 33 additions & 30 deletions packages/run/cheatsheet.md
Original file line number Diff line number Diff line change
@@ -1,50 +1,51 @@
# @marko/run cheat sheet (file-based routing)
# @marko/run cheat sheet

Routes live under `src/routes/`. Only `+`-prefixed files are routable. Dev server: `marko-run dev`.

## Files

| File | Role |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `+page.marko` | the page served at this directory's path (GET) |
| `+layout.marko` | wraps everything below it; layouts NEST — every `+layout.marko` up the tree wraps the next, root→leaf. Render the child with `<${input.content}/>` |
| `+handler.js` | HTTP handlers: `export const GET = Run.GET((ctx, next) => ...)` — verb names MUST be UPPERCASE; `Run` is a global, no import |
| `+middleware.js` | `export default Run.ALL((ctx, next) => ...)` runs before handlers, all methods, root→leaf |
| `+404.marko` / `+500.marko` | root of `src/routes/` only |
| `+page.marko` | the page at this directory's path; `next()` renders it for GET, POST and QUERY |
| `+layout.marko` | wraps everything below it. Layouts nest: every `+layout.marko` up the tree wraps the next, root→leaf. Render the child with `<${input.content}/>` |
Comment thread
DylanPiercey marked this conversation as resolved.
| `+handler.js` | HTTP handlers: `export const GET = Run.GET((ctx, next) => ...)`. Verb names are uppercase; `Run` is a global, no import |
| `+middleware.js` | `export default Run.ALL((ctx, next) => ...)` runs before handlers, root→leaf. `Run.ALL` covers every method; `Run.POST(...)` is skipped for others |
| `+404.marko` / `+500.marko` | root of `src/routes/` only, wrapped by the root layout; `+500` gets `input.error`. A `$$` dir 404s deeper |

## Paths

- `src/routes/about/+page.marko` → `/about`
- `src/routes/products/$id/+page.marko` → `/products/:id` (param `id`)
- `$$rest` dir → catch-all; `_name` dir → no URL segment (grouping)
- `$$rest` dir → catch-all (nothing nests inside it); bare `$`/`$$` match without capturing; `_name` dir → no URL segment (grouping)
- Flat form: `.` is the directory separator, so `products.$id+page.marko` ≡ `products/$id/+page.marko`; mix flat and nested freely.
- One file can serve several paths: `,` lists alternates and `()` groups them. `foo,bar+page.marko` → `/foo` and `/bar`; `(a,b).(c,d)+handler.js` → `/a/c`, `/a/d`, `/b/c`, `/b/d`. Escape a literal `.` / `$` / `,` with backticks: `` `1.0`+page.marko `` → `/1.0`.
- Relative imports from a route file: one `../` per directory after `src/`. From `src/routes/+page.marko` use `../x.js` for `src/x.js`; from `src/routes/a/+page.marko` use `../../x.js`; from `src/routes/a/$b/+handler.js` use `../../../x.js`. Count the directories — off-by-one here is the top import error.
- One file can serve several paths: `,` lists alternates and `()` groups them. `foo,bar+page.marko` → `/foo` and `/bar`; `(a,b).(c,d)+handler.js` → `/a/c`, `/a/d`, `/b/c`, `/b/d`; an empty alternate is optional (`projects.(home,)` → `/projects`, `/projects/home`). Escape a literal `.` `,` `+` `(` `)` `$` `_` with backticks: `` `1.0`+page.marko `` → `/1.0`.

## Request data

- In handlers/middleware: the `ctx` argument.
- In `.marko` pages/layouts: the same object is **`$global`** (NOT `input` — page input is empty).
- In `.marko` pages/layouts: the same object is **`$global`**; page `input` is empty.

In `src/routes/products/$id/+page.marko`:

```marko
/* src/routes/products/$id/+page.marko */
<h1>Product ${$global.params.id}</h1>
<p>query q = ${$global.url.searchParams.get("q")}</p>
<p>query q = ${$global.search.q}</p>
<p>from handler: ${$global.data.title}</p>
```

Context/`$global` properties: `request` (WHATWG Request), `url` (URL), `params`, `search`, `body` (only on POST/PUT/PATCH with a `json`/`form` validator; `await` it), `meta`, `data`, `platform`. Methods: `ctx.render(template, input)`, `ctx.redirect(path, status)` (301/302/303/307/308 only), `ctx.back()`, `ctx.fetch(url)`.
Context/`$global` properties: `request` (WHATWG Request), `url` (URL), `route` (pattern), `method`, `params`, `search` (parsed query object, repeated keys as arrays), `body` (promise; only with a `json`/`form` validator on POST/PUT/PATCH/QUERY; `await` it), `meta`, `data`, `platform`, `parent` (caller under `ctx.fetch`), `serializedGlobals` (props the browser's `$global` gets; `params`/`url` by default). Methods: `ctx.render(template, input)`, `ctx.redirect(to, status = 302)` (301/302/303/307/308 only), `ctx.back(fallback = "/")` (uses `Referer`), `ctx.fetch(resource, init?)` (through the app router).

## Handler contract

In `src/routes/guestbook/+handler.js`:

```js
/* src/routes/guestbook/+handler.js */
import * as v from "valibot";

import { addEntry, loadEntries } from "../../store.js";

export const GET = Run.GET((ctx, next) => {
return next({ title: "Guestbook", entries: loadEntries() }); // next() renders the page; data -> $global.data
return next({ title: "Guestbook", entries: loadEntries() }); // next() renders the page; data merges into $global.data
});

export const POST = Run.POST(
Expand All @@ -61,10 +62,10 @@ export const POST = Run.POST(
);
```

- `Run.GET`/`Run.POST`/… wrap the handler (and skip it for other methods); `Run.ALL` runs for every method. Legacy plain exports (`export function GET(ctx, next) {}`) still work but new code uses `Run.*`.
- Validate inputs by declaring validators in an options object: `params` and `search` on any verb, the body via `json` or `form` (there is no `body` option key). Prefer a Standard Schema library (e.g. valibot); a plain function also works.
- Schema-validated values are `[value, issues]` pairs: `const [search, searchIssues] = ctx.search`, `const [params, paramIssues] = ctx.params`, `const [body, issues] = await ctx.body`. When validation fails `issues` is set (and `value` is the raw input)handle it before use (reject, or re-render the page as in the POST above). Where validators are declared, read these accessors instead of re-parsing `ctx.url.searchParams` or `ctx.request.formData()`.
- A plain function IS a validator: it returns the value `ctx.body` resolves to (typed by its return) and rejects by throwing a `Response`. The validator is the ONLY place to validate and type the body — never cast/re-check `await ctx.body`. Without one anywhere in the chain (`{ maxBytes }` alone just sets limits), `ctx.body` is `undefined`.
- `Run.GET`/`Run.POST`/… wrap the handler (and skip it for other methods); `Run.ALL` runs for every method. Legacy plain exports (`export function GET(ctx, next) {}`) still work but new code uses `Run.*`. Without a `HEAD` export, `HEAD` runs the `GET` handler and `next()` answers with headers only.
- Validate inputs by declaring validators in an options object: `params` and `search` on any verb, the body via `json` or `form`. Prefer a Standard Schema library (e.g. valibot; sync only); a plain function also works. Options-only middleware (`export default Run.ALL({ search: schema })`) validates a subtree; options merge root→leaf, last validator wins.
- Schema-validated values are `[value, issues]` pairs: `const [search, searchIssues] = ctx.search`, `const [params, paramIssues] = ctx.params`, `const [body, issues] = await ctx.body`. When validation fails `issues` is set (and `value` is the raw input); handle it before use (reject, or re-render the page as in the POST above). Validators run lazily on first access. Without one, `ctx.params`/`ctx.search` are plain objects; read them rather than `ctx.url.searchParams` or `ctx.request.formData()`.
- A plain function is a validator: it returns the value `ctx.body` resolves to (typed by its return) and rejects by throwing a `Response`. The validator is the one place to validate and type the body. Without one anywhere in the chain (`{ maxBytes }` alone just sets limits), `ctx.body` is `undefined`. Malformed → 400, oversized → 413, unhandled content type → 415; defaults: `json.maxBytes` and `form.maxFileBytes` 1 MiB, `form.maxBytes` = `maxFiles` (20) × `maxFileBytes`.

```ts
export const POST = Run.POST(
Expand All @@ -80,19 +81,20 @@ export const POST = Run.POST(
);
```

- Raw parsed value: identity validator `json: (value) => value`; same for `form`/`params`/`search`.
- Raw parsed value: identity validator, `json: (value) => value`; same for `form`/`params`/`search`.
- Return a `Response` → sent as-is (page does not render).
- Return nothing → framework calls `next()` for you (page renders).
- If you call `next()` yourself, RETURN its result.
- Load page data HERE, and pass promises unawaited: `next({ entries: loadEntries() })` streams the page shell immediately and the page renders `<await|entries|=$global.data.entries>` when it resolves. Awaiting in the handler delays the first byte; fetching inside components creates waterfalls.
- JSON APIs: `return Response.json(obj)` (add status/headers with `Response.json(obj, { status: 201 })`); it sets `content-type: application/json` for you.
- Return nothing → the framework calls `next()` (page renders).
- When calling `next()`, return its result.
- Load page data here, and pass promises unawaited: `next({ entries: loadEntries() })` streams the page shell immediately and the page renders `<await|entries|=$global.data.entries>` when it resolves. Awaiting in the handler delays the first byte; fetching inside components creates waterfalls.
- JSON APIs: `return Response.json(obj)` (add status/headers with `Response.json(obj, { status: 201 })`); it sets `content-type: application/json`.

## Middleware (auth/logging, written once for a subtree)
## Middleware

Auth, logging and the like, written once for a subtree. In `src/routes/admin/+middleware.js`:

```js
/* src/routes/admin/+middleware.js */
export default Run.ALL((ctx, next) => {
if (ctx.url.searchParams.get("key") !== "letmein") {
if (ctx.search.key !== "letmein") {
return new Response("unauthorized", { status: 401 });
}
return next();
Expand All @@ -101,14 +103,15 @@ export default Run.ALL((ctx, next) => {

## Layout

In `src/routes/+layout.marko`:

```marko
/* src/routes/+layout.marko */
<header><nav><a href="/">Home</a> <a href="/about">About</a></nav></header>
<main><${input.content}/></main>
```

Layouts nest: `src/routes/+layout.marko` and `src/routes/admin/+layout.marko` BOTH wrap `/admin/...` pages (outermost first). Middleware nests the same way, root→leaf.
Layouts nest: `src/routes/+layout.marko` and `src/routes/admin/+layout.marko` both wrap `/admin/...` pages (outermost first). Middleware nests the same way, root→leaf.

Typed links: `<a href=Run.href("/products/$id", { params: { id } })>` (checked against your routes).
Typed links: `<a href=Run.href("/products/$id", { params: { id } })>` (checked against the app's routes); also takes `search` and `hash`, encodes values, catch-all params accept arrays.

Plain `<form method="post">` + a POST handler + redirect = zero-JS forms that just work.
Loading