# Components: one piece of markup, many insertions

**Status:** draft · **Verified:** 2026-09-13, stand (create → zip → publish → page; TypeScript → `app.js`); 2026-10-27, §4.7.1 own zones on the stand (classic and `main-only` pages, bind, read, inheritance, rollback order)
**Owner:** Vizen API documentation · **Serves:** `GET /docs/components`
**Related:** `own-markup.md` (your own HTML in a shop), `transfer.md` (a finished site as a folder)

## 1. What you can do here

Ship one piece of markup that a shop owner can place many times with different
content, without touching code. You write the folder and the schema; they pick
values. A new release of the folder updates every insertion at once.

This is NOT a second way to put HTML on a page. A component is an html document
with `kind=component`: the same folder, the same releases, the same
`/_html/{doc}/{release}/` gateway, the same isolation rules. The only thing it
adds is the schema and the substitution.

## 2. Decide first

| If you want… | Take this path | Cost |
|---|---|---|
| this exact markup on this one page | html widget (`own-markup.md`) | none — editing it means editing code |
| the same markup on many pages with different content | **component** (this doc) | you must write `component.json` |
| a whole finished site as-is | `transfer.md` | — |
| live catalogue data inside the markup | `vz-` keys and the page scope (`own-markup.md`) — they work inside a component too | parameters are values, not queries: the data comes from the keys, see §7 |
| reviews, Q&A, galleries kept as record sets | a level-3 component reading the public `GET /record-sets` (`/docs/record-sets` §4.4) | text appears after JavaScript; open the set to buyers first |

### Coming from Shopify

A component is a theme section: `component.json` is `{% schema %}`, `params` is
`settings`, `{{key}}` is the same brace. Type names map one to one:

| Shopify `settings[].type` | here | substituted as |
|---|---|---|
| `text`, `textarea` | `text` | HTML-escaped string |
| `richtext`, `html` | `html` | the author's markup through the level‑1 sanitiser |
| `image_picker` | `image` | a `file_id` from the media library → public URL (then the resizer) |
| `url` | `link` | `http(s)://`, `/path`, `mailto:`, `tel:` — anything else counts as absent |
| `color` | `color` | `#rgb`…`#rrggbbaa`, else absent |
| `number`, `range` | `number` | the number |
| `checkbox` | `bool` | `true`/`false`, and `{{#key}}…{{/key}}` shows a block — see below |
| `select`, `radio` | `select` | one of `options`, else absent |
| `product` | — | not in step 1, see §7 |
| `collection` | — | not in step 1, see §7 |

An **absent** value (missing, wrong type, outside the whitelist) shows the
parameter's `default`; it is empty only when there is no `default`.

### Conditional blocks: `{{#key}}…{{/key}}`

Everything between the two markers is kept when `key` is `true` and removed
when it is `false` — markup, nested substitutions and all. Use it to drop a
whole column, card or row rather than leaving an empty one:

```html
{{#show4}}<li><img src="{{icon4}}" alt=""><h3>{{head4}}</h3></li>{{/show4}}
```

Three rules, all enforced when you publish the release:

* **`key` must be declared in `component.json` and must be `type: "bool"`.** No
  other type opens a block; a text parameter is not "truthy".
* **Markers must pair.** `{{#a}}` needs its own `{{/a}}`.
* **Blocks nest, they do not overlap.** `{{#a}}…{{#b}}…{{/b}}…{{/a}}` is fine
  at any depth; `{{#a}}…{{#b}}…{{/a}}…{{/b}}` is not. Turning the outer block
  off also removes everything nested inside it.

Break any of the three and the release is refused with
`COMPONENT_MANIFEST_INVALID`, naming the marker and the file. Markers written
inside an HTML comment are prose, not markup, and are not checked.

Not taken from Shopify: Liquid filters and tags (substitution here is typed,
not a template language), `blocks` inside a section (no nesting in step 1),
`limit`/`max_blocks`, and the theme as the unit of delivery — here the unit is
one document.

## 3. Objects and where they live

**The document.** `POST /html-documents` with `kind: "component"`. The value
set is `"" | widget | component`; `theme` (a layout's service folder) is not
creatable here. `kind` comes back on every read of the document and is
read-only afterwards.

**The folder.** Exactly the html-project folder: `index.html` at the root, CSS,
JS, images next to it, uploaded by `POST /html-documents/{id}/releases/zip`
and turned live by `.../releases/{rid}/publish`. A component release must also
contain **`component.json`** — publishing without it is refused.

**`component.json`.**

```json
{
  "name": "Баннер акции",
  "params": [
    { "key": "title", "type": "text",  "label": "Заголовок", "default": "Скидки недели", "max": 80 },
    { "key": "image", "type": "image", "label": "Картинка" },
    { "key": "link",  "type": "link",  "label": "Ссылка" },
    { "key": "sale",  "type": "bool",  "label": "Показать бейдж", "default": false },
    { "key": "size",  "type": "select", "options": ["s", "m", "l"], "default": "m" }
  ],
  "presets": [ { "name": "Осень", "params": { "title": "Осенняя распродажа" } } ]
}
```

Rules, all enforced at publish with the offending field named in the report
(error code `COMPONENT_MANIFEST_INVALID`):

* `key` matches `^[a-z][a-z0-9_]{0,31}$` and is unique;
* at most **32** parameters, the file at most **16 KB**;
* `type` is one of the eight above — an unknown type is an error, because the
  type is what decides the escaping;
* `select` requires a non-empty `options` (≤ 64) and nothing else may carry it;
* `default` must be of the parameter's own type (a string for `text`, a number
  for `number`, `true`/`false` for `bool`, a `#hex` for `color`, a member of
  `options` for `select`);
* `max` caps the length of `text`/`html` (in characters, cut on a character
  boundary);
* unknown **keys** of the object are ignored on purpose — a folder written for
  a later schema still publishes today.

`presets` is optional. Step 1 reads the **first** preset as the base values of
an insertion, on top of the per-parameter `default`s.

The parsed schema is stored on the release row, so a rollback restores both the
markup and the parameter set of that version — and it is read back from the API,
never from the folder: see §4.5.

**The insertion.** An ordinary page section. Write path:

```json
{ "type": "text",
  "payload": { "v": 2, "kind": "component",
               "props": { "component": { "ref": 42, "params": { "title": "Своё", "sale": true } } } } }
```

`type` is `text` because the backend section whitelist is
`image|slideshow|text|video` and every widget rides on it — the widget's real
identity is `payload.kind`, exactly as for `kind: "html"`. `ref` is the
document id; `params` is a plain `{key: value}` object (≤ 8 KB).

## 4. Recipes

### 4.1 Create, upload, publish

```bash
API=https://api.vizen.shop; T="Bearer $VIZEN_TOKEN"

# 1. the document
DOC=$(curl -s -X POST "$API/html-documents" -H "Authorization: $T" \
  -H 'Content-Type: application/json' \
  -d '{"item":{"name":"Баннер акции","kind":"component","level":1}}' | jq -r .result.id)

# 2. the folder (index.html + component.json + style.css) in one zip, published at once
cd banner && zip -qr ../banner.zip . && cd ..
curl -s -X POST "$API/html-documents/$DOC/releases/zip?publish=1" \
  -H "Authorization: $T" -H 'Content-Type: application/zip' --data-binary @banner.zip
```

The publish answer names the schema it accepted:

```json
{ "document_id": 42, "release_id": 17, "active": true, "kind": "component",
  "component": { "name": "Баннер акции",
                 "params": [ {"key":"title","type":"text","default":"Скидки недели"}, … ] } }
```

A refusal is `422` with `{"error":"RELEASE_INVALID","report":{"errors":[…]}}`;
a schema error carries `code: COMPONENT_MANIFEST_INVALID`, `ref` = the field
(`params[0].key`) and `message` = the reason.

### 4.2 Put it on a page, twice, with different values

`GET /content-blocks/{id}`, add two sections, `PUT /content-blocks/{id}` with
the **whole** list (sections are a replace-set):

```json
{"item":{"sections":[
  {"type":"text","payload":{"v":2,"kind":"component","props":{"component":{
     "ref":42,"params":{"title":"Осень","sale":true}}}}},
  {"type":"text","payload":{"v":2,"kind":"component","props":{"component":{
     "ref":42,"params":{"title":"Зима"}}}}}
]}}
```

For an existing block, preserve the other sections in `result.sections` and
send the merged list in `item.sections`. Omitting `item` is invalid; an empty
`item.sections: []` does not clear sections (proto repeated-field semantics).
`POST /docs/validate` takes only `{"sections":[…]}`, without the `item` envelope.

**Verify:** open the page and grep the HTML for both headings. Two insertions of
one document must show two different texts; the image must come through the
resizer (`/w/1024/webp/files/…`), not as the original file.

### 4.3 Read the assembled HTML directly

```bash
curl -s -G "$API/html-documents/42/content" \
  --data-urlencode 'company_id=12' \
  --data-urlencode 'params={"title":"Осень","sale":true}'
```

The answer is the same shape as for an html widget — `{html, level, updated_at}`
— on purpose: the storefront must not be able to tell a component from a widget,
or there would be two loaders for one kind of block. `params` is a JSON object
URL-encoded into the query; it participates in the cache key, so two insertions
never see each other's HTML. `static=true` behaves exactly as for a widget
(payment/cart/account surfaces get the markup without the author's code).

### 4.4 Ship a new version

Upload a new zip with `?publish=1`. Every insertion picks it up after the purge
— insertions store values, never markup. Values whose key disappeared from the
new schema are ignored; parameters the insertion never set fall back to the new
`default`. `POST /html-documents/{id}/rollback` returns the previous release
together with its own schema.

A PAT's contour is fixed by the key passport (`GET /v1/account/token`);
`X-Vizen-Contour` cannot switch it. The header example below applies to an
authorized session, not to a PAT.

The release history is **shared between the draft and the live site** — only the
pointer to the active release is per contour. So a release published with
`X-Vizen-Contour: dev` shows up in the list of a live session too, carrying
`was_live: false`. Activating it there would put draft work on the live site
behind the owner's back, so the answer is `409 RELEASE_DRAFT_ONLY`: publish the
draft (`POST /v1/dev/publish`) and the release goes live through that door. In a
draft session nothing changes.

### 4.5 Read the schema back

You do not fetch `component.json` to learn what a component takes. The parsed
schema comes back on the document and on every release that has one:

```bash
# the document — the schema of its ACTIVE release
curl -s "$API/html-documents/42" -H "Authorization: $T" \
  | jq -r '.result | {active_release_id, manifest}'
# { "active_release_id": "17", "manifest": "eyJuYW1lIjoi…" }

# the releases — each with its OWN schema, which is what a rollback restores
curl -s "$API/html-documents/42/releases" -H "Authorization: $T" \
  | jq '.items[] | {id, active, manifest}'
```

Two shapes for one schema, because the two answers come off two transports:

| where | field | shape |
|---|---|---|
| `GET /html-documents/{id}`, `GET /html-documents` | `result.manifest` | proto `bytes` → **base64** of the raw JSON (decode it before parsing) |
| `GET /html-documents/{id}/releases`, `…/releases/{rid}` | `items[].manifest` | a plain JSON object |

`manifest` is **empty**, never `{}`, whenever there is no schema to show: an
html widget, a layout folder, a draft release, a component whose folder has
never been published. "Empty" is `""` on the document (an empty `bytes`) and the
key missing on a release. An empty *object* would read as "a component with no
parameters", which is a different thing — so neither answer sends one.

`active_release_id` comes back on the document too, and is the honest test for
"can this component be placed yet": no active release, no markup and no schema.
The field is read-only — publish and rollback move it, `PUT /html-documents/{id}`
does not.

### 4.6 Fix one file without a new zip

```bash
# read the source of one file of a release (text only, ≤ 2 MB)
curl -s "$API/html-documents/42/releases/17/file?path=style.css" \
  -H "Authorization: $T" | jq -r .text > style.css
# {"release_id":17,"status":"published","path":"style.css",
#  "mime":"text/css","size":812,"sha256":"…","text":"…"}
# ship it back: one file, the rest inherited from the active release
curl -s -X POST "$API/html-documents/42/releases" -H "Authorization: $T" \
  -d '{"base":"active","note":"edit","files":[{"path":"style.css","sha256":"'$SHA'","size":'$SZ'}]}'
curl -s -X PUT "$UPLOAD_URL" --data-binary @style.css
curl -s -X POST "$API/html-documents/42/releases/$RID/publish" -H "Authorization: $T"
```

Errors: `404 FILE_NOT_FOUND`, `415 FILE_NOT_TEXT` (binary — re-upload it),
`413 FILE_TOO_LARGE`. `index.html` comes back **publish-cleaned**, not the bytes
you uploaded: the converter rewrites it in place, the original is not kept.

### 4.7 Put it on ONE product or ONE category

Three ways; for one page the first one is almost always what you want:

| You need | Do | The page keeps the shop template? |
|---|---|---|
| your block **before or after** the body this page inherits | add it to the page's **own zones** — §4.7.1 | yes |
| a **different structure** for this page's body | copy the body group and bind the copy — §4.7.2 | no, it stops inheriting |
| the **same** block on every product of one or more sections | one feed bound to each section's subtree — §4.7.3 | yes |

Values that differ per product (material, sizes, composition) are not blocks:
keep them in the product's attributes and description and let the template show
them. A different structure for a whole section is a body variant bound to the
category (`category_children/<id>`), not blocks on every product.

#### 4.7.1 Add your block around the inherited body

Every product and category page has its own blocks next to the body it inherits.
They live in service blocks named `__page:<zone>` — a manifest section
`kind:"zone"` whose `refs` lists block ids in display order — and the manifests
and the blocks they list are bound to the page with
`PUT /products/{id}/content-blocks` (`PUT /categories/{id}/content-blocks` for a
category). This is exactly what the shop's page editor writes, so the owner sees
and edits your block there, and the body keeps following the shop template.

**First find which zones the page draws.** Its geometry comes from the nearest of
three layers: the page's own `__page:top` (`page.layout` or `page.layoutConfig` in
its section), then the template (`design.page.body_page`), then the layout
(`design.page.theme_page`); the last two are base64-encoded JSON objects in
`GET /products/{id}` — read the same two keys there.

| The nearest layer has | Zones drawn | Before the body | After the body |
|---|---|---|---|
| no `layout` and no `layoutConfig` anywhere | the classic `top · body · bottom` | `__page:top` | `__page:bottom` |
| a preset (`main-only`, `stacked`, `content-aside`, `content-aside-left`) or `layoutConfig.rows` | the `zoneId`s in its rows; the body is the column with `"body": true` — `main` in every preset | `__page:main`, `zoneFlow` `side:"before"` | `__page:main`, `side:"after"` |

Use only zones that are in the geometry. A zone outside it is not drawn on a
`layoutConfig` `v:2` grid and lands outside the grid otherwise — either way not
where you meant (`__page:bottom` on a `main-only` page).

```bash
API=https://api.vizen.shop; T="Bearer $VIZEN_TOKEN"

# 1. what the page already has: content_blocks[] sits NEXT TO result
curl -s "$API/products/2505" -H "Authorization: $T" > page.json
# {"result":{…}, "content_blocks":[{"id":"3600","name":"__page:top",…}, {"id":"3601",…}],
#  "design":{"page":{"body_page":"<base64>","theme_page":"<base64>",…}}, …}
jq -r '.design.page.body_page // empty' page.json | base64 -d | jq '{layout, layoutConfig}'

# 2. your insertion as a block — here an existing component; an html block or any
#    other block goes in the same way, a finished folder is not turned into a component
curl -s -X POST "$API/content-blocks" -H "Authorization: $T" -d '{"item":{
  "name":"Promo banner (Volstreet 120)",
  "sections":[{"type":"text","payload":{"v":2,"kind":"component","props":{"component":{
     "ref":42,"params":{"title":"Осень"}}}}}]}}'
# → {"result":{"id":"3701", …}}

# 3. the zone after the body. Classic page without "__page:bottom" in step 1 → create it:
curl -s -X POST "$API/content-blocks" -H "Authorization: $T" -d '{"page_design_version":1,
  "item":{"name":"__page:bottom",
  "sections":[{"type":"text","payload":{"v":2,"kind":"zone","refs":[3701]}}]}}'
# → {"result":{"id":"3702", …}}
#    Grid page → the same call with "__page:main" and a placement:
#    "payload":{"v":2,"kind":"zone","refs":[3701],
#      "props":{"zoneFlow":{"v":1,"entries":[{"entryId":"promo-3701","ref":3701,"side":"after"}]}}}
#    The manifest exists → GET /content-blocks/{id}, add 3701 to refs (and its zoneFlow entry),
#    PUT the whole sections list back with "page_design_version":1.

# 4. bind the manifest AND the block — replace-set: also send every id from step 1
curl -s -X PUT "$API/products/2505/content-blocks" -H "Authorization: $T" -d '{"items":[
  {"block_id":"3600","sort_order":0}, {"block_id":"3601","sort_order":1},
  {"block_id":"3702","sort_order":2}, {"block_id":"3701","sort_order":3}]}'
```

Rules of the zones:

* **`refs` are JSON numbers** (`[3701]`, not `["3701"]`). The server checks
  `zoneFlow` against numeric refs, and its "block in use" guard matches numbers
  only: a string ref lets the block be deleted from under the page.
* **A ref must be bound to the page** (step 4). A ref that is not bound is
  skipped, it is not an error.
* **`__page:top` also holds the page settings** in its section's `page` object
  (`hideSiteHeader`, `hideSiteFooter`, `hideSystemBlock`, the layout). Edit that
  manifest — read, change `refs`, send every section back — never recreate it.
* **`zoneFlow`** is `{"v":1,"entries":[…]}` with one entry per ref, in the same
  order: `entryId` is a unique non-empty string, `ref` the same number, `side` is
  `before` or `after` the body inside that zone. If a manifest has `zoneFlow`, add
  an entry for every ref you add: a mismatch answers `400 PAGE_ZONE_FLOW_INVALID`.
  Without `zoneFlow` every ref counts as `after`.
* Send `"page_design_version":1` at the root of every write of a `__page:*`
  manifest: writing grid data (`page.layoutConfig`, `page.zoneProps`, `zoneId` in
  `zoneFlow`), or editing a manifest that already has it, answers
  `400 PAGE_DESIGN_WRITER_UNSUPPORTED` without it.
* An approved product shared on the marketplace (`is_shared`) goes back to
  moderation after step 4, as after any change of its buyer content.

**Verify:** `GET /products/2505` lists the manifest and the block in
`content_blocks`, and the manifest's `refs` come back as numbers;
`GET /design/bindings/product/2505` still answers a body `source` of `shop`,
`category:<id>` or `default`, not `own` — the template is inherited. Then open
the page.

**Rollback**, in this order:

1. `PUT /products/2505/content-blocks` with exactly the items from step 1.
2. The manifest you created → `DELETE /content-blocks/3702`; a manifest that was
   there → `PUT` its sections from step 1 back (with `"page_design_version":1`).
3. `DELETE /content-blocks/3701`. While any live manifest still lists the block,
   this answers `400 DESIGN_BLOCK_IN_USE` — that is why it comes last.

#### 4.7.2 Give one page a different body

A page body is one design group (`chrome.md` §3.2), and a group belongs to
whoever it is bound to. To change the structure of a single page's body you
**copy the body group that page inherits**, change the copy, and bind the copy
to that one resource.

If the group you copy came from the page builder (its section has `props.zoneFlow`
or `page.zoneProps`), copy its `sections` whole, keep its `group_type`
(`category` for a category page), put `"page_design_version": 1` at the root of
the request, and change refs in both `widgets` and `zoneFlow.entries`
(`/docs/chrome` §3.1). Save the binding map before step 4 and restore THAT map on
rollback (§4.8 step 6) — `{"slots":{}}` below is right only when the resource had
no own bindings.

```bash
API=https://api.vizen.shop; T="Bearer $VIZEN_TOKEN"

# 1. what does this page use today, and where does it come from?
curl -s "$API/design/bindings/product/2505" -H "Authorization: $T"
# {"own":[], "resolved":{"body":{"block_id":"3410","source":"shop"}, …}}
#                                             ^ inherited from the shop template

# 2. read that group (4.2 endpoint — a group is a content block)
curl -s "$API/content-blocks/3410" -H "Authorization: $T" > body.json

# 3. a group does NOT hold sections: it holds ONE section kind:"group" whose
#    props.widgets lists widget BLOCKS. So first make your insertion a block…
curl -s -X POST "$API/content-blocks" -H "Authorization: $T" -d '{"item":{
  "name":"Promo banner insertion",
  "sections":[{"type":"text","payload":{"v":2,"kind":"component","props":{"component":{
     "ref":42,"params":{"title":"Осень"}}}}}]}}'
# → {"result":{"id":"3701", …}}

#    …then create YOUR copy of the group: the widgets from step 2 plus yours
curl -s -X POST "$API/content-blocks" -H "Authorization: $T" -d '{"item":{
  "name":"Product card + promo banner (Volstreet 120)",
  "group_type":"product",
  "sections":[{"type":"text","payload":{"v":2,"kind":"group",
     "props":{"widgets":[ …ids from step 2…, 3701 ]}}}]}}'
# → {"result":{"id":"3702", …}}
#    Sections put straight into the group answer 400 DESIGN_SLOT_NEEDS_GROUP at step 4.

# 4. bind the copy to this product only
curl -s -X PUT "$API/design/bindings/product/2505" -H "Authorization: $T" \
  -d '{"slots":{"body":3702}}'
```

`category/<id>` in place of `product/<id>` does the same for one category page;
`category_children/<id>` for every product of a subtree. `group_type` must match
the page kind (`product`, `category`) — the ladder in §3.2 of `chrome.md` picks
groups by type.

**Verify:** `GET /design/bindings/product/2505` must answer
`"body":{"block_id":"3702","source":"own"}`, and a neighbouring product must
still answer `"source":"shop"`. Then open both pages. The product's own address
is on the product itself — `GET /products/2505` returns `url`
(`/catalog/beds/krovat-loft-volstrit-120`) and `slug`; lists return `url` too.

**What the copy costs you.** It is a copy, not an overlay:

* **the page stops inheriting the shop template.** Change the shop-wide product
  body later and this page keeps the old one — nobody warns you. Re-copy, or
  bind the resource back to inheriting when the exception is no longer needed;
* **replacing a CATEGORY listing changes its capabilities**: stock filters,
  sorting controls and its `ItemList` JSON-LD do not appear just because your
  component draws tiles. Own markup does support `products_page` pagination
  for the page's section. Choose deliberately which capabilities to keep;
  replacing only the listing in an existing group is the recipe in §4.8;
* **the product feed is 48 items per page** (`own-markup.md` §2): the page's own
  section pages with `?page=N` and `products_page`, a section named on the insertion
  (`productsCategoryId`) gets its first page only, in the section's own order. The
  insertion can also ask for chosen attributes (`productsAttributes`) and one tile per
  product group with switchable members (`productsMode: "collapse"`, paging by groups)
  — next to `ref` and `params`, never inside `params` (`own-markup.md` §2).

**Rollback**, in this order:

```bash
curl -s -X PUT "$API/design/bindings/product/2505" -H "Authorization: $T" -d '{"slots":{}}'
curl -s -X DELETE "$API/content-blocks/3702" -H "Authorization: $T" -d '{}'
curl -s -X DELETE "$API/content-blocks/3701" -H "Authorization: $T" -d '{}'
```

`{"slots":{}}` is the replace-set that unbinds everything and puts the page back
on the inheritance ladder (`chrome.md` §3.2) — do it **before** deleting the
group, or the page renders with a dangling binding. `{"slots":{"body":0}}` is
not a rollback: `0` means "this page has no body at all" (§3.3 there).

#### 4.7.3 The same block on every product of one or more sections

One banner, delivery note or promo strip on all chairs and all shelves: put it
in a **feed** (`chrome.md` §3.4) and bind the feed to each section's subtree.
The body keeps following the shop template, the block exists once, and one edit
changes every page. Copying the body for this (§4.7.2) works too, but the
copies stop inheriting the template for as long as they are bound.

```bash
# 1. the block itself — any widget or html block; here an existing one, 3801
# 2. a design group holding it
curl -s -X POST "$API/content-blocks" -H "Authorization: $T" -d '{"page_design_version":1,
  "item":{"name":"Promo","group_type":"design",
  "sections":[{"type":"text","payload":{"v":2,"kind":"group","props":{"widgets":[3801]}}}]}}'
# → {"result":{"id":"3802", …}}

# 3. the feed: the body marker -1 and your group in display order (here after the body)
curl -s -X POST "$API/content-blocks" -H "Authorization: $T" -d '{"page_design_version":1,
  "item":{"name":"Promo feed","group_type":"layout",
  "sections":[{"type":"text","payload":{"v":2,"kind":"group","props":{"widgets":[-1,3802]}}}]}}'
# → {"result":{"id":"3803", …}}

# 4. per section: PUT is a replace-set, so read the subtree's own slots first
#    and send them back together with feed
curl -s "$API/design/bindings/category_children/48" -H "Authorization: $T"
# → {"own":[], "resolved":{"feed":{"block_id":"0","source":"default"}, …}}
curl -s -X PUT "$API/design/bindings/category_children/48" -H "Authorization: $T" \
  -d '{"slots":{"feed":3803}}'
# the same for category_children/49 …
```

**Verify:** `GET /design/bindings/product/<a product of section 48>` answers
`"feed":{"block_id":"3803","source":"category:48"}` and its `body` is what it was
before. Open one product from each section, then change block 3801 once and
reload both.

**What the feed costs you.** It **replaces** the feed these products inherited
from a parent section, the layout (`theme`) or the shop. If step 4 showed a
`resolved.feed` other than `0`, start your list from that feed's list and add
your group, or those pages lose what it showed. A product with its own feed
binding keeps its own. Header, body, footer and each page's own zones (§4.7.1)
do not change.

**Rollback**, in this order: `PUT` every `category_children/<id>` back to the
`own` you read in step 4 (`own: []` → `{"slots":{}}`), then
`DELETE /content-blocks/3803` and `3802` (and 3801 if you made it). A binding
still pointing at the feed answers `400 DESIGN_GROUP_IN_USE`, a list still
holding the group `400 DESIGN_BLOCK_IN_USE` — hence bindings first, then from
the outside in. A lone unused `layout` or `design` group can be deleted.
`DELETE` is soft (`deleted_after` is +30 days), but there is no restore call and
a deleted block no longer reads back: keep what you read before deleting.

### 4.8 Replace a category's stock grid through a PAT, with rollback

This recipe replaces the listing inside an existing **category body group**,
keeping its banner and other blocks. It is not the product-body copy recipe
§4.7.2. If the resolved group is inherited/shared and only one page should change,
first copy and bind it to that category (§4.7.2); save the previous binding as well.
Never edit a shared group assuming it belongs to only one page.

1. **Authenticate first** using [Key and permissions](/docs/authentication),
   including its mandatory passport check before writes.
   Read `GET /design/bindings/category/<id>`, then GET the resolved body group.
   Inspect `GET /content-blocks/{id}/usage`: `total` counts direct design bindings
   plus manifest references (groups/layouts/zones); `refs` returns at most 100
   of them, not all pages reached by inheritance.
   In the bindings response, `resolved.body.source: "own"` means a direct body
   choice; another source (for example `"shop"`) identifies inheritance. An own
   binding alone does not prove exclusivity: check usage as well. Save the GET response
   locally and prepare the rollback request **before** writing:

   ```bash
   API=https://api.vizen.shop
   GROUP=YOUR_RESOLVED_BODY_ID  # resolve for your target category
   curl --fail-with-body -sS "$API/content-blocks/$GROUP" \
     -H "Authorization: Bearer $VIZEN_TOKEN" > group-before.json
   jq '{page_design_version:1,item:(.result|{name,group_type,sections})}' \
     group-before.json > group-rollback.json
   ```

   **Already replaced?** If the body is `own`, the stock listing is absent and
   your insertion blocks are already present, keep those references. Update the
   existing insertion settings or publish a new release of its document (§4.4);
   do not create duplicate insertions or run the replacement again. If the
   expected old listing and your insertions are both absent, inspect the body
   before writing rather than guessing a replacement target.

2. **Prepare the component.** Check every scope field your markup reads against
   `GET /docs/vz-keys.json` → `scope`. A field absent there cannot be rendered
   by server substitution: do not publish a release depending on it or invent a
   key; report the need to the platform owner. Record sets are not in the scope —
   read them with the public `GET /record-sets` (`/docs/record-sets` §4.4).
   Upload/publish the component by §4.1 and check
   its accepted schema. Buyer access for attributes and axes: `/docs/catalogue`
   §8.3; markup rules and images: `/docs/own-markup` §§4,6; theme alignment:
   `/docs/chrome` §3.7. Do not hard-code catalogue prices or compute combo quotes.

3. **Create insertion blocks without binding them.** Use `POST /content-blocks`
   with the two complete bodies in [family cards §3](/docs/guide-family-cards#3-configure-two-insertions).
   That is the canonical insertion content, including `show_catalog:false` for
   the combo insertion and the full-width, zero-padding wrapper. Replace all
   illustrative ids with your own. Save the family block's `result.id` as
   `FAMILIES`, and the optional combo block's id as `COMBOS`. The latter selects
   **set** ids and loops over `p.combo_items`. Keep both unbound until verified.
   Selection semantics: `/docs/catalogue` §4.1 and `/docs/own-markup` §2.

4. **Build the replacement from the snapshot.** Identify the stock listing
   block id in the group's `props.widgets`, read that block, and verify its
   `kind` is the listing you intend to replace. Preserve every other block,
   page property and zone setting. Follow `/docs/chrome` §3.1 for the two-list
   invariant and writer version. The example requires OLD to occur exactly once;
   with repeated refs, target a particular `entryId` occurrence instead. Example
   replacing one listing with two blocks:

   ```bash
   OLD=YOUR_LISTING_BLOCK_ID  # verified listing id, not the whole group
   # FAMILIES and COMBOS are the new result.id values from step 3
   jq --argjson old "$OLD" --argjson a "$FAMILIES" --argjson b "$COMBOS" '
     .item.sections |= map(
       if .payload.kind == "group" then
         .payload.props.widgets |= map(if . == $old then $a,$b else . end)
         | if .payload.props.zoneFlow then
             .payload.props.zoneFlow.entries |= map(
               if .ref == $old then
                 (.ref=$a | .entryId=("pat-"+($a|tostring)+"-families")),
                 (.ref=$b | .entryId=("pat-"+($b|tostring)+"-combos"))
               else . end)
           else . end
       else . end)' group-rollback.json > group-next.json
   ```

   Check the generated entry ids are unique in this group, both lists have
   matching order/count, and only the intended occurrence was replaced. The
   example retains the old occurrence's zone/side for both new entries. If you
   want a different zone, select it explicitly without dropping `page.zoneProps`.
   Validate `item.sections` with `POST /docs/validate` (§4.2); it does not check
   the existence of refs, writer version or group consumers. Re-read the group
   before PUT; if it changed since the snapshot, reconcile instead of overwriting.

5. **Switch once, then check.**

   ```bash
   curl --fail-with-body -sS -X PUT "$API/content-blocks/$GROUP" \
     -H "Authorization: Bearer $VIZEN_TOKEN" -H 'Content-Type: application/json' \
     --data-binary @group-next.json
   ```

   GET again: confirm both reference lists, new ids and retained zone properties.
   Check the server HTML, then the browser (`/docs/troubleshooting` §2): first
   pane without JS, size/colour switches, unavailable combinations, SKU/price/
   image/cart arguments together, combo composition, mobile alignment and images.
   Check another category that should remain unchanged. A 200 alone is not acceptance.

6. **Rollback** uses the same writer and the exact saved sections, not an empty
   binding or deletion of the old listing:

   ```bash
   curl --fail-with-body -sS -X PUT "$API/content-blocks/$GROUP" \
     -H "Authorization: Bearer $VIZEN_TOKEN" -H 'Content-Type: application/json' \
     --data-binary @group-rollback.json
   ```

   Re-read and check the page. If step 1 created an exclusive copy, also restore
   the saved binding map (not blindly `slots:{}`). Keep component release rollback
   (§4.4) separate: restoring a group does not restore a shared component release.
   Do not delete old blocks or snapshot files during acceptance.

The tutorial uses one reusable level-1 component with a family insertion and
an optional combo insertion. Resolve each target category's body independently;
example ids are placeholders, never platform defaults.

## 5. TypeScript

Put `app.ts` in the folder and point the markup at the **compiled** file:

```html
<!-- index.html -->
<div class="promo">{{title}}</div>
<script type="module" src="app.js"></script>
```

```ts
// app.ts
type Options = { root: string };

const opts: Options = { root: ".promo" };

export function init(): void {
  const el = document.querySelector<HTMLElement>(opts.root);
  if (el) el.dataset.ready = "yes";
}

init();
```

Publishing the release compiles every `*.ts` of the folder into a sibling
`*.js` and puts it into the release **next to the source**. The source stays:
the next release is built from `base=active`, and a folder whose `.ts` was
thrown away could not be edited without uploading it again. Nothing is bundled
and nothing is minified — the types are erased, that is all.

**What is compiled**

* every `*.ts` at any depth of the folder — `app.ts`, `ui/menu.ts`;
* nothing else. `*.tsx` needs a JSX factory a component has no way to choose,
  `*.d.ts` compiles to an empty file, and `node_modules/` is somebody else's
  delivery. Those files stay in the release untouched — and unused.

**How the result is loaded is decided by your tag, not by the compiler.** The
module syntax of the source is preserved (target ES2020): a source with
`import`/`export` stays a module and runs under
`<script type="module" src="app.js">`; a source without them stays an ordinary
script with the same globals it had, so `onclick="init()"` in the markup keeps
working. No IIFE is wrapped around your code behind your back.

Import paths are **not** rewritten either: write
`import { x } from "./util.js"` — the file the browser will actually fetch.
`"./util.ts"` publishes with a warning and then 404s in the browser;
extensionless `"./util"` is refused as a broken reference, exactly as in a
hand-written `.js`.

**Only `kind=component`.** An html widget and a layout folder publish exactly as
before — a `.ts` in them is just a file that nobody compiles.

**Refusals** — `422` with `{"error":"RELEASE_INVALID","report":{"errors":[…]}}`,
the same shape as a schema error:

| `code` | when | `file` · `ref` |
|---|---|---|
| `COMPONENT_TS_ERROR` | the source does not compile; also a size limit or the timeout | the source · `app.ts:3:19` — line and 1-based column |
| `COMPONENT_TS_CONFLICT` | `app.js` is already in the folder next to `app.ts` | the `.js` · the `.ts` it collides with |

A ready `app.js` is never overwritten silently: if you compiled the script
yourself, drop the `.ts` or rename one of the two. esbuild's own warnings come
back in `report.warnings` with `code: COMPONENT_TS_WARNING` and do not block the
publish.

Limits: **512 KB** per source, **4 MB** of TypeScript per release, **10 s** of
compilation per release. A component is a piece of markup, not an application —
a bundle that hits these belongs in an html project (`transfer.md`).

⚠️ **At level 1 the page never gets the script.** The compiled file is served by
the gateway either way (`/_html/{doc}/{release}/app.js`, `text/javascript`), but
the level‑1 sanitiser strips `<script>` out of the assembled HTML. For the
browser to run it the document must be level 3 (author-owned), exactly as for an
html project — the same rule that eats `<link rel="stylesheet">`, see §6.

⚠️ **Two insertions run the script twice.** Each insertion of a level‑3
component carries its own `<script>`, and each run sees EVERY copy on the page.
A script that binds all `.my-block` nodes gives every button two handlers — a
text update hides it, a toggle fires twice and looks dead. Mark what you have
initialised and skip it:

```js
document.querySelectorAll('.my-block:not([data-ready])').forEach(function (root) {
  root.setAttribute('data-ready', '')
  // bind inside root only
})
```

## 6. Silently ignored

* **A key not in the schema** — both in `params` of an insertion (dropped) and
  in the markup (`{{whatever}}` becomes an empty string). There is no raw
  `{{ }}` for a component: a placeholder either has a type or it is nothing.
* **A value of the wrong type** — treated as absent, so the `default` shows.
  A `200` on `PUT /content-blocks` proves nothing about the value taking effect;
  read the page.
* **A `link`/`image` outside the whitelist** (`javascript:`, `data:`,
  `//other-host`) — the value becomes empty, and an empty URL value means the
  attribute is **not written at all**, not written empty. Write CSS against
  `img:not([src])` and `a:not([href])`, not against `img[src=""]`.
  The whitelist is `https://`, `http://`, `mailto:`, `tel:`, and a same-site
  path — so `#anchor`, `form:<id>` and `modal:…` do **not** survive a `link`
  parameter. To make a component button open a form or a quick-view window,
  declare the parameter as `text` and put its value on the storefront key
  instead of on `href`:
  `<a class="btn" href="{{btnlink}}" vz-modal="{{openmodal}}">{{btnlabel}}</a>`,
  with `openmodal` carrying `modal:product:2808?size=full`. The value is
  escaped like any `text`, and the storefront parses it strictly. A lead form
  opens the same way — a `text` parameter substituted into `href`, since the
  level-1 sanitiser does accept `form:<id>` there.
* **A `select` value outside `options`, a `color` that is not `#hex`** — empty.
* **Writing `params` at the wrong nesting level** (`props.params`, or `ref`
  next to `kind`) — the section renders with defaults only. The write path is
  `props.component.ref` / `props.component.params` and nothing else.
* **`kind` in `PUT /html-documents/{id}`** — the document's kind is fixed at
  creation; the field is not read on edit.
* **Level-1 CSS/attribute sanitising** — canonical rules and the inline
  `<style>` recipe are in `/docs/own-markup` §4. A CSS file in a component ZIP
  does not by itself mean the page loads it.
* **`alt` / `title` carrying markup** — these attributes are allowlisted only
  for plain text, so a `text` parameter whose value contains tags is escaped in
  the body but dropped from the attribute. That is the sanitiser, not the
  substitution.

## 7. Limits of step 1

* **No `products` / `category` parameters.** Substitution is a string
  operation; a product list is data, and data belongs to the page assembler
  ("a widget fetches only its own"). The type will appear together with that
  path, not before — until then it is not in the schema and a manifest using it
  is refused, loudly, at publish.
* **No component inside a component**, and no `<vz-component>` call from
  someone else's HTML.
* **No bundler.** TypeScript is compiled (§5), but nothing is bundled: no
  `npm` imports, no JSX/`.tsx`, no source maps. Every import must resolve to a
  file of the folder.
* **Platform `vz-` keys are left alone**, not substituted: `{{ product.name }}`
  passes through this stage untouched and is expanded by the storefront's own
  engine with the page scope. Only bare `{{key}}` belongs to the component.
