Components: one piece of markup, many insertions
/docs/componentsdraftEN· проверено 2026-09-13
Резюме по-русски. Компонент — та же папка 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`.
{
"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):
keymatches^[a-z][a-z0-9_]{0,31}$and is unique;- at most 32 parameters, the file at most 16 KB;
typeis one of the eight above — an unknown type is an error, because the type is what decides the escaping;selectrequires a non-emptyoptions(≤ 64) and nothing else may carry it;defaultmust be of the parameter's own type (a string fortext, a number fornumber,true/falseforbool, a#hexforcolor, a member ofoptionsforselect);maxcaps the length oftext/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 defaults.
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:
{ "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
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.zipBoth 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:
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 existsThe publish answer names the schema it accepted:
{ "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):
{"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
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:
# 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:
<!-- index.html -->
<div class="promo">{{title}}</div>
<script type="module" src="app.js"></script>// 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
*.tsat any depth of the folder —app.ts,ui/menu.ts; - nothing else.
*.tsxneeds a JSX factory a component has no way to choose,*.d.tscompiles to an empty file, andnode_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
paramsof 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
defaultshows. A200onPUT /content-blocksproves 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, orrefnext tokind) — the section renders with defaults only. The write path isprops.component.ref/props.component.paramsand 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 anhttps://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
textparameter 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
npmimports, 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.
Исходник: https://api.vizen.shop/docs/components