Development GuideExample: admin panel

Example: admin panel

The in-tree packages/svelte-d-kit-admin fixture is a real admin: nested routes, dest-unique host classes, Postgres and Redis soaks, a debug map, an overlay, an IR inspector, and optional Chrome/Firefox consoles. That is more than you need to learn the IR. This page is the same shape with the extras stripped. You get a layout that stays mounted, a dashboard, a user list, and a user :id page. Host files return JSON. There is no database and no browser automation. The copyable files live in docs/examples/admin/ in this repository.

my-admin/
  svelte-d.config.ts
  package.json
  src/
    lib/AdminDash.svelte
    routes/admin/
      +layout.svelte
      +page.svelte
      +page.server.d
      users/
        +page.svelte
        +page.server.d
        [id]/
          +page.svelte
          +page.server.d

mapKitPath for this tree is the fall-through contract in miniature. The layout has no URL of its own; it is a wrapper. The dashboard is /admin. The list is /admin/users. The param page is /admin/users/:id, and the dest directory is _id_/ because LDC will not accept [id] on Windows. Host files land under webserver/source/generated/ with dest-unique class names so assembleHostRoutes can hang every one of them on app.d.

Layouts are @child wrappers, not router entries. KitRoutes remounts the page inside @entering. If the layout were a second wasm module, back/forward would tear down the nav on every navigation, and you would have invented a second DOM. The IR refuses that.

Config and package

// svelte-d.config.ts
export default { workspace: './svelte-engine-ws' }
{
  "name": "my-admin",
  "type": "module",
  "dependencies": { "svelte-d": "github:etcimon/svelte-d" },
  "scripts": {
    "setup": "bunx svelte-d setup",
    "compile": "bunx svelte-d compile --project .",
    "dev": "bunx svelte-kit-d dev"
  }
}
bun install
bunx svelte-d setup
bunx svelte-d drop-ws --force
bunx svelte-d compile --project .

After compile you should be able to open svelte-engine-ws/src-d/routes/admin/page.d and see a struct whose {#if} and <AdminDash /> are @visible and @child. If that file is missing, ingest did not see src/routes. If it exists but still looks like the engine’s stock +page.svelte, the project path was not passed (or cwd had no src/routes when you inferred it).

Layout — slot, {#if}, and svelte:head

The layout is the smallest file that still exercises three IR families at once. open is a host bool. {#if open} is @visible on the nav child, not a second layout type. <slot> is the content hole KitRoutes fills; the fallback text is what prints when nothing is projected. <svelte:head> walks into document().title during onMount, which is after handles exist — putting a DOM write in construct() would be too early.

<script lang="ts" context="module">
  export function adminLayoutReady() { return true }
</script>
 
<script lang="d">
  bool open = true;
</script>
 
<div class="admin-layout">
  <svelte:head><title>Admin</title></svelte:head>
  {#if open}
    <nav>Admin</nav>
  {/if}
  <slot>admin fallback</slot>
</div>

The context="module" TypeScript is not decorative. It splices into src-ts/modules/generated/ so libwasm.init has a named export the tests (and your own jsExports) can see, and it registers on __svelteD.ts[<ident>_mod]. Same-file D may call those exports by the simple name (callTs). Dual-script files do both cells’ jobs without mixing them. See cross-calling.

Dashboard — {#if} plus a component

A page that only inlines markup teaches {#if}. A page that instantiates <AdminDash /> also teaches @child. Both matter, because a component is not a string include: it is import lib.AdminDash; plus @child AdminDash adminDash, and the page’s {#if show} attaches @visible!"adminDash" to the page struct’s bool show. Flip is this.update.show = false. libwasm update writes the field and fires setVisible, which unmounts the child without destroying the page.

<!-- src/lib/AdminDash.svelte -->
<script lang="d">
  bool show = true;
</script>
<div class="admin-dash">
  {#if show}
    <p>Dashboard</p>
  {/if}
</div>
<!-- src/routes/admin/+page.svelte -->
<script lang="d">
  bool show = true;
</script>
<div class="admin-dash">
  {#if show}
    <AdminDash />
  {/if}
</div>

The inner {#if} on AdminDash is a different bool on a different struct. Nested visibility is not a global flag. Each owner carries its own @visible field, which is why a Flip on the page does not accidentally hide a bool inside the child that happens to share a name.

Host JSON, without a database

The kit-admin host talks to Redis and Postgres and records "skip" when they are absent. That is valuable for soak tests. It is noise when you are learning the cell split. A host file is vibe.0: a function that takes HTTPServerRequest / HTTPServerResponse and writes a body. It becomes a dest-unique registerWebInterface class under /__svelte-d/host/. Vite keeps /. Do not import libwasm here.

// src/routes/admin/+page.server.d
void getAdmin(HTTPServerRequest req, HTTPServerResponse res)
{
	Json payload = Json.emptyObject;
	payload["panel"] = "admin";
	res.writeBody(payload.serializeToJsonString(), "application/json");
}

Users list — {#each} inside {#if}

This is the first combination that surprises people. The outer {#if show} shows or hides the list. The inner {#each users as user} is an UnorderedList!User. {user} is @prop on the item, not a concatenate on the ul. <ul>{#each} would absorb the wrapper because UnorderedList is already NodeDef!"ul"; here the each sits inside a div, so the list hangs on that parent.

<script lang="d">
  bool show = true;
</script>
<div class="admin-users">
  {#if show}
    {#each users as user}
      <li>{user}</li>
    {/each}
  {/if}
</div>

The host can seed JSON. The wasm cell still owns the UnorderedList. Putting the array only on the server and expecting the ul to fill itself is SvelteKit SSR thinking; this stack’s v1 page is a SPA struct that puts items in construct / ready.

void getUsers(HTTPServerRequest req, HTTPServerResponse res)
{
	Json payload = Json.emptyObject;
	payload["users"] = Json.emptyArray;
	Json row = Json.emptyObject;
	row["email"] = "ada@example.com";
	payload["users"] ~= row;
	res.writeBody(payload.serializeToJsonString(), "application/json");
}

User :id — params and navigate

A param page is where kit routing and IR meet. @entering assigns ev.parameters["id"] onto the page field and calls applyKitParams(). {id} seeds a child @prop in construct. gotoUrl is the wasm-eh navigation helper, not SvelteKit goto; it exists because libwasm.router is experimental and navigateTo does not pushState when the browser path already matches.

<script lang="d">
  import kit.app_navigation;
  bool show = true;
  string id = "";
  void goList() { gotoUrl("/admin/users"); }
</script>
 
<div class="admin-user">
  <p class="admin-user-id">{id}</p>
  <button type="button" on:click={goList}>List</button>
  {#if show}
    <p>{id}</p>
  {/if}
</div>

The dest path is _id_/. If you look for src-d/routes/admin/users/[id]/page.d you will not find it, and that is not a failed ingest.

What the full kit-admin still is

packages/svelte-d-kit-admin adds logs/ and features/, Postgres/Redis soaks that write "skip" when offline, +error.svelte, debug-map / overlay / IR inspector pages, and optional Chrome/Firefox console rewrite. Those are host and debug surfaces. They are not more Svelte syntax. Learn the IR on this tree; graduate to kit-admin when you need the soaks and the overlay.