rapier-embed 1.2.33 → 1.2.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Embed Rapier
2
2
 
3
+ Put Rapier's Markdown editor or reader in your web app, each one offline HTML file.
4
+
5
+ <picture>
6
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jackskip22/rapier-plugins/main/assets/write-1280-dark.png">
7
+ <img src="https://raw.githubusercontent.com/jackskip22/rapier-plugins/main/assets/write-1280-light.png" alt="The Rapier editor on a computer: a launch plan with an agent's change marked" width="960">
8
+ </picture>
9
+
10
+ Try the editor at [rapier.website](https://rapier.website).
11
+ Install: `npm install rapier-embed`.
12
+
3
13
  ## Start here
4
14
 
5
15
  For embed-only work, start with the supplied application code and the published embed contract below. Use the declared helper release or packaged helper, keeping source, permissions and storage under the host application. Do not create a hosted document. Keep the embed's own WebMCP contract; do not substitute connector calls for its browser API.
@@ -7,10 +17,10 @@ For embed-only work, start with the supplied application code and the published
7
17
  For an explanation-only request, answer in chat without creating a document. If required content or integration details are missing, ask for them; do not create an empty hosted workspace.
8
18
  Honor no-upload or no-host instructions. The workflow choices below are alternatives, not steps to run. Skip hosted suggestions, including opening a workspace or creating a return link, for local-only or embed-only work.
9
19
 
10
- Embed Rapier's Markdown editor or lightweight reader in your web app. Each build is one offline HTML file. Your
20
+ Embed Rapier's Markdown editor or lightweight reader in your web app. Each build is one offline HTML file. The helper is an ES module: `import {Rapier} from 'rapier-embed'` or `<script type="module">`; there is no classic script or UMD build. Your
11
21
  app owns the document and storage; its agent edits live through WebMCP. The person sees the agent's presence and
12
22
  change marks, taps a change to see the before and undoes anything. Honor the person's Will/1 regions.
13
- The reader is one file of about 360 kB, with optional fonts and plug-ins. The helper is MIT with no dependencies. The two builds it frames are AGPL-3.0-only: use them under the AGPL, or
23
+ The reader is one file of about 380 kB, `rapier.html` about 4.0 MB, with optional fonts and plug-ins. The helper is MIT with no dependencies. The two builds it frames are AGPL-3.0-only: use them under the AGPL, or
14
24
  buy a commercial license for other terms (`LICENSING.md` in the Rapier repository).
15
25
 
16
26
  Choose one of two builds:
@@ -22,8 +32,10 @@ Choose one of two builds:
22
32
 
23
33
  Both render the same Markdown standard with the same layout. Download the reader from the root of the
24
34
  [rapier repository](https://github.com/jackskip22/rapier), or frame the hosted copies:
25
- `https://rapier.website/embed/rapier-reader.html` and `https://rapier.website/embed/rapier.html` (current).
26
- Each release's own files stay at its tag: `https://cdn.jsdelivr.net/gh/jackskip22/rapier@v<version>/site/embed/<file>` (download a copy to pin a version).
35
+ `https://rapier.website/embed/rapier-reader.html` and `https://rapier.website/embed/rapier.html` (current). Both live under `/embed/`: `/rapier-reader.html` at the site root is a 404.
36
+ Each release's own files stay at its tag: `https://cdn.jsdelivr.net/gh/jackskip22/rapier-plugins@cdn-v<version>/embed/<file>`. That address is for
37
+ download only: jsDelivr serves the page as `text/plain` with `nosniff`, so a browser will not frame it. To pin a version, serve the
38
+ downloaded copy yourself and pass it as `src`.
27
39
 
28
40
  `rapier-embed` mounts the reader or the editor in an iframe and speaks the
29
41
  [embed contract](https://github.com/jackskip22/rapier/blob/main/docs/embed-contract.md) for you.
@@ -38,7 +50,7 @@ Each release's own files stay at its tag: `https://cdn.jsdelivr.net/gh/jackskip2
38
50
  document access remain enforced.
39
51
 
40
52
  ```sh
41
- npm install rapier-embed@1.2.33
53
+ npm install rapier-embed@1.2.42
42
54
  ```
43
55
 
44
56
  Or copy the package's `embed.mjs` and `contract.mjs` into one directory of your app; no runtime CDN is needed.
@@ -52,11 +64,11 @@ sends Markdown only when saving; change notifications contain no source.
52
64
  Rapier is a phone-first Markdown editor for notes, diagrams, drawing and watercolor painting. It runs offline
53
65
  as one HTML file on Android, Web, Windows and Linux.
54
66
  Collaborate over MCP and WebMCP, use optional plain-file sync to your own cloud storage, or embed the
55
- editor and lightweight reader (about 360 kB). The agent edits live; the person sees its presence and
67
+ editor and lightweight reader (about 380 kB). The agent edits live; the person sees its presence and
56
68
  changes, taps a change to see the before and undoes anything. Use `show {target:"comparison"}` to show a diff deliberately.
57
69
 
58
70
  1. **Work together live.** Connect to `https://mcp.rapier.website/door/mcp`, then call `open` to share one document with the person.
59
- 2. **Deliver an offline editor.** Run `npx rapier-html@1.2.33 notes.md` to put the document and editor in one HTML file ([rapier-html](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-html)).
71
+ 2. **Deliver an offline editor.** Run `npx rapier-html@1.2.42 notes.md` to put the document and editor in one HTML file ([rapier-html](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-html)).
60
72
  3. **Embed in your app.** Install [rapier-embed](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-embed); mount the editor or reader, with your app's agent over WebMCP in the editor.
61
73
  4. **Keep portable Markdown.** Use [rapier-markdown-kit](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-markdown-kit) for pictures, editable SVG drawings and layout in one `.md` file. Respect [Will/1](https://github.com/jackskip22/will) `keep`, `append` and `edit` regions.
62
74
  5. **Encode JPEG XL.** Install [rapier-jxl](https://github.com/jackskip22/rapier-jxl) to encode pixels, photographs and existing JPEGs in JavaScript.
@@ -92,14 +104,44 @@ Its `settings` features are `find`, `readAloud` and `share`; pass `settings: {fe
92
104
  others out. For a plain iframe, put the options in its address:
93
105
  `<iframe src="/vendor/rapier/rapier-reader.html?plugins=/vendor/rapier/plugins/">`.
94
106
 
107
+ ## Where it can be framed
108
+
109
+ The frame needs the host page to be an `https` page, or `http` on `localhost`. `Rapier.mount` checks this before it requests anything, and `ready` rejects at once with a code:
110
+
111
+ | Host page | `ready` rejects with | Why and what to do |
112
+ | --- | --- | --- |
113
+ | `file://`, `app://` or another custom scheme | `host_scheme_unsupported` | The page has no origin the frame can answer. Serve the app over https, or http on localhost. |
114
+ | `http://` on a LAN address or any host but `localhost` | `host_not_secure` | The hosted copies allow https and localhost parents only. Serve the app over https, or serve a copy of the page and set `src`. |
115
+ | Cross-origin isolated (COEP `require-corp`) | `host_cross_origin_isolated` | The hosted copies carry no CORP or COEP header. Serve a copy with those headers and set `src`, or drop COEP. |
116
+ | A page with an opaque origin (a sandbox without `allow-same-origin`: an AI artifact, a playground preview) | `host_sandboxed` | The hosted copies refuse such a page. Give it an origin, or serve a copy of the page and set `src`. |
117
+ | An `http://` page around yours that is not `localhost` | `host_not_secure` | Every page around yours must be https (or localhost) for the hosted copies. |
118
+ | The browser is offline | `offline` | The hosted copies cannot be fetched. Mount again when the network is back, or serve a copy and set `src`. |
119
+ | Your own `<iframe sandbox>` without `allow-scripts` or `allow-same-origin` | `sandbox_token_missing` | `mount` never widens a sandbox you wrote. Add the token, or pass a container and let `mount` create the iframe (it sets `allow-scripts allow-same-origin allow-downloads`). |
120
+
121
+ A frame that loads but never connects rejects `ready` and calls `onError` with `connect_timeout` once `timeout` (15 seconds) has passed since the frame's load event, so a slow download is not counted. A frame that never finishes loading rejects with `load_timeout` after 120 seconds. A frame that connects after `connect_timeout` still gets its document and its handle works (`connected` is true and the `connected` event fires); `ready` has already rejected, so wait for the event instead. A host whose own `frame-src` (or `default-src`) does not list the frame's origin blocks it the same way; the browser console names the policy. A page that does not allow your parent is the usual cause: the hosted pages send `frame-ancestors https: http://localhost:* http://127.0.0.1:*`, so a parent on `file://`, a custom scheme or plain http on a LAN address is blocked and never connects (`mount` names the first three at once, above). A copy you serve sets its own `frame-ancestors` (an HTTP response header; a `<meta>` tag cannot set it); list your parent's origin there. A plain `<iframe src=".../rapier.html">` with no helper opens an empty document when the parent shares the page's origin. From another origin it stays closed and says why. With `?embed=1` and no helper connected, the reader says "No document is loaded."
122
+
123
+ ### Keep typing when the tab closes
124
+
125
+ A reload, a route change and an unmount keep the person's typing for the next mount of the same document (see below). Closing the tab does not: its session storage goes with it. Ask the browser to confirm while something is unsaved:
126
+
127
+ ```js
128
+ let dirty = false;
129
+ const editor = Rapier.mount(element, {save, load, onState: state => { dirty = state.dirty; }});
130
+ addEventListener('beforeunload', event => { if (dirty) event.preventDefault(); });
131
+ ```
132
+
133
+ ### Open a document by link
134
+
135
+ The reader on its own page opens a document at an address on its own origin: `https://your.site/vendor/rapier/rapier-reader.html#src=/plans/a.md`. Word and PDF files open the same way. An address on another origin is refused in words, because the page's security policy reads only its own origin. A framed reader takes its document from `load` and ignores the link. The hosted page, `https://rapier.website/embed/rapier-reader.html`, has no documents of yours on its origin: for it, use `Rapier.mount`.
136
+
95
137
  ## Keep Rapier in your codebase
96
138
 
97
139
  A copy in your app works offline and changes only when you update it.
98
140
 
99
141
  1. Download the build you frame into a static directory, for example
100
- `https://cdn.jsdelivr.net/gh/jackskip22/rapier@v<version>/site/embed/rapier-reader.html` to `public/vendor/rapier/`.
142
+ `https://cdn.jsdelivr.net/gh/jackskip22/rapier-plugins@cdn-v<version>/embed/rapier-reader.html` to `public/vendor/rapier/`.
101
143
  2. Fetch the plug-ins your build uses into one directory beside it, from the manifest of the same version:
102
- `npx rapier-embed@1.2.33 plugins public/vendor/rapier/plugins --build reader`.
144
+ `npx rapier-embed@1.2.42 plugins public/vendor/rapier/plugins --build reader`.
103
145
  3. Mount with `src` and `plugins` pointing at your copies. Serve them over HTTPS (HTTP on localhost) and allow
104
146
  their origin in your `frame-src`. A same-origin parent can script its frame; use a dedicated origin when the
105
147
  frame must be isolated.
@@ -111,18 +153,28 @@ To update, replace the page and its plug-ins with those of the new version toget
111
153
  Large optional renderers are plug-ins: pinned files a page fetches the first time a document needs one, checks by
112
154
  length and SHA-384 before it runs it, and keeps on the device. A file that fails its check is refused.
113
155
 
114
- | Plug-in | Adds | Size | Builds |
156
+ <!-- PLUGIN_TABLE_BEGIN (tools/embed-plugin-table.mjs writes this table) -->
157
+ | Plug-in | Adds | Size | Pages |
115
158
  | --- | --- | --- | --- |
116
- | `flowchart` | Mermaid flowcharts and graphs, drawn natively | 0.26 MB | The reader; built into `rapier.html` |
117
- | `math` | TeX math, `$...$` and `$$...$$` | 11.9 MB | Both |
118
- | `diagrams` | Mermaid 12.1.0 and ZenUML 4.5.0 diagrams | 7.38 MB (2 files) | Both |
119
- | `pdf` | Opens a PDF as a Markdown document: its text layer, or each page as a picture | 6.4 MB (197 files) | Both |
120
- | `docx` | Opens a Word document (`.docx`) as a Markdown document | 0.25 MB | The reader; built into `rapier.html` |
121
- | `ocr` | Finds text in pictures, so search reaches words inside them | 21.5 MB | Both |
122
- | Letter sets | Draw's ornamental capitals | 0.1 MB each | `rapier.html` |
123
-
124
- The manifest lists every plug-in file and, in `builds`, the pages that use it. The reader uses `flowchart`, `docx`, `math`,
125
- `diagrams` and `pdf`.
159
+ | `flowchart` | Draws Mermaid flowcharts and graphs natively, in the editor's own style. | 0.33 MB | The reader |
160
+ | `docx` | Opens a Word document (.docx) as a Markdown document: headings, lists, tables, pictures and comments. | 0.61 MB | The reader |
161
+ | `math` | Typesets TeX math ($...$ and $$...$$) as SVG. | 0.74 MB | Both |
162
+ | `diagrams` | Draws every other Mermaid diagram: sequence, class, state, ER, Gantt, pie and more. | 1.14 MB (2 files) | Both |
163
+ | `pdf` | Opens a PDF as a Markdown document: its text layer, or each page as a picture. Fetches the pdf.js files itself. | 6.13 MB (198 files) | Both |
164
+ | `ocr` | Reads the words in pictures on the device (PP-OCRv6 tiny on ONNX Runtime Web), so search finds them. About 21 MB. | 21.7 MB (10 files) | Both (the reader: only with `--with ocr`) |
165
+ | `font-subset` | Keeps the used characters in fonts embedded in exported pages. | 1.21 MB | `rapier.html` |
166
+ | `letters` | Draw's ornamental letter sets: Field, Relief, Leaf, Arabesque, Silhouette, Gothic, Sugar, Illuminated 1490, Tuscan 1845, Renaissance alphabet. | 1.31 MB (10 files) | `rapier.html` |
167
+ | `templates` | Starters' traced page borders: 72 borders from the ornament book. | 6.03 MB (72 files) | `rapier.html` |
168
+ | `elements` | The Elements sheet's traced ornaments: 3,155 pieces from the ornament book. | 16.2 MB (51 files) | `rapier.html` |
169
+ | `lettersets` | The Gallery's letter sets: 31 sets of ornamental capitals. | 2.82 MB (31 files) | `rapier.html` |
170
+
171
+ Totals: the reader, 8.95 MB (30.6 MB with `ocr`); `rapier.html`, 57.2 MB. Release 1.2.42.
172
+ <!-- PLUGIN_TABLE_END -->
173
+
174
+ The manifest lists every plug-in file and, in `builds`, the pages that use it. `rapier.html` has flowcharts and Word import
175
+ built in. `npx rapier-embed@1.2.42 plugins <directory> --build reader` leaves out `ocr` (text in pictures) unless you add `--with ocr`.
176
+
177
+ **Approve plug-ins up front.** Without `plugins`, a frame asks its person "Install math plug-in?" the first time a document needs math or a diagram, and math and diagrams stay raw until they answer. A host that has decided passes `approvePlugins: true`: the frame installs the plug-in it needs without a question. Naming `plugins` has the same effect, because the host then serves the files itself. In a frame shorter than the question's sheet, the sheet's body scrolls.
126
178
 
127
179
  **With `plugins`.** With `plugins` set, a page (the reader or `rapier.html`) reads every plug-in from
128
180
  that directory, by each file's path in the manifest, and makes no other request, so it works offline and under a strict
@@ -133,7 +185,7 @@ The directory must be on the page's own origin, because the page's Content Secur
133
185
  throws a `TypeError` for a directory on another origin, and a page opened with one shows each plug-in as failed, with the
134
186
  reason, without making a request. To keep the plug-ins on a CDN, serve the page from the same origin.
135
187
 
136
- `npx rapier-embed@1.2.33 plugins <directory>` fetches every file the manifest lists, checks it, and writes it. It
188
+ `npx rapier-embed@1.2.42 plugins <directory>` fetches every file the manifest lists, checks it, and writes it. It
137
189
  keeps a file that is already there and correct, and exits with status 1 if any file is missing or fails its check.
138
190
  `--build reader` or `--build full` keeps the files that page uses, `--only name,name` fetches some
139
191
  plug-ins, `--check` verifies a directory without fetching, `--from <address>` fetches from your own mirror, and
@@ -145,7 +197,7 @@ manifest must be present unless you supply one. Only regular files are accepted
145
197
  symbolic links in plug-in paths are refused. Keep the directory under your control while the command runs.
146
198
 
147
199
  The manifest, `rapier-plugins.json`, ships in the package and stands beside each published reader at
148
- `https://cdn.jsdelivr.net/gh/jackskip22/rapier@v<version>/site/embed/rapier-plugins.json`. Use the manifest of the same version as your
200
+ `https://cdn.jsdelivr.net/gh/jackskip22/rapier-plugins@cdn-v<version>/embed/rapier-plugins.json`. Use the manifest of the same version as your
149
201
  `rapier-reader.html`: each reader pins the files it was built with. It names every plug-in, what it adds, and each
150
202
  file's `file` (a path: the PDF reader's pdf.js files keep their folders), `bytes`, `sha384` (hex), `sri` (base64)
151
203
  and source `url`. Without Node, fetch and check the files like this:
@@ -160,7 +212,18 @@ done
160
212
 
161
213
  **Without `plugins`.** `rapier.html` looks for the `math` and `diagrams` files beside the page,
162
214
  at the file names the manifest lists, before their pinned addresses. The
163
- PDF reader, text in pictures and letter sets come from their pinned addresses.
215
+ PDF reader, text in pictures and letter sets come from their pinned addresses. The reader reads every plug-in from its
216
+ pinned address and never looks beside itself.
217
+
218
+ ## Relative pictures
219
+
220
+ A relative picture address in a document (`![](images/a.png)`) is read from the address you give in `baseUrl`, an absolute https address (http on `localhost`, `127.0.0.1` or `[::1]` too, so you can try it locally), in `load` or `handle.load()`. It is an option of `load`, not of `mount`:
221
+
222
+ ```js
223
+ await reader.load(planText, {revision: 2, baseUrl: 'https://example.com/docs/plan/'}); // images/a.png -> https://example.com/docs/plan/images/a.png
224
+ ```
225
+
226
+ The Markdown keeps the address as written; only the displayed address changes, and a save writes it back unchanged. Without `baseUrl`, a relative picture resolves against Rapier's own origin and does not load. Your page's security policy and the picture host's CORS rules still apply. A `baseUrl` that is neither is refused with `invalid_base_url`.
164
227
 
165
228
  ## Style the reader
166
229
 
@@ -235,12 +298,11 @@ const editor = Rapier.mount(document.querySelector('#editor'), {
235
298
  await editor.ready;
236
299
  ```
237
300
 
238
- Mount takes a container or an iframe and owns its navigation, title, sandbox (`allow-scripts allow-same-origin
239
- allow-downloads`) and clipboard permission. It adds `?embed=1`, keeps any other query, binds its listener before
301
+ Mount takes a container or an iframe and owns its navigation, title and clipboard permission. On an iframe it creates it sets the sandbox `allow-scripts allow-same-origin allow-downloads`. On an iframe you pass, a `sandbox` you wrote stays as written, and `ready` rejects with `sandbox_token_missing` if it lacks `allow-scripts` or `allow-same-origin`. It adds `?embed=1`, keeps any other query, binds its listener before
240
302
  navigation, and accepts readiness only from that iframe's window and exact origin. HTTPS is required, except HTTP
241
303
  on localhost.
242
304
 
243
- `load` is a record or an async callback returning `{content, revision, filename?, readOnly?, title?}`. The `save`
305
+ `load` is a record or an async callback returning `{content, revision, filename?, readOnly?, title?, baseUrl?}`. The `save`
244
306
  callback receives `{content, filename, docKind, codeLang, requestId, baseRevision}` and returns `{revision}` only
245
307
  after durable storage. A repeated request ID shares the first write and repeats its first answer, even while that
246
308
  write is pending. A repeated ID with different source, metadata or base revision is refused as
@@ -249,6 +311,10 @@ Oversized source is `document_too_large`; ill-formed Unicode and malformed metad
249
311
  The helper holds answers for its lifetime; your store must also deduplicate IDs across host
250
312
  reloads. Keep stable session and document IDs when you intend to recover a session; omitted IDs are generated.
251
313
 
314
+ Switch documents with `editor.load(content, {revision, filename})` or `editor.load({content, revision, filename})`. `revision` is optional in `load()`: left out, the helper uses one above the last revision it saw, or 1. `mount`'s `load` needs it. A load always shows and saves the document you gave it. It is refused by code while the person has unsaved edits (`unsaved_changes`; call `save()` first), and for the same filename with a revision older than the frame's (`revision_stale`) or with the same revision and other content (`revision_conflict`). Another filename is another document with its own revisions.
315
+
316
+ Reading the text: the editor sends source only to your `save` callback. `save()` with no edits since the last load or save resolves the current revision and does not call your callback. There is no read that skips `save`; keep the last saved text in memory if you need it.
317
+
252
318
  A save delayed beyond fifteen seconds remains pending in the editor; it can still be confirmed. `editor.save()`
253
319
  waits for the editor to accept the durable revision with its latest edits saved. An unacknowledged write never
254
320
  becomes a successful save because time passed. On conflict, throw `Rapier.conflict(currentRevision)`; the person's
@@ -323,27 +389,43 @@ apply the old insertion to the replacement document. Asset failures do not rejec
323
389
  | `assets(request)` | Grants `assets` only; the callback stores picture bytes and returns `{url}` |
324
390
  | `settings`, `theme`, `style` | Session settings, `'light'`, `'dark'` or `'system'`, and the reader's look |
325
391
  | `plugins` | The directory that holds the page's plug-in files, on the page's own origin (a relative address is read from your page); the page then fetches from nowhere else. Either build |
392
+ | `approvePlugins` | `true`: install math and diagram plug-ins without asking the person. Naming `plugins` does the same. Default `false` |
326
393
  | `title`, `onError(error)` | The iframe's accessible title, and an error listener |
394
+ | `timeout` | Milliseconds after the frame loads that it has to connect before `ready` rejects with `connect_timeout`; default 15000 |
327
395
 
328
396
  The handle has `iframe`, `ready` (a promise) and `connected`. `load(content, {revision, filename?, readOnly?,
329
397
  title?})`, `save()`, `compare(content, {filename?})` and `close()` use their grants. `theme(value)` changes the
330
398
  host theme; `style({css, fonts})` restyles the reader; `disconnect()` ends the connection, rejects unfinished
331
399
  operations and removes the iframe that mount created in a container (an iframe you passed stays). Mount does not size
332
400
  a frame it creates: give it a size in your CSS, for example `#editor iframe {width: 100%; height: 100%; border: 0}`.
401
+ Mount adds the frame after what the container already holds: clear a server-rendered placeholder first (`container.replaceChildren()`). The handle lives as long as its iframe is in the page: if a framework removes it (`innerHTML`, `remove()`) without `disconnect()`, `connected` turns false, an unfinished `save()` rejects, and later calls reject `frame_removed`. The person's typing stays as a draft for the next mount of the same document.
402
+ Tab walks every control and link in the frame, except inside the editor text, where Tab indents. Escape, with nothing open in the frame and no text field focused, moves
403
+ focus to the next control on your page after the frame; Shift+Escape moves it to the control before. From the editor text the way out is Escape, then Tab to a control, then Escape.
333
404
  `on('connected' | 'state' | 'close-request' | 'closed' | 'error', fn)` returns an
334
405
  unsubscribe function. State is `{loaded, dirty, saving, closing, readOnly, filename, docKind}`. Mount a fresh frame to
335
406
  change grants or settings. A denied or dirty replacement load rejects.
336
407
 
337
408
  Every refusal is an `Error` with a `code`. A connect the frame refuses rejects `ready`: `contract_unsupported`,
338
- `capabilities_invalid`, a `settings_*` code, or `invalid_payload` with a `reason`. `save()` resolves with
339
- `{revision}` once the frame holds no newer edits, and rejects with `conflict`, `failed` (your callback threw),
409
+ `capabilities_invalid`, a `settings_*` code, or `invalid_payload` with a `reason`. A frame that cannot start rejects
410
+ `ready` and calls `onError`: `boot_failed` when the page shows its startup error, `connect_timeout` when it does not connect
411
+ within `timeout` of loading, `load_timeout` when it does not load in 120 seconds. `load()` rejects with `document_unshowable` when the
412
+ reader cannot show the document; the reader keeps the document it showed. It rejects with `revision_stale` for a
413
+ revision older than the frame's and `revision_conflict` for the same revision with other content. It rejects with `document_open_elsewhere` when another editor already has the same `sessionId` and `documentId` open (on mount, `ready` rejects with it); the first editor keeps its typing. `mount` throws a
414
+ `TypeError` with a code for an unknown option (`option_unknown`), a build whose `src` names the other page (`build_mismatch`)
415
+ and a reader feature the reader does not have (`settings_feature_unavailable`). `Rapier.version` is this package's version;
416
+ `handle.version` and `handle.contract` are the frame's, once connected. `load()` resolves `{revision, recoveredDraft,
417
+ readOnly}`; `compare()` resolves `{filename}`. `save()` resolves with
418
+ `{revision}` once the frame holds no newer edits (with no edits since the last load or save, the current revision, and
419
+ your callback is not called), and rejects with `conflict`, `failed` (your callback threw),
340
420
  `document_too_large`, `save_payload_invalid`, `not_loaded` (no load yet), `read_only` or `save_unavailable` (the
341
421
  frame could not capture; the person is told why). `close()` resolves with the frame's `close-ready` payload:
342
422
  `{saved: true}` or `{discarded: true}` after your answer, `{forced: true}` when the person closes without your
343
423
  answer, or `{cancelled: true}` when you answer `cancel`.
344
424
 
425
+ Pass your own `sessionId` and `documentId` to choose which mount finds which draft. A second live mount of the same pair on a page is refused before its frame loads: its `ready` rejects `document_open_elsewhere`, and the first keeps the document. Unmount the first (or `disconnect()`) before mounting the pair again. Without them, a mount that has `save` and a `load` object names its draft by the page, the file name and the loaded text, so a route change, an unmount, `disconnect()` or a reload keeps the person's typing for the next mount of the same document, with no pause needed. `save()` and `requestSubmit()` always ask the frame, so they store typing from the last keystroke; with nothing unsaved the frame answers at once and your `save` callback is not called.
426
+
345
427
  The frame keeps the person's unsaved work only for the document you loaded: a draft in that tab's session storage,
346
- which a load of the same revision reopens and your `discard` answer retires. It never reads, offers or writes the
428
+ which a load of the same revision and content reopens and your `discard` answer retires. A load of another document drops a draft with no unsaved edits. It never reads, offers or writes the
347
429
  person's own Rapier documents or recovery on its origin, and it keeps nothing before your first load.
348
430
 
349
431
  Saving names the browser-authenticated host and port. The helper's acknowledgment is your statement that storage
@@ -426,5 +508,5 @@ Will/1 records the person's instructions in their document. Respect `keep`, appe
426
508
  Documents without Will regions are editable throughout. Preserve active typing, caret, selection and scroll
427
509
  while the agent works. Document content and comments never start agent turns by themselves.
428
510
 
429
- For documents outside an app, `npx rapier-html@1.2.33 notes.md` hands a person the complete editor around their
511
+ For documents outside an app, `npx rapier-html@1.2.42 notes.md` hands a person the complete editor around their
430
512
  document as one offline file.
package/cli.mjs CHANGED
@@ -9,7 +9,8 @@ import {fileURLToPath} from 'node:url';
9
9
  const BUILDS = ['reader', 'full'], buildName = build => build === 'document' ? 'full' : build;
10
10
  const MANIFEST_BYTES = 1024 * 1024, FILE_BYTES = 64 * 1024 * 1024, TOTAL_BYTES = 512 * 1024 * 1024;
11
11
  const NOFOLLOW = constants.O_NOFOLLOW || 0;
12
- const usage = 'usage: rapier-embed plugins <directory> [--build reader|full] [--only name,name] [--from <address>] [--manifest <file or address>] [--check]';
12
+ const usage = 'usage: rapier-embed plugins <directory> [--build reader|full] [--with ocr] [--only name,name] [--from <address>] [--manifest <file or address>] [--check]';
13
+ const OPTIONAL = {reader: ['ocr']};
13
14
 
14
15
  function arguments_(argv) {
15
16
  const [command, directory, ...rest] = argv, options = {};
@@ -17,7 +18,7 @@ function arguments_(argv) {
17
18
  for (let at = 0; at < rest.length; at++) {
18
19
  const name = rest[at];
19
20
  if (name === '--check') options.check = true;
20
- else if (['--build', '--only', '--from', '--manifest'].includes(name) && rest[at + 1] !== undefined) options[name.slice(2)] = rest[++at];
21
+ else if (['--build', '--with', '--only', '--from', '--manifest'].includes(name) && rest[at + 1] !== undefined) options[name.slice(2)] = rest[++at];
21
22
  else throw new Error(usage);
22
23
  }
23
24
  if (options.build !== undefined) options.build = buildName(options.build);
@@ -60,8 +61,15 @@ async function fetchChecked(address, limit) {
60
61
  const url = new URL(address);
61
62
  if (url.username || url.password || !(url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
62
63
  throw new Error('a plug-in address needs HTTPS, or HTTP on localhost, without credentials');
63
- const response = await fetch(url, {credentials: 'omit', referrerPolicy: 'no-referrer', redirect: 'error', signal: AbortSignal.timeout(60000)});
64
- if (!response.ok) { await response.body?.cancel(); throw new Error('HTTP ' + response.status + ' from ' + url.origin); }
64
+ const ask = () => fetch(url, {credentials: 'omit', referrerPolicy: 'no-referrer', redirect: 'error', signal: AbortSignal.timeout(60000)});
65
+ let response = await ask();
66
+ if (response.status === 503 || response.status === 504) {
67
+ await response.body?.cancel();
68
+ await new Promise(done => setTimeout(done, 2000));
69
+ response = await ask();
70
+ if (!response.ok) { await response.body?.cancel(); throw new Error('HTTP ' + response.status + ' from ' + url.href + ' twice; try again later, or fetch from another address with --from'); }
71
+ }
72
+ if (!response.ok) { await response.body?.cancel(); throw new Error('HTTP ' + response.status + ' from ' + url.href); }
65
73
  const length = response.headers.get('content-length');
66
74
  if (length !== null && (!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length)) || Number(length) > limit)) {
67
75
  await response.body?.cancel(); throw new Error('response exceeds the permitted ' + limit + ' bytes');
@@ -102,11 +110,16 @@ export async function plugins(argv) {
102
110
  let failed = 0;
103
111
  await mkdir(options.directory, {recursive: true});
104
112
  const directory = await realpath(options.directory);
105
- const uses = (plugin, file) => !options.build || (file.builds || plugin.builds || BUILDS).map(buildName).includes(options.build);
113
+ const wanted = new Set(options.with ? options.with.split(',') : []);
114
+ for (const name of wanted) if (!names.has(name)) throw new Error('no plug-in named ' + name + '; the manifest has ' + [...names].join(', '));
115
+ const optional = plugin => !!options.build && (OPTIONAL[options.build] || []).includes(plugin.name) && !wanted.has(plugin.name) && !only?.has(plugin.name);
116
+ const uses = (plugin, file) => !options.build || !optional(plugin) && (file.builds || plugin.builds || BUILDS).map(buildName).includes(options.build);
106
117
  const jobs = manifest.plugins.filter(plugin => !only || only.has(plugin.name)).flatMap(plugin => plugin.files.filter(file => uses(plugin, file)).map(file => ({plugin, file})));
107
118
  if (!jobs.length) throw new Error('nothing in the manifest matches that --build and --only');
108
119
  const total = jobs.reduce((sum, {file}) => sum + file.bytes, 0);
109
- console.log(jobs.length + ' files, ' + (total / 1048576).toFixed(1) + ' MiB' + (options.build ? ' for the ' + options.build + ' build' : ' for every build; --build reader or full keeps only what that page uses'));
120
+ const skipped = manifest.plugins.filter(optional).map(plugin => plugin.name);
121
+ console.log(jobs.length + ' files, ' + (total / 1048576).toFixed(1) + ' MiB' + (options.build ? ' for the ' + options.build + ' build' : ' for every build; --build reader or full keeps only what that page uses') +
122
+ (skipped.length ? '; without ' + skipped.join(', ') + ' (add --with ' + skipped.join(',') + ')' : ''));
110
123
  const work = async ({plugin, file}) => {
111
124
  const name = plugin.name + ' ' + file.file;
112
125
  let temp = null;
package/contract.d.mts CHANGED
@@ -11,6 +11,8 @@ export const ASSET_TYPES: readonly AssetType[];
11
11
  /** Every refusal is an Error with a code. */
12
12
  export interface Refusal extends Error { code: string; }
13
13
  export function refusal(code: string, reason?: string): Refusal;
14
+ /** A refusal of any error type (TypeError for a bad option) with its code. */
15
+ export function coded<T extends ErrorConstructor>(code: string, Type: T, message: string): InstanceType<T> & {code: string};
14
16
  /** A session's settings. Absent features mean everything the build has; an empty list means none. */
15
17
  export interface Settings {
16
18
  features?: Feature[];
@@ -43,3 +45,6 @@ export function bytesHash(bytes: BufferSource): Promise<string>;
43
45
  export function externalImage(raw: string, asset: {label: string; title?: string}, url: string | null | undefined): string;
44
46
  /** The editor page's picture exchange; used by the page itself. */
45
47
  export function createAssetBroker(ports: Record<string, unknown>): unknown;
48
+ /** Not a mount option: the option is `baseUrl` on `load`. The host's picture base: returns a function that turns a relative picture address into an address under `baseUrl` (or null), with
49
+ * `vouched`, the addresses it produced. Null when no base is given; throws `invalid_base_url` for anything but an absolute https address (or http on localhost). Used by the page itself. */
50
+ export function pictureBase(value: string | null | undefined): (((src: string) => string | null) & {vouched: Set<string>}) | null;
package/contract.mjs CHANGED
@@ -4,6 +4,7 @@ export const LIMITS = Object.freeze({documentBytes: 64 * 1024 * 1024, pictureByt
4
4
  export const ASSET_TYPES = Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/jxl', 'image/svg+xml']);
5
5
  const ACCENTS = Object.freeze(['Teal', 'Blue', 'Amber', 'Green', 'Red', 'Purple', 'Pink', 'Gray']);
6
6
  export const refusal = (code, reason = code) => Object.assign(new Error(reason), {code});
7
+ export const coded = (code, Type, message) => Object.assign(new Type(message), {code});
7
8
  const record = value => value !== null && typeof value === 'object' && !Array.isArray(value) &&
8
9
  (Object.getPrototypeOf(value) === null || Object.getPrototypeOf(Object.getPrototypeOf(value)) === null);
9
10
  const only = (value, keys) => Object.keys(value).every(key => keys.includes(key));
@@ -237,3 +238,19 @@ export function createAssetBroker({current, send}) {
237
238
  function lookup(url) { scope(); return retained.get(url)?.asset || null; }
238
239
  return Object.freeze({request, answer, cancel, lookup});
239
240
  }
241
+
242
+ export function pictureBase(value) {
243
+ if (value === undefined || value === null) return null;
244
+ let base;
245
+ try {
246
+ if (typeof value !== 'string' || !value || value.length > 4096 || /[\s\u0000-\u001f\u007f<>\\]/u.test(value)) throw 0;
247
+ base = new URL(value);
248
+ if (base.username || base.password || !(base.protocol === 'https:' || base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname))) throw 0;
249
+ } catch (_) { throw refusal('invalid_base_url', 'baseUrl is an absolute https address, or http on localhost.'); }
250
+ const vouched = new Set();
251
+ return Object.assign(src => {
252
+ const text = String(src || '').trim();
253
+ if (!text || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(text)) return null;
254
+ try { const href = new URL(text, base).href; vouched.add(href); return href; } catch (_) { return null; }
255
+ }, {vouched});
256
+ }
package/embed.d.mts CHANGED
@@ -10,11 +10,17 @@ export type DocKind = 'markdown' | 'text' | 'code';
10
10
  /** `full` is rapier.html (Draw, Paint, Notes, agents); `reader` is the read-only reader. `document` names `full`. */
11
11
  export type Build = 'full' | 'reader' | 'document';
12
12
 
13
- export interface LoadRecord { content: string; revision: Revision; filename?: string; readOnly?: boolean; title?: string; }
13
+ export interface LoadRecord { content: string; revision: Revision; filename?: string; readOnly?: boolean; title?: string;
14
+ /** An absolute https address (http on localhost) that relative pictures in this document are read from. The Markdown keeps what it says. */
15
+ baseUrl?: string; }
14
16
  export interface SaveRequest { content: string; filename: string; docKind: DocKind; codeLang: string; requestId: string; baseRevision: Revision; }
15
17
  export interface DocumentState { loaded: boolean; dirty: boolean; saving: boolean; closing: boolean; readOnly: boolean; filename?: string; docKind?: DocKind; }
16
18
  export interface AssetRequest { requestId: string; baseRevision: Revision; bytesSha256: string; mediaType: AssetType; bytes: Uint8Array; signal: AbortSignal; }
17
19
  export interface ReaderStyle { css?: string; fonts?: {family: string; source: string | ArrayBuffer | ArrayBufferView; descriptors?: FontFaceDescriptors}[]; }
20
+ /** What load() resolves: the revision the frame now holds, whether it brought back the person's unsaved draft, and read-only. */
21
+ export interface LoadResult { revision: Revision; recoveredDraft: boolean; readOnly: boolean; }
22
+ /** What compare() resolves: the name the comparison shows. */
23
+ export interface CompareResult { filename: string; }
18
24
  export type CloseAnswer = 'save' | 'discard' | 'cancel';
19
25
  export type CloseResult = {saved: true} | {discarded: true} | {forced: true} | {cancelled: true};
20
26
 
@@ -40,7 +46,8 @@ export interface MountOptions {
40
46
  agentName?: string;
41
47
  /** Grants `assets`: store the picture bytes and return their absolute HTTPS address. */
42
48
  assets?(request: AssetRequest): Promise<{url: string}>;
43
- /** Chooses the file the person opens; resolves null when canceled. */
49
+ /** Chooses the file the person opens when they tap Open in the frame (the host's own picker, for example in a WebView).
50
+ * Called with the frame's request; resolve a File, or null when the person cancels. Omitted, the browser's file picker. */
44
51
  openFile?(request: {requestId: string; baseRevision: Revision; signal: AbortSignal}): File | null | Promise<File | null>;
45
52
  settings?: Settings;
46
53
  theme?: Theme;
@@ -48,8 +55,12 @@ export interface MountOptions {
48
55
  style?: ReaderStyle;
49
56
  /** The directory that holds the plug-in files, on the page's own origin. */
50
57
  plugins?: string;
58
+ /** true: the frame installs math and diagram plug-ins without asking the person. Naming `plugins` has the same effect. Default false. */
59
+ approvePlugins?: boolean;
51
60
  /** The iframe's accessible title. */
52
61
  title?: string;
62
+ /** Milliseconds after the frame loads that it has to connect before `ready` rejects with `connect_timeout`; default 15000. A frame that never loads rejects with `load_timeout` after 120 seconds. */
63
+ timeout?: number;
53
64
  onError?(error: Refusal | Error): void;
54
65
  }
55
66
 
@@ -63,13 +74,23 @@ export interface EditorEvents {
63
74
 
64
75
  export interface MountedEditor {
65
76
  readonly iframe: HTMLIFrameElement;
66
- /** Resolves once the frame accepts the grants; rejects with a Refusal. */
67
- readonly ready: Promise<void>;
77
+ /** Resolves with this handle once the frame accepts the grants; rejects with a Refusal (`boot_failed`, `connect_timeout`, ...). */
78
+ readonly ready: Promise<MountedEditor>;
68
79
  readonly connected: boolean;
69
- load(content: string, options?: {revision?: Revision; filename?: string; readOnly?: boolean; title?: string}): Promise<unknown>;
80
+ /** The frame's Rapier version and contract, once connected; null before. */
81
+ readonly version: string | null;
82
+ readonly contract: number | null;
83
+ /** Shows another document. `revision` is optional here: omitted, the helper uses one above the last revision it saw, or 1.
84
+ * Rejects with `revision_stale` for a revision older than the frame's and `revision_conflict` for the same revision with
85
+ * other content (both for the same filename only: another filename is another document), with `unsaved_changes` while
86
+ * the person has unsaved edits, `document_open_elsewhere` when another editor has the same ids open, `draft_retained` while unsaved edits from another revision are kept, and
87
+ * `document_unshowable` when the reader cannot show the document. */
88
+ load(content: string, options?: {revision?: Revision; filename?: string; readOnly?: boolean; title?: string; baseUrl?: string}): Promise<LoadResult>;
89
+ /** The same, with the record `mount` takes: `load({content, revision, filename})`. */
90
+ load(record: {content: string; revision?: Revision; filename?: string; readOnly?: boolean; title?: string; baseUrl?: string}): Promise<LoadResult>;
70
91
  /** Resolves once the frame holds no newer edits than the durable revision. */
71
92
  save(): Promise<{revision: Revision}>;
72
- compare(content: string, options?: {filename?: string}): Promise<unknown>;
93
+ compare(content: string, options?: {filename?: string}): Promise<CompareResult>;
73
94
  close(): Promise<CloseResult>;
74
95
  theme(value: Theme): Promise<void>;
75
96
  style(value: ReaderStyle): Promise<void>;
@@ -79,10 +100,16 @@ export interface MountedEditor {
79
100
  }
80
101
 
81
102
  export const Rapier: {
82
- /** Frames Rapier in an iframe or a container. Throws TypeError for an invalid option. */
103
+ /** Frames Rapier in an iframe or a container. Throws a TypeError for an invalid option, with a code for an unknown option
104
+ * (`option_unknown`), a build that does not match its page (`build_mismatch`) and a reader feature the reader lacks
105
+ * (`settings_feature_unavailable`). */
83
106
  mount(target: HTMLIFrameElement | HTMLElement, options?: MountOptions): MountedEditor;
84
107
  /** The error a save callback throws when the stored document has changed. */
85
108
  conflict(currentRevision: Revision): Error & {code: 'conflict'; currentRevision: Revision};
109
+ /** This package's version. */
110
+ readonly version: string;
111
+ /** The host contract this package speaks. */
112
+ readonly contract: 1;
86
113
  };
87
114
 
88
115
  /** A form-associated field that submits its exact Markdown as a text/markdown file part. */