# Site files: full reference

> **Резюме по-русски.** Полный справочник области «Файлы сайта»: объекты и
> ручки, язык шаблона и функции, рецепты (YML, Google XML, CSV, `ads.txt`,
> верификация, страницы, ссылки по id), отдача и кэш, участие товара в
> рекламе, молча игнорируемое, чек-листы, машинный блок лимитов (§11).
> Короткий вход в область — `/docs/feeds`; раздел отсюда берётся отдельно:
> `GET /docs/feeds-reference?section=<slug>`.

**Status:** draft · **Verified:** 2026-09-24 against the code of branch
`feature/site-files-feeds` (incl. nesting under categories, sitemap flag and
security headers): unit tests of the engine and the raw route,
integration tests on PostgreSQL (`TestSiteFile*`, `TestAdParticipation*`,
golden YML and Google XML); no external run on a live shop yet · **Owner:**
site files line (ФИДЫ-РЕКЛАМЫ)
**Serves:** `GET /docs/feeds-reference`

The short entry to this area — what site files are, the rules and a quick
start — is `GET /docs/feeds`. This document is the whole reference; read one
section at a time with `?section=<slug>` (a wrong slug answers 404 with the
list of slugs), e.g. `GET /docs/feeds-reference?section=5-4-functions`.

## 1. What you can do here

Keep files that live on the **shop's storefront host** — on its root or under
**any category of the site tree** (product, page or news category) — and are
fetched by robots (and, for a simple `.html` page, by people):

- a **product feed** for Yandex Direct / Webmaster / Yandex Products
  (`/yandex-feed.yml`), for Google Merchant Center (`/google-feed.xml`), a
  **CSV** or **JSON** export for a partner;
- **`/ads.txt`**, a **webmaster verification file** (`/yandex_1a2b3c.html`,
  `/google1a2b3c.html`), any small `.txt`;
- a **simple `.html` page** under a section (`/sofas/delivery-terms.html`),
  optionally listed in `/sitemap.xml` (`in_sitemap`). **Scripts in site files
  never run** (§7.2): a page that needs JavaScript is an HTML widget, not a
  site file.

A file is `{category_id, name, body, is_published, in_sitemap}`: where it
lives is the category (`0` — the host root) and the file name; its full
`path` is computed by the platform. The body is a Go `text/template`
(§5): plain text without `{{ }}` is served as it is, a template is rendered on
the **live catalogue** at the moment a robot asks for it. Nothing is exported
or uploaded anywhere — you give the ad system the URL.

| What | Value |
|---|---|
| Place | `category_id` — `0` (or absent) for the host root, otherwise a live category of this shop of **any** type; `name` — the file name, the last path segment |
| Name | `[A-Za-z0-9._-]{1,120}` + `.xml`, `.yml`, `.csv`, `.txt`, `.json` or `.html` (extension lowercase), no slashes |
| Path | computed, read-only: the category's canonical path (the same chain of slugs as its page URL and the sitemap) + `/` + `name`; at the root `/` + `name`. The home category **is** the root: a file placed under it is stored with `category_id` `0` |
| Format | chosen by the extension: it sets `Content-Type` and the escaping of values (§5.5) |
| Reserved | at the **root only**: `robots.txt`, `sitemap.xml`, `yandex-token.html`, `favicon.*`, `icon*`, the platform's IndexNow key file — the platform serves them itself. `/ads.txt` and verification files **are allowed**; under a category every valid name is allowed |
| Category deleted | the file stays, is listed with `orphaned: true`, empty `path` and `url`, and answers **404**; move it (`PATCH {category_id}`) or delete it |
| Publication | `is_published: false` — stored, previewable, **404** on the host |
| Who | the shop **owner or an administrator** only (an editor gets `SITE_FILES_FORBIDDEN` whatever the key's scopes) |
| Contour | production only: files have no draft copy; a dev key reads, a write answers `PAT_CONTOUR_MISMATCH` |
| Tariff | `site_files_max` — live files per shop: **Free 5, Pro 50**; a plan without the limit is unlimited |

The OpenAPI of this area alone is `GET /openapi.json?scope=site-files`;
`capabilities.site_files_read` / `site_files_write` in `GET /v1/account/token`
say what this key may call.

## 2. Decide first (forks)

| If you want… | Take this path | Cost |
|---|---|---|
| a Yandex feed (Direct, Webmaster, Yandex Products) | template `yandex_yml` from `GET /site-files/templates` | none; Yandex Direct also accepts a Google XML feed |
| a Google Merchant Center feed | template `google_xml` | the format works wherever Merchant Center is available to the advertiser (Google Ads is paused for Russia-based advertisers since 2022) |
| to keep **one product** out of **every** feed | `PUT /products/{id}/ad-participation {"mode":"exclude"}` (§8) | `catalog:write`; the storefront is not affected |
| to keep a **category** out of **one** feed | `products "exclude_category=slug"` in that template (§5.2) | per file; other feeds still carry it |
| a feed of part of the catalogue | `products "category=slug-a,slug-b"` | a slug that later disappears breaks the feed (§7, §9) |
| other pictures in ads than on the card | a gallery named e.g. `Feed` on the product, `pictures . 5 "Feed"` (§6.6) | one more gallery per product |
| to know the template works | `POST /site-files/preview` before saving | **saving checks syntax only**: a wrong category slug or option is found by preview or by the robot |
| `ads.txt` or a verification file | the same object at the root, body without `{{ }}` (§6.7, §6.8) | the `.txt` is served as is; publish the file or it answers 404 |
| a feed or file at a section's address (`/sofas/feed.yml`) | `category_id` of that section + `name` | the address follows the section's slugs: renaming a slug moves the file's URL |
| a simple page (terms, a landing text) | an `.html` file, `in_sitemap: true` to list it (§6.9) | **no scripts** (CSP sandbox); interactive pages are HTML widgets |

## 3. Quick start: a Yandex feed in three requests

```bash
API=https://api.vizen.shop
T="Authorization: Bearer vz_pat_…"          # owner/admin key with site-files:read + site-files:write
J="Content-Type: application/json"

# 1. The reference template (the same body the cabinet offers)
BODY=$(curl -s "$API/site-files/templates" -H "$T" | jq -r '.items[] | select(.key=="yandex_yml") | .body')

#    optional, and worth it: render it on 20 products of your live catalogue
jq -n --arg b "$BODY" '{path:"/yandex-feed.yml", body:$b, limit:20}' \
  | curl -s -X POST "$API/site-files/preview" -H "$T" -H "$J" -d @- | jq '{items_count, truncated, errors}'
# → {"items_count": 20, "truncated": false, "errors": []}

# 2. Create it published (at the root; add "category_id" to put it under a section)
jq -n --arg b "$BODY" '{name:"yandex-feed.yml", body:$b, is_published:true}' \
  | curl -s -X POST "$API/site-files" -H "$T" -H "$J" -d @- | jq '{id, path, url, row_version}'
# → {"id": "12", "path": "/yandex-feed.yml", "url": "https://myshop.vizen.shop/yandex-feed.yml", "row_version": "1"}

# 3. Fetch it the way Yandex will
curl -s -D - "https://myshop.vizen.shop/yandex-feed.yml" -o feed.yml
# → 200, Content-Type: application/xml; charset=utf-8, ETag, X-Robots-Tag: noindex
xmllint --noout feed.yml && grep -c '<offer ' feed.yml
```

Verify: the offer count equals the number of published, priced products you
expect (§5.2 lists what is always left out); open two `<url>`s and compare the
price on the page with `<price>`. Then give the URL to Yandex Direct
(Feeds → Add feed → by link). The site must be **published** (not closed by a
PIN) or the URL answers 404 (§7.1).

## 4. Requests

All paths are on the API base, no `/v1` prefix. Bodies are JSON with
`snake_case` names; the answer is the object itself (no `result` wrapper),
every field present. **64-bit numbers are JSON strings** on output (`id`,
`row_version`, `size`); input accepts `12` and `"12"`. Timestamps are
RFC 3339 strings in UTC. An unknown body field is refused with
`400 invalid request body: <field>`.

Errors come as `{"error": "rpc error: code = <Code> desc = <CODE>[: detail]"}` —
match on the `CODE`, not on the HTTP status.

### 4.1. Objects

**SiteFile** — `GET /site-files/{id}`, create and edit answers:

```json
{
  "id": "12",
  "category_id": "57",
  "name": "yandex-feed.yml",
  "path": "/furniture/sofas/yandex-feed.yml",
  "content_type": "application/xml; charset=utf-8",
  "body": "{{- /*\nYandex Market / Yandex Direct product feed (YML).…",
  "is_published": true,
  "in_sitemap": false,
  "orphaned": false,
  "row_version": "3",
  "url": "https://myshop.vizen.shop/furniture/sofas/yandex-feed.yml",
  "created_at": "2026-09-24T10:00:00Z",
  "updated_at": "2026-09-24T10:05:12Z",
  "size": "1843"
}
```

- `category_id` — `"0"` for a file at the host root; `name` — the file name;
  `path` — computed from them (§1), read-only. A path you cannot compute
  yourself is not needed: the answer always carries it.
- `in_sitemap` — only meaningful for a published `.html` file: it is then
  listed in `/sitemap.xml` (§7.4); other formats are never listed.
- `orphaned: true` — the category was deleted: `path` and `url` are `""` and
  the file answers 404 until you move or delete it.

- `row_version` grows on **every** change (edit, publish switch, delete); send
  it back as `know_version` to edit safely.
- `url` is the address on the shop's **active custom domain**, otherwise on its
  `<slug>` host; `""` when the host is not known or the file is orphaned. Both
  hosts serve the file (§7.1).
- `size` is the body length in bytes; `updated_at` equals `created_at` for a
  file never edited.

**SiteFileListItem** — the same without `body`.

### 4.2. Endpoints

| Method and path | Scope | What it does |
|---|---|---|
| `GET /site-files` `?category_id=` `&q=` | `site-files:read` | `{"items": [SiteFileListItem…]}`, ordered by category, then name (case-insensitive); deleted files are not listed. `category_id=0` — root files only, `category_id=N` — files of category N only (not of its subcategories), absent — all files. `q` (≤ 200 characters) — case-insensitive substring of the name **or** the full path (`q=feed`, `q=/sofas/`), combined with `category_id`; empty — no filter |
| `GET /site-files/templates` | `site-files:read` | `{"items": [{key, name, path, body}]}` — `yandex_yml` (`/yandex-feed.yml`), `google_xml` (`/google-feed.xml`), `empty` (`/file.txt`, empty body) |
| `GET /site-files/{id}` | `site-files:read` | one file with its body |
| `POST /site-files/preview` `{path, body, limit}` | `site-files:read` | renders a body **without saving** (§4.3) |
| `POST /site-files` `{category_id?, name, body, is_published?, in_sitemap?}` | `site-files:write` | creates a file; `category_id` absent or `0` — the root; `is_published` and `in_sitemap` default to `false`. The place is given only as `category_id` + `name`; `path` exists only in answers (computed) |
| `PATCH /site-files/{id}` `{category_id?, name?, body?, is_published?, in_sitemap?, know_version?}` | `site-files:write` | partial edit: a field you do not send is left alone. **Moving** is `category_id` (`0` — to the root); renaming is `name`; both are checked against the final place (reserved names at the root, a taken name in the target category) |
| `DELETE /site-files/{id}` | `site-files:write` | soft delete: the place is freed at once, the URL answers 404; no body needed; answers `{}` |
| `GET /products/{id}/ad-participation` | `catalog:read` | `{"mode": "include"}` or `"exclude"` (§8) |
| `PUT /products/{id}/ad-participation` `{mode}` | `catalog:write` | include or exclude the product from every feed |

Every call is private to the key's shop: another shop's file answers like a
missing one. Ad participation uses the same owner/admin gate as files.

**Safe edit (`know_version`).** Read the file, keep `row_version`, send it
back:

```bash
curl -s -X PATCH "$API/site-files/12" -H "$T" -H "$J" \
  -d '{"body":"google.com, pub-0000000000000000, DIRECT, f08c47fec0942fa0\n","know_version":"3"}'
```

If someone saved in between, the answer is
`409 SITE_FILE_VERSION_CONFLICT` and nothing is written: read again, merge,
retry. `know_version` absent or `0` writes without the check. Renaming is
`{"name": "new-name.yml"}`, moving is `{"category_id": "57"}` or
`{"category_id": "0"}` for the root — the old URL answers 404 from that
moment.

### 4.3. Preview

`POST /site-files/preview {"path":"/yandex-feed.yml","body":"…","limit":20}`
renders exactly as the host would — the live production catalogue, seen as an
anonymous buyer, same prices, same escaping — with three differences:

- **`limit`** (0..50, `0` → 20) caps the products of **every** `range
  products` separately; a `limit=` option in the template larger than that is
  cut to it. `limit` > 50 → `400`.
- **`output`** is cut to **200 KB** (on a UTF-8 character boundary) and
  `truncated: true` is set; the render itself stops at 4 MB.
- **Errors do not fail the call.** A syntax or runtime error answers `200`
  with `errors: [{"line": 3, "message": "function \"picture\" not defined", "code": "SITE_FILE_TEMPLATE_INVALID"}]`
  and `output` holding what was produced before the error. `line` is 1-based,
  `0` when unknown. A render over the preview time limit answers the same way
  with `"code": "SITE_FILE_PREVIEW_TIMEOUT"` (line `0`). Match on `code`, never
  on the English `message`. Only a transport or database failure fails the
  request.

```json
{"output": "<?xml version=\"1.0\"…", "truncated": false, "items_count": 20, "errors": []}
```

`items_count` is the number of **distinct** products the template received.
`path` sets the format — a full path (`/sofas/feed.yml`) or just a name
(`feed.yml`); its last segment must match the name grammar (a reserved name
is fine here); nothing is stored, the file need not exist. Preview has its own
budget per shop — **30 calls a minute** — and stops a render after 10 s. A shop
renders **at most 2 files at a time** (previews and builds for robots
together); a preview that cannot get a slot within 3 s is refused the same
way. Both refusals are `429 SITE_FILE_PREVIEW_RATE_LIMITED` with a
`Retry-After` header and `{"code": "SITE_FILE_PREVIEW_RATE_LIMITED",
"retry_after": N}` in the body. If the client disconnects, the render stops.

### 4.4. Refusals

| Code | HTTP | When |
|---|---|---|
| `SITE_FILE_NAME_INVALID` | 400 | `name` outside the grammar (§1), empty, or with a slash; the text repeats the rule |
| `SITE_FILE_CATEGORY_NOT_FOUND` | 404 | `category_id` is not a live category of this shop (deleted, another shop's, never existed) |
| `SITE_FILE_PATH_RESERVED` | 400 | a platform name **at the root** (§1) |
| `SITE_FILE_PATH_TAKEN` | 409 | a live file with that name exists **in the same category** (or at the root) — compared **case-insensitively** (`Feed.xml` = `feed.xml`) |
| `SITE_FILE_TEMPLATE_INVALID` | 400 | the body does not parse: `SITE_FILE_TEMPLATE_INVALID: line 12: function "picture" not defined`. Unknown function names are caught here; unknown **fields** and option values only at render time |
| `SITE_FILE_BODY_TOO_LARGE` | 400 | body over 65 536 bytes (proto validation usually answers first, with the field name) |
| `SITE_FILE_BODY_INVALID` | 400 | body is not UTF-8 |
| `SITE_FILE_VERSION_CONFLICT` | 409 | `know_version` ≠ the current `row_version` |
| `SITE_FILE_NOT_FOUND` | 404 | no such file in this shop, or deleted |
| `SITE_FILE_LIMIT_REACHED` | 429 | the plan's `site_files_max` is used up (counted in the same locked transaction as the insert, so parallel creates cannot overshoot); no `Retry-After` — retrying does not help, delete a file or change the plan; the body carries `"code": "SITE_FILE_LIMIT_REACHED"` and `"limit_code": "site_files_max"` (every tariff refusal names its limit this way) |
| `SITE_FILE_PREVIEW_RATE_LIMITED` | 429 | more than 30 previews a minute for the shop, or the shop's render slots are busy (§4.3); `Retry-After` and `retry_after` in the body are set |
| `SITE_FILES_FORBIDDEN` | 403 | the key's user is not the owner or an administrator of the shop |
| `PRODUCT_NOT_FOUND` | 404 | ad participation of a product that does not exist or belongs to another shop |
| `PAT_SCOPE_MISSING` | 403 | the key lacks the scope in §4.2 |
| `PAT_CONTOUR_MISMATCH` | 403 | a dev-contour key tried to write (create, edit, delete, set ad participation) |
| `DEV_MODE_UNAVAILABLE` | 400 | the same write from a cabinet session switched to the dev contour |

Proto validation (`name` longer than 130 characters, a negative
`category_id`, `mode` not `include|exclude`, `limit` > 50) answers 400 with
the field name.

## 5. The template language

Go `text/template` (`{{ }}` actions, `if`, `range`, `with`, `else`, `define`,
variables, `eq`/`ne`/`lt`/`gt`, `and`/`or`/`not`, `len`, `index`, `printf`)
plus eight platform functions. No file system, no network, no raw output.

Work is bounded, whatever the template does: a `{{ template }}` that calls
itself, directly or through other `define`s, is refused at save
(`SITE_FILE_TEMPLATE_INVALID: … calls itself (recursion is not allowed)`); a
render stops after 30 s, 50 MB of output or 20 000 000 steps (a step is
every `range` iteration, `define` call and `if`/`with` branch —
`the template does too much work`); a value built by `printf`, `print`,
`println`, `html`, `js` or `urlquery` may not exceed 1 MB, and a `printf`
width or precision above 1000 or `*` is an error.

### 5.1. Data

The root holds one object: **`.Shop`**.

| Field | Value |
|---|---|
| `.Shop.Name` | shop name |
| `.Shop.URL` | scheme + host the file is served from, no trailing slash (`https://myshop.vizen.shop`) |
| `.Shop.Currency` | shop currency code (`RUB` when not set) |

Inside `range` the dot is the element: reach the shop as `$.Shop.Name`.

### 5.2. `products` — the offers

```
{{ range products }} … {{ end }}
{{ range products "category=sofas,armchairs" "exclude_category=outlet" "limit=500" }} … {{ end }}
```

Options are separate strings `name=value`:

| Option | Meaning |
|---|---|
| `category=a,b` | only these categories **and their subcategories**, by slug (case-insensitive) or id; a product counts when its main **or** an extra category matches — the storefront's own rule |
| `exclude_category=a,b` | minus these categories and their subcategories (main or extra category) |
| `limit=N` | at most N products (positive); never more than 50 000 per `range` |
| `include_zero_price=true` | keep products whose price is 0 (skipped by default) |

An unknown option, a malformed value or a category that is not in the shop is
an **error at render time** with the line number — a typo never silently
returns the whole catalogue or an empty feed.

**Always left out, whatever the options:** unpublished products, products
hidden from the catalogue (`catalog_hidden`), products an anonymous visitor
may not see (access policies, closed sections), products that the storefront
listing hides itself (items shown only inside a set), products **excluded from
ads** (§8), and products with price 0 (unless `include_zero_price=true`). The
list is exactly what the storefront grid shows an anonymous visitor.

Order: by creation time, oldest first — new products are appended, the feed
stays stable between builds. With `category=a,b` the categories are walked in
the given order and a product appears once. The same selection is evaluated once
per render: calls that differ only in letter case, spaces, option order,
repeated categories or a smaller `limit` reuse the list already loaded. A
render may make at most **20 different selections** — the 21st is an error with
its line (`products: more than 20 different product selections…`); load once
into a variable (`{{ $all := products }}`) and reuse it.

**Product fields**

| Field | Value |
|---|---|
| `.ID` | product id (number) — stable, use it as offer id |
| `.Name` | product name |
| `.Description` | the description **as stored**; markup in it comes out escaped, as text |
| `.URL` | absolute canonical product page URL on the served host; `/product/{id}` when the product has no clean path (the storefront redirects it to the canonical one) |
| `.SKU` | article / vendor code, may be `""` |
| `.Price` | the **storefront listing price** for an anonymous visitor, whole units of the currency (§7.3) |
| `.OldPrice` | the crossed-out price, only when higher than `.Price`; `0` — none |
| `.Currency` | currency code of the price |
| `.CategoryID` | the product's main category — or, if that category is not in `categories` (unpublished, the home page, not a product category), its **nearest ancestor that is**; `0` when there is none |
| `.CategoryName` | name of that category |
| `.CategoryPath` | `Parent > Child` names down to that category |
| `.Rating`, `.RatingCount` | average rating (decimal) and number of ratings |
| `.Kind` | `standard`, `combo` or `multi` |

### 5.3. `categories`

`{{ range categories }}` — the shop's categories a feed may name: **published,
product-type, not the home page, visible to an anonymous visitor**, in tree
order (a parent always before its children).

| Field | Value |
|---|---|
| `.ID` | category id |
| `.ParentID` | the nearest ancestor **that is also in this list**; `0` — top level |
| `.Name`, `.Slug` | name, slug |
| `.URL` | absolute URL of the category page |

Because of the fallback in `.CategoryID`, every non-zero `.CategoryID` of a
product is an `.ID` in this list — `<categoryId>` never points to a category
the feed does not declare.

### 5.4. Functions

| Call | Returns |
|---|---|
| `attr . "code"` | the value of the attribute with that code, **product first, code second**. Only attributes visible to buyers in the catalogue list come back; hidden, internal and restricted ones return `""`. Several values are joined with `", "`. Missing — `""`: wrap in `with` |
| `pictures . N ["Gallery"] ["w=800"]` | up to N absolute picture URLs of the product. The optional strings go in any order: a gallery name (case-insensitive) and `w=` width 1..2560. Without a name — the product's **first gallery**; a name no gallery has — also the first gallery, silently; no galleries — the listing preview picture. Without `w=` — the original file (CDN URL); with `w=` — the storefront resizer `/w/<width>/webp/…` on the served host, i.e. **WebP** (files outside the shop's storage come back unchanged) |
| `now "layout"` | current time in **UTC**, Go layout (`"2006-01-02 15:04"`); `""` — RFC 3339 |
| `truncate s N` | first N **characters** (not bytes), no ellipsis added; counted before escaping |
| `default FALLBACK VALUE` | `VALUE` unless it is empty (`""`, `0`, `false`, empty list), else `FALLBACK`. Sprig order, so it works in a pipeline: `{{ .SKU \| default "n/a" }}`, `{{ attr . "brand" \| default $.Shop.Name }}` |
| `plain s` | `s` as plain text: HTML tags removed (block tags and `<br>` leave a space, `<script>`/`<style>` go with their content), entities decoded (`&amp;` → `&`, `&nbsp;` → space), runs of whitespace and line breaks collapsed to one space. The result is then escaped for the file's format like any value. Use it for descriptions: Google wants plain text, and Yandex accepts HTML only inside CDATA. Combine: `{{ truncate (plain .Description) 3000 }}`, `{{ default .Name (plain .Description) }}` |
| `file_url ID` | the full URL of the shop's site file `ID` on the served host: `https://<host>/<its current path>`. The path is taken **at render time** from the file's current category and name — move or rename the target and the next build follows it (§7.2) |
| `file_path ID` | the same path without the host: `/catalog/feed.yml` |
| `category_url ID` | the canonical absolute URL of a **published** category (the same as `.URL` in `categories` and in `/sitemap.xml`); the home category — the host root |
| `product_url ID` | the canonical absolute URL of a product an anonymous visitor sees in the catalogue (the same as `.URL` in `range products`) |
| `products …`, `categories` | §5.2, §5.3 |

`attr` and `pictures` need a product as the first argument (`.` inside
`range products`); anything else is a render error.

The link functions take an id as a number or a string of digits (`15`, `"15"`,
`.ID`). A target that does not exist, belongs to another shop, is deleted or
**unpublished** — a file whose category was deleted or closed by an access
policy, too; a hidden or unpublished category; a product an anonymous visitor
cannot see — is a render **error** with the line (`file_url: file 15 not found
or not published`), not an empty string: a broken link must show in preview,
not in the ad system. One build asks for at most **1000 different** link
targets (repeats are free); inside `range products` use `.URL`.

### 5.5. Escaping is automatic — write only the constant markup

Every value that leaves an action `{{ … }}` is escaped for the file's format —
fields, function results, `printf` output, variables, even string constants.
Literal text of the template is never touched. There is no way to print a
value raw.

| Extension | What happens to a value |
|---|---|
| `.xml`, `.yml` | `& < > " '` become entities; characters illegal in XML 1.0 (control characters) are removed |
| `.html` | HTML entities |
| `.csv` | RFC 4180: a value with `,` `;` `"` a line break, a tab or an edge space is put in quotes, inner quotes doubled. **Do not write quotes around `{{ }}` yourself** — they would be doubled. Formula guard: a text value starting with `=` `+` `-` `@`, a tab or a carriage return gets a leading `'` (`=SUM(A1)` → `'=SUM(A1)`), so Excel and Google Sheets show it as text instead of running it; numbers (`-5`) are printed as they are |
| `.json` | the inside of a JSON string, **without** the quotes: write `"name": "{{ .Name }}"`; numbers without quotes: `"price": {{ .Price }}` |
| `.txt` | as is |

Numbers and booleans are printed as they are in every format. Consequences:

- literal markup is yours: write `&amp;` in constant XML text, or let `printf`
  build the string — `{{ printf "%s?utm_source=yandex&utm_medium=cpc" .URL }}`
  prints `…?utm_source=yandex&amp;utm_medium=cpc`;
- **no CDATA** around values: the escaped text inside `<![CDATA[ ]]>` would
  reach the parser with the entities undecoded. Escaped text is what any XML
  parser reads as the original string;
- the built-ins `html`, `js`, `urlquery` escape a second time — do not use them;
- `printf` verbs `%#v`, `%T` and `%p` are refused (render error with the
  line): they print internal type names and memory addresses. Print a field
  (`.Name`) instead.

### 5.6. Errors

At **save**, the body must parse (syntax, known function names) —
`SITE_FILE_TEMPLATE_INVALID` with the line. At **render** (preview, or a robot
fetching the URL) the rest is checked: fields (`can't evaluate field Nope`),
option values, categories, argument types, link targets (`file_url` and
friends). In preview they come back in
`errors[]` with the line; on the host the URL answers an error (§7.1). So:
**preview after every edit, and again after renaming or deleting a category
your template names**.

## 6. Recipes

Each recipe is a body for `POST /site-files` (or `PATCH`). Verify each with
preview first (`errors: []`), then fetch the URL.

### 6.1. Yandex YML with UTM tags, parameters and collections

`<collections>` are category pages as ad landing pages (Direct's combined
format); `<collectionId>` links an offer to its page. The data has no category
picture — add `<picture>` to a collection only if you have one.

```
<?xml version="1.0" encoding="UTF-8"?>
<yml_catalog date="{{ now "2006-01-02 15:04" }}">
<shop>
<name>{{ .Shop.Name }}</name>
<company>{{ .Shop.Name }}</company>
<url>{{ .Shop.URL }}</url>
<currencies><currency id="{{ .Shop.Currency }}" rate="1"/></currencies>
<categories>
{{- range categories }}
<category id="{{ .ID }}"{{ if .ParentID }} parentId="{{ .ParentID }}"{{ end }}>{{ .Name }}</category>
{{- end }}
</categories>
<offers>
{{- range products }}
<offer id="{{ .ID }}" available="true">
<name>{{ .Name }}</name>
<url>{{ printf "%s?utm_source=yandex&utm_medium=cpc&utm_campaign=feed" .URL }}</url>
<price>{{ .Price }}</price>
{{- if .OldPrice }}
<oldprice>{{ .OldPrice }}</oldprice>
{{- end }}
<currencyId>{{ .Currency }}</currencyId>
{{- if .CategoryID }}
<categoryId>{{ .CategoryID }}</categoryId>
<collectionId>{{ .CategoryID }}</collectionId>
{{- end }}
{{- range pictures . 5 }}
<picture>{{ . }}</picture>
{{- end }}
{{- with .SKU }}
<vendorCode>{{ . }}</vendorCode>
{{- end }}
<description>{{ truncate (plain .Description) 3000 }}</description>
{{- with attr . "color" }}
<param name="Color">{{ . }}</param>
{{- end }}
{{- with attr . "material" }}
<param name="Material">{{ . }}</param>
{{- end }}
</offer>
{{- end }}
</offers>
<collections>
{{- range categories }}
<collection id="{{ .ID }}">
<url>{{ .URL }}</url>
<name>{{ .Name }}</name>
</collection>
{{- end }}
</collections>
</shop>
</yml_catalog>
```

Replace `color` / `material` with your attribute codes (`GET /attributes`,
`/docs/catalogue`); Yandex takes up to 10 `<param>` per offer. Direct can also
strip UTM tags itself (an option when adding the feed).

### 6.2. Google Merchant Center XML

```
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">
<channel>
<title>{{ .Shop.Name }}</title>
<link>{{ .Shop.URL }}</link>
<description>Products of {{ .Shop.Name }}</description>
{{- range products }}
<item>
<g:id>{{ .ID }}</g:id>
<g:title>{{ truncate .Name 150 }}</g:title>
<g:description>{{ truncate (default .Name (plain .Description)) 5000 }}</g:description>
<g:link>{{ .URL }}</g:link>
{{- range $i, $p := pictures . 11 }}
{{- if eq $i 0 }}
<g:image_link>{{ $p }}</g:image_link>
{{- else }}
<g:additional_image_link>{{ $p }}</g:additional_image_link>
{{- end }}
{{- end }}
<g:availability>in_stock</g:availability>
<g:condition>new</g:condition>
{{- if .OldPrice }}
<g:price>{{ .OldPrice }} {{ .Currency }}</g:price>
<g:sale_price>{{ .Price }} {{ .Currency }}</g:sale_price>
{{- else }}
<g:price>{{ .Price }} {{ .Currency }}</g:price>
{{- end }}
<g:brand>{{ truncate (attr . "brand" | default $.Shop.Name) 70 }}</g:brand>
<g:identifier_exists>no</g:identifier_exists>
{{- with .CategoryPath }}
<g:product_type>{{ . }}</g:product_type>
{{- end }}
</item>
{{- end }}
</channel>
</rss>
```

With a crossed-out price, `g:price` is the old price and `g:sale_price` the
current one — Google's own rule. If your products carry GTINs as an attribute,
output `<g:gtin>` from `attr` and drop `identifier_exists`.

### 6.3. CSV for a partner

Path `/partner-prices.csv`. No quotes in the template — values that need them
get them.

```
id,name,price,old_price,url,picture,category
{{ range products }}{{ .ID }},{{ .Name }},{{ .Price }},{{ .OldPrice }},{{ .URL }},{{ range pictures . 1 }}{{ . }}{{ end }},{{ .CategoryPath }}
{{ end }}
```

A name `Sofa "Oslo", grey` comes out as `"Sofa ""Oslo"", grey"`. Put the
header and every row on its own line; `{{ end }}` right after the line break
keeps one row per product.

### 6.4. A/B feeds with different UTM tags

Two files, the same body except the tag — each is its own URL in Direct:

```
/yandex-a.yml   <url>{{ printf "%s?utm_source=yandex&utm_content=a" .URL }}</url>
/yandex-b.yml   <url>{{ printf "%s?utm_source=yandex&utm_content=b" .URL }}</url>
```

Both count toward `site_files_max`. Offer ids stay the product ids in both —
that is what Yandex expects of one product across feeds.

### 6.5. A feed per category

```
{{- range products "category=sofas" }} … {{ end }}
```

`categories` still lists the whole tree — that is valid YML. Name categories
by slug for readability or by id to survive a slug change; renaming a slug
the template uses breaks the feed until you edit it (preview shows
`… "sofas" is not a category of this shop`).

### 6.6. Pictures for ads that differ from the card

1. Create a gallery named `Feed` with the ad pictures (`POST /galleries`,
   `/docs/catalogue` §7) and link it to the product **after** its storefront
   gallery: `PUT /products/{id} {"item":{"gallery_ids":["<main>","<feed>"],
   "gallery_ids_replace":true}}` — the first gallery stays the card's.
2. In the template: `{{ range pictures . 5 "Feed" }}<picture>{{ . }}</picture>{{ end }}`.

A product without a `Feed` gallery falls back to its first gallery — the
feed never loses its pictures. Add `"w=1200"` for resized WebP copies; the
originals (no `w=`) are safest when you do not control their size (Yandex
wants ≥ 450 px on the short side).

### 6.7. `ads.txt`

Path `/ads.txt`, body — the lines your ad network gave you, as they are:

```
google.com, pub-0000000000000000, DIRECT, f08c47fec0942fa0
```

`.txt` is not escaped. Publish it; verify `curl https://<host>/ads.txt`.

### 6.8. Webmaster verification file

Verification files live at the **root** (`category_id` `0`) — that is where
Yandex and Google look.


Yandex Webmaster offers an HTML file: path `/yandex_<code>.html`, body
exactly what it shows, e.g.

```
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"></head><body>Verification: <code></body></html>
```

Google Search Console: path `/google<code>.html`, body
`google-site-verification: google<code>.html`. Literal text is served
unchanged, and `.html` files carry no `X-Robots-Tag` — the verifying robot
reads them. Publish, fetch the URL (200, body as you wrote it), then press
"Verify". Unpublished, the address answers 404.

### 6.9. A simple page under a section

`POST /site-files {"category_id": "57", "name": "delivery-terms.html",
"is_published": true, "in_sitemap": true, "body": "…"}` — a static HTML page
at `/furniture/sofas/delivery-terms.html`, listed in `/sitemap.xml`. Values
from `{{ }}` are HTML-escaped. The page is served with
`Content-Security-Policy: sandbox`: **scripts, forms and pop-ups do not
work** and the page has no access to the shop's cookies. Anything interactive
— a calculator, a form, a slider — belongs in an HTML widget
(`/docs/webcoding`), not in a site file.

### 6.10. Links between files: an index file

A sitemap-like list of your feeds for a partner, or a page that links to
another file. Link **by id**, never by a written path — the link survives
moving and renaming the target:

`feeds.xml` at the root — an index of the shop's feeds (files 12 and 14):

```
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap><loc>{{ file_url 12 }}</loc></sitemap>
  <sitemap><loc>{{ file_url 14 }}</loc></sitemap>
</sitemapindex>
```

In an `.html` file: `<a href="{{ file_path 14 }}">Price list (CSV)</a>`,
`<a href="{{ category_url 57 }}">Sofas</a>`. The ids are the `id` of
`GET /site-files` (and of categories and products).

When file 14 moves to another category, the index is rebuilt with the new
address within about 15 seconds (§7.2). When file 14 is unpublished or
deleted, the index fails to build and the host keeps serving its last good
copy (§7.1) — preview shows the error; remove the line.

Outside site files (HTML widgets, components, HTML blocks, a menu item) link
to a file by its **permanent address** `/_file/<id>` (§7.1) — it too survives
moves.

## 7. How serving works

### 7.1. The URL and its answers

`https://<host>/<path>` — `<path>` is the file's computed path: the canonical
path of its category (the same slugs as the category page) and the name. The
shop's `<slug>` host and its active custom domain both serve the file; links inside (`.Shop.URL`, `.URL`, category and
resized picture URLs) use the host that was asked. Point the ad system at the
domain it knows the shop by.

| Answer | When |
|---|---|
| **200** | a published file on a live, published site |
| **304** | `If-None-Match` matches the ETag, or `If-Modified-Since` is not older than the build |
| **404** | unknown host; the **dev** (draft) host; the site is closed (unpublished / PIN); a reserved name at the root or an invalid path; the category part of the path is not the canonical path of a live category an anonymous visitor may see (deleted, **closed by an access policy**, a non-canonical chain) — an unpublished or `noindex` category does **not** hide its files, they have their own `is_published`; no such file, deleted or **unpublished**. On such a miss the storefront keeps its old behaviour: an `.html` path that is not a verification file (`yandex_*`, `google*`, …) redirects (308) to the path without `.html`, the IndexNow key file is served, a seller "on miss" redirect rule applies, otherwise 404 `noindex` |
| **200, the last good build** | the render failed (a category it names was deleted, a render over 30 s or 50 MB, the shop's render slots busy for 10 s) **while the file itself was not edited** since that build (same `row_version`) and the build is **not older than 7 days** — the robot gets that last good copy with its old `ETag` and `Last-Modified` (a 304 on a conditional request), so an ad system does not drop your products over a catalogue change. The error is only in the server log; **preview shows it with its line** — check preview after every edit, the served feed does not tell you |
| **502** | a render failure with **no** usable last good build: a new file, **an edited template that fails** (the old copy of a different template is not served — preview protects you), a last build older than 7 days, or the first build after a core restart without the disk cache. The core answers 500 (503 with `Retry-After` when the render slots are busy); the storefront passes both on as 502. Details are only in the server log |
| **504** | the core did not answer within 120 s |

A seller redirect rule on the same path that is set to override live pages
**wins** over the file (those rules run first); an "on miss" rule applies only
when the file is absent or unpublished.

**Permanent address by id.** `https://<host>/_file/<id>` (anything after the
id, `/_file/<id>/whatever.yml`, is ignored) serves the same file wherever it
lives now — link to it from HTML widgets, components, HTML blocks and menus:
the link survives moving and renaming the file. The answers, headers, caching
and 304s are exactly those of the file's own path; a file that is unpublished,
deleted, of another shop or under a deleted / closed category answers 404. The
answer carries `Link: <https://<host>/<current path>>; rel="canonical"`, so a
robot indexes the real path. Behind the storefront it is
`GET /seo/site-file?host=<host>&id=<id>` on the core (`id` wins over `path`).

### 7.2. Headers and caching

| Header | Value |
|---|---|
| `Content-Type` | by extension: `application/xml` (`.xml`, `.yml`), `text/csv`, `text/plain`, `application/json`, `text/html`; all `; charset=utf-8` |
| `ETag` | hash of the body |
| `Last-Modified` | when this copy was built |
| `Cache-Control` | `public, max-age=300` |
| `X-Robots-Tag` | `noindex` for every format except `.html` |
| `X-Content-Type-Options` | `nosniff` — the browser keeps the declared type |
| `Content-Security-Policy` | `sandbox` (no `allow-scripts`) — on **every** answer, `.html` included: scripts, forms and pop-ups in site files never run. Pages with scripts are HTML widgets |
| `Link` | `<https://<host>/<current path>>; rel="canonical"` — the file's current address, on its path and on `/_file/<id>` alike |

A built file is cached **on disk, one copy per file and host** (shop, host,
file), stamped with the file's `row_version` and the catalogue revision; the
body is streamed from disk and a 304 is decided before it is read. Each shop
may take at most a fifth of the cache (never less than two 50 MB files), so one
shop's big feeds do not push out the others'. The copy is rebuilt when:

- **editing the file** changes `row_version` — the next request rebuilds it;
- **a catalogue change** — products, categories, promotions and price rules
  (including their start and end moments), combos, galleries, attributes and
  their visibility, ad participation, shop name, currency and access, **and
  any site file of the shop** (created, edited, moved, renamed, unpublished,
  deleted — so files that link to it with `file_url` follow) — changes the
  revision, noticed **within about 15 seconds**;
- otherwise a copy lives **60 minutes** at most;
- a failed build is remembered for **60 seconds** per (file version, catalogue
  revision): within that window requests get the last good copy (or 502)
  without a new render; after it, or as soon as the file or the catalogue
  changes, the next request tries again. The last good copy is the same cached
  copy, not a second one; it is served for at most 7 days after its build and
  a copy nobody asked for in 7 days is removed;
- one build per file version at a time: robots arriving during a build wait
  for it; if **every** waiting client disconnects, the build is cancelled;
- a path with no published file answers 404 without a database read: the set
  of the shop's published files is remembered for 30 seconds and forgotten at
  once when a file is created, edited or deleted on the same core;
- a client or CDN honouring `max-age=300` may keep its copy up to 5 minutes;
  saving a file purges the storefront host's cache.

Robots re-fetching with `If-None-Match` get a 304 while nothing changed.

### 7.3. Price parity with the storefront

`.Price` is taken from the **same listing call** that draws the storefront
grid, as an anonymous visitor: active promotions, combos priced by their
composition, price rules — byte for byte what the category page shows. A feed
therefore does not disagree with the site, which is the first thing Yandex and
Google moderation checks. Prices of signed-in buyers (personal or group rules)
are not in feeds.

### 7.4. Sitemap

A published `.html` file with `in_sitemap: true` is listed in the host's
`/sitemap.xml` by its full path, `lastmod` — its last edit. Feeds, `.txt`,
`.csv`, `.json` are never listed, whatever the flag. A file of a category an
anonymous visitor cannot see (closed, deleted) is not listed either. The
sitemap is cached up to 15 minutes.

## 8. Ad participation

```bash
curl -s "$API/products/808/ad-participation" -H "$T"
# → {"mode": "include"}
curl -s -X PUT "$API/products/808/ad-participation" -H "$T" -H "$J" -d '{"mode":"exclude"}'
# → {"mode": "exclude"}
```

`exclude` removes the product from **every** file of the shop at the next
build (≤ ~15 s); `include` (the default for every product) brings it back. The
storefront, search and sitemap are not affected. Scopes `catalog:read` /
`catalog:write`, owner or administrator, production only (a dev key writing
gets `PAT_CONTOUR_MISMATCH`). The cabinet shows it as "Participates in ad
feeds" on the product. To exclude a whole category from one feed use
`exclude_category=` in that template instead.

## 9. Silently ignored

| Written | What happens |
|---|---|
| a gallery name in `pictures` that the product does not have | the first gallery is used, no error |
| `attr . "code"` with an unknown, hidden or empty attribute | `""`; the line still prints unless wrapped in `with` |
| `limit=` above 50 000 (or above the preview `limit`) | cut to the ceiling |
| a product with price 0 | not in the feed unless `include_zero_price=true` |
| a sold-out product | still in the feed: the data has **no stock field**, and the reference templates write `available="true"` / `in_stock`. Exclude it (§8) or unpublish it |
| markup in `.Description` without `plain` | printed as escaped text (`&lt;p&gt;…`), not stripped — wrap it in `plain` (the reference templates do) |
| `truncate` | cuts without an ellipsis |
| a seller redirect rule on the file's path, set to override live pages | the redirect wins, the file is never served |
| the same path with other letter case (`/Feed.xml`) | served — file names are case-insensitive; the category part is compared case-insensitively too |
| `in_sitemap: true` on a non-`.html` file or an unpublished one | stored, never listed |
| `category_id` of the home category | stored as `0`: the file is at the root |
| `<script>` in an `.html` file | served, never executed (CSP `sandbox`) |
| `{{ html . }}`, `{{ js . }}`, `{{ urlquery . }}` | escaped twice |
| a path written by hand in the body (`/catalog/feed.yml`) | printed as is — it breaks when the target moves; link by id (`file_url`, §6.10) |

## 10. Checklists before submitting

**Yandex Direct / Webmaster / Yandex Products (YML)**

- [ ] The site is **published** and the URL answers 200 from outside
      (`curl -I`), on the domain the ad account knows.
- [ ] `xmllint --noout` passes; `<currencies>`, `<categories>`, `<offers>` in
      this order inside `<shop>`, `<collections>` after `<offers>`.
- [ ] Every offer has `<url>`, `<price>`, `<currencyId>`, one `<categoryId>`
      (products with `.CategoryID` 0 have none — give them a published
      category), `<name>` and at least one `<picture>` (≥ 450 px on the short
      side, JPG/PNG/WebP/GIF).
- [ ] Prices on three random product pages equal `<price>`; sold-out products
      are excluded (§9).
- [ ] Up to 10 `<param>`; attribute codes return values in preview.
- [ ] Nothing from restricted categories; shop legal details are published on
      the site (Yandex Products moderation).
- [ ] The feed is under 512 MB (Direct by link); ours stops at 50 MB anyway.

**Google Merchant Center (RSS 2.0 + `g:`)**

- [ ] Available only where Merchant Center serves the advertiser's country;
      the feed domain matches the verified website (use §6.8 to verify).
- [ ] Every item has `g:id`, `g:title` (≤ 150), `g:description` (≤ 5000,
      plain text), `g:link` to the product page, `g:image_link`,
      `g:availability`, `g:price` with currency, `g:condition`, and `g:brand` /
      `g:gtin` / `g:identifier_exists`.
- [ ] `g:sale_price` only with a higher `g:price`; prices match the site.
- [ ] Consider `g:google_product_category` (a Google taxonomy id) — the
      template can print it from an attribute.

## 11. Limits

```json
{
  "template_max_bytes": 65536,
  "name_pattern": "^[A-Za-z0-9._-]{1,120}\\.(xml|csv|txt|json|yml|html)$",
  "name_base_max": 120,
  "extensions": ["xml", "csv", "txt", "json", "yml", "html"],
  "root_reserved": ["/robots.txt", "/sitemap.xml", "/yandex-token.html"],
  "root_reserved_prefixes": ["/favicon.", "/icon"],
  "id_zone": "/_file",
  "render_steps_max": 20000000,
  "value_max_bytes": 1048576,
  "link_lookups_max": 1000,
  "preview_timeout_seconds": 10,
  "preview_calls_per_minute": 30,
  "product_selections_max": 20,
  "renders_per_shop_max": 2,
  "preview_queue_wait_seconds": 3,
  "last_good_max_age_days": 7,
  "missing_path_memory_seconds": 30,
  "output_max_bytes": 52428800,
  "render_timeout_seconds": 30,
  "products_per_range_max": 50000,
  "cache_ttl_minutes": 60,
  "render_failure_ttl_seconds": 60,
  "catalogue_change_seconds": 15,
  "cache_control_max_age_seconds": 300,
  "preview_limit_default": 20,
  "preview_limit_max": 50,
  "preview_output_max_bytes": 204800,
  "pictures_width_max": 2560,
  "functions": ["attr", "categories", "category_url", "default", "file_path", "file_url", "now", "pictures", "plain", "product_url", "products", "truncate"],
  "tariff_limit": "site_files_max"
}
```

`TestFeedsAreaLimitsMatchCode` checks these numbers, the function list, the
name grammar (`name_pattern`, `extensions`), the reserved root names and the
`/_file` zone against the code — this block is the machine-readable source the
storefront and the cabinet check their own copies against. The root also
reserves `/{API_INDEXNOW_KEY}.txt` when that key is configured.

## 12. How this was verified

- `go test ./internal/api/sitefiles/` — escaping per format and that no path
  around it exists (`printf`, variables, `define`, `block`), function
  semantics, `products` options, line numbers of runtime errors, output and
  time limits, `plain`, cache TTL, singleflight, disk cache, last good build
  on a failed render, a failed build remembered per key; hostile templates
  (recursion, 2^60 nested `template` calls, `range` over 10^12, three nested
  `range products` over 50 000, `printf` width bombs, a value doubled in a
  loop) all stop within the time limit; link functions: escaping, errors
  with lines, memory per build, the 1000-target ceiling; `products` memory by
  the parsed query and the 20-selection ceiling; refused `printf` verbs; the
  CSV formula guard; one disk copy per file, streaming without loading the
  body, cancel when the last waiter leaves, last good build bound to
  `row_version` and 7 days; the per-shop render gate.
- `go test ./internal/api/seohttp/ -run 'TestSiteFile|TestSitemap_'` —
  headers (incl. `nosniff` and CSP `sandbox` on 200 and 304), 404 gates (dev
  host, closed site, reserved and unpublished paths, unknown or
  non-canonical category), nested files, the last good build with its ETag
  when a render fails, neutral 500 without one, `.html` files in the sitemap;
  `?id=` — the same gates and cache as the path, `Link` rel=canonical
  following a move.
- `go test -tags=integration ./internal/api/catalog/ -run 'TestSiteFile|TestAdParticipation'`
  on PostgreSQL — CRUD, case-insensitive uniqueness, `know_version`, tariff
  limit, reference templates rendered to valid YML and RSS, **feed price =
  listing price** with a promotion and a combo, excluded / hidden /
  unpublished / zero-price products absent, hidden attribute not leaking,
  gallery by name, category fallback, preview, and the raw route (200, 304,
  new ETag after a product edit); nesting — the same name in two categories,
  moving, the root-only reserve, the home category as the root, orphaned
  files, the list filter, a closed and a deleted category answering 404, the
  tariff limit under parallel creates.
- Storefront side (`vizen-market`, `test:site-file`): rewrite of root paths,
  fallback on 404, 502/504.
