# Editorial content: rubrics, materials and their feeds

> **Резюме по-русски.** Область редакционного контента магазина: рубрики
> (категории с `type: "news"`), датированные материалы внутри них (`/articles`)
> и виджеты, которые их показывают. Слово «материал» здесь намеренно общее —
> платформе всё равно, называет продавец это новостями, блогом, кейсами или
> справочником: это страница с заголовком, датой, обложкой и телом из блоков.
> Страница рубрики и страница материала собираются СТРОГО из виджетов двух
> шаблонов, которые сеются каждому магазину («Лента новостей», «Тело статьи»);
> код витрины ничего не дорисовывает. Материал создаётся только в рубрику и
> приходит с одним пустым текстовым блоком — областью для содержимого; любые
> другие блоки добавляются как у любой страницы. Три ловушки, из-за которых
> написан документ: рубрика создаётся НЕОПУБЛИКОВАННОЙ (витрина отвечает 404),
> публикация материала НЕ проставляет дату (без `published_at` он уезжает в
> конец ленты), а обложку и заголовок рисует виджет шаблона — заголовок в теле
> даёт второй H1. Старая боковая колонка (`sidebar_config`) снесена
> 2026-09-24: поле принимается и ничего не делает.

**Status:** current · **Verified:** 2026-09-24, live calls on the stand (shop
181, owner token + anonymous) and `curl` of the storefront pages; data shapes
read on a local copy of the production database · **Owner:** news-standard line
**Serves:** `GET /docs/news`
**Related:** `catalogue.md` §8.1 (the author of a material), `chrome.md` §3.6
(a layout per rubric or per material), `widgets.md` (how a widget block is
written), `own-markup.md` (your own HTML inside a material)

## 1. What you can do here

Build the editorial part of a shop: a tree of **rubrics**, dated **materials**
inside them, and **feeds** of those materials — on the rubric page itself and on
any other page (home, a product page, a landing). The platform draws the rubric
page and the material page from two templates it seeds into every shop; you
configure their widgets, add blocks to a material's body, and bind other
designs to rubrics or single materials the same way as for products.

A material is a page with a title, a short summary, a cover, an author, dates,
a badge, galleries, SEO fields and a body made of ordinary content blocks. What
the seller calls it is not encoded anywhere: the same objects serve a company
blog, a news column, case studies or a help section.

## 2. Decide first (forks)

| If you want… | Take this path | Cost |
|---|---|---|
| the page of a rubric listing its materials | nothing to build — the shop's `news` template (breadcrumbs · heading · `articleGrid`) draws it; configure the feed widget (§3.5) | props only; the look is bounded by the widget |
| a feed of materials on **another** page (home, a product page) | `articleListing` (`props.articles`, compact, 1..24 items) for a short selection, or `articleGrid` (`props.articleGrid`) for the full feed with rubric switcher, search and paging — as a block of that page | one block; data comes with the page |
| the **body** of a material | its own content blocks: the seeded text block for plain text, an `html` block for your own markup (`own-markup.md`), any widget (`widgets.md`) | the same blocks as any page; attach them with `PUT /articles/{id}/content-blocks` (§3.3) |
| the **head** of a material — cover, title, summary, rubric, dates, author | drawn by the `articleHeader` widget of the `article` template; do **not** add a heading or a cover image to the body | none — but a second heading in the body is a second `<h1>` (§5) |
| another design for one rubric or one material | bindings: `PUT /design/bindings/category/{id}` or `/article/{id}` with `theme` / `body_news` / `body_article` — `chrome.md` §3.6 | the same ladder as products; `article` is a design resource |
| materials linked to one another ("read also") | `PUT /articles/{id}/related` stores the list and `GET /articles/by-slug/{slug}` returns it as `related[]` — **no storefront widget draws it today** | to show it, print `related[]` from your own markup on the material page |

## 3. Objects and where they live

### 3.1. Rubric = a category with `type: "news"`

```jsonc
POST /categories
{ "item": { "name": "Guides", "type": "news", "parent_id": 0 } }   // parent_id: another rubric's id, or 0 for the root
```

A rubric is an ordinary category (`catalogue.md`) whose `type` is `news`: it
holds materials instead of products, and sub-rubrics are `news` categories
under it. Everything else — `seo.slug`, `seo.page_title`, `description`,
`is_show_menu`, galleries, design bindings — works as for any category.

- **Created unpublished.** `POST /categories` answers with `is_published:
  false`. Until `PUT /categories/{id} {"item": {"is_published": true}}` the
  storefront answers **404** for the rubric and **308** for its materials (the
  address collapses to the root-level slug). Measured 2026-09-24.
- `count_articles` is read-only and counts the rubric's own materials.
- `sidebar_config` is **deprecated**: accepted, validated and never read (§5).

Storefront address: the tree path of the category, `/<parent-slug>/<rubric-slug>`;
the feed reads `?page=N` and the search field submits `?q=<text>`.

### 3.2. Material = `/articles`

| Field | Write | Notes |
|---|---|---|
| `category_id` | required | a live `news` rubric of **your** shop; anything else answers 404 (the platform does not reveal whether the id exists) |
| `title` | required, min 1 | the `<h1>` drawn by `articleHeader` |
| `announce` | optional | the short summary: card text in feeds, `description` in the header, `description` in the JSON-LD |
| `cover_id` | optional | uuid of an uploaded file (`/v1/storage`, see `catalogue.md` §6); resolved back as `cover {url}` |
| `badge` | optional, ≤ 48 | a short label drawn **over the cover** in feeds; `""` removes it |
| `is_published` | bool | anonymous readers and the storefront see published materials only |
| `published_at` | timestamp | **not stamped by publishing** — set it yourself (§5) |
| `slug` | optional | top-level field; falls back to `seo.slug`; empty → generated from the title; `SLUG_TAKEN` (409) if the shop already has it |
| `seo` | object | `page_title`, `meta_title`, `meta_description`, `meta_keywords[]`, `og_image_id`, `noindex`, `nofollow` |
| `gallery_ids` | ≤ 15 | galleries linked to the material (`catalogue.md` §7); on update add `gallery_ids_replace: true` to replace the set |
| `author` | object | `{mode: "none"\|"member"\|"manual", user_id, name ≤ 255, description ≤ 500, photo_id}` — `catalogue.md` §8.1 |

Read-only in the answer: `views` (public reads of a published material on the
production contour increment it; `sort=popular` orders by it), `category_name`,
`created_by` / `updated_by` (owner only), `dev_dirty`.

```
GET /articles?company_id=181&category_id=1173&include_subtree=true&sort=recent&query=sofa&page.number=1&page.limit=20
GET /articles/by-slug/{slug}?company_id=181      # public: result + content_blocks + related + modal_groups + design
GET /articles/{id}                               # owner token: result + content_blocks + related; no design
POST /articles                { "item": { … } }
PUT  /articles/{id}           { "item": { … } }  # partial: only the fields you send change
DELETE /articles/{id}                            # soft, 30-day window; PUT {"item":{"is_deleted":false}} restores
```

- **`company_id` is required for an anonymous call** — without it the answer
  is `403 COMPANY_ID_PROBLEM`. With an owner token it defaults to the token's
  shop; naming another shop switches the call to visitor mode (published only).
- `include_subtree=true` aggregates the rubric **and its descendants**; it needs
  `category_id` (§5).
- `sort` is `recent` (default, `published_at DESC NULLS LAST, id DESC`) or
  `popular` (`views DESC`); anything else is `400 INVALID_SORT`.
- `query` (≤ 100 runes) searches the title and the summary: substring,
  case-insensitive including Cyrillic, ranked by similarity (`pg_trgm`). Longer
  → 400.
- `page.limit` is 1..100 (default 20); 101 → 400. `total` counts the whole
  filter, `current_page` echoes the page.

Storefront address of a material: `/<rubric tree path>/<slug>`. The flat
`/article/<slug>` and the numeric `/<id>` forms answer 308 to that path.

### 3.3. The body: the material's own blocks

A new material comes with two blocks already attached:

| Block | Section | What it is |
|---|---|---|
| `__page:top` | `type:"text"`, `payload {v:2, kind:"zone", refs:[…]}` | the technical manifest of the page's top zone: `refs` is the order in which the own blocks are drawn. Keep it, keep it first, keep `refs` in sync with what you attach |
| `Текст статьи` | `type:"text"`, `payload {v:2, origin:"builder", text:""}` | the seeded area for the material's text: **plain text**, line breaks kept, tags printed literally (`<b>` shows as `&lt;b&gt;`) |

Until 2026-09-24 a third block, a `cover` section with the title, was seeded
too; together with `articleHeader` it produced two `<h1>` on every new material,
so it is no longer created. Materials that still carry one show it as a dark
banner with the title above the header widget — detach it (below).

```jsonc
// 1. write the text
PUT /content-blocks/2533
{ "id": 2533, "item": { "sections": [
  { "type": "text", "payload": { "v": 2, "origin": "builder", "text": "First paragraph.\nSecond paragraph." } } ] } }

// 2. add any other block (own markup, a widget) — widgets.md §5, own-markup.md
POST /content-blocks  { "item": { "name": "Gallery of the project", "sections": [ … ] } }   // → id 2600

// 3. attach the set (replace-set, ≤ 50 items, blocks must be yours) and update the manifest
PUT /articles/132/content-blocks
{ "items": [ { "block_id": 2534, "sort_order": 1 }, { "block_id": 2533, "sort_order": 2 }, { "block_id": 2600, "sort_order": 3 } ] }
PUT /content-blocks/2534
{ "id": 2534, "item": { "sections": [ { "type": "text", "payload": { "v": 2, "kind": "zone", "refs": [2533, 2600] } } ] } }
```

`sections` is a replace-set (send the whole list), `items` is a replace-set
too. A block that is not yours answers `404 BLOCK_NOT_FOUND`; the same block
twice answers `400 DUPLICATE_BLOCK_IN_SET`. A block you detach is not deleted —
`DELETE /content-blocks/{id}` soft-deletes it when you no longer need it.

### 3.4. The two templates: the pages are widgets, nothing else

Every shop is seeded with two body templates (groups, `chrome.md` §3.1):

| Group | `group_type` | Widgets | Draws |
|---|---|---|---|
| «Лента новостей» | `news` | `breadcrumbs` · `pageHeading` · `articleGrid` `{view: "tiles", columns: 3, coverAspect: "3/4", limit: 20}` | the page of every rubric |
| «Тело статьи» | `article` | grid: `breadcrumbs` and `articleHeader` in `main`, `articleListing` `{source: "news", limit: 5}` in `aside`; the material's own blocks flow into `main` | the page of every material |

The storefront draws **nothing** beyond these: no breadcrumbs, heading, cover
or side column from code. To change the look of all rubrics, edit the widgets
of the `news` group (`GET /content-blocks` → the block with `group_type:
"news"` → its `props.widgets` are the ids → `PUT` the widget block). To give one
rubric or one material another body, bind `body_news` / `body_article` on a
layout or `theme` on the resource — `chrome.md` §3.6. JSON-LD (`NewsArticle`,
`BreadcrumbList`, `ItemList` of the feed page) is printed by the page from the
same data and needs no widget.

### 3.5. The widgets

The generated reference is `GET /docs/widgets?kind=<kind>`; the write path of
each widget is nested (`widgets.md` §5).

- **`articleGrid`** — `props.articleGrid`. The feed of a rubric. `view` tiles ·
  masonry · rows; `columns` 1..6 (+ `columnsTablet`, `columnsMobile`);
  `coverAspect` 3/4 · 4/3 · 3/2 · 16/9 · 1/1 (default 3/2, rows 4/3); `limit`
  1..48 (20); `paging` none · pages · more · auto (default `auto`: the next pages
  load by themselves near the end, three in a row, then a button; the page
  links stay in the markup for crawlers); `categoryId` 0 = the page's rubric
  with descendants; `rubricsVisible`, `searchVisible` (the field searches inside
  the page's rubric and submits `?q=`); `hiddenParts` cover · badge · rubric ·
  date · author · announce · readMore; `authorPosition` inside · below;
  `datePosition` meta · title · author; `rowCoverWidth` 160..640, `rowMaxWidth`
  (rows only); `readMoreStyle` (a button style); surfaces `cardStyle`,
  `coverStyle`, `textStyle`, `rubricsStyle`, `searchStyle`, `badgeStyle`; text
  styles `headlineStyle`, `rubricStyle`, `datesStyle`, `announceStyle`,
  `authorStyle`. The badge is drawn over the cover: a material without a cover
  shows no badge. Markup markers: `.vz-article-grid[data-view=…]`, `.vz-article-grid__item`,
  the rubric switcher marks the current rubric with `aria-current`.
- **`articleHeader`** — `props.articleHeader`. The head of a material from the
  page context: title (`titleTag`, default `h1`), `announce` as the description,
  rubric, dates, author (photo `authorPhotoSize` 24..96), the cover
  (`coverSource` article · custom, `coverPosition` before · after, `coverAspect`,
  `coverFit`) and optional static labels `tags[] {key, text, target?}`. Missing
  author or cover render nothing.
- **`articleListing`** — `props.articles`. A short selection: `source` news
  (automatic: the page's top rubric or `categoryId`, `limit` 1..24, default 3)
  or manual (`section.items`, hand-written cards); `layout` feature ·
  feature-list · compact · numbered.
- **`articleRubrics`** — `props.articleRubrics`. A rubric switcher (`layout`
  list · chips) for pages that need it outside the feed.
- **`breadcrumbs`**, **`pageHeading`** — no props; fed by the page.

## 4. Recipes

### 4.1. A rubric, a first material, published and visible

```bash
API=https://api.vizen.shop; H="Authorization: Bearer $PAT"; J="Content-Type: application/json"
curl -s -X POST $API/categories -H "$H" -H "$J" -d '{"item":{"name":"Guides","type":"news","parent_id":0}}'
# → result.id = 1186, result.is_published = false
curl -s -X PUT $API/categories/1186 -H "$H" -H "$J" -d '{"item":{"is_published":true}}'
curl -s -X POST $API/articles -H "$H" -H "$J" -d '{"item":{"category_id":1186,"title":"How the set is assembled","announce":"Six steps, two tools.","is_published":true,"published_at":"2026-09-24T09:00:00Z","badge":"New"}}'
# → result.id = 132, result.slug = "how-the-set-is-assembled"
```

Verify: `GET /articles?company_id=<shop>&category_id=1186` **without a token**
answers `total: 1`; `GET /articles/by-slug/how-the-set-is-assembled?company_id=<shop>`
answers with `design.page.body_id` (the `article` template) and
`content_blocks` (2 blocks); `curl <storefront>/<rubric-path>/how-the-set-is-assembled`
answers 200 with one `data-vz-kind="articleHeader"` and one `<h1>` holding the
title; `curl <storefront>/<rubric-path>` shows the card inside
`.vz-article-grid`.

### 4.2. Text and other blocks in the body

Read `GET /articles/{id}` → `content_blocks`; find the block named
`Текст статьи` and `PUT /content-blocks/{id}` its section with your `text`
(§3.3). Verify by re-reading the block and by `curl` of the page: the text is
printed inside `data-vz-block="{id}"`. For formatted content create an `html`
block instead (`own-markup.md`), attach it with `PUT /articles/{id}/content-blocks`
and add its id to the manifest's `refs`.

### 4.3. Another look for every rubric feed

```bash
curl -s "$API/content-blocks?page.limit=100" -H "$H" | jq '.result[] | select(.group_type=="news") | {id, name, widgets: .sections[0].payload.props.widgets}'
# → the third id is the feed widget; read it, change one leaf, send the whole sections list back
curl -s -X PUT $API/content-blocks/<feedId> -H "$H" -H "$J" -d '{"id":<feedId>,"item":{"sections":[{"type":"text","payload":{"v":2,"kind":"articleGrid","props":{"articleGrid":{"view":"rows","limit":20,"paging":"more"}}}}]}}'
```

Verify: re-read the block (`props.articleGrid.view == "rows"`), then `curl` the
rubric page and find `data-view="rows"` on `.vz-article-grid` and the
"show more" button after the cards. Run `POST /docs/validate` with the same
`sections` first: a value outside an enum comes back as `unknown` with the
allowed list.

### 4.4. Search and paging from outside

The feed page answers the same addresses a visitor uses: `?q=sofa` searches
inside the rubric and its descendants, `?page=2` is the second page (in
`pages` mode as links; in `more`/`auto` the links are still in the markup for
crawlers). The API behind both is §3.2: `query`, `page.number`, `page.limit`.

## 5. Silently ignored

| Written | What happens |
|---|---|
| `sidebar_config` on a rubric (`PUT /categories/{id}`) | 200, validated as a JSON array of `{type}` (≤ 20), **never read**: the side column of a material is the `aside` of the `article` template. Measured 2026-09-24: PUT → 200, page unchanged |
| `include_subtree=true` without `category_id` | ignored; `total` is the same with and without it (110 = 110) |
| `is_published: true` without `published_at` | published, but the date stays empty: no date on the card or in the header, and the material sorts **last** in `sort=recent` (`NULLS LAST`; measured: position 111 of 111). Send `published_at` |
| `PUT /articles/{id}/related` | stored and returned as `related[]`; no widget on the storefront draws it — the page does not mention the linked titles (grep: 0) |
| `props.articleGrid.view`, `paging`, `coverAspect`, `datePosition`, `authorPosition` outside their enums | 200, the default is drawn. `/docs/validate` names the allowed values since 2026-09-24 (before that date it reported such a value as `applied`) |
| `props.articleGrid.limit: 999`, `columns: 9` | 200; clamped to 48 and 6 |
| a feed prop at the root of `props` (`props.limit`) | 200, ignored — the widget reads `props.articleGrid.limit`; `/docs/validate` reports it |
| a cover image or a heading in the body of a material | drawn **in addition** to the header widget's cover and `<h1>` — two headings on the page |
| tags in the seeded text block | printed literally (`&lt;b&gt;`), not rendered |
| `company_id` of another shop with an owner token | not an error: the call switches to visitor mode (published only) |

Loud, for contrast: `sort` outside `recent|popular` → `400 INVALID_SORT`;
`page.limit` > 100 → 400; `query` > 100 runes → 400; a `category_id` that is
not a `news` rubric of your shop → 404; the same block twice → `400
DUPLICATE_BLOCK_IN_SET`; a material related to itself → `400 SELF_RELATED`.

## 6. Limits

| What | Limit |
|---|---|
| `title` | min 1 character |
| `badge` | ≤ 48 characters; `""` removes it |
| `author.name` / `author.description` | ≤ 255 / ≤ 500 |
| `gallery_ids` | ≤ 15 per material |
| `related` items | ≤ 50 per material |
| own blocks attached (`content-blocks` items) | ≤ 50 per material |
| `query` | ≤ 100 runes |
| `page.limit` | 1..100, default 20 |
| `articleGrid.limit` | 1..48, default 20 |
| `articleGrid.columns` | 1..6 (each breakpoint) |
| `articleGrid.rowCoverWidth` / `rowMaxWidth` | 160..640 / 0..3000 px |
| `articleListing.limit` | 1..24, default 3 |
| `articleHeader.authorPhotoSize` | 24..96 px |
| auto-paging | 3 pages in a row, then a button |
| soft-delete window (material, block) | 30 days |

## 7. How this was verified

2026-09-24, stand (`core-api` from commit `7f8e4220` + this line, storefront
`dfd4d6f2` + this line), shop 181, owner token `test@test.com` and anonymous
`curl`:

- `POST /categories {type: "news"}` → `is_published: false`; storefront 404 for
  the rubric, 308 → `/<slug>` for its material; after `PUT {is_published: true}`
  both pages 200; the material page holds `data-vz-kind="articleHeader"` × 1 and
  `<h1>` = the title; the rubric page holds `.vz-article-grid` with the card.
- `POST /articles` → `content_blocks` seeded (before the fix: `__page:top`,
  cover, text — the page showed `<h1>` × 2; an older material with a cover image
  showed the same; after the fix: `__page:top` and text only).
- `GET /articles?company_id=181&category_id=1186` anonymous → `total: 1`,
  `badge: "New"`, `category_name` filled; `published_at: null` sorted last
  (page 2, position 11 of 11 in a 111-item feed).
- `GET /articles/by-slug/…?company_id=181` → keys `result, content_blocks,
  related, modal_groups, design`; `design.page.body_id` = the `article` group.
- `page.limit` 100 → 200, 101 / 200 / 1000 → 400; `sort=bogus` → 400;
  `query` of 101 runes → 400 with the validation text; `include_subtree` without
  a rubric → same `total`.
- `PUT /content-blocks/{text}` with `<b>tags</b>` → page prints `&lt;b&gt;`.
- `PUT /articles/132/related [117]` → `related[]` returned; storefront page
  does not contain the linked title. `PUT /categories/1186 {sidebar_config}` →
  200, page unchanged.
- `POST /docs/validate` with `articleGrid {limit: 999, view: "bogus", nonsense: 1}`
  and `props.limit` → `nonsense` and `props.limit` reported `ignored`; `view`
  was `applied` before the enum lists were added to the widget the same day.
- Production data shape: a local copy of the production database (2026-09-24)
  holds 29 materials of one shop, all built from an `html` block plus the zone
  manifest — no seeded cover blocks, so no data migration ships with the fix.
