HTML is canonical. data-kedge binds an element to the app database. Markdown directives lower to the same HTML before compilation.

bound regions

A collection binding repeats one direct child <template>:

<ol data-kedge="stories?$order=-created_at&$limit=20">
  <template>
    <li><a href="{{url}}">{{title}}</a></li>
  </template>
  <li data-when=":empty">No stories yet.</li>
</ol>

A single-record binding uses the bound element's body directly:

<!-- item/[id].html serves /item/:id -->
<article data-kedge="stories/{{$url.id}}">
  <h1>{{title}}</h1>
  <p data-when=":empty">Story not found.</p>
</article>

Lists require exactly one direct <template>; records must not contain one. data-when=":empty" is a direct child and cannot contain another binding.

binding grammar

[me/]<collection>[/<id>][?filters&$modifiers]

Names use letters, numbers, and underscores and cannot start with a number.

target meaning
stories public collection
stories/abc public record with a literal ID
stories/{{$url.id}} public record selected by a route or query parameter
stories/{{id}} public record selected by the enclosing row
me/votes records authored by the current viewer
me/preferences/self one viewer-owned record for a fixed field tuple

{{field}} is valid only inside a record template. {{$url.name}} comes from a parameterized route or request query.

filters

stories?kind=link
comments?story={{$url.id}}
events?starts_at.gte=$now

Comparison suffixes are .gt, .gte, .lt, .lte, .ne, and .in; .in accepts up to 32 comma-separated values. Values may be literals, {{$url.name}}, $viewer, $now, or row-local {{field}} for equality.

URL parameters with an empty value are ignored. A missing $viewer suppresses a viewer-dependent query rather than broadening it.

list and pagination modifiers

modifier meaning
$order=field ascending order
$order=-field descending order
$limit=n return 1 through 1000 rows
$after=cursor continue after an opaque signed cursor
$count return one record with count
$count&$group=field return field and count for each group

Ordering defaults to id, which is also the stable tiebreaker. A direct data-when=":more" child receives {{$next}}:

<ol data-kedge="stories?$order=-created_at&$limit=20&$after={{$url.after}}">
  <template><li>{{title}}</li></template>
  <li data-when=":more"><a href="?after={{$next}}">more</a></li>
</ol>

Dynamic $order={{$url.sort}} is accepted only when a select, radio group, or button group named sort enumerates 1 through 32 allowed order values.

private rows and aggregates

me/ restricts rows to the current author. $tally exposes only a count:

<span data-kedge="me/votes?story={{id}}&$tally">{{count}}</span>

$tally=field groups by an enumerated managed-form field.

me/votes?author=$viewer&$via=story:stories,comment:comments&$tally

$via joins up to four field:collection pairs. It requires a filter and an ungrouped tally.

<span data-kedge="comments?story={{id}}&$count">{{count}}</span>
<span data-kedge="me/votes?story={{id}}&$tally">{{count}}</span>

These are the only nested read shapes. All rows are grouped into one query.

audiences

$verified and $owner restrict a binding or write. Every use of one collection must have a compatible audience and public/private spelling. App authentication is a separate ingress check.

interpolation

value meaning
{{field}} record field
{{author.name}} managed author display name
{{$url.name}} route or query parameter
{{$viewer.id}}, {{$viewer.name}} current app identity
{{$viewer.verified}}, {{$viewer.owner}} identity facts
{{$dc.code}}, {{$dc.metro}} serving datacenter
{{$page.path}}, {{$page.dir}}, {{$page.slug}} current page
{{$now}} server time in UTC

All values are escaped. Interpolation is rejected in <script>, <style>, and event-handler attributes. A context value in href or src needs a fixed relative, http, https, or mailto prefix.

expression result
{{created_at | ago}} relative time
{{created_at | date}} calendar date
{{url | host}} lowercase hostname without www.
{{count | number}} grouped integer
{{count | plural:reply}} count and singular/plural noun
{{text | truncate:80}} at most 80 characters plus an ellipsis
{{body | markdown}} sanitized stored Markdown

markdown is element-content only. It drops raw HTML and unsafe URLs and does not run app directives.

conditional content

Outside rows, data-when accepts $viewer.verified, !$viewer.verified, $viewer.owner, and !$viewer.owner. Record templates also accept:

condition meaning
:mine current viewer authored the record
!:mine current viewer did not author the record
field field is non-empty
!field field is empty
field=value field equals the literal value

:empty, :more, and :error are region and form states.

managed forms

<form data-kedge="comments?story={{$url.id}}">
  <textarea name="text" required maxlength="2000"></textarea>
  <button>comment</button>
  <p data-when=":error">{{$error}}</p>
</form>

Kedge supplies method, action, CSRF protection, and the write capability. Do not set action; an explicit method must be POST.

target operation
comments create a record
comments/<id> update a record
comments/{{id}} update the enclosing row
me/preferences/self create or replace the viewer's fixed-tuple record

Equality filters are sealed setters. Writes return to the current page; $return=/path overrides the redirect. Native controls infer fields:

control inferred field
text, hidden input, or textarea text
type=url absolute URL
type=number integer, or number when step allows fractions
select, radio, named button enumerated value
type=file replicated image URL

Server-enforced constraints are required, minlength, maxlength, pattern, min, max, step, and enumerated values. Forms set at most 32 fields; id, created_at, updated_at, and author are reserved.

File fields accept GIF, JPEG, PNG, or WebP values from 1 byte through 8 MiB. The stored value works directly in src="{{photo}}". All submitted field values together may use at most 64 KiB, excluding file bytes.

Rejected submissions preserve values and render {{$error}} in the data-when=":error" branch. Kedge adds the branch when omitted.

Authored records are mutable by their author. The app owner may delete any managed record and update records with no author. Redirects wait briefly for the committed database frontier.

action buttons

<strong data-kedge="counters/home">{{visits}}</strong>
<button data-kedge="counters/home?$increment=visits">add one</button>

$increment requires a literal public record. It creates an INTEGER COUNTER NOT NULL DEFAULT 0; each action adds one and concurrent increments merge.

<button data-kedge="comments/{{id}}?$delete" data-when=":mine">delete</button>
<button
  data-kedge="me/votes/self?story={{id}}&$toggle"
  data-when="!:mine">vote</button>

$toggle requires one field={{field}} filter. Kedge manages aria-pressed and the viewer-owned row. $return, $verified, and $owner also apply.

schema and storage

Managed tables receive id, created_at, updated_at, and author columns. Schema inference is additive; deploy does not drop, retype, or tighten fields.

SQLite views are read-only and provide their own indexes. Handlers and services can use the same /shared.db.

On a managed create, Kedge removes authored rows older than one year and rejects the write when the collection already has 100,000 authored records. Rows created directly through SQL are not part of those browser-write limits.

identity

A first authored write creates an anonymous app identity; reads and public counters do not. me/, :mine, authored writes, and sign-in migration use it. See app authentication for verified identity and ingress.

live updates

Read bindings use one signed event stream for server-rendered fragments. Managed forms use fetch when available and POST/303 otherwise. Static assets remain cacheable; rendered data pages are private, no-store.

Markdown shorthand

Markdown source accepts title and style front matter. Without title, the first level-one heading supplies the browser title.

Markdown canonical HTML
:::record{bind="…"} <article data-kedge="…">
:::each{bind="…" as=ol} bound <ol> with <template><li>
:::form{bind="…"} <form data-kedge="…">
:::details[summary] <details><summary>…
::empty[text] direct data-when=":empty" list item
:value[text]{bind="…"} <span data-kedge="…">
:button[text]{bind="…"} managed <button data-kedge="…">
:input[label]{name=…} native labeled <input>
:textarea[label]{name=…} native labeled <textarea>
:author[text] author span using {{author.name}}
:account[login]{provider=… next=…} login, identity, and logout controls

Attributes pass through. .class and #id set class and ID; bind and when become data-kedge and data-when. Raw HTML remains available.