Building
Plugins
Add behaviour the engine does not have, and update pages live with Datastar.
A plugin is a unit of behaviour the engine does not have: a basket, a search box, a comment thread. It brings its own routes, pages, styles and state, and a site opts in with one line.
Using a plugin
func mount(e *quire.Engine) error {
// collections and pages first
return e.Use(basket.New(items...))
}
Use attaches the plugin: its routes, its page, its stylesheets and its control in the nav.
A plugin's pages live under /_<name> unless the site moves them:
e.Use(plugin.At("/basket", b))
examples/4-plugin is a shop built this way.
Search
Quire ships one plugin, in plugin/search: a search box in the nav whose results arrive as the visitor types.
s := search.New()
s.Add(pages)
return e.Use(plugin.At("/search", s))
Add indexes a collection, one entry for each document's opening and one for each H2 and H3, held in memory. Call it for every collection the box should find.
In the box, / focuses it, up and down move through the results, and enter opens one.
Without JavaScript the form loads a results page instead, at /_search unless the site moves it.
Writing a plugin
A Plugin is two methods:
type Plugin interface {
Name() string
Attach(*Mount) error
}
Name is one word, and it is where the plugin lives: its routes are under /_<name>/, its CSS classes start <name>-, and its signals are $<name>.
Attach is called once and declares what the plugin adds:
func (p *Plugin) Attach(m *plugin.Mount) error {
p.mount = m
p.streams = m.NewStreams()
m.UseSessions()
m.AddStylesheets(css, "basket.css")
m.AddNavEnd(p.nav())
m.Route("GET", "events", p.events)
m.Route("POST", "add", p.add)
m.Page("GET", "", p.show)
return nil
}
e.Use(p...) runs Attach during the site's mount: after content loads, so a plugin can read collections, and before the handler is taken, so its assets reach the stylesheet.
The plugin API is package plugin, and a plugin imports it, model and ui, never the engine.
A plugin holds its own state, embeds its own assets, and declares an interface for whatever the host must supply. Lifting one into Quire is moving its file; nothing about how it is wired changes.
What a plugin can do
plugin.Mount is the grant: the engine's doors and nothing else of it.
go doc github.com/lahickey/quire/plugin.Mount is the allow-list.
Routes and pages
m.Route(method, path, h)is private, under/_basket/. It is named from the plugin, so two plugins cannot pick the same namespace;quireandsiteare reserved.m.URL(path)is where it ended up.m.Page(method, path, h)is a page a visitor sees and may bookmark, never cached.pathis relative, so a plugin claims nothing of the site's:""is/_basketand"checkout"is/_basket/checkout. A site moves them withe.Use(plugin.At("/basket", b)), to/basketand/basket/checkout. A path already claimed by the site or another plugin is a boot error naming the owner.m.PageURL(path)is where that page ended up, for links andPageSpec.Path.m.Shell(PageSpec)is the document chrome, so a plugin page is still a site page.
Forms
@m.Form(path) { … } is a form that posts to a route: a plain post without JavaScript, and a Datastar post that stays on the page with it.
End its handler with plugin.Done(w, r), which answers either kind of caller: 204 to a live page, whose stream already carries the change, and a redirect for a plain form submission, back to the page it came from if that is on this site, otherwise to /.
Live updates
m.NewStreams()is Datastar SSE with a subscriber list.Subscribe(w, r, topic, send)is the whole handler: it opens a connection, sends the current state and holds it open.Publish(topic, send)reaches every connection on that topic, so a change made in one tab lands in all of them.Signals(v)marshalsvand merges it into the page's signals under the plugin's name. The basket sends{"count": 3}and the page holds$basket.count, so two plugins cannot patch each other's.Elements(ctx, component)replaces the element with the same id.m.Listen(path)is the Datastar action that subscribes to a route:data-init={ m.Listen("events") }. Quire ships Datastar, so it owns the options too: a subscription reconnects for as long as the page is open and closes when its element is removed.m.Signals(v)is adata-signalsvalue under the plugin's name, so a page starts with whatStream.Signalslater patches:data-signals={ m.Signals(map[string]int{"count": 0}) }.
Styles, nav and sessions
AddStylesheets/AddStylesheetsAtgo into theplugincascade layer, so the site can restyle a plugin and never the reverse.AddStylesheetsAtis CSS for one step of the scale; Scale writes the query.AddNavEnd(component)is a control in the nav.UseSessions()gives oneqsidfor every plugin, minted on navigation only.plugin.Session(r)reads it. Two cold tabs can race; last cookie wins. That is inherent to HTTP.
CSS
The stylesheet is one sheet in three layers, @layer quire, plugin, site, which fixes precedence but not collision: two plugins both styling .line still clash.
So a plugin prefixes every class with its own name (.basket-line, not .line), and Use fails on a stylesheet with a class outside that prefix.
Layers beat specificity, so a bare button {} in a site sheet outranks a plugin's .basket-qty-btn. A site that mounts plugins should scope its element selectors.
Per-visitor content on a cached page
A prepared page is one body for everyone, so it cannot carry a basket count. It carries an empty badge instead, and a publish patches it per visitor. The page stays immutable; only the patch differs.
Datastar
Datastar is the small library Quire ships for live behaviour. A page declares what it wants in data-* attributes, and the server sends HTML or values over a stream.
Syntax
Attribute syntax is data-plugin:key, colon not hyphen: data-on:submit, not data-on-submit.
A hyphen there does nothing at all and reports nothing. Keyless attributes (data-text, data-signals) work either way, which is what lets the mistake survive a casual test.
Signals or elements
Choose the payload by what the audience can receive, and give each audience its own topic.
Signals are page-global and land wherever a tab happens to be, which suits a count in the nav.
Element patches need the element to exist, and Datastar warns PatchElementsNoTargetsFound otherwise, so a view-specific patch gets a topic only that view subscribes to.
Where a subscription lives
Put a page-global subscription (a count, a badge) on chrome that is not swapped: a soft-nav replaces main and nothing else.
A view-specific subscription lives on the view's own element, inside main, so leaving the page cancels it.
Two standing streams per tab stall HTTP/1.1 (six connections per host) at three tabs; a second stream only while that view is open does not. Production over TLS is HTTP/2 and multiplexes, so this bites in dev first and looks like the site hanging. A hidden tab drops its stream and resubscribes on return.
Push, always
Signalling a page so it turns around and fetches its own update is a round trip that should not exist.
In depth
A publisher writes into another request's connection, so every write to a stream is serialised and carries a five-second deadline: one stalled reader cannot hold up the request that published to it. A publish writes to all of a topic's streams at once. An idle stream is sent a comment every 25 seconds, because proxies drop a silent connection.