# Plugin Authoring Guide Balanced Breakfast plugins are Rhai scripts (`.rhai` files) that fetch content from external sources. Each plugin implements a small interface, uses host functions for HTTP and parsing, and returns structured feed items. No Rust compilation required. ## Required Functions Every plugin must define these four functions: ### `fn id() -> String` A unique identifier for this plugin type. Used as a namespace in item IDs and database storage. Must be stable across versions. ```rhai fn id() { "my-source" } ``` ### `fn name() -> String` A human-readable display name shown in the UI. ```rhai fn name() { "My Source" } ``` ### `fn config_schema() -> Map` Returns a map describing the configuration fields the user must fill in when adding a feed. The UI generates a form from this schema. ```rhai fn config_schema() { #{ description: "Fetch posts from My Source.", fields: [ #{ key: "feed_url", label: "Feed URL", field_type: "url", required: true, description: "The URL to fetch", placeholder: "https://example.com/feed" } ] } } ``` See "Config Schema Field Types" below for all supported `field_type` values. ### `fn fetch(config, cursor) -> Map` The core function. Called by the runtime to retrieve feed items. **Parameters:** - `config` -- A map containing the user's configuration values (keyed by the field `key` from the schema). Also contains a `feeds` array if additional feed URLs were configured. - `cursor` -- A string for pagination (the `next_cursor` from a previous fetch), or `()` (Rhai's unit/nil) on the first call. **Must return a map with:** - `items` -- An array of item maps (see "Item Structure" below) - `has_more` -- Boolean indicating whether more pages are available - `next_cursor` -- (Optional) String cursor for the next page ```rhai fn fetch(config, cursor) { let xml = http_get(config.feed_url); let feed = parse_feed(xml); let items = []; for entry in feed.entries { items.push(#{ id: #{ source: "my-source", item_id: entry.id }, bite: #{ author: "My Source", text: truncate(entry.title, 100), indicator: ">>>" }, content: #{ title: entry.title, body: entry.summary, url: entry.link }, meta: #{ source_name: "My Source", published_at: entry.published, tags: [] } }); } #{ items: items, has_more: false } } ``` ## Item Structure Each item in the `items` array must be a map with these four keys: ### `id` | Field | Type | Description | |-------|------|-------------| | `source` | String | Must match the plugin's `id()` return value | | `item_id` | String | Unique within this source (URL, GUID, or other stable ID) | ### `bite` Compact display shown in the item list. | Field | Type | Description | |-------|------|-------------| | `author` | String | Attribution line (feed name, username, etc.) | | `text` | String | Primary display text (truncated title or summary) | | `secondary` | String or `()` | Optional secondary info (score, comment count) | | `indicator` | String or `()` | Optional type indicator (emoji or short string) | ### `content` Full content shown in the detail panel. | Field | Type | Description | |-------|------|-------------| | `title` | String or `()` | Article/post title | | `body` | String or `()` | Full body text or HTML | | `url` | String or `()` | Link to the original content | ### `meta` | Field | Type | Description | |-------|------|-------------| | `source_name` | String | Display name for the source | | `published_at` | Integer | Unix timestamp (seconds) of publication | | `score` | Integer or `()` | Optional engagement score (upvotes, likes) | | `tags` | Array | Array of tag strings (can be empty) | ## Host Functions These functions are registered into the Rhai engine and available to all plugins. ### HTTP | Function | Signature | Description | |----------|-----------|-------------| | `http_get` | `(url: String) -> String` | Fetch a URL, return the response body as a string | | `http_get_json` | `(url: String) -> Map` | Fetch a URL, parse the response as JSON, return a Rhai map | ### Parsing | Function | Signature | Description | |----------|-----------|-------------| | `parse_json` | `(json_str: String) -> Map` | Parse a JSON string into a Rhai map/array | | `parse_xml` | `(xml_str: String) -> Map` | Parse XML into a nested map with `name`, `attrs`, `text`, `children` keys | | `parse_feed` | `(input: String) -> Map` | Auto-detect RSS/Atom/JSON Feed and parse into a structured map with `title` and `entries` | `parse_feed` is the recommended function for standard feeds. It auto-detects the format (JSON Feed if input starts with `{`, otherwise XML) and extracts normalized fields: `title`, `link`, `id`, `summary`, `author`, `published` (as Unix timestamp), and `tags` for each entry. `parse_xml` is lower-level, useful when `parse_feed` does not cover a custom XML format. Each element becomes a map: `#{ name: "tag", attrs: #{ ... }, text: "...", children: [...] }`. ### Text | Function | Signature | Description | |----------|-----------|-------------| | `html_to_text` | `(html: String) -> String` | Strip HTML tags, render to plain text (80-char line width) | | `truncate` | `(text: String, max_len: Integer) -> String` | Truncate with ellipsis (`...`) if longer than `max_len` | | `str_contains` | `(text: String, pattern: String) -> Boolean` | Check if `text` contains `pattern` | | `str_split` | `(text: String, sep: String) -> Array` | Split string by separator, return array of strings | | `str_replace` | `(text: String, from: String, to: String) -> String` | Replace all occurrences of `from` with `to` | | `str_trim` | `(text: String) -> String` | Trim leading and trailing whitespace | ### Utilities | Function | Signature | Description | |----------|-----------|-------------| | `timestamp_now` | `() -> Integer` | Current UTC time as Unix epoch seconds | | `parse_datetime` | `(date_str: String) -> Integer` | Parse RFC 3339 or RFC 2822 date string to Unix timestamp | | `strip_tracking` | `(url: String) -> String` | Remove tracking query parameters (`utm_*`, `fbclid`, `gclid`, etc.) from a URL | | `parse_int` | `(text: String) -> Integer or ()` | Parse string to integer; returns `()` on failure | ### Debug | Function | Signature | Description | |----------|-----------|-------------| | `debug_print` | `(val: Dynamic) -> ()` | Print a value to the tracing debug log (visible in dev console) | ## Capabilities Declaration Plugins can optionally define a `capabilities()` function to advertise what they support. If omitted, all capabilities default to off and the fetch interval defaults to 900 seconds (15 minutes). ```rhai fn capabilities() { #{ supports_pagination: true, supports_date_filter: true, fetch_interval_secs: 600 } } ``` | Field | Type | Default | Description | |-------|------|---------|-------------| | `supports_pagination` | Boolean | `false` | Plugin returns `next_cursor` for paged fetches | | `supports_search` | Boolean | `false` | Plugin can filter by search query | | `supports_date_filter` | Boolean | `false` | Plugin can filter by date range | | `supports_read_state` | Boolean | `false` | Plugin tracks read/unread server-side | | `supports_starring` | Boolean | `false` | Plugin tracks starred/favorited server-side | | `requires_auth` | Boolean | `false` | Plugin needs authentication credentials | | `fetch_interval_secs` | Integer | `900` | Auto-fetch interval in seconds (0 disables auto-fetch) | ## Config Schema Field Types The `field_type` string in each schema field controls how the UI renders the input: | Type | Description | |------|-------------| | `text` | Single-line text input | | `textarea` | Multi-line text input | | `secret` | Masked password/API key input | | `url` | URL input | | `number` | Numeric input | | `toggle` | Boolean on/off switch | | `select` | Dropdown; requires an `options` array of strings | Each field in the `fields` array supports these properties: | Property | Type | Required | Description | |----------|------|----------|-------------| | `key` | String | Yes | Configuration key (used to access the value in `fetch`) | | `label` | String | Yes | Display label for the form | | `field_type` | String | Yes | One of the types above | | `required` | Boolean | No | Whether the field must be filled in (default: false) | | `description` | String | No | Help text shown below the field | | `default` | String | No | Default value | | `options` | Array | No | Options for `select` type | | `placeholder` | String | No | Placeholder text shown when empty | ## Safety Limits The Rhai engine enforces these limits per plugin execution: - **Max operations:** 100,000 -- Caps total operations per script call. A typical RSS fetch uses 1,000-5,000 operations. This limit catches infinite loops while allowing complex plugins. - **Max expression depth:** 128 -- Limits AST nesting depth for both expressions and function calls, preventing stack overflows from deeply recursive scripts. If a plugin exceeds either limit, the engine terminates execution and returns an error. ## Complete Minimal Example A bare-minimum plugin that fetches an RSS feed: ```rhai fn id() { "minimal-rss" } fn name() { "Minimal RSS" } fn config_schema() { #{ description: "A minimal RSS feed reader.", fields: [ #{ key: "feed_url", label: "Feed URL", field_type: "url", required: true } ] } } fn fetch(config, cursor) { let xml = http_get(config.feed_url); let feed = parse_feed(xml); let items = []; if feed.entries != () { for entry in feed.entries { let title = if entry.title != () { entry.title } else { "Untitled" }; let link = if entry.link != () { entry.link } else { "" }; let published = if entry.published != () { entry.published } else { timestamp_now() }; items.push(#{ id: #{ source: "minimal-rss", item_id: link }, bite: #{ author: "RSS", text: truncate(title, 100) }, content: #{ title: title, body: entry.summary, url: link }, meta: #{ source_name: "RSS", published_at: published, tags: [] } }); } } #{ items: items, has_more: false } } ``` ## Reference Plugins The built-in plugins serve as working examples: | Plugin | Lines | Demonstrates | |--------|-------|--------------| | `rss.rhai` | 148 | `parse_feed` for RSS/Atom, multi-URL support, tag extraction | | `hackernews.rhai` | 228 | `http_get_json` for API calls, pagination with cursors, score metadata | | `arxiv.rhai` | 179 | `parse_xml` for custom XML, `select` config field, category filtering | `rss.rhai` is the canonical example -- it covers the most common pattern (fetch XML, parse with `parse_feed`, map entries to items) in under 150 lines. ## Plugin File Location - **Development:** Place `.rhai` files in the `plugins/` directory at the project root. - **Production:** Place `.rhai` files in `/plugins/`. Bundled plugins are copied there automatically on first launch.