Documentation menu

Design proposal

Draft API

[!WARNING] This is an aspirational API sketch. It is intended to become an executable acceptance test. Names and signatures in this document are not stable and do not describe the current goldar implementation.

The draft favors ordinary class fields, direct composition, callable views, and platform primitives. Framework machinery should sit behind these shapes.

Complete one-file example

import ContainerView from "./container.hbs";
import PostsErrorView from "./posts-error.hbs";
import PostsLoadingView from "./posts-loading.hbs";
import PostsView from "./posts.hbs";
import SidebarView from "./sidebar.hbs";
import {
	Application,
	AsyncView,
	createApplication,
	Data,
	FormAction,
	Route,
	Router,
	Store,
	type FormActionContext,
} from "goldar";

type Post = {
	id: string;
	title: string;
	content: string;
};

class DashboardStore extends Store {
	isSidebarOpen = false;

	toggleSidebar() {
		this.isSidebarOpen = !this.isSidebarOpen;
	}
}

class PostsData extends Data {
	async load(): Promise<Post[]> {
		return fetch("https://example.com/api/posts").then((response) => response.json());
	}
}

class CreatePost extends FormAction {
	async submit({ redirect }: FormActionContext) {
		return redirect("/posts/1");
	}
}

class DashboardRoute extends Route {
	readonly actions = {
		createPost: new CreatePost(),
	};

	readonly data = {
		posts: new PostsData(),
	};

	readonly store = new DashboardStore();

	render() {
		const posts = AsyncView.create(this.data.posts, {
			loading: () => PostsLoadingView(),
			error: (error, retry) => PostsErrorView({ error, retry }),
			data: (value) =>
				PostsView({
					posts: value,
					createPost: this.actions.createPost,
					onRefresh: () => this.data.posts.refresh(),
				}),
			revalidating: (value) =>
				PostsView({
					posts: value,
					createPost: this.actions.createPost,
					isRefreshing: true,
					onRefresh: () => this.data.posts.refresh(),
				}),
		});

		return ContainerView(
			{ title: "Dashboard" },
			SidebarView({
				isOpen: this.store.isSidebarOpen,
				onToggle: () => this.store.toggleSidebar(),
			}),
			posts,
		);
	}
}

const router = new Router().addRoute("/", DashboardRoute);

export default createApplication(new Application(router));

The example intentionally omits unsettled details such as the precise form-action template binding and cache-policy vocabulary.

View

View is an opaque render node:

interface View {}

interface CompiledTemplate<Props extends object = object> extends ViewFunction<Props> {}

type ViewFunction<Props> = (
	props: Props,
	...childrenToRenderInOutlet: readonly View[]
) => View;

Compiled .hbs modules default-export a callable CompiledTemplate<Props>. The template declares Props in leading TypeScript frontmatter. Goldar uses that one contract for generated import types and typed-handlebars, so authors call the import directly without a runtime wrapper.

The renderer may turn the same view tree into blocking HTML, streamed HTML, or a client update.

Escaped expressions accept primitive values. Triple-mustache expressions require an explicit TrustedHtml value created by trustedHtml(), keeping raw server-rendered markup visible at the TypeScript call site. Sanitization remains the caller’s responsibility.

Resolvable view inputs

A view prop may accept a resolved value or a resource that produces that value:

type ResourceFor<T> = {
	load(...arguments_: never[]): T | Promise<T>;
};

type Resolvable<T> = T | ResourceFor<T>;

This is conceptual typing. The final compiler-generated type should infer concrete resource outputs without exposing never-based helper types to authors.

PostView({ posts: new PostsData() });

The renderer must never invoke the same resource repeatedly merely because multiple views reference it.

Store

abstract class Store {}

Authors add ordinary fields and domain methods. Direct field assignment is reactive in compiled client code:

class CounterStore extends Store {
	count = 0;

	increment() {
		this.count += 1;
	}
}

Open details include observation granularity, derived state, serialization, and preservation across client navigation.

Data

Conceptual surface:

abstract class Data {
	abstract load(context: DataContext): unknown | Promise<unknown>;

	refresh(): Promise<void>;
}

The concrete load() method’s return type defines the resource value. Goldar owns resolution state and refresh coordination.

Possible resource state:

type DataState<T> =
	| { status: "idle" }
	| { status: "loading" }
	| { status: "data"; value: T }
	| { status: "error"; error: unknown; value?: T }
	| { status: "revalidating"; value: T };

The final public API may not expose this union directly. It defines the semantics that AsyncView needs.

Implemented server context, with platform services and schema adapters still future work:

interface DataContext {
	readonly params: Params;
	readonly request: Request;
	readonly signal: AbortSignal;
	readonly platform: PlatformContext;
	readonly services: ApplicationServices;
}

Most data resources can omit the context parameter.

AsyncView

Conceptual typing:

type DataResult<D extends Data> = Awaited<ReturnType<D["load"]>>;

type AsyncViews<D extends Data> = {
	loading: () => View;
	error: (error: unknown, retry: () => void) => View;
	data: (value: DataResult<D>) => View;
	revalidating?: (value: DataResult<D>) => View;
	container?: (content: View) => View;
};

class AsyncView<D extends Data> implements View {
	static create<D extends Data>(
		resource: D,
		views: AsyncViews<D>,
	): AsyncView<D>;
}

AsyncView contains state views; it does not implement them. It is declarative and does not resolve the resource in its constructor.

The renderer supplies the retry function and chooses the appropriate state view.

FormAction

Conceptual surface:

abstract class FormAction {
	abstract submit(context: FormActionContext): ActionResult | Promise<ActionResult>;
}

Draft context:

interface FormActionContext {
	readonly form: FormSubmission;
	readonly params: Params;
	readonly request: Request;

	invalid(
		errors: FieldErrors,
		options?: { values?: Record<string, FormDataEntryValue> },
	): ActionResult;

	redirect(location: string, status?: 303 | 307 | 308): ActionResult;

	success(options?: {
		revalidate?: readonly Data[];
	}): ActionResult;
}

The implemented path passes a FormActionReference returned by RouteContext.action(action) to the view and renders its stable hidden field. A future compiler may lower an action instance directly to the same ordinary form submission and optional client enhancement.

The server results, action reference, and RouteContext.submission(action) feedback lookup are implemented. Automatic template binding, client enhancement, platform services, and schema-library integration remain open.

Route

Conceptual surface:

abstract class Route {
	readonly actions?: Readonly<Record<string, FormAction>>;
	readonly data?: Readonly<Record<string, Data>>;
	readonly store?: Store;

	abstract render(context: RouteContext): View;
}

render() may omit its context when the route only uses its owned fields.

Draft context:

interface RouteContext {
	readonly params: Params;
	readonly request: Request;
	readonly url: URL;
}

The routine authoring context should not expose Hono directly.

Router

class Router {
	addRoute(path: string, route: RouteConstructor): this;
}

addRoute() is implemented. It accepts a constructor so Goldar can create isolated server instances and returns the router for chained registration.

Nested route composition remains open. It may use child routers, a tree declaration, or repeated registration with parent metadata.

navigate() remains aspirational client-runtime work. Views use ordinary anchors by default.

Application

class Application {
	constructor(
		router: Router,
		options?: ApplicationOptions,
	);
}

Draft options:

interface ApplicationOptions {
	readonly server?: (hono: Hono) => void;
}

The server option is implemented. Application services remain future work. The application definition is passed to createApplication() to assemble a Hono application for the server target. Most authors do not interact with that Hono instance. A directly instantiated base Application must have a router; the parameterless constructor exists for legacy subclasses with static routes.

The primary API is HTML:

<a href="/posts/42">Read post</a>

Possible optional attributes, not yet stable:

<a href="/posts" data-preload="hover">Posts</a>
<a href="/settings" data-transition="slide">Settings</a>
<a href="/legacy" data-reload>Legacy page</a>

Static links may be checked against the route manifest without requiring a <Link> component.

Forms

The baseline is standard HTML:

<form method="post">
	<label>
		Title
		<input name="title">
	</label>
	<button name="_goldar_action" value="create-post">Create post</button>
</form>

The current connection is explicit: route rendering calls context.action(this.actions.createPost), passes the resulting reference to the view, and renders its field name and route-local identifier. Whether the compiler later provides a narrower direct binding syntax remains open.

Custom elements

Views use custom elements directly:

<fancy-tabs active-tab={{activeTab}} onchange={{onTabChange}}>
	{{ outlet }}
</fancy-tabs>

Implemented local component discovery:

// src/fancy-tabs.ts
export default class FancyTabs extends HTMLElement {}

The compiler records <fancy-tabs> in template capabilities. Vite maps that tag to the unique local fancy-tabs.ts module. Integrated CLI builds generate the browser entry; a custom Vite pipeline imports virtual:goldar/client itself. The virtual module registers components in lexical tag order and rejects constructor conflicts. Route-level loading chunks remain future work.

Compiler configuration

Possible configuration:

import { defineConfig } from "goldar/cli";

export default defineConfig({
	entry: "./src/index.ts",
	routes: {
		include: ["./src/routes/**/*.ts"],
	},
	views: {
		include: ["./src/**/*.hbs"],
	},
});

This broader route discovery configuration remains aspirational. Local component discovery is implemented directly from compiler-emitted template capabilities. Every future discovery feature must lower to the same inspectable manifest as explicit imports and registration.