# Components: one piece of markup, many insertions

> **Резюме по-русски.** Компонент — та же папка HTML/CSS, что у html-виджета,
> но рядом с `index.html` лежит `component.json` со схемой параметров, а в
> разметке стоят `{{key}}`. Один компонент ставится на пять страниц с разными
> заголовком и картинкой; автор выпускает новый релиз — обновились все пять.
> Подстановка ТИПИЗИРОВАННАЯ и серверная: сырого `{{ }}` компонента на витрине
> не бывает, а `text` экранируется, `html` проходит санитайзер, `link`/`image`
> — whitelist схем. Схема повторяет Shopify theme section (§2), чтобы её можно
> было писать не переучиваясь. В папке разрешён TypeScript: `app.ts` компилируется
> в соседний `app.js` на публикации релиза, сборщик автору не нужен (§5).

**Status:** draft · **Verified:** 2026-09-13, stand (create → zip → publish → page; TypeScript → `app.js`)
**Owner:** components line (Б-Компонент-с-параметрами.md) · **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 | html widget with `vz-` keys (`own-markup.md`) | a component's parameters are values, not queries — see §7 |

### 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 becomes empty |
| `color` | `color` | `#rgb`…`#rrggbbaa`, else empty |
| `number`, `range` | `number` | the number |
| `checkbox` | `bool` | `true`/`false`, and `{{#key}}…{{/key}}` shows a block |
| `select`, `radio` | `select` | one of `options`, else empty |
| `product` | — | not in step 1, see §7 |
| `collection` | — | not in step 1, see §7 |

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
```

Both steps in one command, if you have the repository — it creates the document,
uploads the folder, publishes and prints the parsed schema and the section to
insert:

```bash
node backend-3D/tools/html-transfer/transfer.mjs ./banner --component --name "Баннер акции"
#   --level 3   when the component needs live JS (see §5)
#   --doc 42    a new release of a component that already exists
```

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
{"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":"Зима"}}}}}
]}
```

**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.

### 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.

## 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.

## 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`) — becomes an empty attribute, not an error.
* **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.
* **`<link rel="stylesheet" href="style.css">` at level 1** — the level-1
  sanitiser keeps `<link rel=stylesheet>` but only with an `https://` href, so a
  relative one arrives stripped and the file never loads. This is not specific
  to components (an html widget of level 1 behaves the same). Put the CSS in an
  inline `<style>` — it is kept and scoped to `.vz-body-{doc}` — or make the
  document level 3, where the author's `<link>`, `<style>` and `<script>` are
  served as written (owner/admin plus the server flag, exactly as for an html
  project).
* **`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.
