Authoring model
Status: Design direction. The object relationships are intentional; exact constructor and method signatures remain draft API.
Goldar applications are explicit object graphs. Routes own the resources and state they need, views receive named inputs, and the router connects route constructors to URLs.
Views
A compiled .hbs import is a callable view. Its first argument is the props object; every remaining
argument is a child View to render at the imported template’s {{ outlet }}:
type ViewFunction<Props> = (
props: Props,
...childrenToRenderInOutlet: readonly View[]
) => View;
For a view without props, pass an empty object as the first argument.
import PostView from "./post.hbs";
const view = PostView({
post,
onDelete,
});
The template declares its Props type in leading TypeScript frontmatter. goldar sync generates
compiler-owned declarations beneath .goldar/types, and typed-handlebars uses the same type for
completion and diagnostics inside the template. Templates without frontmatter retain the broad
wildcard CompiledTemplate<object> fallback.
---
type Props = {
readonly post: Post;
readonly onDelete: () => void;
};
---
<article>{{post.title}}</article>
Calling a view produces a View, not a browser DOM node and not a Response. The renderer decides how to turn the view tree into an HTML response, a stream, or a client update.
Double-mustache expressions escape text. Deliberately rendered HTML crosses an explicit boundary:
import { trustedHtml } from "goldar/view";
PreviewView({ html: trustedHtml(markdown.render(source)) });
<article>{{{html}}}</article>
The runtime rejects ordinary strings in triple-mustache expressions. trustedHtml() does not
sanitize content; the caller must establish that the producer and configuration are safe.
For example, given these three views, container.hbs contains:
<main>
{{ outlet }}
</main>
sidebar.hbs contains:
<aside></aside>
And article.hbs contains:
<article></article>
Calling the container with the sidebar and article as child views:
const page = ContainerView(
{},
SidebarView({}),
ArticleView({}),
);
Ignoring source-formatting whitespace, the rendered HTML has this shape:
<main>
<aside></aside>
<article></article>
</main>
{{ outlet }} is structural composition, not a filesystem-routing convention. A compiled template
may declare at most one outlet. The outlet renders all child views in call-site order without adding
a wrapper. With no child views, it renders nothing. Passing child views to a template without an
outlet is an error, and outlet arguments must be Goldar View values rather than strings or raw
HTML.
Collection rendering belongs in the template rather than in route code. The intended block shape
uses an explicit lexical binding rather than rebinding an implicit this context:
<section>
{{#each posts as post}}
<article>
<h2>{{post.title}}</h2>
<p>{{post.content}}</p>
</article>
{{else}}
<p>No posts yet.</p>
{{/each}}
</section>
{{#if path}} and {{#each path as name}} support optional {{else}} branches. Paths resolve
from root props or an explicit lexical binding. Implicit this, parent-context traversal,
destructuring, indices, subexpressions, and general helpers are intentionally unsupported.
Templates may import another compiled template and assign it a lexical tag:
---
import PostItemView from "./post-item.hbs";
PostItemView.as("post-item");
type Props = {
readonly posts: readonly Post[];
};
---
{{#each posts as post}}
<post-item post={{post}} />
{{/each}}
The .as() statement is compiler syntax available only in template frontmatter. It does not exist
on imported templates in application TypeScript. Aliased tags receive exactly typed props and
compile away during rendering; unaliased hyphenated tags remain browser Custom Elements. A paired
aliased tag passes its body to the imported template’s {{ outlet }}.
Frontmatter defines domain types, unions, and optionality; typed-handlebars verifies the template’s property access against it. A view remains a normal imported function from the author’s perspective.
Stores
A store owns mutable interactive state and domain methods:
class DashboardStore extends Store {
isSidebarOpen = false;
toggleSidebar() {
this.isSidebarOpen = !this.isSidebarOpen;
}
}
Direct assignment is the intended authoring model. The reactive implementation remains an open compiler/runtime decision, but it must preserve ordinary property access.
When a method is passed as a callback, an explicit closure preserves its owner:
SidebarView({
isOpen: this.store.isSidebarOpen,
onToggle: () => this.store.toggleSidebar(),
});
Passing this.store.toggleSidebar directly would normally lose its this binding in JavaScript. Goldar may eventually offer safe compiler binding, but explicit closures define the honest baseline.
Data
A data resource owns one asynchronous read:
type Post = {
id: string;
title: string;
};
class PostsData extends Data {
async load(): Promise<Post[]> {
return fetch("https://example.com/api/posts").then((response) => response.json());
}
}
Goldar infers the resolved value from the concrete load() return type. A resource also owns its loading, resolved, failure, and revalidation state.
refresh() is framework behavior inherited from Data; authors should not implement it by calling load() directly. Goldar must coordinate refreshes with deduplication, caching, view state, and rerendering.
Routes compose named resource instances:
class PostsRoute extends Route {
readonly data = {
posts: new PostsData(),
};
}
The same resource object may flow into a view. A typed view can accept either its resolved value or a resource that resolves to that value:
PostsView({ posts: this.data.posts });
This lets the renderer coordinate asynchronous state without making authors manually unwrap signal-like containers.
Async views
AsyncView binds a data resource to the views for its states:
return AsyncView.create(this.data.posts, {
loading: () => PostsLoadingView(),
error: (error, retry) => PostsErrorView({ error, retry }),
data: (posts) =>
PostsView({
posts,
onRefresh: () => this.data.posts.refresh(),
}),
revalidating: (posts) =>
PostsView({
posts,
isRefreshing: true,
onRefresh: () => this.data.posts.refresh(),
}),
container: (content) => PostsPanelView({}, content),
});
container wraps whichever state view is active, so shared surrounding markup does not have to be
repeated by loading, error, data, and revalidating. AsyncView is a declarative view node.
It should not immediately execute the resource. The renderer decides whether initial SSR blocks,
streams a loading state, or resumes an existing resource.
Form actions
A form action owns a named mutation:
class CreatePost extends FormAction {
async submit(context: FormActionContext) {
const title = context.form.string("title").trim();
if (title.length === 0) {
return context.invalid({ title: "Enter a title." });
}
const post = await posts.create({ title });
return context.redirect(`/posts/${post.id}`);
}
}
A route owns the actions available at its URL:
class PostsRoute extends Route {
readonly actions = {
createPost: new CreatePost(),
};
}
The exact template binding syntax is not settled. The durable behavior is:
- The baseline is an ordinary HTML form.
- The submitted action has a stable route-local name.
- Validation failures preserve submitted values and field errors.
- Successful mutations can redirect or revalidate resources.
- Enhanced submission is optional and produces the same application result.
The server runtime implements the form snapshot and the invalid, redirect, and success
results. Invalid results rerender the same request-owned route at status 422; route rendering reads
the submitted action’s preserved values and field errors through RouteContext.submission(action).
Successful results reload route-owned data before rendering. Schema adapters, application services,
client-enhanced submission, and cross-request cache revalidation remain design direction.
Routes
A route owns one matched URL lifecycle and its direct object graph:
class DashboardRoute extends Route {
readonly data = {
posts: new PostsData(),
};
readonly store = new DashboardStore();
render() {
return DashboardView({
posts: this.data.posts,
isSidebarOpen: this.store.isSidebarOpen,
onToggleSidebar: () => this.store.toggleSidebar(),
});
}
}
The server creates a fresh route object graph for each request. The browser may preserve a route instance for the lifetime of a matched route so interactive state can survive rerenders and nested navigation.
Router and application
The router maps URL patterns to route constructors:
const router = new Router()
.addRoute("/", DashboardRoute)
.addRoute("/posts/:postId", PostRoute);
The router accepts constructors rather than preconstructed route instances. Registering a live route instance in a long-lived Hono application could accidentally share stores and mutable state between concurrent server requests.
Routes that need the same stores declare a shared record type:
type ApplicationStores = {
readonly session: SessionStore;
};
class DashboardRoute extends Route<ApplicationStores> {
render() {
return DashboardView({ user: this.stores.session.user });
}
}
const router = new Router({
createStores: (): ApplicationStores => ({ session: new SessionStore() }),
})
.addRoute("/", DashboardRoute)
.addRoute("/posts/:postId", PostRoute);
The runtime calls createStores() once per matched server request and attaches the exact named
record to each constructed route before subclass field initializers run. The Route generic makes
this.stores explicit and typed without a runtime lookup API. This is shared route wiring rather
than cross-request in-memory persistence. Cookies, databases, and Hono-injected services remain the
appropriate cross-request mechanisms.
The application composes the router and server configuration:
const app = new Application(router, {
server(hono) {
hono.use(authentication());
}
});
export default createApplication(app);
Most applications should not need the server hook. It exists for Hono middleware, runtime-specific
bindings, and other HTTP-layer configuration. The hook runs once while Goldar assembles the Hono
application, before route handlers are registered.
Server-side route registration and application composition are implemented. Browser navigation and preserving route instances across client navigation remain design direction.
Named functions and runtime provenance
Views can receive functions without knowing how they are implemented:
EditorView({
onCancel: () => this.store.cancelEditing(),
onSave: savePost,
});
Goldar distinguishes capabilities by provenance:
- A closure over a store is a local client callback and requires corresponding client code.
- A form action is a server capability that can lower to a progressive form submission.
- A navigation capability may lower to an ordinary URL.
The compiler must not attempt to serialize arbitrary closures. It identifies which client code must be shipped and which server capabilities must be represented by stable identifiers.