Skip to content

Core concepts

Page contracts

Page ids and payload types are the contract packs implement.

The host registry is the contract: a stable page id, a payload type, and a path the host owns. Packs implement ids. They never own URLs.

One pack is your app UI. Many packs are installed themes against the same types. The call site passes the one that renders; the API does not change.

@KeelType("harbor.home")
@Serializable
data class HomePage(
    val viewer: UserRef?,
    val feed: List<FeedItem>,
)

@KeelType("harbor.user")
@Serializable
data class UserPage(
    val user: UserRef,
    val messages: List<FeedItem>,
)

pages {
    page<HomePage>("harbor.home", "/") { HomePage(Board.viewer(call)?.toRef(), Board.feed()) }
    page<UserPage>("harbor.user", "/u/{id}") { call ->
        UserPage(Board.user(params.getValue("id"))!!.toRef(), Board.messagesFor(params.getValue("id")))
    }
}

Typegen walks @KeelType / @KeelAction and emits .d.ts plus JSON. Gradle generateKeelTypes is the offline scan. Pack authors depend on that file — not on your running server.

export interface HomePage { viewer: UserRef | null; feed: FeedItem[] }
export interface UserPage { user: UserRef; messages: FeedItem[] }
export interface HarborPages {
  "harbor.home": HomePage
  "harbor.user": UserPage
  "harbor.notFound": NotFoundPage
}
export type HarborPageId = keyof HarborPages

export interface HarborActions {
  "harbor.setName": { in: SetNameIn; out: SetNameOut }
  "harbor.postMessage": { in: PostMessageIn; out: PostMessageOut }
}

A page is keyed by id: pages/harbor/home/+page.svelte implements harbor.home. The file extension follows the adapter, and the page is still just a function of the typed seed.

<script lang="ts">
import { Head, Link, page } from "@kolektiv/keel-svelte"
import type { HomePage } from "../../../lib/page-types"

const ctx = page<HomePage>()
</script>

<Head />
<h1>Harbor</h1>
{#each ctx.data.feed as item (item.id)}
<p>
  <Link href={`/u/${item.userId}`}>{item.displayName}</Link>
  {item.body}
</p>
{/each}
<script>
import { Head, Link, page } from "@kolektiv/keel-svelte"

const ctx = page()
</script>

<Head />
<h1>Harbor</h1>
{#each ctx.data.feed as item (item.id)}
<p>
  <Link href={`/u/${item.userId}`}>{item.displayName}</Link>
  {item.body}
</p>
{/each}
import { Head, Link, usePage } from "@kolektiv/keel-react"
import type { HomePage } from "../../../lib/page-types"

export default function Home() {
const seed = usePage<HomePage>()
return (
  <>
    <Head />
    <h1>Harbor</h1>
    {seed.data.feed.map((item) => (
      <p key={item.id}>
        <Link href={`/u/${item.userId}`}>{item.displayName}</Link>
        {item.body}
      </p>
    ))}
  </>
)
}
import { Head, Link, usePage } from "@kolektiv/keel-react"

export default function Home() {
const seed = usePage()
return (
  <>
    <Head />
    <h1>Harbor</h1>
    {seed.data.feed.map((item) => (
      <p key={item.id}>
        <Link href={`/u/${item.userId}`}>{item.displayName}</Link>
        {item.body}
      </p>
    ))}
  </>
)
}
<script setup lang="ts">
import { Head, Link, usePage } from "@kolektiv/keel-vue"
import type { HomePage } from "../../../lib/page-types"

const seed = usePage<HomePage>()
</script>

<template>
<Head />
<h1>Harbor</h1>
<p v-for="item in seed.data.feed" :key="item.id">
  <Link :href="`/u/${item.userId}`">{{ item.displayName }}</Link>
  {{ item.body }}
</p>
</template>
<script setup>
import { Head, Link, usePage } from "@kolektiv/keel-vue"

const seed = usePage()
</script>

<template>
<Head />
<h1>Harbor</h1>
<p v-for="item in seed.data.feed" :key="item.id">
  <Link :href="`/u/${item.userId}`">{{ item.displayName }}</Link>
  {{ item.body }}
</p>
</template>
import { Head, Link, usePage } from "@kolektiv/keel-solid"
import type { HomePage } from "../../../lib/page-types"

export default function Home() {
const seed = usePage<HomePage>()
return (
  <>
    <Head />
    <h1>Harbor</h1>
    {seed().data.feed.map((item) => (
      <p>
        <Link href={`/u/${item.userId}`}>{item.displayName}</Link>
        {item.body}
      </p>
    ))}
  </>
)
}
import { Head, Link, usePage } from "@kolektiv/keel-solid"

export default function Home() {
const seed = usePage()
return (
  <>
    <Head />
    <h1>Harbor</h1>
    {seed().data.feed.map((item) => (
      <p>
        <Link href={`/u/${item.userId}`}>{item.displayName}</Link>
        {item.body}
      </p>
    ))}
  </>
)
}
import { Head, Link, usePage } from "@kolektiv/keel-preact"
import type { HomePage } from "../../../lib/page-types"

export default function Home() {
const seed = usePage<HomePage>()
return (
  <>
    <Head />
    <h1>Harbor</h1>
    {seed.data.feed.map((item) => (
      <p key={item.id}>
        <Link href={`/u/${item.userId}`}>{item.displayName}</Link>
        {item.body}
      </p>
    ))}
  </>
)
}
import { Head, Link, usePage } from "@kolektiv/keel-preact"

export default function Home() {
const seed = usePage()
return (
  <>
    <Head />
    <h1>Harbor</h1>
    {seed.data.feed.map((item) => (
      <p key={item.id}>
        <Link href={`/u/${item.userId}`}>{item.displayName}</Link>
        {item.body}
      </p>
    ))}
  </>
)
}
import { html } from "lit"
import { KeelElement } from "@kolektiv/keel-lit"
import type { HomePage } from "../../../lib/page-types"

export default class Home extends KeelElement<HomePage> {
render() {
  return html`
    <keel-head></keel-head>
    <h1>Harbor</h1>
    ${this.page.data.feed.map(
      (item) => html`<p>
        <keel-link href="/u/${item.userId}">${item.displayName}</keel-link>
        ${item.body}
      </p>`,
    )}
  `
}
}
import { html } from "lit"
import { KeelElement } from "@kolektiv/keel-lit"

export default class Home extends KeelElement {
render() {
  return html`
    <keel-head></keel-head>
    <h1>Harbor</h1>
    ${this.page.data.feed.map(
      (item) => html`<p>
        <keel-link href="/u/${item.userId}">${item.displayName}</keel-link>
        ${item.body}
      </p>`,
    )}
  `
}
}
import { Component } from "@angular/core"
import { KeelHead, KeelLink, injectKeelPage } from "@kolektiv/keel-angular"
import type { HomePage } from "../../../lib/page-types"

@Component({
selector: "app-harbor-home",
standalone: true,
imports: [KeelHead, KeelLink],
template: `
  <keel-head />
  <h1>Harbor</h1>
  @for (item of page().data.feed; track item.id) {
    <p>
      <keel-link href="/u/{{ item.userId }}">{{ item.displayName }}</keel-link>
      {{ item.body }}
    </p>
  }
`,
})
export default class Home {
readonly page = injectKeelPage<HomePage>()
}
import { Component } from "@angular/core"
import { KeelHead, KeelLink, injectKeelPage } from "@kolektiv/keel-angular"

@Component({
selector: "app-harbor-home",
standalone: true,
imports: [KeelHead, KeelLink],
template: `
  <keel-head />
  <h1>Harbor</h1>
  @for (item of page().data.feed; track item.id) {
    <p>
      <keel-link href="/u/{{ item.userId }}">{{ item.displayName }}</keel-link>
      {{ item.body }}
    </p>
  }
`,
})
export default class Home {
readonly page = injectKeelPage()
}

A pack declares which ids it implements. It may implement a subset — omitting harbor.user is valid, and the call site passes the pack that covers the page.

harbor/manifest.json
{
"format": "keel/1",
"id": "harbor",
"version": "0.1.0",
"framework": "svelte",
"pages": {
  "harbor.home": { "module": "pages/harbor.home.js" },
  "harbor.user": { "module": "pages/harbor.user.js" },
  "harbor.notFound": { "module": "pages/harbor.notFound.js" }
}
}