
import { Callout } from "zudoku/ui/Callout";

# Pages and rendering

A **surface** is a place where a document can be used. `home` fills the home
page, `product` fills every product page, and `area:header` fills the header on
every page. You do not need all of them at once; most themes start with `home`
and `product`.

Each surface has a **document**: a JSON list of the sections that surface shows
and the values those sections hold. The theme ships a default document; a store
can save its own. The SDK chooses which one renders and repairs the parts that
no longer match the theme.

## Surfaces

```ts
const productSurface: SurfaceSpec = {
  key: "product",
  label: "Product",
  pageType: "product",
  document: productDocument,
  required: [{ type: "product-main", min: 1, max: 1 }],
};
```

| Field | Meaning |
| --- | --- |
| `key` | The surface key stored in the document |
| `label` | Editor-facing name |
| `pageType` | The platform page type the surface renders |
| `document` | The theme's shipped default |
| `required` | Top-level sections that must exist |
| `max` | At most this many top-level sections (1–50; absent: 50) |
| `hideableAreas` | The theme's areas a page of this surface may leave out ([below](#a-page-without-its-header-or-footer)) |

A surface key is lowercase and starts with a letter or digit. It can never
start with `lp:` or `area:`: those prefixes name a landing page's own
document and an area's.

## Every platform page type

`pageType` is one of the platform's page types, the same names, with the same
URLs, as `STOREFRONT_PAGE_PATHS` in `@wocart/storefront`. A theme composes
**each** of them as a surface with one required main section: a page type the
theme declares no surface for cannot be composed in Studio, and its preview is
a 404 (`previewTarget` answers `null`). The SDK accepts a theme with fewer
surfaces; the [theme starter](/sdk/theme/starter) declares all twelve, and
its contract test fails on a missing one.

| `pageType` | Path | What the route reads and passes to its sections | The starter's surface and main section |
| --- | --- | --- | --- |
| `home` | `/` | `pageData.get("home")` | `home`, no required section |
| `catalog` | `/products` | `?page=`, `?sort=` (from `PRODUCT_SORTS`) | `catalog`, `listing-main` |
| `category` | `/category/:slug` | The category by `slug`; `?page=`, `?sort=`; 404 when unknown | `category`, `listing-main` |
| `collection` | `/collections/:handle` | `collections.get(handle)`; `?page=`, `?sort=`; 404 when unknown | `collection`, `listing-main` |
| `product` | `/products/:handle` | `products.get(handle)`; 404 when unknown | `product`, `product-main` (its `info` slot requires one `product-order` block) |
| `page` | `/pages/:slug` | `pages.get(slug)`; 404 when unknown | `page`, `page-main` |
| `landing` | `/lp/:slug` | `pages.landing(slug)` with its own `document`; 404 when unknown | `landing`, `landing-order` |
| `checkout` | `/checkout` | The browser basket; only when `config.cartEnabled` (the starter redirects to the home page otherwise) | `checkout`, `checkout-main` |
| `thankYou` | `/thank-you` | The order the query names (the theme's choice of keys) | `thank-you`, `thank-you-main` |
| `trackOrder` | `/track-order` | `lookupOrder` from a posted form; `?order=<number>` fills the number in | `track-order`, `track-order-main` |
| `search` | `/search` | `products.search(q)`; `?q=<words>`, `?page=`, `?sort=` | `search`, `search-main` |
| `contact` | `/contact` | `config.contact` and `whatsAppContact` | `contact`, `contact-main` |

The section names are the starter's choice; one required main section per page
type is the rule. On the product and landing surfaces the required section
must always render an order form, or `validateThemeDefinition` reports
`order-form-not-guaranteed` ([Composition](/sdk/theme/composition)).

### What the contact page may say

The contact page shows only what the store has:

- its phone, email and address from `config.contact`, each shown once the
  merchant set it (each is `null` until then);
- its WhatsApp from `whatsAppContact`, when the store has the App;
- opening hours and a note as the merchant's own section fields, per language,
  shown only once written.

The theme's words label these (`Phone`, `Opening hours`) and never fill one
in. A default such as "Open every day 9:00–18:00" in a content pack is a store
fact written as a theme word: it shows on every store that never set its
hours.

### Templates

A page type with a resource — `product`, `category`, `collection`, `page`,
`landing` — makes the surface a template. One document renders every product,
category or page of that type. The route reads the resource first and passes
the route parameters:

```ts
const context: ThemeRenderContext = {
  // …
  params: { handle: "robe-kabyle" },
};
```

Sections read the resource through `context.params` and a read key that
includes it, so the page and its sections share one read.

### Landing pages

A landing page is composed per page. The theme declares **one** `landing`
surface (the key must be `landing`, its `pageType` `landing`): its rules, its
sections and its default document apply to every landing page. Each page
stores its own document under `landingSurface(page.id)`, `lp:<id>`, and the
storefront receives it with the page itself, as `LandingPage.document`:

```ts
import { landingSurface, resolveSurfaceDocument } from "@wocart/theme";

const landing = await store.client.pages.landing(Astro.params.slug!);
if (landing === null) return notFound(Astro);

const surface = landingSurface(landing.id); // "lp:<id>"
const { page } = resolveSurfaceDocument(theme, surface, landing.document, scope);
```

`resolveSurfaceDocument`, the document operations, `isInsertableOn` and
`previewTarget` read an `lp:` surface by the surface it follows:
`templateSurface("lp:<id>")` is `"landing"` (`LANDING_TEMPLATE`). No other
surface may use `pageType: "landing"`.

### Alternates

A surface can be an alternate of another: `product.compact` is a second
product layout. Its base (`product`) must be declared and render the same page
type. `capabilities.surfaces: ["product"]` covers the alternate too.

## Areas

An area is a part every page shares — the header, the footer. It is composed
of sections like a page and edited once for the whole store.

```ts
areas: [
  {
    key: "header",
    label: "Header",
    document: headerDocument,
    required: [{ type: "header", max: 1 }],
    max: 4,
  },
  { key: "footer", label: "Footer" },
],
```

An area's document is the surface `area:<key>`; `areaSurface("header")` builds
that string. A component stays in the header by declaring
`capabilities: { surfaces: ["area:header"] }`. An area key is lowercase
kebab-case and unique among the theme's areas.

### A page without its header or footer

A surface may let its pages leave areas out. A landing page that sends an
ad's visitor straight to its order form is the case:

```ts
{ key: "landing", label: "Landing page", pageType: "landing", hideableAreas: ["header", "footer"], /* … */ }
```

The page's document lists the areas it hides in `hiddenAreas`. Read it with
`isAreaShown(theme, page, key)`, never from the array: only an area the surface
lists in `hideableAreas` can be hidden. Studio offers the choice
(`hideableAreas(theme, surface)`) and writes it with `setAreaHidden`.

```astro
---
import { isAreaShown } from "@wocart/theme";
---
{isAreaShown(theme, page, "header") && <Sections theme={theme} surface="area:header" document={headerDocument} />}
```

While Studio edits an area over a page, show that area even if the page hides
it, so the merchant can see what they edit; the starter's preview route does
(`target.area === "area:header" || isAreaShown(…)` in
`src/pages/wocart-preview.astro`).

## The document

A page document is JSON:

```json
{
  "version": 1,
  "surface": "home",
  "sections": [
    { "id": "hero-1", "type": "hero", "props": { "title": "Bienvenue" } },
    { "id": "faq-1", "type": "faq", "hidden": true }
  ]
}
```

`version` is `1`. `surface` is the surface key, `area:<key>` or `lp:<id>`.
`sections` is the top level. An instance has `id`, `type`, optional `props`,
`children`, `slots`, `fixed` and `hidden`. `hiddenAreas` lists the areas the
page leaves out.

A document may also name its provenance:

```json
{ "theme": { "key": "my-theme", "version": "1.0.0" } }
```

A document that names another theme is reported (`theme-mismatch`) and the
shipped default renders instead.

Unknown **top-level** keys are carried through verbatim. Unknown keys inside
an instance are stripped. The envelope is forward-compatible; the instance is
not.

## What renders

`resolveSurfaceDocument(theme, surface, saved, scope)` applies the policy:

| Saved state | Renders |
| --- | --- |
| No saved document | The theme's shipped default |
| Envelope invalid (`page: null`) | The shipped default |
| Surface or theme does not match | The shipped default |
| No saved section maps to a catalog type | The shipped default |
| Partial overlap | What maps; unknown types render nothing |
| Empty saved document | Empty — the merchant emptied it |

It returns the document, its source and the reason for a fallback:

```ts
const { page, source, reason, issues } = resolveSurfaceDocument(
  theme,
  "home",
  await store.client.pageData.get("home"),
  { content: store.content, locales: [store.locale, ...store.fallbackLocales] },
);
```

The result is completed with required and fixed blocks (`completePage`), so
what you render is what the contract requires.

## Render a page

One `InstanceView` per section, one read cache per page render.
`Astro.locals.store` below is the theme's own request store, built by its
middleware — the [quick start](/sdk/theme/build#7-the-requests-store) shows the
whole thing:

```astro
---
import type { ThemeRenderContext } from "@wocart/theme";
import InstanceView from "@wocart/theme/astro/InstanceView.astro";
import { createReadCache } from "@wocart/theme/astro";
import { resolveSurfaceDocument } from "@wocart/theme";
import { theme } from "../theme/theme";

const store = Astro.locals.store;
const { page } = resolveSurfaceDocument(
  theme,
  "home",
  await store.client.pageData.get("home"),
  { content: store.content, locales: [store.locale, ...store.fallbackLocales] },
);
const context: ThemeRenderContext = {
  store: store.config,
  content: store.content,
  locale: store.locale,
  fallbackLocales: store.fallbackLocales,
  isRTL: store.isRTL,
  url: store.pathname,
};
const reads = createReadCache();
---
{page.sections.map((section) => (
  <InstanceView
    instance={section}
    theme={theme}
    context={context}
    storefront={store.client}
    reads={reads}
  />
))}
```

`InstanceView` is the reference renderer. For one instance it:

1. skips a hidden instance,
2. finds the definition by `type`,
3. resolves defaults, translations, fields and composition,
4. runs the optional `load` hook,
5. renders the theme's own component with `props` and a `blocks` handle.

An unknown type renders nothing; the rest of the page survives. A `load` that
throws is reported as `load-failed` and the base props still render.

## One catalog per route

A route imports its catalog, and with it every component the catalog names.
A theme with many sections can give each route a catalog of its own surface
and the components that surface uses, so the thank-you page does not load the
home page's sections. Each family is a `defineTheme` with the same `key` and
`version`, one surface and its components, and a contract test checks that
the families together are exactly the full catalog the manifest describes.
That is a performance choice, not a rule; the starter renders every route
with its one catalog (`src/theme/theme.ts`).

## Issues

A page has two report channels:

- **Page-data issues** come from parsing and resolution: `unknown-type`,
  `unknown-prop`, `invalid-prop`, `missing-required-prop`, `unknown-slot`,
  `disallowed-child`, `duplicate-id`, `limit-exceeded`, `theme-mismatch` and
  others.
- **Renderer issues** add `missing-component`, `load-failed` and
  `multiple-slots-unsupported`.

Pass `onIssue` to collect them in tests or logging:

```astro
<InstanceView
  instance={section}
  theme={theme}
  context={context}
  storefront={store.client}
  reads={reads}
  onIssue={(issue) => issues.push(issue)}
/>
```

The default reporter is `console.warn` with a stable prefix. An issue is a
degradation with a report, not a thrown error.

## One named slot per instance

The reference renderer fills at most **one named slot** per instance. Astro's
compiler can lift only the first dynamic slot attribute into the slots object.
A second named slot with content is reported as `multiple-slots-unsupported`
and is not rendered. A component can declare several slots; it can also
transfer Astro's own slots inside itself when it needs more than one filled.

<Callout type="info" title="Preview is a separate render mode">
A preview in Wocart Studio uses the same `InstanceView` with `preview` set.
The markers it adds are read by the preview bridge, not by shoppers. See
[Preview](/sdk/theme/preview).
</Callout>
