Modules & multi-file apps.
A single program is one flat list of statements — great for a
streamed reply, cramped for a real app. Modules let you
split an app across many .aktion files that
import and export components, variables,
functions, and reactive $state, with
JavaScript-like syntax and true per-file
scope. The files are linked in the browser —
no build step — or
ahead of time by the Vite plugin, from the same linker.
A module can also be written in TypeScript or
JavaScript (.aktion.ts, .aktion.js).
A first split
Here is a two-file app. app.aktion is the entry —
it holds the $app(…) root and pulls in a component
declared in Greeting.aktion. Editing either file re-links the
whole program; the preview shows the linked result.
import { Greeting } from "./Greeting.aktion"
$app(Greeting("Aktion"))
export function Greeting(name) {
return Card([
CardHeader(`Hello, ${name}!`, { subtitle: "Rendered from an imported file" })
])
}
Exporting
Prefix any top-level declaration with export to make it
importable from another file. Anything not exported is private to
its file. Every binding kind can be exported — components, actions,
hooks, reactive $state, and plain bindings:
// components.aktion
export function Card2({ title }) { // a component
return Card([CardHeader(title)])
}
export function track(name) { // an action (lowercase)
console.log(name)
}
export function $useToggle() { // a hook ($-prefixed)
$on = $state(false)
return { on: $on, toggle: () => { $on = !$on } }
}
export $count = 0 // a reactive atom
export accent = "var(--rui-primary)" // a plain binding
export of a destructuring declaration is not supported —
export each binding inline, or list the names it declares (below).
Export lists and re-exports
An export { … } list exports bindings the module already
declares, with as to rename them, and a $ atom keeps
its $ on both sides. It does exactly what writing
export before the declaration does — the same atom, shared
by every importer — and the declaration may come before or after the list.
A name can be exported once.
let $count = 0
function increment() { $count += 1 }
export { $count, increment as bump }
export { … } from "…" and
export * from "…" forward another module's exports
without binding them in this file, so a barrel file can gather
a library behind one import. The source is linked where the statement stands
(see link order). Naming something the source does not
export is a link error, as is a name two export * statements supply
from different modules; an explicit export always wins over a star. A name that was
imported cannot be listed — re-export it with from. Aktion has no
namespace objects, so export * as ns from "…" is rejected.
// barrel.aktion
export { $count, bump } from "./counter.aktion"
export { Card2 } from "./components.aktion"
export * from "./format.aktion"
Importing
Import named bindings with import { … } from "…".
Rename with as, and import reactive state by keeping its
$ sigil on both sides:
import { Card2 } from "./components.aktion"
import { Card2 as PanelCard } from "./components.aktion" // aliased
import { $count, increment } from "./store.aktion" // a shared atom + an action
import { $count as $total } from "./store.aktion" // $ stays across `as`
Names are matched by their bare name, so export $count lines up
with import { $count }. A $state import must keep
its $ across as ({ $a as $b }) —
mixing { $a as b } is a syntax error. from and
as are ordinary identifiers everywhere else, so existing code
that uses them as variable names keeps working.
A specifier list may span several lines and may end with a trailing comma, so a long import formats the way you would format it in JavaScript:
import {
Button,
Card,
PrimaryButton,
} from "./ui.aktion"
All three names bind exactly as they would on a single line — the layout carries no meaning.
If a multi-line import used to vanish, this is why
Multi-line specifier lists used to fail to parse.
Because parse() records the error and recovers to the next
statement, the entire import disappeared while the
program still “parsed” — leaving every imported name
undefined and every reference to it silently null. Both the
line breaks and the trailing comma parse correctly now, so a program
that was quietly missing bindings starts working without any edit.
True per-file scope
Each file is its own scope. A file's non-exported top-level names are
private, so two files can reuse the same name without
clashing — the linker renames each file's private symbols behind the
scenes. Only the names you import cross the boundary.
// Button.aktion
icon = "bolt" // private to this file
export function PrimaryButton({ label, onClick }) {
return Button(label, { variant: "primary", icon: icon, onClick: onClick })
}
// Link.aktion
icon = "arrow-up-right-from-square" // a DIFFERENT, private `icon`
export function LinkButton({ label, onClick }) {
return Button(label, { variant: "ghost", icon: icon, onClick: onClick })
}
Built-in library components (Button, Card,
Column, …) and runtime globals ($util,
route, console) are resolved by the runtime, not by
a module, so they are always available without importing — and never
renamed.
Sharing reactive state
Export a $state atom and the files that import it read and write
the same reactive cell. This is the simplest way to share
state across an app — a tiny store in its own file:
import { $count, increment } from "./store.aktion"
$app(Column([
Card([
CardHeader("Shared store"),
Text(`Count: ${$count}`),
Button("Increment", { variant: "primary", onClick: increment })
])
], { gap: "lg", align: "center", padding: "xl" }))
export $count = 0
export function increment() {
$count = $count + 1
}
The entry file keeps its own names canonical: the
$state atoms you declare or import into app.aktion
are exactly the names that serializeState(),
hydrateState(), and applyDelta() target.
Writing shared state from another file
Two shapes work for state that several files change, and both are shared by every importer (measured with a toggle written from the entry and from an imported component: either button flips the text in both):
// 1. An exported atom plus an exported action that writes it
// ui.aktion
export let $open = false
export function toggle() { $open = !$open }
// any importer
import { $open, toggle } from "./ui.aktion"
Button("Toggle", { onClick: toggle })
// 2. An exported store, written by property
// ui.aktion
export const ui = $store({ open: false })
// any importer
import { ui } from "./ui.aktion"
Button("Toggle", { onClick: () => { ui.open = !ui.open } })
Declare the atom with let (export let $open = false):
a bare export $open = false becomes a const under
the ESLint processor, and the setter’s write is then reported as
no-const-assign.
Writing an imported atom directly —
() => { $open = !$open } in a file that only
imports $open — also works at runtime. It is
the one shape JavaScript tooling flags, because in JavaScript an
import binding is read-only (ESLint’s
no-import-assign), so prefer the two above. A shared module may
not export a $store(…) factory: a store is
created once per source location, so every caller of a factory would get
the same instance — export the store itself, as above.
Which shape to use
Prefer the exported setter action. Every write to the atom then lives in the module that declares it, the importers are plain JavaScript (they only call a function), and nothing about reactivity changes. Measured on the runtime, for an atom or store read in three modules:
| Setter action | Store property | Direct write | |
|---|---|---|---|
| Readers in other modules update | Yes | Yes | Yes |
| Valid JavaScript in the importer | Yes | Yes | No (no-import-assign) |
$effect in the owner and each importer re-runs | Yes, in link order (owner first) | No: [ui.open] is not a valid dependency, and an effect with no list runs once | Yes, same order |
| Called at the top level of an importer | Works: setOpen(true) sets it | Works | Does nothing: a top-level $x = … is a set-once declaration and the imported atom wins |
| Called while rendering | A function setter does nothing; an arrow setter applies without a re-render and logs the render-write warning | Applies without a re-render and logs the same warning | A component body gets a private copy that other modules never see; in a lowercase function it does nothing |
Compound writes (+=, ++, array updates) | Inside the setter, in the owner | By property | Works |
Survives formatProgram unchanged | Yes | Yes | Yes |
Use a store when the shared value is naturally a record read and written by
property and nothing needs to $effect on it; use an atom with a
setter when it is a single value, a list that is replaced, or the target of
an effect. Do not write shared state while rendering with either shape: do
it from a handler or an effect.
// ui.aktion
export let $open = false
export const setOpen = (value) => { $open = value }
// any importer
import { $open, setOpen } from "./ui.aktion"
Button("Toggle", () => setOpen(!$open))
The imported-state-write lint finds the direct writes in a
program. It is on by default in getLintWarnings,
getDiagnostics, tools/validate-aktion.mjs and
tools/validate-aktion-app.mjs; turn it off with
{ importedStateWrites: false } or
--no-imported-state-writes.
Each warning names the imported
atom, the module it comes from and the setter to add. To migrate a site, add
one setter per imported atom to the owning module (the warning spells it
out: setMockClusters for $mockClusters) and
replace $x = value with setX(value); a compound
write becomes setX($x + 1). It is a warning, never an error, so
the direct write still runs; a job that treats warnings as failures will
report these writes.
The lint reads one file at a time, so it cannot tell the module that
declares an atom from a barrel that re-exports it, and it does not check
that the owner exports the setter. The setter belongs in the module that
declares the atom (export let $open), and its body
assigns the atom under that module's own name. When the import comes
through a re-export (export { $open as $isOpen } from
"./store.aktion"), add the setter to the declaring module
(export const setIsOpen = (value) => { $open = value }) and
re-export it from the barrel
(export { $open as $isOpen, setIsOpen } from "./store.aktion");
a setter written in the barrel with $isOpen = value links and
writes nothing. The name the warning suggests follows the name the imported
module exports (setIsOpen).
The lint does not track scope, because $ names have none: a
$-prefixed parameter or loop binding is a parse error, and a
let $x = … or const $x = … inside a function or a
handler does not shadow the import (measured on the runtime: it writes the
shared atom). A declaration below the top level is therefore flagged like an
assignment. delete $o.key on an imported atom changes nothing at
run time and is not flagged.
Modules in TypeScript and JavaScript
The file name says which language a module is written in. All three compile
to the same program, import each other, and share $ state:
| File | Language | Notes |
|---|---|---|
counter.aktion | The Aktion DSL | Linked everywhere, including in the browser. |
counter.aktion.js | JavaScript in the Aktion subset | Checked for code that would behave differently from JavaScript; linked everywhere. |
counter.aktion.ts | The same, with TypeScript types | The types are erased by ts-blank-space, so it is compiled by the Vite plugin and the Node compile helpers — not by the in-browser linker. |
counter.ts, counter.js, .json, .css | Native code | Not importable from an Aktion module: the import is refused with a message naming the file to rename. |
// store.aktion.ts
export let $count = 0;
export function increment(step: number = 1): void { $count = $count + step; }
// app.aktion — imports the TypeScript module like any other
import { $count, increment } from "./store.aktion.ts"
$app(Button("n=" + $count, { onClick: () => increment() }))
aktion-runtime/dsl declares every built-in for TypeScript; an
Aktion module may import from it (in any language), only by the built-in’s
own name. Writing modules in TypeScript, and what Aktion does differently from
JavaScript, is covered in the TypeScript
guide.
Where modules resolve from
A specifier can be relative, absolute (from the project root), or a full URL. URL modules are fetched over the network when the project is linked, so you can pull a shared component straight from a CDN or gist:
import { Button } from "./Button.aktion" // same folder
import { Card2 } from "../components/Card2.aktion" // parent folder
import { Nav } from "/layout/Nav.aktion" // from the project root
import { Hero } from "https://example.com/ui/Hero.aktion" // remote, fetched on link
A relative import inside a URL module resolves against that URL, so a remote
file can pull in its own neighbours. Bare specifiers
(import { x } from "lodash") have no meaning and are reported as
unresolved. These are the exact rules
resolveSpecifier(spec, importerPath) applies:
| Specifier | Importer | Module key |
|---|---|---|
./Button.aktion | app.aktion | Button.aktion |
../Button.aktion | components/App.aktion | Button.aktion |
/layout/Nav.aktion | any project file | layout/Nav.aktion — from the project root, leading slash dropped |
https://x.test/H.aktion | any | the URL itself, normalised |
./n.aktion | https://x.test/ui/H.aktion | https://x.test/ui/n.aktion |
lodash | any | null — reported as Cannot resolve import |
Keys are normalised, so . and .. segments collapse
before lookup (/a/../b.aktion → b.aktion)
and two specifiers that name the same file share one module instance.
Root‑absolute and URL specifiers are browser‑only
The table above describes the in‑browser linker. The
Vite plugin resolves against the Node filesystem
instead, where a leading / means filesystem root, not
project root — so "/layout/Nav.aktion" lands outside the
project and is refused. URL specifiers are refused there too: that resolver
only handles filesystem paths, and anything that is neither ./,
../, nor / is treated as a non‑project module.
In a bundled project, use relative specifiers only.
On the filesystem, a specifier that names no file is completed in this order:
.aktion, .aktion.ts, .aktion.js, then
/index.aktion, /index.aktion.ts,
/index.aktion.js — so ./store finds
store.aktion.ts. A specifier that names a file exactly is not
completed (./store.aktion when only store.aktion.ts
exists reports Did you mean "./store.aktion.ts"?). Neither is
the TypeScript habit of writing .js for a .ts file:
./store.aktion.js when only store.aktion.ts exists
reports Did you mean "./store.aktion.ts"? too. The
extensions option replaces this list outright, so a project that
pins it must add the new suffixes itself.
Keep one language per module name
store.aktion next to store.aktion.ts is
refused, whatever the specifier:
"store.aktion" and "store.aktion.ts" both exist — keep one (TypeScript and Vite resolve "./store.aktion" to different files).
TypeScript would type-check one file while the bundler built the other.
The entry file
Linking starts from one entry file —
app.aktion in the playground. The entry is what defines the
rendered root (a $app(…) statement, or the legacy
aktion = … binding); imported files normally only
declare and export. export default $app(…) is the
same statement — it is what gives TypeScript a typed default export for
import app from "./app.aktion.ts"; any other
export default is an error. The linker walks every import from
the entry, merges the whole graph into one program, and the runtime renders
it — import cycles are fine (each file is merged once).
Host code imports only the default export. The compiled
program is the one thing an Aktion module gives main.ts, a test or
any other native module. Its named exports — components, helpers,
atoms — exist inside Aktion programs, so import { summary } from
"./api.aktion.ts" in host code fails the vite build (with
this explanation on Vite 5–7; Vite 8 reports the missing export
itself), and under vite dev or Vitest the binding throws the
explanation as soon as it is used. (An export named then is
left out there instead: with a callable then the module would be
a thenable, and await import() would call it instead of
returning the module.) To unit-test a helper, call it from
a program (compileAktionSource, see Testing).
Link order
The linker walks the graph depth-first and merges each module
after the modules it imports (a post-order walk), taking a
file’s import statements in the order they are written.
That is the order ES modules evaluate in, and the merged program’s
top-level statements run in it: the entry comes last, and a dependency
runs before its importer except within an import cycle (entry →
a → b → a links
b, a, entry, with no diagnostic, as with ES modules). Importing a then
b links a, b, entry; importing b then
a links b, a, entry; if a itself imports
c, the order is c, a, b, entry.
LinkResult.dependencies and program.sources (after
the entry at index 0) report the same order.
So import order is meaningful. A tool that sorts, groups or otherwise reorders a file’s imports — an import-sorting lint rule with autofix, say — changes the order the modules’ top-level statements run in, exactly as it would for JavaScript modules with side effects.
When a link fails
Linking produces its own diagnostics, separate from parse errors. A problem found in an imported file is prefixed with that file’s path, so a broken import in a five‑file app points at the file that has the problem instead of at the entry; the entry’s own diagnostics carry no prefix.
| Message | What happened | Fix |
|---|---|---|
Cannot resolve import "…" |
The specifier maps to no module key — a bare specifier, or one the active resolver refuses (see the resolution rules). | Use a relative ./ / ../ path, or check the spelling. |
Failed to load imported module "…" |
The key resolved but there is no source behind it — the file does not exist in the project. | Create the file, or fix the path. |
Failed to fetch module "…": … |
A URL import could not be fetched (network, CORS, non‑200). The rest of the graph still links. | Check the URL and its CORS headers. |
"./store.aktion" does not export `increment` |
The name exists in that file but is not exported, or an export { x } from re-exports a name its source lacks (reported at the specifier). State imports report the sigil: does not export `$count`. |
Add export to the declaration. |
Cannot export `x`: this module declares no top-level `x`. |
An export { x } list names something the file does not declare (check the $ too: a and $a are different names). An imported name gets its own message pointing at export { x } from "…". |
Declare it, or fix the spelling. |
`x` is exported more than once. |
Two exports — declarations, lists or re-exports — give the same name. | Export it once, or rename one with as. |
"./barrel.aktion" exports `x` from more than one module through `export *` |
Two export * statements reach different declarations of x. |
Export it by name (export { x } from "…") to choose one. |
"./a.aktion" re-exports `x` in a cycle of re-exports that never reaches a declaration |
Re-exports of x point at each other and no file declares it. |
Declare it in one of the files. |
Greeting.aktion: Unexpected token … |
A dependency’s own parse error, prefixed with its path. | Fix the syntax in that file. |
"./utils.ts" is not an Aktion module. Aktion modules end in .aktion, .aktion.ts or .aktion.js — rename it to utils.aktion.ts … |
An import of native code. It is never read; host code passes such values in instead. | Rename the file to an Aktion suffix, or keep it native and pass values from the host. |
"…" is a typescript Aktion module, but no typescript frontend is configured |
A .aktion.ts module reached a linker without a TypeScript frontend — the in-browser linker, or a Node tool that did not pass one. |
Build with aktion-runtime/vite, or pass frontends: { typescript: await loadTypeScriptFrontend() }. |
E101 … E127, W201, W202 |
A .aktion.ts / .aktion.js construct that Aktion would run differently from JavaScript. |
See the TypeScript guide; each message says what to write instead. |
Entry module "app.aktion" was not found |
The entry key is not present in the file map handed to the linker. | Check the entry name against the keys you passed. |
Where they show up depends on the host: <aktion-app src="…">
surfaces them in the error banner and through the error event
alongside parse errors, the playground lists them in
its error inspector, and the Vite plugin turns them into
build failures.
A dependency’s syntax error used to be invisible
Only the entry’s parse errors used to travel out.
parse() drops a malformed statement and recovers, so an
imported file with a syntax error linked “successfully” minus
whatever the parser threw away — you saw a missing component, not an
error. Every module’s parse errors are now reported as link
diagnostics, which means an app that used to link clean may start reporting
errors it always had.
Building with the Vite plugin
For a bundled app, run the linker at build time instead of in the browser.
aktion-runtime/vite compiles every .aktion file it
sees into a module that default‑exports a pre‑linked program, so the
browser never parses that source. Projects created with
create-aktion ship this
wired up already:
// vite.config.ts
import { defineConfig } from "vite";
import aktion from "aktion-runtime/vite"; // default export = aktionPlugin
export default defineConfig({
plugins: [aktion()],
});
With the plugin installed, a .aktion file is an importable
module. Its default export is a CompiledProgram, which the
element mounts with mountCompiled():
// src/main.ts
import "aktion-runtime"; // registers <aktion-app>
import type { AktionElement } from "aktion-runtime";
import app from "./app.aktion"; // linked at build time
document.querySelector<AktionElement>("#app")?.mountCompiled(app);
The plugin follows every import from the entry, registers each
dependency as a watched file, and inlines the whole graph into that one
module — so editing an imported file re-transforms the entry.
In vite dev it also appends a self‑accepting HMR hook that
re‑mounts each matching <aktion-app> with its
serializeState() snapshot. That is why a hot edit keeps live
$state.
Plugin options
aktionPlugin(options?) accepts these options, all optional:
| Option | Type | Default | What it does |
|---|---|---|---|
allowOutsideRoot |
boolean |
false |
Let .aktion imports resolve outside the Vite project root. See the warning below. |
runtimeModuleId |
string |
"aktion-runtime" |
Specifier the generated module imports defineCompiledProgram from. Change it only when the runtime is aliased. |
strict |
boolean |
false |
Promote linker warnings to build errors. Errors already fail the build either way. |
alias, roots, extensions |
see AktionResolveOptions |
— | Map package-style prefixes to directories, admit extra directories, or replace the extension list. Also read from an aktion.config.json above the project (config: false turns that off). |
typescript |
{ eraser? } | false |
on when ts-blank-space is installed |
Compile .aktion.ts modules. { eraser } plugs in another position-preserving type eraser; false refuses .aktion.ts. |
devtools |
boolean |
true |
Ship each module’s original text (sourcesContent) so DevTools shows the files you wrote. Turn off to trim production bundles. |
dts |
boolean | { include?, exclude?, outDir? } |
false |
Write name.d.aktion.ts declarations for .aktion modules, so TypeScript can import from them (see the TypeScript guide). The modules of each alias target are declared too, mirrored under .aktion-types/<prefix>/; give the alias a tsconfig paths entry that falls back to that mirror, e.g. "@acme/ui/*": ["../../libs/ui/src/*", "./.aktion-types/@acme/ui/*"] (TypeScript needs the paths entry for an alias either way). A directory reached through roots is not mirrored: run aktion-dts --out-dir . in that package, so each declaration sits next to its module. |
Breaking: .aktion imports are confined to the project root
An import that resolves outside the Vite root is now
refused — resolution returns nothing and the build fails with
Cannot resolve import "…"; a second check in the loader
refuses the read outright
([aktion] refusing to read "…" — outside the project root).
The comparison is separator‑aware, so /srv/app does not
admit a sibling named /srv/app-secrets.
A .aktion file is source, but it is also data that may have
arrived with an untrusted repository. Without the check,
"../../../../etc/passwd" is read by the build and — under
vite dev — served to the browser inside the compiled
module.
This breaks a monorepo that imports .aktion
files from a sibling package. The opt‑out is
aktion({ allowOutsideRoot: true }), and it is only appropriate
for a layout you control and trust. Prefer raising the Vite
root, or re-exporting the shared files from inside the project.
See Security for the reasoning.
Linking from JavaScript
Three public entry points turn source text into something
mountCompiled() accepts. Pick by how the sources reach you:
a resolver you own, an in-memory map that may contain URLs, or a single
string.
| Function | Shape | Reach for it when |
|---|---|---|
linkProgram |
Synchronous. (entrySource, entryPath, resolver) → { program, diagnostics, dependencies }. |
You can load every module synchronously — a filesystem, a bundler, an in-memory map. This is what the Vite plugin uses. |
linkProject |
Async. ({ entry, files, fetch? }) → Promise<{ program, source, diagnostics, dependencies }>. |
The graph may reach the network. It walks the imports, fetches every URL specifier, then links. This is what the playground and <aktion-app src> use. |
compileLite |
Synchronous. (source, { path?, library? }) → CompiledProgram. |
One file, no imports. It parses and wraps — no linking and no schema validation — so it is the cheapest way to mount a string through mountCompiled() instead of setResponse(). |
linkProject is async only because of URL fetches: a
project with no remote imports settles on one microtask. It also returns
source — the merged program re‑emitted as text —
which is what keeps applyDelta() and
serializeState() round‑trips working after a link.
import { linkProject, defineCompiledProgram, COMPILED_PROGRAM_VERSION } from "aktion-runtime";
const files = {
"app.aktion": `
import { $count, increment } from "./store.aktion"
$app(Button("+1", { onClick: increment }))
`,
"store.aktion": `
export $count = 0
export function increment() { $count = $count + 1 }
`,
};
const res = await linkProject({ entry: "app.aktion", files });
if (res.diagnostics.length) console.error(res.diagnostics); // [] when the link is clean
document.querySelector("aktion-app").mountCompiled(
defineCompiledProgram({
__aktionCompiled: COMPILED_PROGRAM_VERSION,
program: res.program,
source: res.source,
path: "app.aktion",
}),
);
That is exactly what the multi‑file demos on this page do. Diagnostics are returned, never thrown — a URL that fails to fetch becomes one diagnostic and the rest of the graph still links, so you decide whether to mount a partial program or stop.
Supplying your own resolver
linkProgram does no I/O itself. You hand it a
ModuleResolver with two methods, and it calls them as it walks
the graph:
import { linkProgram, createMemoryResolver, resolveSpecifier } from "aktion-runtime";
// The ready-made one: a complete { path: source } map.
const res = linkProgram(files["app.aktion"], "app.aktion", createMemoryResolver(files));
// Or your own — `resolve` returns a module key or null, `load` returns text or throws.
const resolver = {
resolve: (spec, importerPath) => resolveSpecifier(spec, importerPath),
load: (path) => readMySource(path), // throws → "Failed to load imported module"
};
A custom resolver gets no path confinement for free
Confinement lives in the resolver, not in the linker. The
Vite plugin’s resolver is the one that refuses paths outside the
project root; linkProgram itself will load whatever your
resolve returns. If your specifiers can come from a source you
do not control, check the resolved path against your own root before
returning it — and read Security first.
The compiled‑program contract
A CompiledProgram is the artefact
mountCompiled() accepts. It carries the parsed AST so the
runtime skips parse(), plus the original source so the
text‑based features keep working. The reactive runtime — state,
$http, effects, routing — still runs in the browser
exactly as it does for a streamed string; only the parse is gone.
| Member | Type | Purpose |
|---|---|---|
__aktionCompiled | 1 | Version marker. Must equal COMPILED_PROGRAM_VERSION. |
program | Program | The merged, scope-renamed AST. |
source | string | The whole linked program as runnable Aktion text (the Vite plugin and compileAktion* print the merged AST). Powers applyDelta, serializeState round‑trips, and debugging. To server-render the program, pass the whole artefact — renderToString(app) renders program, not this text. |
path | string | Module id. Used for diagnostics and to target HMR — it is what el.sourceId reports. |
sourcesContent | string[] (optional) | Each module’s original text, indexed like program.sources — for a .aktion.ts module, the TypeScript you wrote. DevTools shows these; older artefacts without the field still mount. |
Because source is the merged program, an imported
module’s private names appear in it under their linker symbols
(__a1_Counter, $__a2_total): an
applyDelta that targets one must use that spelling.
moduleLocalBaseName("__a2_total") recovers the name you wrote,
and the entry module’s own names are never renamed.
Two helpers guard that shape. defineCompiledProgram(obj) is an
identity function whose only job is to give you one typed construction point,
so a malformed payload is a compile error rather than a runtime surprise.
isCompiledProgram(value) is the runtime narrowing guard
mountCompiled() itself applies — fail it and the call is
ignored with
[aktion] mountCompiled() expected a CompiledProgram … on the
console.
COMPILED_PROGRAM_VERSION is 1 today and is bumped
only on a breaking change to the shape, so a stale build artefact is rejected
instead of mounted as garbage.
In the playground
The playground is multi-file. Use the
file explorer on the left to create, rename, switch, and
delete files; app.aktion is always the entry. Press
to download the whole project
as a .zip, or the share / download buttons to get a
single linked program (every file inlined). Examples and shared links load
into app.aktion. Try the
“Multi-file modules” preset to start from a
ready-made split.
Lazy loading & code-splitting
Modules organise your source; the Lazy(…) helper
defers rendering. Reach for Lazy(…) when a part
of the UI should mount only when it is needed (showing a fallback until a
promise resolves), and reach for modules to keep that part — and the
rest of the app — in readable, reusable files.
// app.aktion
import { Header } from "./Header.aktion"
import { Dashboard } from "./Dashboard.aktion"
$app(Column([
Header(),
// Render the heavy view behind a fallback until it's ready.
Lazy(() => Dashboard(), { fallback: Skeleton({ lines: 6 }) })
]))
The two are orthogonal: the import still links
Dashboard.aktion into the program, so Lazy defers
when the subtree renders, not when its source arrives. For code that
should not be in the bundle at all, split at the bundler level — a
dynamic import() of a module that itself imports the
.aktion entry.
Best practices
- One component per file (named to match), so imports read like a manifest.
- Keep shared
$statein its own store file and export the atoms plus the actions that mutate them — importers get a consistent surface. - Keep the entry thin. Let
app.aktionwire pieces together and own the$app(…)root; push real UI into imported files. - Export only what other files need. Everything else stays private — that is what makes a name safe to reuse.
- Prefer relative imports within a project; reserve URL imports for genuinely shared, versioned components — and remember they only resolve in the browser linker, not under the Vite plugin.
- Check
diagnosticsbefore you mount. A link returns its problems instead of throwing, so an unchecked result mounts a partial program with no complaint.
Next
Open the playground
Create files, import between them, and download the project as a zip.
Launch playground → ReferenceLanguage
Program structure, $state, expressions, and control flow.
Global state
Patterns for sharing reactive state across an app.
Read the guide →