-
Notifications
You must be signed in to change notification settings - Fork 0
Custom Mappings
Mappings tell Canvasflow how to recognise an HTML element as a specific component instead of skipping it or applying the default rules. They are supplied through a Params object passed to RSSFeed or HTMLMapper.toComponents(). This page documents the model with worked examples.
← Back to Home · Related: HTML Mapping · Component Types
| Property | Required | Description |
|---|---|---|
mappings |
No | Component mappings — how to detect components from HTML. |
excludes |
No | Base mappings (match + filters only); matches are removed with all children. |
unwrap |
No | Base mappings; matches are removed but their children are kept and processed. |
ignoreParagraphWrap |
No | When true, extracted text is not wrapped in paragraph tags. |
Elements can also be excluded directly in the HTML with the
data-cf-ignoreattribute.
Every mapping shares these foundational fields:
| Property | Required | Description |
|---|---|---|
match |
Yes | How many filters must match: any or all. |
filters |
Yes | The list of filters identifying the element. |
properties |
No | An arbitrary object copied verbatim onto the resulting component's properties. |
name |
No | An optional label (identification only; not used for matching). |
Three filter types are available.
{
"type": "tag",
"items": ["section"]
}{
"type": "class",
"match": "any",
"items": ["cf-columns"]
}match is one of any (at least one class present), all (every class present, any order), or equal (exactly those classes and nothing else, order-independent).
The attribute filter has two forms, both keyed by type: "attribute":
Exact-value — matches when the attribute equals value (use null for valueless boolean attributes):
{
"type": "attribute",
"key": "data-component",
"value": "gallery"
}Pattern — matches when the attribute is present and its value matches a regular expression (when pattern is present, value is ignored). An invalid pattern is treated as a non-match rather than throwing:
{
"type": "attribute",
"key": "id",
"pattern": "^article-body-\\d+$"
}match: "any" means a single filter matching is enough; match: "all" requires every filter to match.
Each component mapping extends the base mapping with a component field. Some types need extra sub-mappings:
component |
Extra property | Notes |
|---|---|---|
container |
— | Groups child components into one unit. |
recipe |
— | Like container plus a url; the page should expose an LD+JSON recipe. |
columns |
column |
A sub-mapping describing each column. |
live_container |
post |
A sub-mapping describing each live_post. |
gallery |
slide |
A sub-mapping describing each slide; only valid image slides become items. |
custom |
— | Preserves the matched element as raw/sanitized HTML instead of converting children. |
divider |
— | Maps the matched element to a divider component, like the default <hr> rule. |
spacer |
— | Maps the matched element to a spacer component (margin: "margin-20"), like the default <br> rule. |
| text type | — | Any text type (headline, body, crosshead, text1–text60, …) — an alternative to the role attribute. |
Groups matched child elements into a single container component. All children are converted normally and nested inside the resulting component.
| Field | Required | Description |
|---|---|---|
component |
Yes | Must be "container". |
match |
Yes |
"any" or "all". |
filters |
Yes | Filters identifying the container element. |
name |
No | Optional label for identification. |
properties |
No | Arbitrary object copied onto the component. |
{
"component": "container",
"match": "all",
"filters": [
{
"type": "tag",
"items": ["section"]
}
]
}<section>
<h2>Section heading</h2>
<p>Body text inside the container.</p>
</section>Marks the matched element as a recipe container. The page at the item's url is expected to expose LD+JSON recipe structured data. Children are converted the same way as container.
| Field | Required | Description |
|---|---|---|
component |
Yes | Must be "recipe". |
match |
Yes |
"any" or "all". |
filters |
Yes | Filters identifying the recipe element. |
name |
No | Optional label for identification. |
properties |
No | Arbitrary object copied onto the component. |
{
"component": "recipe",
"match": "all",
"filters": [
{
"type": "class",
"match": "any",
"items": ["recipe-block"]
}
]
}<div class="recipe-block">
<p>Step one: preheat the oven.</p>
</div>Splits a matched wrapper element into a multi-column layout. The required column sub-mapping identifies which direct children become individual columns.
| Field | Required | Description |
|---|---|---|
component |
Yes | Must be "columns". |
match |
Yes |
"any" or "all" — applied to the outer wrapper. |
filters |
Yes | Filters identifying the wrapper element. |
column |
Yes | Sub-mapping (match + filters) that selects each column child. |
name |
No | Optional label for identification. |
properties |
No | Arbitrary object copied onto the component. |
column sub-mapping fields:
| Field | Required | Description |
|---|---|---|
match |
Yes |
"any" or "all" — applied to each candidate child. |
filters |
Yes | Filters identifying a column child. |
{
"component": "columns",
"match": "all",
"filters": [
{
"type": "class",
"match": "any",
"items": ["columns-wrapper"]
}
],
"column": {
"match": "any",
"filters": [
{
"type": "class",
"match": "any",
"items": ["column"]
}
]
}
}<div class="columns-wrapper">
<div class="column"><p>Left column content.</p></div>
<div class="column"><p>Right column content.</p></div>
</div>Identifies a live-blog wrapper and its individual posts. The required post sub-mapping selects which children become live_post components inside the container.
| Field | Required | Description |
|---|---|---|
component |
Yes | Must be "live_container". |
match |
Yes |
"any" or "all" — applied to the outer wrapper. |
filters |
Yes | Filters identifying the wrapper element. |
post |
Yes | Sub-mapping (match + filters) that selects each post child. |
name |
No | Optional label for identification. |
properties |
No | Arbitrary object copied onto the component. |
post sub-mapping fields:
| Field | Required | Description |
|---|---|---|
match |
Yes |
"any" or "all" — applied to each candidate child. |
filters |
Yes | Filters identifying a post child. |
If no children match the
postsub-mapping, the resultinglive_containercomponent will carry a non-emptyerrorsarray.
{
"component": "live_container",
"match": "all",
"filters": [
{
"type": "class",
"match": "any",
"items": ["live-blog"]
}
],
"post": {
"match": "any",
"filters": [
{
"type": "class",
"match": "any",
"items": ["live-post"]
}
]
}
}<div class="live-blog">
<div class="live-post"><p>First update.</p></div>
<div class="live-post"><p>Second update.</p></div>
</div>Identifies an image gallery wrapper and its individual slides. The required slide sub-mapping selects which children become slide items. Only children that contain a valid image are kept; non-image slides are silently discarded.
| Field | Required | Description |
|---|---|---|
component |
Yes | Must be "gallery". |
match |
Yes |
"any" or "all" — applied to the outer wrapper. |
filters |
Yes | Filters identifying the wrapper element. |
slide |
Yes | Sub-mapping (match + filters) that selects each slide child. |
name |
No | Optional label for identification. |
properties |
No | Arbitrary object copied onto the component. |
slide sub-mapping fields:
| Field | Required | Description |
|---|---|---|
match |
Yes |
"any" or "all" — applied to each candidate child. |
filters |
Yes | Filters identifying a slide child. |
{
"component": "gallery",
"match": "all",
"filters": [
{
"type": "class",
"match": "any",
"items": ["image-gallery"]
}
],
"slide": {
"match": "any",
"filters": [
{
"type": "class",
"match": "any",
"items": ["gallery-slide"]
}
]
}
}<div class="image-gallery">
<div class="gallery-slide">
<img src="https://example.com/photo1.jpg" alt="Photo 1" />
</div>
<div class="gallery-slide">
<img src="https://example.com/photo2.jpg" alt="Photo 2" />
</div>
</div>Preserves the matched element as sanitized raw HTML rather than recursing into its children. Useful for embeds, ads, or any block whose internal markup should be kept verbatim.
| Field | Required | Description |
|---|---|---|
component |
Yes | Must be "custom". |
match |
Yes |
"any" or "all". |
filters |
Yes | Filters identifying the element. |
name |
No | Optional label for identification. |
properties |
No | Arbitrary object copied onto the component. |
{
"component": "custom",
"match": "all",
"filters": [
{
"type": "class",
"match": "any",
"items": ["third-party-embed"]
}
]
}<div class="third-party-embed">
<span data-widget="poll" data-id="42">Loading…</span>
</div>Route any element to a DividerComponent or SpacerComponent, the same shape produced by the default <hr>/<br> rules — useful when a publisher marks a visual break with something other than those tags (e.g. <div class="section-break">). Neither mapping type takes a sub-mapping or affects the component's fields: a divider mapping always produces { component: 'divider' }, and a spacer mapping always produces { component: 'spacer', margin: 'margin-20' }, regardless of which element matched.
| Field | Required | Description |
|---|---|---|
component |
Yes |
"divider" or "spacer". |
match |
Yes |
"any" or "all". |
filters |
Yes | Filters identifying the element. |
name |
No | Optional label for identification. |
properties |
No | Arbitrary object copied onto the component. |
{
"component": "divider",
"match": "all",
"filters": [
{
"type": "class",
"match": "any",
"items": ["section-break"]
}
]
}<!-- Rendered as a divider component instead of being descended into -->
<div class="section-break"></div>Any named text type or numbered slot can be used as the component value, overriding the default tag-based mapping. This is an alternative to adding a role attribute in the HTML source.
Named types: headline, title, subtitle, intro, body, crosshead, byline, blockquote, footer, imagecaption.
Numbered slots: text1 through text60.
| Field | Required | Description |
|---|---|---|
component |
Yes | Any valid TextType (see list above). |
match |
Yes |
"any" or "all". |
filters |
Yes | Filters identifying the element. |
name |
No | Optional label for identification. |
properties |
No | Arbitrary object copied onto the component. |
{
"component": "crosshead",
"match": "all",
"filters": [
{
"type": "class",
"match": "any",
"items": ["section-heading"]
}
]
}<!-- Rendered as a crosshead component instead of the default <p> → body mapping -->
<p class="section-heading">Chapter Two</p>{
"excludes": [
{
"match": "any",
"filters": [
{
"type": "class",
"match": "any",
"items": ["advertisement", "newsletter-signup"]
}
]
}
]
}unwrap takes the same base-mapping shape as excludes (match + filters, no component). Both remove the matched element itself; they differ in what happens to its children:
| Option | Matched element | Its children |
|---|---|---|
excludes |
removed | removed |
unwrap |
removed | kept, spliced in its place |
Use it to reach content trapped inside a wrapper. A custom mapping is only tested against the element currently being visited, and the default text rules are applied before the reducer descends into children. So a wrapper whose own tag has a default mapping consumes its whole subtree, and nothing inside it can ever be matched:
<!-- The <p> maps to `body` and swallows everything; the div is never reached. -->
<p>
<ad>
<div data-testid="affiliate-widget">…</div>
</ad>
</p>Unwrapping the <p> lets the walk continue to the div, so a mapping can match it:
{
"unwrap": [
{
"match": "all",
"filters": [
{
"type": "tag",
"items": ["p"]
}
]
}
]
}The result is flat — the unwrapped element adds no component of its own, so the matched div appears at the top level rather than nested inside a wrapper component.
<ad>needs no entry here. Only tags with a default mapping (p,h1–h6,ol,ul,a,blockquote,footer) consume their subtree; anything else is already descended into.
Evaluation order. unwrap is checked after excludes/data-cf-ignore but before all built-in detection and custom mappings, so it wins over the default text rules — that is what makes it able to pierce a <p>. If the same element matches both excludes and unwrap, excludes wins and the subtree is dropped.
⚠️ Scopeunwrapfilters narrowly. A bare{ "type": "tag", "items": ["p"] }unwraps every paragraph in the content, dissolving legitimate body copy into its inline children. Prefer pairing the tag with a class or attribute filter that identifies the specific wrapper.
Params/Mapping are validated with Zod schemas (mapping/mapping.schema.ts). The exported helpers isValidParams(), isValidMapping(), and validateParams() (and the static RSSFeed.validateParams()) reuse those schemas. See API Reference.
Start here
Reference
Operations