Skip to main content

max / balanced_breakfast

11.5 KB · 325 lines History Blame Raw
1 # Plugin Authoring Guide
2
3 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.
4
5 ## Required Functions
6
7 Every plugin must define these four functions:
8
9 ### `fn id() -> String`
10
11 A unique identifier for this plugin type. Used as a namespace in item IDs and database storage. Must be stable across versions.
12
13 ```rhai
14 fn id() {
15 "my-source"
16 }
17 ```
18
19 ### `fn name() -> String`
20
21 A human-readable display name shown in the UI.
22
23 ```rhai
24 fn name() {
25 "My Source"
26 }
27 ```
28
29 ### `fn config_schema() -> Map`
30
31 Returns a map describing the configuration fields the user must fill in when adding a feed. The UI generates a form from this schema.
32
33 ```rhai
34 fn config_schema() {
35 #{
36 description: "Fetch posts from My Source.",
37 fields: [
38 #{
39 key: "feed_url",
40 label: "Feed URL",
41 field_type: "url",
42 required: true,
43 description: "The URL to fetch",
44 placeholder: "https://example.com/feed"
45 }
46 ]
47 }
48 }
49 ```
50
51 See "Config Schema Field Types" below for all supported `field_type` values.
52
53 ### `fn fetch(config, cursor) -> Map`
54
55 The core function. Called by the runtime to retrieve feed items.
56
57 **Parameters:**
58 - `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.
59 - `cursor` -- A string for pagination (the `next_cursor` from a previous fetch), or `()` (Rhai's unit/nil) on the first call.
60
61 **Must return a map with:**
62 - `items` -- An array of item maps (see "Item Structure" below)
63 - `has_more` -- Boolean indicating whether more pages are available
64 - `next_cursor` -- (Optional) String cursor for the next page
65
66 ```rhai
67 fn fetch(config, cursor) {
68 let xml = http_get(config.feed_url);
69 let feed = parse_feed(xml);
70
71 let items = [];
72 for entry in feed.entries {
73 items.push(#{
74 id: #{ source: "my-source", item_id: entry.id },
75 bite: #{
76 author: "My Source",
77 text: truncate(entry.title, 100),
78 indicator: ">>>"
79 },
80 content: #{
81 title: entry.title,
82 body: entry.summary,
83 url: entry.link
84 },
85 meta: #{
86 source_name: "My Source",
87 published_at: entry.published,
88 tags: []
89 }
90 });
91 }
92
93 #{ items: items, has_more: false }
94 }
95 ```
96
97 ## Item Structure
98
99 Each item in the `items` array must be a map with these four keys:
100
101 ### `id`
102
103 | Field | Type | Description |
104 |-------|------|-------------|
105 | `source` | String | Must match the plugin's `id()` return value |
106 | `item_id` | String | Unique within this source (URL, GUID, or other stable ID) |
107
108 ### `bite`
109
110 Compact display shown in the item list.
111
112 | Field | Type | Description |
113 |-------|------|-------------|
114 | `author` | String | Attribution line (feed name, username, etc.) |
115 | `text` | String | Primary display text (truncated title or summary) |
116 | `secondary` | String or `()` | Optional secondary info (score, comment count) |
117 | `indicator` | String or `()` | Optional type indicator (emoji or short string) |
118
119 ### `content`
120
121 Full content shown in the detail panel.
122
123 | Field | Type | Description |
124 |-------|------|-------------|
125 | `title` | String or `()` | Article/post title |
126 | `body` | String or `()` | Full body text or HTML |
127 | `url` | String or `()` | Link to the original content |
128
129 ### `meta`
130
131 | Field | Type | Description |
132 |-------|------|-------------|
133 | `source_name` | String | Display name for the source |
134 | `published_at` | Integer | Unix timestamp (seconds) of publication |
135 | `score` | Integer or `()` | Optional engagement score (upvotes, likes) |
136 | `tags` | Array | Array of tag strings (can be empty) |
137
138 ## Host Functions
139
140 These functions are registered into the Rhai engine and available to all plugins.
141
142 ### HTTP
143
144 | Function | Signature | Description |
145 |----------|-----------|-------------|
146 | `http_get` | `(url: String) -> String` | Fetch a URL, return the response body as a string |
147 | `http_get_json` | `(url: String) -> Map` | Fetch a URL, parse the response as JSON, return a Rhai map |
148
149 ### Parsing
150
151 | Function | Signature | Description |
152 |----------|-----------|-------------|
153 | `parse_json` | `(json_str: String) -> Map` | Parse a JSON string into a Rhai map/array |
154 | `parse_xml` | `(xml_str: String) -> Map` | Parse XML into a nested map with `name`, `attrs`, `text`, `children` keys |
155 | `parse_feed` | `(input: String) -> Map` | Auto-detect RSS/Atom/JSON Feed and parse into a structured map with `title` and `entries` |
156
157 `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.
158
159 `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: [...] }`.
160
161 ### Text
162
163 | Function | Signature | Description |
164 |----------|-----------|-------------|
165 | `html_to_text` | `(html: String) -> String` | Strip HTML tags, render to plain text (80-char line width) |
166 | `truncate` | `(text: String, max_len: Integer) -> String` | Truncate with ellipsis (`...`) if longer than `max_len` |
167 | `str_contains` | `(text: String, pattern: String) -> Boolean` | Check if `text` contains `pattern` |
168 | `str_split` | `(text: String, sep: String) -> Array` | Split string by separator, return array of strings |
169 | `str_replace` | `(text: String, from: String, to: String) -> String` | Replace all occurrences of `from` with `to` |
170 | `str_trim` | `(text: String) -> String` | Trim leading and trailing whitespace |
171
172 ### Utilities
173
174 | Function | Signature | Description |
175 |----------|-----------|-------------|
176 | `timestamp_now` | `() -> Integer` | Current UTC time as Unix epoch seconds |
177 | `parse_datetime` | `(date_str: String) -> Integer` | Parse RFC 3339 or RFC 2822 date string to Unix timestamp |
178 | `strip_tracking` | `(url: String) -> String` | Remove tracking query parameters (`utm_*`, `fbclid`, `gclid`, etc.) from a URL |
179 | `parse_int` | `(text: String) -> Integer or ()` | Parse string to integer; returns `()` on failure |
180
181 ### Debug
182
183 | Function | Signature | Description |
184 |----------|-----------|-------------|
185 | `debug_print` | `(val: Dynamic) -> ()` | Print a value to the tracing debug log (visible in dev console) |
186
187 ## Capabilities Declaration
188
189 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).
190
191 ```rhai
192 fn capabilities() {
193 #{
194 supports_pagination: true,
195 supports_date_filter: true,
196 fetch_interval_secs: 600
197 }
198 }
199 ```
200
201 | Field | Type | Default | Description |
202 |-------|------|---------|-------------|
203 | `supports_pagination` | Boolean | `false` | Plugin returns `next_cursor` for paged fetches |
204 | `supports_search` | Boolean | `false` | Plugin can filter by search query |
205 | `supports_date_filter` | Boolean | `false` | Plugin can filter by date range |
206 | `supports_read_state` | Boolean | `false` | Plugin tracks read/unread server-side |
207 | `supports_starring` | Boolean | `false` | Plugin tracks starred/favorited server-side |
208 | `requires_auth` | Boolean | `false` | Plugin needs authentication credentials |
209 | `fetch_interval_secs` | Integer | `900` | Auto-fetch interval in seconds (0 disables auto-fetch) |
210
211 ## Config Schema Field Types
212
213 The `field_type` string in each schema field controls how the UI renders the input:
214
215 | Type | Description |
216 |------|-------------|
217 | `text` | Single-line text input |
218 | `textarea` | Multi-line text input |
219 | `secret` | Masked password/API key input |
220 | `url` | URL input |
221 | `number` | Numeric input |
222 | `toggle` | Boolean on/off switch |
223 | `select` | Dropdown; requires an `options` array of strings |
224
225 Each field in the `fields` array supports these properties:
226
227 | Property | Type | Required | Description |
228 |----------|------|----------|-------------|
229 | `key` | String | Yes | Configuration key (used to access the value in `fetch`) |
230 | `label` | String | Yes | Display label for the form |
231 | `field_type` | String | Yes | One of the types above |
232 | `required` | Boolean | No | Whether the field must be filled in (default: false) |
233 | `description` | String | No | Help text shown below the field |
234 | `default` | String | No | Default value |
235 | `options` | Array | No | Options for `select` type |
236 | `placeholder` | String | No | Placeholder text shown when empty |
237
238 ## Safety Limits
239
240 The Rhai engine enforces these limits per plugin execution:
241
242 - **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.
243 - **Max expression depth:** 128 -- Limits AST nesting depth for both expressions and function calls, preventing stack overflows from deeply recursive scripts.
244
245 If a plugin exceeds either limit, the engine terminates execution and returns an error.
246
247 ## Complete Minimal Example
248
249 A bare-minimum plugin that fetches an RSS feed:
250
251 ```rhai
252 fn id() {
253 "minimal-rss"
254 }
255
256 fn name() {
257 "Minimal RSS"
258 }
259
260 fn config_schema() {
261 #{
262 description: "A minimal RSS feed reader.",
263 fields: [
264 #{
265 key: "feed_url",
266 label: "Feed URL",
267 field_type: "url",
268 required: true
269 }
270 ]
271 }
272 }
273
274 fn fetch(config, cursor) {
275 let xml = http_get(config.feed_url);
276 let feed = parse_feed(xml);
277 let items = [];
278
279 if feed.entries != () {
280 for entry in feed.entries {
281 let title = if entry.title != () { entry.title } else { "Untitled" };
282 let link = if entry.link != () { entry.link } else { "" };
283 let published = if entry.published != () { entry.published } else { timestamp_now() };
284
285 items.push(#{
286 id: #{ source: "minimal-rss", item_id: link },
287 bite: #{
288 author: "RSS",
289 text: truncate(title, 100)
290 },
291 content: #{
292 title: title,
293 body: entry.summary,
294 url: link
295 },
296 meta: #{
297 source_name: "RSS",
298 published_at: published,
299 tags: []
300 }
301 });
302 }
303 }
304
305 #{ items: items, has_more: false }
306 }
307 ```
308
309 ## Reference Plugins
310
311 The built-in plugins serve as working examples:
312
313 | Plugin | Lines | Demonstrates |
314 |--------|-------|--------------|
315 | `rss.rhai` | 148 | `parse_feed` for RSS/Atom, multi-URL support, tag extraction |
316 | `hackernews.rhai` | 228 | `http_get_json` for API calls, pagination with cursors, score metadata |
317 | `arxiv.rhai` | 179 | `parse_xml` for custom XML, `select` config field, category filtering |
318
319 `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.
320
321 ## Plugin File Location
322
323 - **Development:** Place `.rhai` files in the `plugins/` directory at the project root.
324 - **Production:** Place `.rhai` files in `<app_config_dir>/plugins/`. Bundled plugins are copied there automatically on first launch.
325