Vizen Shop

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 pathCost
this exact markup on this one pagehtml widget (own-markup.md)none — editing it means editing code
the same markup on many pages with different contentcomponent (this doc)you must write component.json
a whole finished site as-istransfer.md
live catalogue data inside the markuphtml 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[].typeheresubstituted as
text, textareatextHTML-escaped string
richtext, htmlhtmlthe author's markup through the level‑1 sanitiser
image_pickerimagea file_id from the media library → public URL (then the resizer)
urllinkhttp(s)://, /path, mailto:, tel: — anything else becomes empty
colorcolor#rgb#rrggbbaa, else empty
number, rangenumberthe number
checkboxbooltrue/false, and {{#key}}…{{/key}} shows a block
select, radioselectone of options, else empty
productnot in step 1, see §7
collectionnot 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):

  • 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 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.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:

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:

{ "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:

wherefieldshape
GET /html-documents/{id}, GET /html-documentsresult.manifestproto bytesbase64 of the raw JSON (decode it before parsing)
GET /html-documents/{id}/releases, …/releases/{rid}items[].manifesta 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 *.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.

Refusals422 with {"error":"RELEASE_INVALID","report":{"errors":[…]}}, the same shape as a schema error:

codewhenfile · ref
COMPONENT_TS_ERRORthe source does not compile; also a size limit or the timeoutthe source · app.ts:3:19 — line and 1-based column
COMPONENT_TS_CONFLICTapp.js is already in the folder next to app.tsthe .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.

Исходник: https://api.vizen.shop/docs/components

Работает в демо-режиме