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.