# @asubasu/schema-kit

Shared primitives for declaring an app's [Pixel](https://github.com/asubasu/pixel) content schema.

Pixel inverts the usual CMS relationship: instead of an admin defining fields by hand in the CMS, **the consuming app declares its own schema in code**, publishes it as a small JSON manifest, and Pixel ingests that manifest to know what fields exist.

This package is the declaration API shared by every app that does this.

Fields are for values that aren't part of a page's UI, such as OpenGraph titles and descriptions, structured data, or settings the app reads in code. Content visitors see on the page belongs in [islands](../../docs/islands.md#islands-or-fields), which `@asubasu/pixel-next/islands` declares.

## Install

Published publicly on [npm](https://www.npmjs.com/package/@asubasu/schema-kit) — no registry configuration or authentication required:

```sh
pnpm add @asubasu/schema-kit
# or: npm install @asubasu/schema-kit
```

## Usage

Create a central schema module in your app (e.g. `src/pixel.schema.ts`):

```ts
import { definePage, defineField, buildManifest } from '@asubasu/schema-kit'

definePage('home', 'Home')

export const HOME_DESCRIPTION = defineField({
    id: 'home_description', // stable, opaque — never change this once content exists for it
    page: 'home',
    label: 'Description',
    type: 'string',
    comment: 'Shown in search results and link previews for the home page',
    default: 'Editorial, portrait and volunteer photography.',
})

export { buildManifest }
```

Serve the manifest at a well-known path so Pixel can fetch it (e.g. as an Astro build integration, or any static-file step in your build):

```ts
import { buildManifest } from './pixel.schema.ts'

await writeFile('dist/.well-known/_pixel/schema.json', JSON.stringify(buildManifest()))
```

Editors change field values in Pixel's Content editor, and the app reads them from Pixel's content API, for example into the page's `<meta>` and OpenGraph tags. Fields are never edited in the live preview; only islands are.

### Image sizes

Your app also decides which image variants Pixel generates for every uploaded image — the widths, heights, and output formats — by declaring them once in the schema. They ride along in the manifest, and on ingestion Pixel adopts them as the site's image sizes (so they're no longer a manual admin setting):

```ts
import { defineImageSizes } from '@asubasu/schema-kit'

defineImageSizes([
    { width: 600, height: 900, formats: ['webp', 'avif'] },
    { width: 1200, height: 1800, formats: ['webp', 'avif'] },
    { width: 2048, height: 2048, formats: ['jpeg', 'webp', 'avif'] },
])
```

Formats are `'jpeg' | 'webp' | 'avif'`. Declare `defineImageSizes` once (calling it again replaces the list). If your app declares none, Pixel keeps the site's current sizes, falling back to its defaults.

These sizes are the site's `default` image profile, with `contain` fit. Pixel makes them for an image the first time something uses it with the default profile, such as a page field. Collections, islands and blocks can name other profiles, declared with `imageProfile()` from `@asubasu/pixel-next/islands` (see [Image profiles](#image-profiles)).

### Image fields

A field of type `'image-ref'` lets an editor pick one of the site's uploaded images for that field, such as the image link previews show. Declare it like any other field:

```ts
export const HOME_SHARE_IMAGE = defineField({
    id: 'home_share_image',
    page: 'home',
    label: 'Share image',
    type: 'image-ref',
})
```

In Pixel's Content editor an image field opens a picker of the site's uploaded images. Pixel never uploads from the picker; it only chooses an already-uploaded image.

Unlike a text field, the content API resolves an image field's value to a full **image reference object**, so you can build the asset URLs and render it without a second request. It is `null` when the field is unset or points at an image that no longer exists. The `PixelImageRef` type describes the shape:

```ts
import type { PixelImageRef } from '@asubasu/schema-kit'

// { id, caption?, date?, location?, createdAt, updatedAt, profile, assets: { width, height, size, format, url }[], preparing? }
const shareImage: PixelImageRef | null =
    page.fields.find((f) => f.name === 'home_share_image')?.value ?? null
```

A resolved image carries the files of one image profile only, so a `srcset` built from `assets` never mixes cropped and uncropped files. Page fields always use the `default` profile, which is your `imageSizes`. `preparing: true` only appears in draft views, while the profile's files are still being made; `assets` then holds the library previews.

### Image profiles

An image profile is a named set of boxes, one fit (`contain` or `cover`) and the formats to encode. Next.js apps declare profiles with `imageProfile()` from `@asubasu/pixel-next/islands` and name them on collections, islands and image fields; `catalog.manifest()` publishes them as the manifest's `imageProfiles`. This package holds the shared types: `PixelImageProfile`, `PixelImageProfileSize` and `PixelImageFit`.

The site snapshot's `images` and an entry's `refs.images` carry **image records** (`PixelImageRecord`) instead of resolved references: the default profile's files in `assets`, and every other profile something uses the image with under `profiles`. `resolveImage(record, profile)` picks one profile's files and returns a `PixelImageRef`:

```ts
import { resolveImage } from '@asubasu/schema-kit'

const photo = resolveImage(record, 'gallery') // reads record.profiles.gallery
const preview = resolveImage(record, 'default') // reads record.assets
```

A profile the record holds no files for falls back to the record's own `assets`, which a draft snapshot fills with library previews for images no page uses yet.

### Article categories

Your app also decides which article categories exist on the site — each a stable UUID plus a human name — by declaring them once in the schema. They ride along in the manifest, and on ingestion Pixel adopts them as the site's category set (so they're no longer a manual admin setting). A site has articles enabled only when at least one category is declared; an app that never calls this leaves articles disabled.

```ts
import { defineArticleCategories } from '@asubasu/schema-kit'

defineArticleCategories([
    { id: '11111111-1111-4111-8111-111111111111', name: 'News' },
    { id: '22222222-2222-4222-8222-222222222222', name: 'Essays' },
])
```

Each `id` must be a valid UUID and unique within the declaration; never change an id once articles exist for it. Each `name` is non-empty and at most 100 characters. Declare `defineArticleCategories` once (calling it again replaces the list). Pixel re-validates on ingestion and reconciles the stored set to what you declared, preserving any category that still has articles until you empty it by hand.

### Local-only defaults

A field's `default` is **never sent to Pixel** — `buildManifest()` omits it entirely, so Pixel has no knowledge of it. It exists purely so your own app can show something sensible before an editor has set a real value in Pixel:

```ts
import { getDefault } from '@asubasu/schema-kit'

const description = pixelValue ?? (getDefault(HOME_DESCRIPTION) as string)
```

### Integration routes

When an integration uses Pixel's standard Next.js endpoints, omit `routes`. `buildManifest()` fills in the default revalidation and preview paths. If a site needs a custom path, declare only that override.

```ts
buildManifest({
    version: '1',
    capabilities: {
        draftModePreview: true,
        popupPreview: true,
        contentRevalidation: true,
    },
    routes: { preview: '/custom-preview' },
})
```

### Islands

Islands are regions of a page whose content is a tree of app-defined components, stored as a [json-render](https://json-render.dev) spec. Apps declare them with `@asubasu/pixel-next/islands`, which publishes extra manifest keys: `components` and `islands`, plus `collections` and `imageProfiles` when the app uses them (see [docs/islands.md](../../docs/islands.md)).

The `@asubasu/schema-kit/islands` subpath holds what is framework-agnostic about them: the TypeScript types for those manifest keys and for stored specs, and `normalizeIslandSpec`, the rules that decide which parts of a spec are kept. Pixel, the server renderer and the Pixel editor all apply the same rules, and all three run the fixtures in [`testdata/islands`](../../testdata/islands):

```ts
import { normalizeIslandSpec } from '@asubasu/schema-kit/islands'

const result = normalizeIslandSpec(spec, { components, island }, { mode: 'publish', images })
if (result.ok) {
    // result.spec keeps every valid part; result.issues lists what was dropped and why
} else {
    // result.error is invalid_shape, root_mismatch or root_incomplete: render the island's default
}
```

`mode: 'draft'` keeps incomplete elements (a Photo with no image yet) and reports them, while `mode: 'publish'` drops them along with any container whose slot falls below its minimum. Prop values are checked with `checkJSONSchemaValue` against the component's JSON Schema unless you pass your own `checkValue`.

`images` is the set of image ids (lowercase) that exist on the site, or `null` to skip the existence check. An image prop (`x-pixel: image-ref`) pointing at a missing image is dropped. An image list prop (`x-pixel: image-list`, made by `pixel.images()`) is dropped when it isn't an array of image ids, lists an image twice in any letter case, or holds more ids than its `maxItems` (50 when the schema declares none). Otherwise the ids missing from `images` are left out quietly, in order, and the schema check runs on what remains; an empty list is a value, not an unset prop. `islandPropValue` runs these checks for one value and returns the value to keep.

## API

- `definePage(slug, label)` — registers a page's human label for the manifest.
- `defineField(def)` — registers one field; returns its `id` for convenient reuse. `def.id` must match `[A-Za-z0-9._-]{1,64}` and be unique within its page.
- `defineImageSizes(sizes)` — declares the image variants (`{ width, height, formats }`) Pixel should generate for uploaded images; published in the manifest and adopted as the site's image sizes on ingestion. Formats are `'jpeg' | 'webp' | 'avif'`.
- `defineArticleCategories(categories)` — declares the article categories (`{ id, name }`) Pixel should create for the site; published in the manifest and adopted as the site's category set on ingestion. Each `id` is a stable UUID (unique within the declaration); each `name` is non-empty and ≤100 characters. A site has articles enabled only when at least one category is declared.
- `buildManifest(integration?)` — serializes every declared page/field (plus any `imageSizes` and `articleCategories`) into the JSON Pixel ingests. Standard integration routes are filled in automatically, while explicit route values override their defaults. The output also carries a `schemaKitVersion` field (the version of this package that generated it); Pixel ignores unknown manifest keys today, so it's informational — handy for confirming which build a deployed site's manifest came from.
- `getDefault(id)` — reads back a field's locally-declared default; `undefined` if none was set.
- `PixelImageRef` / `PixelImageAsset` — types for a resolved image reference (and its files): an `image-ref` field's value, each item of an image list, and what blocks receive.
- `PixelImageRecord` and `resolveImage(record, profile)` — an image as snapshots and entry refs carry it, with the files of every profile it is used with, and the function that resolves it with one profile. `DEFAULT_IMAGE_PROFILE` is `'default'`.
- `PixelImageProfile` / `PixelImageProfileSize` / `PixelImageFit` — the shape of an image profile in the manifest's `imageProfiles`.
- `VERSION` — the published package version (baked in at build time from `package.json`).
- `@asubasu/schema-kit/islands` — island, collection and image profile manifest types and stored spec types, `normalizeIslandSpec(spec, rules, options)` and its helpers (`allowedInSlot`, `islandAllowRule`, `isEditableProp`, `requiredProps`, `isSafeLinkHref`, `checkJSONSchemaValue`, `checkIslandPropValue`, `islandPropValue`, `imageListShapeProblem`, `islandPixelKind`, `islandImageProfile`), plus the global limits (`ISLAND_SPEC_MAX_BYTES`, `ISLAND_MAX_ELEMENTS`, `ISLAND_MAX_DEPTH`) and the image list limits (`ISLAND_IMAGE_LIST_MAX_ITEMS` and `ISLAND_IMAGE_LIST_MAX_ITEMS_LIMIT` for block props, `COLLECTION_IMAGE_LIST_MAX_ITEMS` and `COLLECTION_IMAGE_LIST_MAX_ITEMS_LIMIT` for collection fields).

## Releasing

`@asubasu/schema-kit` and [`@asubasu/preview-agent`](../preview-agent) are released together at one shared version, from the monorepo root:

```sh
pnpm release 0.1.3   # explicit version — or: pnpm release patch
```

This bumps both packages to that version, builds and tests them, and publishes both to npm (`pnpm publish` builds fresh via each package's `prepack`). It does not touch git — commit, tag, and push the version bump yourself. Add `--dry-run` to rehearse without uploading.
