Detail
The detail page shows one record. Without configuration every field renders
with a sensible default; Detail[T] customizes projection and presentation.
posts.Detail(func(d *steward.Detail[Post]) {
d.Field("Title")
d.Field("Status").Badge(map[any]steward.BadgeColor{"draft": steward.BadgeSecondary, "published": steward.BadgeGreen})
d.Field("Cover").Image(320, 0)
d.Field("Body").Markdown()
d.Field("Author.Name", "Author")
})A field path may cross one relation, as Author.Name does; the relation is
loaded for you.
Computed rows
FieldFunc(name, label, fn) adds a row whose value comes from the whole
record rather than from one path — a collection, a summary, anything a struct
field cannot name. It is ColumnFunc on the show
view:
d.FieldFunc("tags", "Tags", func(p *Post) template.HTML {
return template.HTML(strings.Join(tagNames(p.Tags), ", "))
})The record is whatever the repository loaded, so a collection needs its
relation preloaded — repo.With("Tags") — the same as for a computed column.
Layout
Each field is one row: label on the left, value on the right, with a rule between rows. On a narrow screen the label sits above its value.
HTML(), Markdown() and JSON() take the row’s full width, with the label
above them — long markup needs the width, and the two-column grid this replaced
paired unrelated fields into rows, so one long value left an empty column beside
itself for its whole height. Block() asks for the same treatment on any other
field.
d.Field("Notes").Block()Field renderers
A row shows the value as text unless a renderer says otherwise. One example of each:
Values with a meaning of their own
d.Field("Status").Using(map[any]string{0: "Draft", 1: "Published"})
d.Field("Status").Badge(map[any]steward.BadgeColor{0: steward.BadgeSecondary, 1: steward.BadgeGreen})
d.Field("Featured").Bool() // Yes / No
d.Field("Featured").Bool("Ya", "Tidak") // or words of your own
d.Field("Size").Filesize() // 2411724 → 2.3 MBBadge and Using combine, in either order: the colour comes from the stored
value and the text from Using’s map, so a status shows the word rather than
the number underneath it.
d.Field("Status").Badge(map[any]steward.BadgeColor{1: steward.BadgeGreen}).
Using(map[any]string{1: "Live"})A value the map does not list renders as it is stored — a status added to the
database later is readable rather than blank. Bool takes two words or none;
one is a mistake Verify reports, since there is no telling which half it is.
Long values
d.Field("Body").Markdown() // GitHub-flavoured, sanitized
d.Field("Content").HTML() // the read side of a Richtext field
d.Field("Payload").JSON() // pretty-printed in a code block
d.Field("Notes").Preformatted() // keeps line breaks and runs of spacesEach of these takes the row’s full width; see layout.
Lists
d.Field("Keywords").Tags() // ["launch","steward"] → two chipsThe read side of a Tags field: one text column holding a JSON
array, drawn as chips. Values are escaped, and a column holding a plain string
rather than an array shows as one chip.
Files and links
d.Field("Cover").Image(480, 0) // 0 leaves that dimension free
d.Field("Attachment").Link() // resolves through Storage
d.Field("Avatar").Image(96, 96).Disk("public")Disk names which disk a stored path belongs to,
for a field that does not live on the default one.
Anything else
As receives the raw value and the typed record, and returns markup:
d.FieldFunc("tags", "Tags", func(p *Post) template.HTML {
return template.HTML(strings.Join(tagNames(p.Tags), ", "))
})
d.Field("PublishedAt").As(func(v any, p *Post) template.HTML {
if p.PublishedAt == nil {
return "not yet"
}
return template.HTML(p.PublishedAt.Format("2 January 2006"))
})As reads one path and can see the whole record; FieldFunc needs no path at
all. Use FieldFunc for a value no single field names — see computed
rows.
Copyable
Copyable() puts a copy button beside the value. It copies what is stored,
not what is shown, so a badge, a shortened path, or a formatted number still
yields the value someone would paste elsewhere.
d.Field("ID").Copyable()
d.Field("Slug").Copyable()Column.Copyable does the same in a grid cell. The button
appears on hover, and stays visible where there is no hover to give.
Markdown
Markdown() renders the value as GitHub-flavoured markdown — headings, lists,
tables, strikethrough, autolinks — and passes the result through the same
allowlist a Richtext value goes through. Markdown permits
raw HTML, so the allowlist is not optional decoration here: it is what keeps a
<script> in a stored document from becoming a script on the page.
Preformatted() is the one to use for text that is not markdown: it escapes the
value and keeps its line breaks and runs of spaces.
Uploads
Image(w, h) and Link() both put the stored value into a src or an href,
so both resolve it through the configured
Storage first. A File/Image form field stores a
storage-relative path rather than a URL, which the browser would otherwise read
relative to the current page.
d.Field("Cover").Image(480, 0) // <img src="/app/_uploads/…">
d.Field("Attachment").Link() // downloads instead of 404ingClicking the image opens it full size in the panel’s viewer. A stored file gets a Preview button beside its link when it is something the viewer can show — an image or a PDF — so a document can be read without leaving the record. An absolute URL pointing at another site is left to navigate.
Either way the value carries a glyph saying what following it does: a paperclip for a stored file, an arrow for a link that leaves the panel. Both read as plain text otherwise, which is what made a file path look like a value nobody could act on.
A value that is already absolute (http://, https://, /…, data:) is left
alone, so a column of external URLs behaves as before. Link() shows the stored
value and points at the resolved one, so the path stays readable rather than
being replaced by its URL.
HTML
HTML() renders the value as markup rather than escaped text — the read side of
a Form.Richtext field:
d.Field("Body").HTML()The value is sanitized here too, not merely trusted for having been sanitized on save. Rows predating the field, rows written by a migration or a direct SQL fix, and rows from another writer never passed through that path, so cleaning on render is what makes the guarantee hold for the data actually in the table.
Relation grids
Embed another registered resource’s grid, scoped to the shown record:
steward.RelationGrid[Author, Post](d, "Posts by this author",
func(q *steward.ListQuery, a *Author) {
q.Conds = append(q.Conds, steward.Cond{Path: "AuthorID", Op: steward.OpEq, Val: a.ID})
})The embedded table reuses the related resource’s grid columns and links to
its full listing. The related resource’s policy applies: its
RowScoper narrows the rows, and if its ViewAny denies the viewer the
section disappears entirely.
JSON
GET /{slug}/{id} with Accept: application/json returns the record
itself — see JSON API.