Begin go documentation (ai slop for now)

This commit is contained in:
2026-07-15 09:58:33 -04:00
parent 7d7b7354df
commit 0267297534
7 changed files with 431 additions and 82 deletions

View File

@@ -0,0 +1,303 @@
package app
import (
. "kjol/vdom"
ui "kjol/webui"
)
// The Go layer's documentation.
//
// One page, like /c and the component pages. The Go module is the biggest layer by far —
// a dozen small packages plus both web engines — so the job here is a MAP, not a manual:
// say what each subsystem is for and name the handful of identifiers you would reach for,
// and leave the exhaustive reference to go doc.
//
// The web engines get a deliberately short section. They are the two compositions, and
// each already has a whole documentation section of its own (/wasm, /js) — repeating it
// here would be two maps of the same ground, kept in sync by hand. So this page points at
// them and moves on.
//
// The organising idea is the same as the C page: every section is a SUBSYSTEM, and the
// sidebar lists them. A subsystem here is a small group of packages that answer one
// question — "how does it talk to a database", "how does it not trust its input" — rather
// than one package per section, which for thirteen packages would be a wall.
// goSubsystems is the single source for the page's sections AND the sidebar that jumps to
// them, so the sidebar cannot offer a jump to a section that does not exist.
func goSubsystems() []subsystem {
return []subsystem{
{ID: "config", Label: "Configuration", Icon: "bolt",
Blurb: "Where the binary learns its world: the environment baked in at compile time, and the config struct read at startup."},
{ID: "data", Label: "Data", Icon: "table",
Blurb: "A PostgreSQL query builder and row-to-struct automapper, and CSV in and out."},
{ID: "http", Label: "HTTP & email", Icon: "globe",
Blurb: "Dependency-free HTTP glue — CORS, responses, a typed client — and pluggable mail."},
{ID: "values", Label: "Values", Icon: "calculator",
Blurb: "The small stuff done once: generic helpers, UTC-first time, and money as integer cents."},
{ID: "trust", Label: "Trust & logging", Icon: "shield-check",
Blurb: "Crypto, input validation, and logging — the three that decide what the program will believe and remember."},
{ID: "text", Label: "Text", Icon: "code",
Blurb: "Syntax highlighting: source in, coloured HTML out. A lexer, not a parser."},
{ID: "engines", Label: "Web engines", Icon: "layers",
Blurb: "The two front-end frameworks are Go too — but documented on their own pages. This is only the pointer."},
{ID: "tooling", Label: "Tooling", Icon: "cube",
Blurb: "The command-line tools, most of them run by the build rather than by hand."},
}
}
// goNav is the sidebar while you are reading /go. The group is called Subsystems, as on /c.
func goNav() []docsGroup {
items := make([]docsItem, 0, len(goSubsystems()))
for _, s := range goSubsystems() {
items = append(items, docsItem{
Path: "/go#" + s.ID,
Label: s.Label,
Icon: s.Icon,
Blurb: s.Blurb,
})
}
return []docsGroup{
{
Title: "Introduction",
Items: []docsItem{
{Path: "/go", Label: "Overview", Icon: "book-open",
Blurb: "What the Go layer is, and the one rule that shapes all of it."},
},
},
{Title: "Subsystems", Items: items},
}
}
//gowasm:page /go static layout=app
func GoPage(d Deps) func() *VNode {
return func() *VNode {
return docPage("Layers", "Kjøl Go",
"The oldest and largest layer: configuration, a database toolkit, logging, HTTP helpers, "+
"mail, money math, validation — the parts an application needs that are not the application. "+
"Both web engines live here too, but those have their own pages; this is the base beneath them.",
docSection("what-this-is", "What this is",
prose("A single Go module, imported package by package as kjol/<name>. Each package is small, "+
"stdlib-first, and does one thing — there is no framework object to construct and no "+
"lifecycle to learn. You import config, or dbutil, or chrono, and call it."),
prose("One rule shapes the whole layer, and it is worth stating before the parts: the framework "+
"NEVER imports application code. Where a package needs something only the app knows — the "+
"names of its tables, where to write a log, its mail credentials, the shape of its config — "+
"the app hands that in, and the package is written against the gap. That is why dbutil has a "+
"Register, l4g a SetDatabaseWriter, snailmail a Configure, and config a generic Load[T]. The "+
"inversions are not decoration; they are the reason one base layer can sit under several "+
"different applications without knowing anything about any of them."),
note("This layer is consumed in place, not published",
"kjøl is a git submodule inside each app, wired up with a go.work file — so editing a file "+
"here takes effect in the consuming app immediately, with no version to bump and no go get. "+
"There is no ABI to keep stable because there is nothing to keep stable between: the layer "+
"and its consumer are built together."),
),
goConfig(),
goData(),
goHTTP(),
goValues(),
goTrust(),
goText(),
goEngines(),
goTooling(),
)
}
}
// ---- configuration -------------------------------------------------------
func goConfig() *VNode {
return docSection("config", "Configuration",
prose("Two packages, and they answer the same question — what world is this binary running in — at "+
"two different times. appenv answers it at COMPILE time: Environment is a const chosen by a build "+
"tag, so a production binary cannot be talked into thinking it is staging by a stray environment "+
"variable. The value is fixed the moment go build runs, and the bundler reads it to define the "+
"same constant for the JavaScript side."),
prose("config answers it at RUNTIME. Load[T] fills the app's own config struct from environment "+
"variables and an optional .env file, with real environment variables always winning over the "+
"file. The app defines the struct — its fields, its env tags, its defaults — and the package only "+
"provides the generic loading, so no two apps have to agree on what configuration means."),
apiTable(
apiRow{"appenv.Environment", "The deployment environment, a const fixed by build tag (-tags staging / production). No runtime path can change it."},
apiRow{"config.Load[T](file, *T)", "Fill your config struct from env + .env. Generic over the struct; environment variables beat the file."},
),
)
}
// ---- data ----------------------------------------------------------------
func goData() *VNode {
return docSection("data", "Data",
prose("dbutil is the largest package in the module, and it is two things: a PostgreSQL query builder "+
"and a reflection-based row-to-struct automapper. It is deliberately NOT an ORM — there are no "+
"migrations here (that is a separate tool) and no magic persistence. You bind a model type to a "+
"table, build parameterized SQL through chainable Select / InsertInto / Update / DeleteFrom, and "+
"scan the result straight into your structs by their db tags — including LEFT JOINs, which map a "+
"missing joined row to a nil pointer rather than a lie."),
prose("The table names come from a registry the app fills at startup (RegisterAll), which is the "+
"inversion at work: the builder resolves a Go type to a table without ever importing the app's "+
"models. Field references are type-safe — you pass a pointer to a struct field and the builder "+
"turns it into a column — so a renamed field is a compile error, not a wrong query at runtime."),
prose("csv is the small sibling: build CSV text from headers and rows, or straight from a slice of "+
"structs, and stream it to the browser as a download."),
apiTable(
apiRow{"dbutil.Init / ConnConfig", "Open the pooled *sql.DB and pin the session to UTC. Credentials are injected, never read from app config."},
apiRow{"dbutil.Register / RegisterAll", "Map a model type to its table name. The inversion: dbutil never imports your models."},
apiRow{"dbutil.Select / InsertInto / Update / DeleteFrom", "Chainable builders — Where, joins, order, paging — that emit $1,$2 parameterized SQL and its args."},
apiRow{"dbutil.ScanAll / QueryOne / QueryScalar[T]", "Result rows into your structs by db tag; joined rows that are all-NULL become nil pointers."},
apiRow{"dbutil.ParseFilterFromRequest / ApplyPagination", "Turn a request's query-string filters and paging into WHERE and LIMIT."},
apiRow{"csv.MakeCSV / StructToCSV / WriteCSVtoHTTP", "CSV from rows or from a slice of structs, and the headers to send it as an attachment."},
),
)
}
// ---- http & mail ---------------------------------------------------------
func goHTTP() *VNode {
return docSection("http", "HTTP & email",
prose("httputil is the HTTP glue, and it imports nothing of the app's. CorsMiddleware is "+
"constructor-style — you hand it the allowed domains and a function that reports the current "+
"bundle version, and it never reads those from config itself. Alongside it are the response "+
"writers (JSON, gob, error) and their client-side mirrors: FetchGob[T] and FetchJSON[T] do a typed "+
"GET-and-decode, and are a deliberate no-op during server rendering, so an SSR pass keeps its "+
"loading state instead of blocking on a network call."),
prose("snailmail sends mail through a provider chosen at startup — SMTP or Cloudflare — with the "+
"credentials injected via Configure and the actual branded message composed on the app side. The "+
"package takes an already-rendered Email and a type (text or HTML) and sends it; it does not know "+
"or care what the mail says."),
apiTable(
apiRow{"httputil.CorsMiddleware(CorsConfig)", "CORS as constructor-style middleware; allowed domains and the bundle-version source are injected."},
apiRow{"httputil.RespondJSON / RespondGob / RespondError", "Encode and write a response."},
apiRow{"httputil.FetchGob[T] / FetchJSON[T]", "Client-side typed GET+decode. A no-op during SSR, so the server keeps a loading state."},
apiRow{"snailmail.Configure(Settings)", "Pick SMTP or Cloudflare and hand it credentials, once at startup."},
apiRow{"snailmail.SendMail(Email, type)", "Send an already-rendered message. TYPE_TEXT or TYPE_HTML."},
),
)
}
// ---- values --------------------------------------------------------------
func goValues() *VNode {
return docSection("values", "Values",
prose("Three packages of the small stuff, done once so the apps do not each do it slightly "+
"differently. basic is the personal standard library: generic slice, map and pointer helpers, "+
"name capitalization that knows about Mc and O', number-to-string with commas, a reflection-based "+
"struct diff. chrono treats every stored time as UTC and only localizes at the edge — format a "+
"time in a timezone for display, parse an HTML date input back to UTC, render \"3 days ago\". "+
"finance keeps money as integer cents, never a float, and formats it back out with a symbol and "+
"grouping."),
apiTable(
apiRow{"basic.Reverse / IndexOf / RemoveDuplicates / MapMerge", "Generic slice and map helpers, stdlib-only."},
apiRow{"basic.NormalizeName / Int64ToStringWithCommas / CompareStructs", "Name casing, grouped numbers, and a field-by-field struct diff."},
apiRow{"chrono.FormatWithTz / DateToHTMLString", "A UTC time localized for display, or fed into an HTML date field."},
apiRow{"chrono.HTMLDateToTime / TimeSinceToString", "An HTML input parsed back to UTC; and \"Just now\" / \"3 days ago\"."},
apiRow{"finance.Int64ToMoneyWithCommas / MoneyToInt64", "Cents-as-int64 to a dollar string and back — no float ever touches the money."},
apiRow{"finance.MultiplyByPercentage / DaysToRateTerm", "Percentage math on cents, and a day count as a best-fit term string."},
),
)
}
// ---- trust & logging -----------------------------------------------------
func goTrust() *VNode {
return docSection("trust", "Trust & logging",
prose("Three packages that decide what the program will believe and what it will remember. security "+
"is the crypto: bcrypt for passwords, AES-256-GCM for secrets (with generic EncryptData[T] that "+
"gob-serializes then encrypts), hashing, base58/64, random keys, and a bluemonday HTML "+
"sanitization policy you initialize at startup. validation cleans and checks input — email, "+
"phone, US state, tax id, ZIP — and its validators return descriptive errors rather than a bare "+
"false, so the caller can say what was wrong."),
prose("l4g is logging, and it carries the same inversion as dbutil. It owns the Entry type — the "+
"framework's mirror of the app's log-entry model — and persists to the database through a function "+
"the app registers with SetDatabaseWriter. If none is registered it falls back to the terminal, so "+
"a line is never silently dropped. The main logger is terminal, file, or database, chosen by an "+
"environment variable."),
apiTable(
apiRow{"security.HashPassword / ComparePasswords", "bcrypt."},
apiRow{"security.EncryptData[T] / DecryptData[T]", "gob-serialize then AES-256-GCM, generic over the value."},
apiRow{"security.Init / SanitizationPolicy", "The bluemonday UGC policy (extended to allow svg/path). Initialize it before use."},
apiRow{"validation.SanitizeEmail / ValidatePhoneNumber / ValidateStateCode", "Clean and check US-centric input; validators return an error, not a bool."},
apiRow{"l4g.Init / Write / Fatal", "Terminal, file, or database logging, selected by LOGGER_TYPE."},
apiRow{"l4g.SetDatabaseWriter(func(Entry) error)", "The inversion: l4g owns Entry, the app owns the table it lands in."},
),
)
}
// ---- text ----------------------------------------------------------------
func goText() *VNode {
return docSection("text", "Text",
prose("lexer is syntax highlighting: source code in, HTML with coloured spans out. It is a lexer and "+
"not a parser on purpose — it degrades to escaped plain text on anything it does not understand "+
"rather than failing, so an unknown language is not an error and a half-written snippet still "+
"renders. It lives beside webui rather than inside it because it touches no DOM; it is a string in "+
"and a string out. It is what colours the C snippets over on the C page."),
apiTable(
apiRow{"lexer.Highlight(lang, src)", "Dispatch by language name. An unknown language comes back escaped and unpainted, not wrong."},
apiRow{"lexer.HighlightGo / HighlightC", "The two languages implemented so far."},
),
note("Its output is class names, so the stylesheet has to know it exists",
"The spans lexer emits carry Tailwind classes (text-emerald-300 and the like), so any stylesheet "+
"that renders a code block has to scan lexer/**/*.go for them. A build that forgets still "+
"compiles and just renders the snippet unstyled — which is exactly how it is wired into this "+
"site's Tailwind step."),
)
}
// ---- web engines ---------------------------------------------------------
// The short section, on purpose: these are the two compositions, and each is documented in
// full elsewhere. All this page owes them is a sentence and a door.
func goEngines() *VNode {
return docSection("engines", "Web engines",
prose("Both of kjøl's web frameworks are assembled out of this layer — and both have their own "+
"documentation, so this is only the map. The gowasm engine (the packages vdom, wasmruntime, rsc "+
"and wasmdevserver, plus the webui component kit) lets you write user interfaces as ordinary Go "+
"compiled to WebAssembly, server-rendered and then hydrated, with no JavaScript build at all. "+
"jsbundler and tw are the other road: the JavaScript build — TSX to Solid to esbuild — and a "+
"Tailwind v4 compiler written in Go, which is what styles both engines."),
P(Attr("class", "mt-4 flex flex-wrap gap-3"),
engineLink("/wasm", "code", "Kjøl Wasm Web"),
engineLink("/js", "table", "Kjøl JS Web"),
),
)
}
// engineLink is a pill that crosses into a composition's documentation. A plain href, not a
// client-side route: /js is a different binary's SPA, and even /wasm is reached most simply
// by letting the browser navigate rather than asking this page to swap itself out. No Deps,
// therefore — there is no navigate() to intercept.
func engineLink(href, icon, label string) *VNode {
return A(Attr("class", "inline-flex items-center gap-2 rounded-default border border-line px-3 py-1.5 text-sm font-medium text-ink-soft no-underline hover:border-primary-border hover:bg-primary-subtle hover:text-accent"),
Attr("href", href),
ui.IconInline(icon, 14, "text-ink-faint"),
Text(label),
ui.IconInline("arrow-right", 12, "text-ink-faint"),
)
}
// ---- tooling -------------------------------------------------------------
func goTooling() *VNode {
return docSection("tooling", "Tooling",
prose("The module ships a handful of command-line programs under cmd/. Most of them are run by the "+
"build rather than typed by hand: wasmgen reads the //gowasm: directives and writes the route and "+
"layout glue, twcss compiles the Tailwind stylesheet, bundle drives the JavaScript build, and "+
"typecheck runs the TypeScript checker. The rest are operational: migrate applies database "+
"migrations, loc reports the lines of code across the repository, and passgen bcrypt-hashes a "+
"password from the command line."),
apiTable(
apiRow{"cmd/wasmgen", "Preprocesses the //gowasm: directives into glue: the route map, the layouts, the server-component calls."},
apiRow{"cmd/twcss", "The Tailwind v4 compiler as a CLI — scan the sources, write the stylesheet."},
apiRow{"cmd/bundle", "A thin CLI over jsbundler: TSX → Solid → esbuild, plus the SSR bake."},
apiRow{"cmd/typecheck", "Runs the frontend TypeScript checker (tsgo, the native-Go TypeScript compiler)."},
apiRow{"cmd/migrate", "PostgreSQL migrations — up and down, behind an advisory lock."},
apiRow{"cmd/loc", "A lines-of-code report over git-tracked files (gocloc), vendored code excluded."},
apiRow{"cmd/passgen", "bcrypt-hash a password given on the command line."},
),
)
}

View File

@@ -72,6 +72,8 @@ func Languages() []Layer {
Name: "Go",
Href: "/go",
Tagline: "The base: config, database, logging, HTTP, mail, validation — and both web engines.",
Sub: "the base layer",
Live: true,
Icon: "server",
},
{
@@ -196,9 +198,12 @@ func layerGrid(rows []Layer) *VNode {
return Div(mods...)
}
// No icon beside the name, and none on the "Read the docs" link. The front page reads as a
// short list of what kjøl is, and a glyph next to every row — a boat, a table, a globe —
// asks to be decoded before the word beside it is read. The words are the point; they carry
// themselves. (The reference badge stays: it says something the name does not.)
func layerRow(l Layer) *VNode {
head := Span(Attr("class", "flex items-center gap-2"),
ui.IconInline(l.Icon, 15, iff(l.Live, "text-accent", "text-ink-muted")),
Span(Attr("class", "font-medium text-ink"), Text(l.Name)),
iff2(l.Live,
func() *VNode { return nil },
@@ -207,7 +212,7 @@ func layerRow(l Layer) *VNode {
Text("reference"))
}),
)
body := P(Attr("class", "mt-1 pl-[23px] text-sm leading-relaxed text-ink-muted"), Text(l.Tagline))
body := P(Attr("class", "mt-1 text-sm leading-relaxed text-ink-muted"), Text(l.Tagline))
if !l.Live {
return Div(Attr("class", "px-5 py-4 opacity-75"), head, body)
@@ -215,22 +220,13 @@ func layerRow(l Layer) *VNode {
// A real navigation: the next layer is a different binary.
return A(Attr("class", "block px-5 py-4 no-underline hover:bg-surface-muted"), Attr("href", l.Href),
head, body,
Span(Attr("class", "mt-2 inline-flex items-center gap-1.5 pl-[23px] text-sm text-accent"),
Text("Read the docs"),
ui.IconInline("arrow-right", 12, ""),
),
Span(Attr("class", "mt-2 inline-block text-sm font-medium text-accent"),
Text("Read the docs")),
)
}
// iff picks a string; iff2 picks a node. Go has no ternary, and a four-line if
// statement inside a tree literal breaks the shape of the markup worse than these do.
func iff(cond bool, a, b string) string {
if cond {
return a
}
return b
}
// iff2 picks a node. Go has no ternary, and a four-line if statement inside a tree literal
// breaks the shape of the markup worse than this does.
func iff2(cond bool, a, b func() *VNode) *VNode {
if cond {
return a()
@@ -238,18 +234,20 @@ func iff2(cond bool, a, b func() *VNode) *VNode {
return b()
}
// No icon on the menu rows either — the name and its one-line tagline are the whole item,
// same as the front-page list and the sidebar. (The chevron on the menu TRIGGER stays: it
// is not a layer's glyph, it is the cue that the thing opens.)
func layerItem(d Deps, l Layer) *VNode {
active := CurrentLayer(d.Path()) != nil && CurrentLayer(d.Path()).Href == l.Href
if !l.Live {
return Div(Attr("class", "flex cursor-default flex-col gap-0.5 px-3 py-2 opacity-55"),
Span(Attr("class", "flex items-center gap-2 text-sm font-medium text-ink-muted"),
ui.IconInline(l.Icon, 14, "text-ink-faint"),
Text(l.Name),
Span(Attr("class", "rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted"),
Text("reference")),
),
Span(Attr("class", "pl-6 text-xs text-ink-muted"), Text(l.Tagline)),
Span(Attr("class", "text-xs text-ink-muted"), Text(l.Tagline)),
)
}
@@ -258,10 +256,7 @@ func layerItem(d Deps, l Layer) *VNode {
cls += " bg-primary-subtle"
}
return A(Attr("class", cls), Attr("href", l.Href),
Span(Attr("class", "flex items-center gap-2 text-sm font-medium text-ink"),
ui.IconInline(l.Icon, 14, "text-accent"),
Text(l.Name),
),
Span(Attr("class", "pl-6 text-xs text-ink-muted"), Text(l.Tagline)),
Span(Attr("class", "text-sm font-medium text-ink"), Text(l.Name)),
Span(Attr("class", "text-xs text-ink-muted"), Text(l.Tagline)),
)
}

View File

@@ -78,7 +78,11 @@ func wordmark(d Deps, href string) *VNode {
}
return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default bg-ink text-surface"),
// text-white, not text-surface: the flag is the same in both themes, so the boat on
// top of it has to be too. text-surface inverts to near-black in dark mode, which
// would hide the boat against the navy cross. The flag itself carries a dark scrim
// (see .flag-no) so this plain white boat reads without a shadow of its own.
Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default flag-no text-white"),
ui.IconInline("sailboat", 17, "")),
Span(Attr("class", "flex items-baseline gap-1.5"),
Span(Attr("class", "text-lg font-semibold tracking-tight text-text-heading"), Text(name)),
@@ -202,10 +206,14 @@ func AppLayout(d Deps, content *VNode) *VNode {
// documents the C base layer. A sidebar listing the engine's chapters while you are
// reading about arenas would be worse than no sidebar at all.
func sidebarNav(path string) []docsGroup {
if path == "/c" || strings.HasPrefix(path, "/c/") {
switch {
case path == "/c" || strings.HasPrefix(path, "/c/"):
return cNav()
case path == "/go" || strings.HasPrefix(path, "/go/"):
return goNav()
default:
return docsNav()
}
return docsNav()
}
func docsSidebar(d Deps) *VNode {
@@ -225,11 +233,13 @@ func docsSidebar(d Deps) *VNode {
return El("aside", mods...)
}
// No icon: the sidebar is a list of words, and a glyph on every row is noise the reader has
// to look past to read the label. The label is the navigation. (docsItem still carries an
// Icon — it is used on the /docs index cards, where a larger tile earns one.)
func sidebarLink(d Deps, it docsItem) *VNode {
base, frag, isAnchor := strings.Cut(it.Path, "#")
cls := "flex items-center gap-2 rounded-default px-2 py-1.5 text-sm no-underline text-ink-soft hover:bg-surface-raised hover:text-ink"
iconCls := "text-ink-faint"
cls := "block rounded-default px-2 py-1.5 text-sm no-underline text-ink-soft hover:bg-surface-raised hover:text-ink"
// A section link is NEVER "active", and that is deliberate. It cannot be: it would
// have to know which section you had scrolled to, which means measuring all fifteen of
@@ -240,8 +250,7 @@ func sidebarLink(d Deps, it docsItem) *VNode {
// than no highlight: it tells you nothing and looks broken.)
active := !isAnchor && d.Path() == it.Path
if active {
cls = "active flex items-center gap-2 rounded-default px-2 py-1.5 text-sm no-underline bg-primary-subtle font-medium text-accent"
iconCls = "text-accent"
cls = "active block rounded-default px-2 py-1.5 text-sm no-underline bg-primary-subtle font-medium text-accent"
}
click := navigate(d, it.Path)
@@ -250,7 +259,6 @@ func sidebarLink(d Deps, it docsItem) *VNode {
}
return A(Attr("class", cls), Attr("href", it.Path), click,
ui.IconInline(it.Icon, 14, iconCls),
Text(it.Label),
)
}

View File

@@ -9,6 +9,7 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
"/": HomePage(d),
"/about": AboutPage(d),
"/c": CPage(d),
"/go": GoPage(d),
"/wasm": DocsPage(d),
"/wasm/chart": ChartPage(d),
"/wasm/components": ComponentsPage(d),
@@ -22,6 +23,7 @@ var StaticPaths = map[string]bool{
"/": true,
"/about": true,
"/c": true,
"/go": true,
"/wasm": true,
"/wasm/chart": true,
"/wasm/data": true,
@@ -32,6 +34,7 @@ var RouteLayout = map[string]string{
"/": "public",
"/about": "public",
"/c": "app",
"/go": "app",
"/wasm": "app",
"/wasm/chart": "app",
"/wasm/components": "app",