Modules
A module extends Silgi at build time. It can do everything you can do with files: add pages, layouts, route middleware, app plugins, a server door with its handlers, plugins and middleware, Bun plugins, imports, generated files and types.
Write a module
A module default-exports defineModule. setup runs once, while the config loads, and may be async.
import { addServer, createResolver, defineModule, logger, updateRuntimeConfig } from "silgi/kit";
export default defineModule<{ hostname: string }>({
meta: { name: "sitemap", configKey: "sitemap" },
defaults: { hostname: "" },
setup(options) {
const resolver = createResolver(import.meta.url);
addServer(resolver.resolve("./runtime/server.ts"));
updateRuntimeConfig({ public: { sitemapHostname: options.hostname } });
logger("sitemap").info(`serving /sitemap.xml for ${options.hostname}`);
},
});
The door is read like server/index.ts: its named exports are handlers, its default export, if it has one, is defineServer.
export { sitemap } from "./sitemap.ts";
import { defineHandler, setHeader } from "silgi/server";
export const sitemap = defineHandler({
route: "/sitemap.xml",
method: "GET",
raw: true,
handler(_, { event }) {
setHeader(event, "content-type", "application/xml; charset=utf-8");
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>${event.url.origin}/</loc></url>
</urlset>`;
},
});
meta.name makes a module run once even if it is listed twice. meta.configKey names the config key that holds the module's options; it defaults to meta.name, so meta: { name: "sitemap" } reads config.sitemap. The options setup receives are the inline options of the modules entry, over the value at configKey, over defaults. defaults is an object or a function of the build context, (silgi) => ({ dev: silgi.options.dev }), called once. hooks registers build hooks just before setup, also for a module without one: hooks: { "modules:done": fn }, each value a function or an array of them. A module listed twice registers its hooks once. To type the options in defineConfig, add the module's key to SilgiModuleOptions with declare module "silgi", as the silgi page shows; without it the key is a type error.
Use a module
modules takes a path that starts with . or is absolute, resolved from the app root; a package name; a module object; or [entry, options]. Modules run in array order. A module's default export may also be a plain setup(options, silgi) function.
import { defineConfig } from "silgi";
export default defineConfig({
framework: "react",
modules: [["./modules/sitemap.ts", { hostname: "https://example.com" }]],
});
@silgi/i18n is a first-party module: see Internationalization.
The kit
silgi/kit has the functions a module calls. They need the module's context and throw "silgi/kit used outside a module" when called outside setup and the build hooks. createResolver, logger and defineModule work anywhere.
The kit also works inside the build hooks, so a module can wait for the others with silgi.hooks.hook("modules:done", () => addServer(...)). A call in pages:extend, server:extend or templates:extend adds to that hook's list, and a silgi dev rescan does not add it twice. From build:before on, the functions that add pages, layouts, route middleware, app plugins, a server door, templates, type templates or imports throw, naming the function and the hook, because the build has already read them; useSilgi, createResolver, logger and addBunPlugin keep working. updateRuntimeConfig changes the config, which is frozen after setup.
| Function | Effect |
|---|---|
useSilgi() | the build context |
createResolver(base) | resolve(...paths) and resolvePath(id); base is a directory, a file or import.meta.url |
addPage(route), extendPages(fn) | add or change routes |
addLayout({ name, file }) | add a layout; the app's own layout of the same name wins |
addRouteMiddleware({ name, path, global? }) | add route middleware |
addAppPlugin(src), addAppPlugin({ src, mode? }) | add an app plugin; mode is "client", "server" or "all" |
addServer(file, { root? }) | add the module's server door: handlers, plugins and middleware; see below |
addTemplate({ filename, getContents, write? }) | a virtual module whose id is exactly #silgi/<filename>, extension included, loaded by that extension (.ts, .tsx, .jsx, .json, .css, else JavaScript); write also writes .silgi/<filename> |
updateTemplates({ filter? }) | recompute the templates filter accepts and rewrite their write files; silgi dev also does it for all of them after files are added or removed |
addTypeTemplate({ filename, getContents }) | a type file in .silgi/types/ |
addBunPlugin(plugin, { client?, server? }) | a Bun plugin for one or both builds |
addImports(imports) | auto-imports |
updateRuntimeConfig(patch) | merge into runtimeConfig, during setup only |
useLogger(tag) | the build logger for a module: [tag] lines, levels, fields, JSON with SILGI_LOG_FORMAT=json; logger(tag) is its deprecated alias |
addServer(file, { root? }) takes one .ts file, read like server/index.ts (see Server): its named exports are the module's handlers, and its default export, defineServer, adds the module's plugins and middleware.
meta.namenames the door, and a module without it fails withaddServer(file) needs meta.name in defineModule. The app takes one of the module's handlers by re-exporting it from its own door:export { sitemap } from "#server/sitemap".- A module has one door. A second call fails with
Module "sitemap" has one door; list its handlers in <file>. A file that is not.tsfails withaddServer("x.js"): a door is a .ts file. root(default the file's folder) holds the module's client code, whose#serveris this door.- A module's plugins and
middlewarejoin the lists in module order, before the app's; itshandlerMiddlewaregoverns its own handlers, with a re-exporting door's list outside it (Handlers). - A route of the door is checked with the app's. A pattern that does not parse, a route that matches the same URLs as another with different param names, or the same method and path as another route, the app's included, fails the build and names both sides. In
silgi devthe error is printed and the previous routes keep serving. - A path that comes from module options goes through
addTemplate: the generated door is read like any other, so itsrouteis still a literal.
The build hooks, for hooks in the config and for modules that use useSilgi().hooks, are modules:done, pages:extend, server:extend, templates:extend, prepare:types, build:before, build:client:config, build:server:config, build:done, prerender:routes, dev:ready and close. The config's hooks may also use config:resolved, since they are registered before it is called. A module cannot hook it; it has already run.
App plugins
A file in app/plugins/ runs when the app starts, on the server for each request and in the browser at load, in file name order. Prefix names with numbers to order them. A .client.ts or .server.ts ending limits the side. The plugin gets the app context and may return provide, which becomes properties of the app context and is available as useApp() in the adapter. Typegen types the provided keys from the plugin files, so a misspelt key fails tsc.
import { definePlugin } from "silgi/app";
export default definePlugin((app) => {
app.hooks.hook("page:finish", (route) => {
console.log(`rendered ${route.path}`);
});
return { provide: { track: (event: string) => console.log(`track ${event}`) } };
});
The app hooks are app:created, app:adapter, app:mounted, page:start, page:finish, page:error and app:error. app:error runs in the browser only, for every error that reaches the navigator or a root error boundary. app:adapter gets the UI library's app instance before the first render (Vue's App, null in the other adapters), which is where a Vue plugin such as pinia is installed with instance.use(). A provide key that the context already has is an error.
Auto-imports
With imports.autoImport Silgi inserts the import for a name that you use without importing, in app and server files. It is off by default.
import { defineConfig } from "silgi";
export default defineConfig({
framework: "react",
imports: { autoImport: true, dirs: ["app/stores"] },
components: true,
});
In app files the names come from app/composables/*, app/utils/*, imports.dirs, modules (addImports), the adapter's package and silgi/app; the first source of a name wins. Server files get server/utils/*, modules and silgi/server. A name that you declare or import yourself anywhere in the file is never inserted. silgi typegen writes .silgi/types/imports.d.ts, so the globals type-check.
Components
With components: true, every file under app/components/ is a component you can use without an import. The name is each path segment in PascalCase: base/Button.tsx is BaseButton, text-field.tsx is TextField. Two files with the same name fail the build. The html library has no tags to resolve, so it ignores the option.
Layers
extends lists layers: directories or packages with their own silgi.config.ts, app/, server/ and public/.
import { defineConfig } from "silgi";
export default defineConfig({
framework: "react",
extends: ["../base", "@acme/silgi-layer"],
});
Configs merge with the app over its layers, and earlier layers over later ones. Pages, layouts, route middleware and app plugins are scanned from every layer; for the same route path, name or file name, the app wins, then earlier layers. A layer's server/index.ts is its own door: the layer's pages read it as #server, its plugins and middleware run before the app's, and the app takes one of its handlers with export { name } from "#server/<layer>" (the layer's name in its config, else the basename of its folder). Layer modules run before the app's. public/, app/app.config.ts, app/error.* and server/utils come from the app only.
Types
silgi typegen writes .silgi/tsconfig.json, the four context files .silgi/tsconfig.app.json (app/, shared/), tsconfig.server.json (server/, shared/), tsconfig.shared.json (shared/, without Bun's types, so Bun there is an error) and tsconfig.node.json (silgi.config.ts, app/app.config.ts), and the type files in .silgi/types/, without building. silgi dev, silgi build and silgi generate write them too. typescript.strict (default true) and typescript.tsConfig in the config go into the compilerOptions of all five (paths are added to the generated ones, rootDirs appended), the node context also includes the addTypeTemplate files so a module's options are typed in silgi.config.ts, and a module changes one context in the prepare:types hook, which gets { app, server, shared, node }, each { compilerOptions, include, exclude }, before they are written. All four keep the DOM library for now: Silgi ships its TypeScript sources, and its server and config types reach its client code.
silgi typegen
Your tsconfig.json references the four contexts, and tsc -b checks each of them (tsc --noEmit -p on it checks nothing). It also extends .silgi/tsconfig.json, with empty files and include, because Bun reads the aliases and JSX settings of bun test and bun <script> through extends and does not follow references. A tsconfig.json that only extends .silgi/tsconfig.json, as apps made before the contexts have, keeps working as one program, but prepare:types changes do not reach it; replace it with this file:
{
"extends": "./.silgi/tsconfig.json",
"files": [],
"include": [],
"references": [
{ "path": "./.silgi/tsconfig.app.json" },
{ "path": "./.silgi/tsconfig.server.json" },
{ "path": "./.silgi/tsconfig.shared.json" },
{ "path": "./.silgi/tsconfig.node.json" }
]
}
The files give you typed route names and params (SilgiRoutes), typed loader data (LoaderData), the shapes of the runtime config and app config, and typed $fetch calls to your own server routes: the URL is checked against your routes, and the result is the handler's return type.