html
html(options: HtmlOptions): Transformer // no path: extract from the HTML string in the body
html(options: HtmlOptions & { path }): HtmlAdapter // Source<HtmlResult> & Destination<unknown> & Enricher<unknown, HtmlResult>
Extract data from HTML using CSS selectors (powered by cheerio), or read/write HTML files. The presence of path selects the file roles; the operation keyword then picks one: .from() reads and extracts, .to() writes, .enrich() extracts mid-route. Without path, html() is a transformer over the body.
Requires the optional peer cheerio: bun add cheerio. A missing peer raises RC5017 with an install hint the first time the adapter parses.
"Presence" means the key was supplied, not that it holds something truthy. Only an omitted path selects the transformer role; a supplied path that is empty or undefined is refused with RC5003 rather than silently demoted to a transformer that would ignore every file option passed alongside it.
Transformer role (in-memory HTML parsing):
// Extract text from title
.transform(html({ selector: 'title', extract: 'text' }))
// Extract multiple elements (returns array)
.transform(html({ selector: 'h2', extract: 'text' }))
// Result: ['First Heading', 'Second Heading', ...]
// Extract HTML content
.transform(html({ selector: '.content', extract: 'html' }))
// Extract attribute value
.transform(html({ selector: 'a', extract: 'attr', attr: 'href' }))
// Extract outer HTML (including element tag)
.transform(html({ selector: 'article', extract: 'outerHtml' }))
// Custom parsing from sub-field
.transform(html({
selector: 'p',
extract: 'text',
from: (body) => body.htmlContent,
to: (body, result) => ({ ...body, paragraphs: result })
}))
Source role (read HTML files and extract):
// Read HTML file and extract title
.from(html({
path: './page.html',
selector: 'title',
extract: 'text'
}))
// Extract multiple links from file
.from(html({
path: './page.html',
selector: 'a',
extract: 'attr',
attr: 'href'
}))
// Emits array: ['https://example.com', '/about', ...]
Read mid-route (extract from an HTML file partway through a route): The adapter is also an enricher whose fetch reads the file and extracts via the selector, so .enrich() can pull the result in. The extracted value replaces the body; pass an aggregator such as only() to merge instead. The fetch role accepts dynamic (function) paths. Extraction failures throw and surface through the pipeline (the onParseError lifecycle controls apply to the source role only).
// Replace the body with the extracted value
.enrich(html({ path: './page.html', selector: 'title' }))
// Enrich the body with a value extracted from a file, keeping existing fields
.enrich(
html({ path: './page.html', selector: 'h1' }),
only((title) => title, 'title'),
)
Destination role (write HTML files). The send is void: the body flows through the .to() step unchanged.
// Write HTML string to file
.to(html({ path: './output.html' }))
// Dynamic paths with directory creation
.to(html({
path: (exchange) => `./pages/${exchange.body.slug}.html`,
createDirs: true
}))
// Append to HTML file
.to(html({
path: './log.html',
append: true
}))
// Delete an HTML file (idempotent: an already-absent path is a no-op)
.to(html({ path: (ex) => ex.body.processedPath, delete: true }))
Transformer Options (when no path provided):
File Options (when path is provided):
All transformer options above (except from / to, which only apply to the transformer role; selector is optional in the send role), plus:
Passing both append: true and delete: true throws RC5003 at construction.
Extract types:
-
text/innerText/textContent: Plain text content, with<style>and<script>subtrees removed and the result trimmed.innerTextandtextContentare aliases oftext; there is no layout server-side to tell them apart.Two things to know about the value you get back. Entities are decoded, so a page that escapes markup (
Array<string>) yields it as written (Array<string>); the result is unsanitised, and it is the route's job to escape it at the sink before writing it into HTML or into a line-structured format. And whitespace inside the match survives, so a<pre>keeps its line breaks and an ordinary indented page yields the source indentation and newlines between its elements. Collapse it in the route when you want one line. A selector that matches a single element yields a string, and the second type parameter names that, so the transform after it does not have to handle the array case:.transform(html<unknown, string>({ selector: '.card', extract: 'text' })) .transform((text) => text.replace(/\s+/g, ' ')) -
html: Inner HTML content -
attr: Attribute value (requiresattroption) -
outerHtml: Element including its tag
Behavior:
- Single match: Returns string
- Multiple matches: Returns array of strings
- No matches: Returns empty string
- Source role: Reads HTML file and extracts data using selector
- Destination role: Writes HTML string (from
exchange.bodyorexchange.body.body) to file; the body flows through unchanged
Exported types: HtmlAdapter, HtmlOptions, HtmlResult