@human-synthesis/norns
v0.0.19
Published
Norns — SvelteKit with Civet, Pug, and the .n / .civet / .c file extensions
Readme
Norns
AI-driven software architecture and development framework, based on Svelte.
SvelteKit with Pug + Civet and the .n / .c file extensions — preconfigured. The .c extension is recognised as an alias for .civet; both compile through Civet.
Includes a small runtime layer: feature-folder modularity, a DI container, route/page wrappers with valibot validation, and a migrations CLI.
Stack
- Svelte 5 — components and runes
- SvelteKit 2 — file-system routing, SSR, endpoints
- Pug — templates
- Civet — script (TypeScript-flavored, indented)
- Tailwind CSS v4 — recommended styling (consumer-installed)
- Vite — bundler
- bun — runtime / package manager
Install
bun add -D @human-synthesis/norns @sveltejs/kit svelteOr use the norns-app starter, which has everything wired up.
Setup
svelte.config.js:
import { nornsConfig } from '@human-synthesis/norns/config';
export default nornsConfig({
// your overrides here
});vite.config.js:
import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { nornsCivetPlugin } from '@human-synthesis/norns/vite';
export default defineConfig({
plugins: [nornsCivetPlugin(), sveltekit()]
});package.json:
{
"scripts": {
"dev": "norns dev",
"build": "norns build",
"preview": "norns preview",
"migrate": "norns migrate"
}
}Auto-imports
nornsAutoImport() returns an object that's both a Svelte preprocessor (for .n / .svelte files) and a Vite plugin (for standalone .c / .civet modules). The same instance has all four resolvers: framework helpers, project components, library presets, and (opt-in) project utilities. Wire it in both places — Svelte's compiler ignores the Vite hooks, Vite ignores the Svelte hooks:
// svelte.config.js
import { nornsConfig } from '@human-synthesis/norns/config';
import { nornsPreprocess } from '@human-synthesis/norns/preprocess';
import { nornsAutoImport } from '@human-synthesis/norns/auto-import';
export default nornsConfig({
preprocess: [
...nornsPreprocess(),
nornsAutoImport({
componentDirs: ['src/lib/components', 'src/routes']
})
]
});// vite.config.js
import { nornsCivetPlugin } from '@human-synthesis/norns/vite';
import { nornsAutoImport } from '@human-synthesis/norns/auto-import';
export default {
plugins: [
nornsCivetPlugin(),
nornsAutoImport(),
sveltekit()
]
};What gets auto-imported
| Layer | Resolves | Examples |
|-------|----------|----------|
| Helpers | Hardcoded module-name lists, optionally path-gated | onMount from svelte, redirect from @sveltejs/kit, page from $app/state (client) or @human-synthesis/norns/server (server) |
| Components (dir scan) | Capitalised basenames in componentDirs | <Card> → $lib/components/Card.svelte; <Game> → ./Game.n (route-colocated, importer-relative) |
| Components (preset map) | Bare-specifier Record<name, importPath> from a UI library | <Btn> → '@human-synthesis/norns-ui/components/Btn.n' (used verbatim) |
| Project utilities (opt-in) | Named exports (export const X, export X := …, export { a, b }) found in files matching exportGlobs | notes from $lib/notes/server/public when exportGlobs: ['src/lib/**/public.c'] |
Resolution priority is helpers → component dir → component preset → exports. A name picked up earlier shadows a later match silently — first-match-wins lets you override a library preset by dropping a file under your own componentDirs.
exportGlobs is off by default: project code (facades, schemas, services, stores) is imported explicitly unless you opt in. The recommended opt-in is barrel scope only (['src/lib/**/public.c']) so a feature's internals never leak through auto-import. Server-path files (/server/, *.server.*, +server.*, hooks.server.*) are never auto-imported into client files, and a name exported from two files in the same scope is logged and excluded. The starter and demo apps do not enable it. (The older exportDirs option was replaced by exportGlobs in 0.0.11.)
Path emission:
- Files inside
$libemit$lib/...paths (portable, friendly to the dts file). - Files outside
$libemit a path relative to the importer. - Project-utility paths are stripped of their file extension (
'$lib/notes/server/public', not…/public.c); the configuredextensionsarray does the rest.
Files without a <script> block get one prepended automatically when a known component is referenced from markup. Runes ($state, $derived, $effect, $props) are Svelte compiler globals — no import needed; the plugin doesn't touch them.
Defaults
- Helpers:
svelte,svelte/store,@sveltejs/kit,$app/state(non-server paths),@human-synthesis/norns/server(server paths only). - Component dirs:
['src/lib/components']. - Component extensions:
['.svelte', '.n']. - Export globs:
[](off) — opt in with e.g.['src/lib/**/public.c']. SvelteKit route/hook files (+*,hooks.*) are excluded from the export scan since their named exports (load,actions,handle, …) are framework-consumed. - Export extensions:
['.c', '.civet', '.js']..tsis excluded by default — regex-based scanning can't reliably tell value exports from type-only ones underverbatimModuleSyntax.
UI library presets
A preset is a function returning a config slice — typically a components map. Compose it with your own config:
// vite.config.js
import { presetUI } from '@human-synthesis/norns-ui/auto-import';
const ui = presetUI();
export default {
plugins: [
nornsCivetPlugin(),
nornsAutoImport({
components: ui.components // { Btn: '@human-synthesis/norns-ui/components/Btn.n', … }
}),
sveltekit()
]
};Drop src/lib/components/Btn.n in your project and it shadows the preset's Btn silently — componentDirs resolves first.
Roadmap. Helpers from a preset (e.g.
toast()from a UI library) currently can't merge with the defaults — passinghelperstonornsAutoImportreplaces the default list. Apresets(oradditionalHelpers) option to extend without replacing is a planned follow-up; for now, presets only deliver components.
Full options reference
| Option | Default | Notes |
|--------|---------|-------|
| helpers | DEFAULT_HELPERS (5 modules) | Pass false to disable. Each entry: { from, imports[], match? } where match is a regex tested against the filename. |
| componentDirs | ['src/lib/components'] | false or [] to disable. |
| componentExtensions | ['.svelte', '.n'] | |
| components | null | Record<name, importPath> — bare-specifier preset map. |
| exportGlobs | [] | Off by default. Opt in with e.g. ['src/lib/**/public.c']. |
| exportExtensions | ['.c', '.civet', '.js'] | |
| libRoot | 'src/lib' | Project-relative root that libAlias maps to. |
| libAlias | '$lib' | Alias prefix emitted in import paths. |
| root | process.cwd() | Project root. |
Runtime — feature folders + DI
Wire your hooks once:
# src/hooks.server.c
import { boot } from '@human-synthesis/norns/server'
features := import.meta.glob './lib/*/server/module.c', { eager: true }
app := await boot { features }
{ handle, handleError } := app
export { handle, handleError }Each feature is a folder under src/lib/<feature>/:
src/lib/notes/
server/
module.c # registers DI bindings + migrations
repo.c # SQL / data access
service.c # business logic
public.c # the ONLY file other features may import
shared/
schema.c # valibot validation schemasRoutes use thin wrappers from @human-synthesis/norns/server:
# src/routes/notes/+page.server.c
import { page } from '@human-synthesis/norns/server'
import { notes } from '$lib/notes/server/public'
import { createNoteSchema } from '$lib/notes/shared/schema'
export load := page.load
handler: ({ container }) =>
notes: notes(container).list()
export actions := page.actions
create:
input: createNoteSchema
run: ({ input, container }) =>
id := notes(container).create input
throw redirect 303, `/notes/${id}`The wrappers handle: input parsing, valibot validation, container resolution, and consistent error mapping. route() reads JSON, form and (through the app-wide serializer) TRON bodies with one readBody(); page.actions uses the same reader, though SvelteKit itself only dispatches form-encoded POSTs to actions, so JSON/TRON clients target a +server.c route.
List endpoints and caching
Large lists stay out of load: a route() serves one page at a time, and the page owns the paging state. listQuery() is the query convention (?page=&pageSize=&sort=&dir=&q=, validated: unknown sort keys are a 400) and listResult() the { data, total, page, pageSize } envelope that norns-ui's useList() and a TRON path: '$.data' contract expect:
# src/routes/api/notes/+server.c
export GET := route
query: listQuery { sort: ['title', 'updated_at'], defaultSort: 'updated_at', defaultDir: 'desc', pageSize: 25 }
handler: async ({ query, container }) =>
{ data, total } := await notes(container).page query # query.offset / pageSize / sort / dir / q
listResult query, data, totalcache: { ttl } turns a GET route into a cacheable one: Cache-Control + ETag on the response, If-None-Match answered with a 304, and on Cloudflare Workers (event.platform.caches) the encoded body is stored in the edge cache so the handler and the serializer run once per TTL. Entries vary on Accept by default, so JSON and TRON never mix; private: true skips the shared cache. The response carries x-norns-cache: hit|miss. Note that wrangler's dev platform proxy implements the Cache API as a no-op, so locally every request is a miss; the header logic is still exercised.
export GET := route
serializer: tronSerializer({ columnar: true })
cache: { ttl: 30 }
handler: ({ container }) => notes(container).stats()CLI
The norns binary wraps Vite and adds the checks an agent (or a human) should run before calling a change done:
norns dev | build | preview # vite, with framework-source watching in workspace mode
norns lint [--json] # Civet / Pug pitfall scan over .c / .civet / .n (templates AND script blocks) + vite.config
norns check [--json] [--warnings] # preprocess + compile every .n / .c / .civet through your svelte.config.js; file:line:column errors
norns diag <file> # the JS Civet emits for a .c / .civet / .n script block
norns diag --template <file.n> # the Svelte source the compiler sees after Pug / Civet / auto-import preprocessing
norns migrate status | up | create <feature>/<name>Verification order for a change: lint, check, build, then curl through dev. lint catches the known traps (isnt, := $state reassigned, +each with of, leading { in Pug, #{} interpolation); check is the full compile. Pug and Civet errors are mapped to the line you wrote (not to svelte-preprocess's mixin prelude or the script block's own numbering). Svelte compile errors inside Pug-rendered markup are reported against the preprocessed output with a note, because Pug emits no source map — diag --template shows that output. svelte-check never reads .n / .c, so it is not a pass signal for Norns code.
Migrations
bun run migrate create notes/add_pinned # scaffold migrations/notes/<ts>_add_pinned.sql
bun run migrate up # apply pending migrations
bun run migrate status # list applied + pendingMigration files live at <project>/migrations/<feature>/*.sql. The CLI tracks applied migrations in a norns_migrations table.
v1 supports SQLite via better-sqlite3. For Cloudflare D1 use wrangler d1 migrations apply. Postgres / libSQL via the CLI are planned.
Drivers
The db helpers wire Drizzle across multiple targets:
# module.c — Node + better-sqlite3 in dev
import { betterSqlite } from '@human-synthesis/norns/server'
db := await betterSqlite 'data/app.db', { pragma: ['journal_mode = WAL'] }
app.single 'db', => dbD1, libSQL, and Postgres factories ship in the same module; the driver packages are user-installed (peer-style).
License
MIT © Daniel Teodoroiu / Human Synthesis. Built on top of SvelteKit and Svelte © Svelte Contributors, MIT licensed.
