rapier-html 1.2.7 → 1.2.9

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.
@@ -1,9 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Shared by the writer and the page. Every page Rapier writes (page.mjs's wrap, the editor's Share and HTML export) carries
3
- // PAGE_SEED once, after its charset (which must stay in the first 1024 bytes): an HTML comment no person sees that tells an
4
- // agent what the file is and how to make one, never part of the carried document.
5
2
  export const PAGE_SEED = '<!-- This page is a Rapier document: Markdown in one HTML file, its source inside, offline. Read it in any browser; edit it in Rapier, https://rapier.website. Agents: https://rapier.website/agents. `npx rapier-html document.md` makes a page like this. -->';
6
- // A file can only return to this worker's one-use route.
7
3
  export const RETURN_ORIGIN = 'https://mcp.rapier.website';
8
4
  export function returnAddress(value) {
9
5
  if (typeof value !== 'string' || /[\s\u0000-\u001f\u007f-\u009f\\@?#]/u.test(value))
package/dist/wrap.mjs CHANGED
@@ -5,23 +5,47 @@ import {PAGE_SEED, returnAddress, returnExpiresAt} from './return-address.mjs';
5
5
 
6
6
  const START = '<script type="text/markdown" id="rapier-document"';
7
7
  const DRAWING_START = '<script type="text/plain" id="rapier-drawing"';
8
- // The original text is a display reference: the page opens on its diff from the current document.
9
8
  const END = '</script>';
10
9
  // The page's search words (rapier.html's RAPIER_SEO regions: description, canonical address, previews, structured data and the plain guide
11
- // a crawler reads) are rapier.website's own. A page that carries someone's document never claims that address or that description.
12
10
  const SEARCH_WORDS = /<!-- RAPIER_SEO_BEGIN -->[\s\S]*?<!-- RAPIER_SEO_END -->\n?/g;
13
11
  const OFFLINE_POLICY = "default-src 'none'; script-src 'unsafe-inline' 'wasm-unsafe-eval' blob:; style-src 'unsafe-inline'; " +
14
12
  "img-src data: blob:; media-src data: blob:; font-src data: blob:; connect-src 'none'; worker-src blob:; manifest-src 'none'; base-uri 'none'; form-action 'none'";
15
13
 
16
- // One encoding, exactly reversible: no `<` before `/` or `!`, no CR, no NUL. engine.js _rapierDecodeCarried is its
17
- // inverse.
18
14
  export {encodeCarried, decodeCarried};
19
15
 
16
+ const PREVIEW = /<meta (?:name="(?:description|generator)"|property="og:(?:title|description)") content="[^"]*" data-rapier-preview>\n?/g;
17
+ const plainWords = text => text.replace(/!\[[^\]]*\]\[[^\]]*\]|!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]*)\](?:\([^)]*\)|\[[^\]]*\])/g, '$1')
18
+ .replace(/<[^>]*>/g, '').replace(/[*_`~=]+|\[\^[^\]]*\]/g, '').replace(/\s+/g, ' ').trim();
19
+ export function pagePreview(markdown) {
20
+ const lines = String(markdown).replace(/^\uFEFF/, '').split(/\r?\n/);
21
+ let title = '', paragraph = [], fence = null, at = 0;
22
+ if (/^---\s*$/.test(lines[0] || '')) { at = lines.indexOf('---', 1) + 1 || lines.length; }
23
+ for (; at < lines.length; at++) {
24
+ const line = lines[at], marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
25
+ if (fence) { if (marker && marker[0] === fence[0] && marker.length >= fence.length) fence = null; continue; }
26
+ if (marker) { fence = marker; if (paragraph.length) break; continue; }
27
+ const heading = /^ {0,3}#{1,6}\s+(.*?)\s*#*\s*$/.exec(line);
28
+ if (heading) { if (paragraph.length) break; if (!title) title = plainWords(heading[1]); continue; }
29
+ if (!/\S/.test(line)) { if (paragraph.length) break; continue; }
30
+ if (!paragraph.length && /^\s*(?:[>|<]|[-*+]\s|\d+[.)]\s|\[[^\]]+\]:|!\[|\$\$|[-*_]{3,}\s*$)/.test(line)) continue;
31
+ paragraph.push(line);
32
+ if (title && paragraph.length > 8) break;
33
+ }
34
+ const words = plainWords(paragraph.join(' '));
35
+ const sentence = /^.{20,300}?[.!?](?=\s|$)/.exec(words)?.[0] || (words.length <= 200 ? words : words.slice(0, 200).replace(/\s+\S*$/, '') + '…');
36
+ return {title, description: sentence};
37
+ }
38
+ function previewHead(markdown, fallbackTitle) {
39
+ const {title, description} = pagePreview(markdown);
40
+ const meta = (key, name, value) => '<meta ' + key + '="' + name + '" content="' + value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;') + '" data-rapier-preview>\n';
41
+ return (description ? meta('name', 'description', description) : '') + meta('property', 'og:title', title || fallbackTitle) +
42
+ (description ? meta('property', 'og:description', description) : '') + meta('name', 'generator', 'Rapier (https://rapier.website)');
43
+ }
44
+
20
45
  function safeName(name, fallback) {
21
46
  return [...String(name || fallback).replace(/[\\/\0"<>&]/g, '-')].slice(0, 256).join('').replace(/[\uD800-\uDBFF]$/, '') || fallback;
22
47
  }
23
48
 
24
- // Only the trusted shell head changes. Carried source and inert application bytes stay exact.
25
49
  export function offlineTemplate(pageHtml, address = null) {
26
50
  const end = pageHtml.indexOf('</head>');
27
51
  if (end < 0) throw new Error('not a Rapier page: no </head>');
@@ -35,14 +59,11 @@ export function offlineTemplate(pageHtml, address = null) {
35
59
  const anchor = /<meta charset[^>]*>\n?/i.exec(head) || /<head[^>]*>\n?/.exec(head);
36
60
  if (!anchor) throw new Error('not a Rapier page: no <head>');
37
61
  const at = anchor.index + anchor[0].length;
38
- // A minted return is an explicit Send back destination; boot and rendering stay local.
39
62
  const policy = address == null ? OFFLINE_POLICY : OFFLINE_POLICY.replace("connect-src 'none'", 'connect-src ' + new URL(returnAddress(address)).origin);
40
63
  return head.slice(0, at) + '<meta http-equiv="Content-Security-Policy" content="' + policy + '">\n' +
41
64
  head.slice(at) + pageHtml.slice(end);
42
65
  }
43
66
 
44
- // What a carried page may hold beyond the base editor: Draw (its part and its letter sets) and the plug-ins a document
45
- // displays with. Notes and the font subsetter never travel; without the subsetter a page keeps its fonts whole.
46
67
  export const CHOICES = Object.freeze(['drawing', 'math', 'mermaid']);
47
68
  const INVENTORY = '<script type="application/json" id="rapier-builtin-plugins">';
48
69
  const element = (html, id) => {
@@ -52,16 +73,14 @@ const element = (html, id) => {
52
73
  return {start, end: html[end] === '\n' ? end + 1 : end};
53
74
  };
54
75
 
55
- // The full carried editor cut to the base editor and `include` (ids from CHOICES). Only whole inert elements and the
56
- // inventory rows naming them are removed; every kept byte is unchanged.
57
76
  export function carry(pageHtml, include = []) {
58
77
  const kept = new Set(include);
59
78
  for (const id of kept) if (!CHOICES.includes(id)) throw new Error('a carried page includes ' + CHOICES.join(', ') + '; not ' + id);
60
79
  if (kept.has('drawing')) kept.add('letters');
61
80
  let html = pageHtml;
62
81
  const cut = id => { const at = element(html, id); if (at) html = html.slice(0, at.start) + html.slice(at.end); };
63
- cut('rapier-part-notes');
64
- if (!kept.has('drawing')) cut('rapier-part-drawing');
82
+ for (const id of [...new Set([...html.matchAll(/<script type="application\/rapier-runtime" id="(rapier-part-[a-z-]+)">/g)].map(match => match[1]))])
83
+ if (id !== 'rapier-part-drawing' || !kept.has('drawing')) cut(id);
65
84
  const start = html.indexOf(INVENTORY);
66
85
  if (start < 0) return html;
67
86
  const close = html.indexOf(END, start), end = html[close + END.length] === '\n' ? close + END.length + 1 : close + END.length;
@@ -71,8 +90,6 @@ export function carry(pageHtml, include = []) {
71
90
  return html;
72
91
  }
73
92
 
74
- // The plug-ins a Markdown text displays with, read without a Markdown parser, so it leans toward carrying: math for a `$`
75
- // or `$$` span outside code, Mermaid for a mermaid fence (`flowchart(fence)` true marks one Rapier draws natively).
76
93
  export function portableNeeds(text, {base, flowchart} = {}) {
77
94
  const needs = new Set();
78
95
  for (const source of [text, base]) {
@@ -86,7 +103,7 @@ export function portableNeeds(text, {base, flowchart} = {}) {
86
103
  for (at++; at < lines.length && !new RegExp('^ {0,3}' + open[1][0] + '{' + open[1].length + ',}\\s*$').test(lines[at]); at++) body.push(lines[at]);
87
104
  if (open[2].toLowerCase() === 'mermaid' && flowchart?.(body.join('\n')) !== true) needs.add('mermaid');
88
105
  }
89
- if (/\$\$[\s\S]+?\$\$|(?:^|[^\\$])\$(?![\s$])[^$\n]*[^\s\\$]\$(?!\d)/.test(prose.replace(/(`+)[\s\S]*?\1/g, ''))) needs.add('math');
106
+ if (/\$\$[\s\S]+?\$\$|(?:^|[^\\$])\$(?![\s$])[^$\n]*[^\s\\$]\$(?!\d)/.test(prose.split(/\n[ \t]*\n/).map(paragraph => paragraph.replace(/(`+)[\s\S]*?\1/g, '')).join('\n\n'))) needs.add('math');
90
107
  }
91
108
  return CHOICES.filter(id => needs.has(id));
92
109
  }
@@ -100,7 +117,6 @@ export function wrap(pageHtml, text, name, options) {
100
117
  const opts = {...saved, ...(options || {})};
101
118
  const docName = safeName(name, 'document.md');
102
119
  if (opts.kind != null && !['markdown', 'text', 'code'].includes(opts.kind)) throw new Error('invalid document kind');
103
- // `view`: the view the page opens on with the document behind it, `draw` or `notes`.
104
120
  if (opts.view != null && !['draw', 'notes'].includes(opts.view)) throw new Error('the view is draw or notes');
105
121
  const address = opts.return == null ? null : returnAddress(opts.return);
106
122
  const expiry = opts.return_expires_at == null ? null : returnExpiresAt(opts.return_expires_at);
@@ -119,8 +135,6 @@ export function wrap(pageHtml, text, name, options) {
119
135
  if (typeof opts.base === 'object') baseline = readBase(opts.base);
120
136
  else {
121
137
  if (typeof opts.base !== 'string') throw new Error('the base must be text');
122
- // A returned Share page uses its own source dialect; its original base is the same carrier.
123
- // Read that base before optional current-document history, which belongs to the current text.
124
138
  const carriedBase = /^\s*<!doctype html/i.test(opts.base) ? takeBase(opts.base).base : null;
125
139
  const pageBase = !carriedBase && /^\s*<!doctype html/i.test(opts.base) ? unwrap(opts.base) : null;
126
140
  const source = carriedBase ? {text: carriedBase.text} : pageBase?.text != null ? readDocument(pageBase.text) : readDocument(opts.base);
@@ -129,21 +143,18 @@ export function wrap(pageHtml, text, name, options) {
129
143
  by: opts.by == null ? 'Agent' : String(opts.by).trim(), at: opts.at == null ? new Date().toISOString() : String(opts.at)});
130
144
  }
131
145
  }
132
- // Rewrapping retains the original text, hash and revision; attribution names this comparison.
133
146
  if (baseline) baseline = readBase({...baseline, ...(opts.by != null ? {by: String(opts.by).trim()} : {}), ...(opts.at != null ? {at: String(opts.at)} : {})});
134
147
  carriedBlocks += '\n' + writeBase(text, baseline);
135
- // The agent seed goes after the charset (or <head>), once: a page that already carries it, wrapped again, loses its old one.
136
- const unseeded = offlineTemplate(previous.html.replace(SEARCH_WORDS, '').split(PAGE_SEED + '\n').join(''), address);
148
+ const unseeded = offlineTemplate(previous.html.replace(SEARCH_WORDS, '').replace(PREVIEW, '').split(PAGE_SEED + '\n').join(''), address);
137
149
  const headTag = /<meta charset[^>]*>\n?/i.exec(unseeded) || /<head[^>]*>\n?/.exec(unseeded);
138
150
  if (!headTag) throw new Error('not a Rapier page: no <head>');
139
151
  const seededAt = headTag.index + headTag[0].length;
140
152
  const stripped = unseeded.slice(0, seededAt) + PAGE_SEED + '\n' + unseeded.slice(seededAt);
141
- // Before Rapier's scripts: parsed before the engine boots.
142
153
  const bodyTag = /<body[^>]*>/.exec(stripped);
143
154
  if (!bodyTag) throw new Error('not a Rapier page: no <body>');
144
155
  const at = bodyTag.index + bodyTag[0].length;
145
- // A function, not a string: `$'`, `$&` and `` $` `` are replacement patterns.
146
- const titled = stripped.replace(/<title>[^<]*<\/title>/, () => '<title>' + docName.replace(/&/g, '&amp;').replace(/</g, '&lt;') + ' — Rapier</title>');
156
+ const titled = stripped.replace(/<title>[^<]*<\/title>\n?/, all => '<title>' + docName.replace(/&/g, '&amp;').replace(/</g, '&lt;') + ' — Rapier</title>' +
157
+ (all.endsWith('\n') ? '\n' : '') + previewHead(text, docName.replace(/\.[^.]*$/, '')));
147
158
  const shift = titled.length - stripped.length;
148
159
  return titled.slice(0, at + shift) + '\n' + block + carriedBlocks + titled.slice(at + shift);
149
160
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-html",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "One command turns a Markdown file into one offline HTML file: the whole Rapier editor with the document inside. Opens in any browser, phone or desktop, with no install or account. Write, draw and paint watercolor, then Send back the edits to your AI agent.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -40,8 +40,12 @@
40
40
  "bin": {
41
41
  "rapier-html": "dist/cli.mjs"
42
42
  },
43
+ "types": "./dist/page.d.mts",
43
44
  "exports": {
44
- ".": "./dist/page.mjs"
45
+ ".": {
46
+ "types": "./dist/page.d.mts",
47
+ "default": "./dist/page.mjs"
48
+ }
45
49
  },
46
50
  "files": [
47
51
  "dist",