Your own markup
/docs/skill/vizen-own-markupcurrentEN
The platform substitutes data on the server and hands back plain HTML. No foreign JavaScript is required for data, and the text is in the page source, so search engines see it.
1. Two vocabularies
Substitutions print data: {{ shop.name }}, {{ p.price_text }}. Keys are attributes that add behaviour or structure: vz-for, vz-if, vz-add-to-cart, vz-toggle.
Full machine list: GET /docs/vz-keys.json. Never guess a key — an unknown vz- attribute is removed by the sanitiser.
2. What is in scope
shop, menu, crumbs everywhere; product on a product page; category and products on a section page.
`products` is the section's feed, live. Write vz-for="p in products" and you get real catalogue rows — the price you print is the price the cart charges, and it keeps up when the owner edits the catalogue. This is recent: until 2026-08-20 there was no product list in scope at all, and a custom row meant prices typed into the document by hand, which started lying the moment anything changed. If you have older notes saying own markup freezes prices, they are out of date.
The feed is loaded only when your document asks for it, so a page that never mentions products pays nothing for it. Fields: id, name, sku, price, price_text, old_price, old_price_text, url, preview, in_stock, promotion_name. Up to 48 rows.
preview is already a resizer path, so a plain <img src="{{ p.preview }}"> is a thumbnail rather than a multi-megabyte original. Do not rebuild it by hand.
<article vz-for="p in products">
<img vz-src="p.preview" alt="">
<h3>{{ p.name }}</h3>
<span>{{ p.price_text }}</span>
<s vz-if="p.old_price_text">{{ p.old_price_text }}</s>
<button vz-add-to-cart="{{ p.id }}">Buy</button>
</article>Discounts and sets
Prices in the feed already account for promotions — the server runs every card through the engine the cart uses. promotion_name is the rule that fired, empty when none did.
On a product page, product.in_combos[] lets the card say where the product is cheaper, with numbers and ready strings side by side: item_price_text is what it costs inside that set, item_regular_price_text what it costs alone, and item_times_cheaper_text is a NUMERAL — you write the words around it.
<p vz-for="c in product.in_combos" vz-if="c.item_times_cheaper_text">
In «{{ c.name }}» — {{ c.item_price_text }} instead of
{{ c.item_regular_price_text }}
</p>item_times_cheaper_text is empty whenever a ratio would overstate the benefit, so guarding with vz-if is all the honesty check you need. A gift is item_price equal to 0 — render it from that, never from the ratio.
Full field list and the rules behind the numbers: GET /docs/vz-keys.json (scope.combos) and GET /docs/promotions for the discount engine itself.
Live regardless of everything above: vz-add-to-cart, vz-cart-count, vz-fav, vz-variants — buttons keep working.
Still a real fork. The platform listing (vizen-widgets) brings filters, paging and sorting; the feed in scope does not. Choose own markup for the look, the listing for the machinery — but no longer for the freshness of the data.
3. Verified behaviours you will not find in the guide
- nested `vz-for` works three levels deep —
m in menu→c in m.children→g in c.children, expanded on the server, SEO intact. That is how a mega menu is built; - `vz-for` and `vz-if` on the same node make the node disappear entirely — put them on different nodes;
- `vz-toggle` with no value sets
vz-state="open"on the node itself. That is the only way to build a click-dropdown without JS:.trigger[vz-state="open"] + .panel { … }; - `vz-toggle="#id"` sets the state on a target by selector — that is a modal;
- `vz-gallery` reads `product.gallery` only and ignores
preview: an empty gallery draws nothing.
4. "I want this block to look unique" — the route
Every block with a box — a platform widget, a level-1 html block, a level-3 block with props.html.boxed: true — is drawn inside the same wrapper: div.vz-box (the band — background, padding, radius, and the node that goes sticky) → div.vz-inner (the content box — it caps your width) → for kind: "html" at level 1, div.vz-radius, the shadow host, which clips your dropdowns and modals by default. A transferred folder (level 3, unboxed) has none of these: your root element is the block, and the axes below have nothing to land on — style your root directly, or set boxed: true first.
Do not fight it, and do not restyle its class names. Pick one of two moves:
| You want | Move |
|---|---|
| a full re-layout — your grid, your breakpoints, nothing of the platform left | `props.wrapperClass: "my-hero"` → your class lands on the band → you restyle it from your own CSS: .my-hero > .vz-inner { max-width: none; display: grid } |
| one behaviour switched off — clipping, the width cap, the air, the stacking | the wrapper axis for it: props.overflow: "visible", props.blockWidth: "full", props.paddingSides, props.position: "sticky" … |
Both are written at the root of props, next to kind — not inside the widget's own write path. props.wrapperId gives the band one id, for #anchors and for your scripts.
Prefer the axis when an axis can do it: it is read by the code that draws the block, so it survives changes to the markup.
⚠️ Two things that make Path A silently do nothing:
- level-1 CSS cannot see the wrapper. Your
<style>lives inside the shadow root;.vz-boxis outside it. Path A needs a level 3 document, which is real page DOM. Level 3 needs the owner/admin role; the server flag is on in production — ifPOST /html-documents {level:3}is refused, report it to the owner instead of falling back to level 1; - names are dropped whole, not cleaned. At most 3 classes, one id, pattern
^[a-zA-Z_][a-zA-Z0-9_-]*$, and nothing may start withvz-(the platform's own namespace). A rejected name simply is not inclass=.
⚠️ **Never target .vz-box, .vz-inner, .vz-radius, .vz-edge, .vz-vis-* directly.** They are internal, they carry no version, and they have already moved once — that is why wrapperClass exists. Always scope through your own class: .my-hero > .vz-inner, never .vz-inner alone.
Sticky works — props.position: "sticky" on any block, or vz-sticky on the root node of your markup, including a custom header inside a chrome kit. It is applied to the band, the only node with room to travel. Older notes prescribing position: fixed plus a spacer are obsolete. props.overflow: "clip" and sticky are mutually exclusive.
Where the numbers live. Every default of both wrapper divs, with the file:line in the storefront that sets it, is generated into GET /docs/widgets → wrapper:
curl -s https://api.vizen.shop/docs/widgets | jq -r '
.wrapper.elements[] as $e | $e.defaults[]
| [$e.selector, .property, .value, (.when // "-"), .anchor.at] | @tsv'Full recipe, the anti-path and the surfaces where sticky is still not asked: GET /docs/own-markup §5.
The block's own padding and radius are zero for kind: "html" — the platform adds nothing around your design. Want air? Set props.paddingSides explicitly.
5. Images
Write an ordinary <img src> pointing at a shop file. The server rewrites it to the resizer and adds srcset (640/1024/1600, webp). You do not build those paths.
But set `sizes` yourself. Only you know how wide the slot is; without a hint the server assumes full width and the browser takes the largest step. On a card 268px wide that meant 1600w instead of 640w. Your sizes is never overwritten, and your own srcset disables the rewrite entirely.
Scope image fields are already resizer paths since 2026-08-20 — product.preview_url, product.gallery[].url, the combo previews and products[].preview. Print them as they come; rebuilding one by hand is how you ship a multi-megabyte original.
⚠️ vz-src is filled in *after* the server rewrite has run, so nothing rewrites it. That is safe only because the scope fields arrive pre-sliced — point vz-src at a URL of your own and the browser gets exactly what you wrote.
6. Scripts
Level 3 renders inline, in the real page DOM. Scripts run on a direct hit and after an in-site link click. Two rules:
- never hide content behind a script. Show it; let the script decorate. A landing whose cards started at
opacity: 0looked empty when the script did not run; - do not rely on `DOMContentLoaded` — after an in-site click the document is already loaded. Write code that works when executed right now.
Check both ways: direct URL and a click from another page.
6.1 A finished folder → a page of the site (HTML project)
The transfer path lives in the skill vizen-transfer and the area GET /docs/transfer: folder = level-3 widget in real page DOM, one command per folder (node backend-3D/tools/html-transfer/transfer.mjs ./folder --page <slug>), nothing cut, no path rewritten, and the contract of what the platform does to your file — the wrapper .vz-body-{doc} with data-vz-base, the head, CSS scoping, the shared cascade with the storefront's preflight, scripts re-run after soft navigation, the guarded zones. Two facts stay here because they are about keys inside the folder:
- the scope (
shop,menu,crumbs,product,category,products[]) and everyvz-key of §1–3 work inside a transferred folder exactly as in any other level-3 document — that is how a moved product page shows the real product; - ZIP (
POST /html-documents/{id}/releases/zip?publish=1) creates a release only: the document must exist and the block is mounted by hand; onlytransfer.mjscreates the document and mounts the block.
- One mechanism — the release. Script, ZIP, manual manifest and the admin's "Page code" window all end in a release. "Change this text" on a published page = a partial release, no re-upload of the project: find the document id (
GET /html-documentsby name, orGET /categories/by-slug/{slug}?company_id=…→content_blocks[].sections[].payload.props.html.html_document_id) → read the current clean markup fromGET /html-documents/{id}→result.source.url(the active release'sindex.html, already without head/body) → edit →POST /html-documents/{id}/releases {"base":"active","files":[{"path":"index.html","sha256":"…","size":N}],"note":"…"}→PUTthe bytes toupload_url→POST …/releases/{rid}/publish. Every other file is inherited from the active release; rollback stays (POST …/rollback). One image: the same call with that path; remove:"delete":["path"].index.htmlis never deduplicated (the converter changes it in the release) — normal, not an error. - Full recipe and the folder rules:
/docs/webcoding§2а.
6.2 Site-wide CSS, fonts and scripts — the LAYOUT's folder
⚠️ Site-wide CSS belongs in the layout's folder, not in the header widget. Not because a level-3 header cannot share CSS — it is real page DOM and its stylesheet does reach the whole page (measured on a live shop) — but because the editor canvas loads only the layout's <head>, the layout is linked before the first paint, in the guarded zones (/cart, /checkout, /account…) it is the only stylesheet that still arrives (a widget's relative <link> is dropped there with the scripts), and a header unbound from a section takes its styles with it. A level-1 header keeps its CSS inside its shadow root. So a stylesheet, a font or a script that belongs to the whole site goes into the layout's folder, which lands on every page.
A layout is a group-container (group_type:"theme") that also holds the slots header/feed/body/footer for the pages it covers (vizen-chrome). Its folder is the release mechanism of §6.1 addressed by the layout, not by a document id:
# which layouts the shop has — filter the list yourself, the API has no filter
curl -s -H "Authorization: Bearer $VZ" $API/content-blocks \
| jq '[.result[] | select(.group_type=="theme") | {id,name}] | sort_by(.id)' # earliest = "Основной"
node backend-3D/tools/html-transfer/transfer.mjs ./theme --theme 118 # folder → release → publish
curl -s -H "Authorization: Bearer $VZ" $API/themes/118/site | jq '{base, scope_class, icons_generated}'index.html here is the skeleton of the site, not content: <head> nodes go into the head of every page, the <body> class onto the page root, the body content before </body>; <title>, <base>, <meta http-equiv> and <link rel=manifest> are stripped on publish. Any *.css in the folder has its bare html, body and :root selectors scoped to .vz-theme-{doc} — the class of the page root — so body {} and :root {--brand: …} are the intended way to style the site (compound body.dark is deliberately left on the real body). icon.png (or .jpg/.jpeg/.webp) generates icon-32/180/192/512.png and favicon.ico; your own files under those names are never overwritten.
Endpoints: GET /themes/{id}/site · POST …/site/releases · …/releases/zip · …/releases/{rid}/publish|validate · GET …/site/releases[/{rid}] · POST …/site/rollback. Same scopes and role as §6.1. The folder's service document is invisible in /html-documents and refuses edits (THEME_DOCUMENT_PROTECTED); a bad id is 404 THEME_NOT_FOUND.
Two things to plan around:
- scripts do not run in the guarded zones —
/cart,/checkout,/account,/wishlist,/orders,/deals,/dashboardand anything under them. Nohead_html, nofoot_html, nowindow.VZ_THEME_BASE, and a level-3 header renders without its scripts. Styles and markup stay; - navigation is soft: after every navigation and the first load
documentgetsCustomEvent('vz:navigate', {detail:{path}})— re-init from it. Moving to a page with a different layout is a full reload.
Scripts of the layout read their asset root from window.VZ_THEME_BASE (/_html/{doc}/{rid}/). Files of the ACTIVE layout also have a second, unversioned address — /_site/<path> — for links that outlive a release (an email, a signature, someone else's site); inside the site keep using /_html/{doc}/{rid}/…, which is immutable and cached forever.
The layout's CSS now reaches inside a level-1 shadow root (2026-09-13). A shadow root isolates in both directions, so a level-1 widget used to render "without the theme" on a site that had one. The storefront now puts the layout's stylesheets into the shadow root as well and wraps your markup there in <div class="vz-theme-{doc}">, so the theme's fonts, CSS variables and body {} / :root {} rules apply — while your own rules still win, because the theme comes first. Scripts of the layout are not injected into the shadow (it is drawn in the guarded zones too), and a level-1 header still keeps its OWN CSS to itself. Full recipe: /docs/own-markup §7.2, /docs/webcoding §2б.
6.3 A component with parameters — the same markup on many pages
Ask one question before you write the markup: is this needed more than once? If the answer is "on five pages, with a different heading and picture each time", an html widget is the wrong object — you would be copying code, and the sixth edit would be six edits. That case is a component: the same folder and the same releases, plus a schema of parameters. The owner fills the values per insertion and never touches the code; you publish a new release and all five insertions change at once. Full area: GET /docs/components.
The folder is the html folder plus one file. index.html, CSS, JS as always, and component.json beside them — publishing a component release without it is refused.
{ "name": "Promo banner",
"params": [
{ "key": "title", "type": "text", "label": "Heading", "default": "Sale", "max": 80 },
{ "key": "image", "type": "image", "label": "Picture" },
{ "key": "sale", "type": "bool", "label": "Show the badge", "default": false },
{ "key": "size", "type": "select", "options": ["s", "m", "l"], "default": "m" } ] }If you know Shopify theme sections, you already know this file: component.json is {% schema %}, params is settings, and the type names map one to one (text→text, richtext→html, image_picker→image, url→link, checkbox→bool, select→select, number/range→number, color→color). product/collection have no counterpart yet — a component's parameters are values, not queries.
In the markup the parameter is `{{key}}`, and a bool also opens a block:
<section class="promo">
<h2>{{title}}</h2>
{{#sale}}<span class="badge">-30%</span>{{/sale}}
<img src="{{image}}" alt="">
</section>Substitution is typed and server-side: text is escaped, html goes through the level-1 sanitiser, link/image are checked against a whitelist of schemes, a select outside options and a color that is not #hex become empty. There is no raw {{ }} on the storefront, and a key that is not in the schema prints nothing — both in the markup and in the values of an insertion.
TypeScript is allowed in the folder. Put app.ts next to index.html and point the tag at the compiled neighbour (<script type="module" src="app.js">) — publishing compiles every *.ts into a sibling *.js and keeps the source, so the next release can be built from base=active. Nothing is bundled: every import must resolve to a file of the folder, written with the extension the browser will fetch ("./util.js", never "./util.ts"). No .tsx, no npm imports, no source maps. A refusal is COMPONENT_TS_ERROR with file:line:col, or COMPONENT_TS_CONFLICT when a hand-written app.js already sits next to app.ts. And remember §4: at level 1 the sanitiser strips <script> from the page anyway — live code needs a level-3 document, exactly as an html project does.
# create the document, upload the folder, publish and see the parsed schema
node backend-3D/tools/html-transfer/transfer.mjs ./banner --component --name "Promo banner"Putting it on a page is an ordinary section — the values live in the insertion, never the markup:
{ "type": "text", "payload": { "v": 2, "kind": "component",
"props": { "component": { "ref": 42, "params": { "title": "Autumn", "sale": true } } } } }ref is the document id; params is a flat {key: value} object. Written at any other nesting level (props.params, ref next to kind) the section renders with defaults only and answers 200 — read the page, not the status code.
Shipping a new version changes every insertion. Upload a new release and publish: insertions store values, not markup. A value whose key disappeared from the new schema is ignored, a parameter an insertion never set falls back to the new default, and POST /html-documents/{id}/rollback brings back the previous release *together with its own schema*. You never read component.json through the gateway to learn what a component takes: the parsed schema comes back on the document (manifest, base64 of the raw JSON, next to active_release_id) and on each release (manifest as a plain object). active_release_id is the honest test for "can this be placed yet" — no active release, no markup and no schema.
7. Before you finish
vizen-troubleshoot holds the acceptance checklist. The minimum: untouched pages unchanged, both navigation paths work, text present in raw HTML, cart accepts an item.
Исходник: https://api.vizen.shop/docs/skill/vizen-own-markup