@daugasauron/dolly-host-html 0.1.5-rc.0 → 0.1.6

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.
Files changed (2) hide show
  1. package/html.mjs +59 -49
  2. package/package.json +2 -2
package/html.mjs CHANGED
@@ -56,13 +56,18 @@ const popupStyle = `
56
56
  :user-invalid { border-color: #e07a5f; }
57
57
  `;
58
58
 
59
- // The page's one script in the popup's frame, given the document as a string
60
- // and run before anything else there. It builds the popup from the document's
61
- // text and the elements and attributes below, copied into new elements of its
62
- // own: the document is never parsed in a page that could load or navigate,
63
- // and nothing else of it exists in the popup. It reports a submitted form's
64
- // fields and Escape to the page.
65
- function popup({ source, theme, font, origin }) {
59
+ // The popup's frame: srcdoc, sandboxed without allow-scripts, so nothing runs in it,
60
+ // and of the page's origin (allow-same-origin), so the page builds it: the document's
61
+ // text and the elements and attributes below, copied into new elements of the frame's
62
+ // own. DOMParser parses the document, which loads and runs nothing there, and nothing
63
+ // else of it exists in the popup. A form's submit event reaches the page (allow-forms),
64
+ // which takes its fields and stops the submission. Without allow-popups,
65
+ // allow-top-navigation or allow-modals it opens nothing and navigates no other page.
66
+ // The frame has no script or style element, which the page's policy, which a srcdoc
67
+ // frame takes, could refuse.
68
+ const frameSandbox = "allow-same-origin allow-forms";
69
+ function build(frame, { source, theme, font }, answer) {
70
+ const view = frame.contentWindow, page = frame.contentDocument;
66
71
  const shown = new Set(("h1 h2 h3 h4 h5 h6 p br hr div span section article header footer main aside nav " +
67
72
  "blockquote pre code kbd samp var em strong b i u s small mark sub sup abbr cite q dfn time data del ins " +
68
73
  "figure figcaption address ul ol li dl dt dd table caption colgroup col thead tbody tfoot tr th td " +
@@ -88,6 +93,7 @@ function popup({ source, theme, font, origin }) {
88
93
  // one, and a plain button does nothing without a script.
89
94
  const types = { input: new Set(("text search email url tel number range date month week time datetime-local " +
90
95
  "color checkbox radio hidden submit reset").split(" ")), button: new Set(["submit", "reset"]) };
96
+ const autofilled = new Set(["input", "select", "textarea"]);
91
97
  const copy = (from, to) => {
92
98
  for (const node of from.childNodes) {
93
99
  if (node.nodeType === Node.TEXT_NODE) to.append(node.data);
@@ -96,37 +102,34 @@ function popup({ source, theme, font, origin }) {
96
102
  if (!shown.has(node.localName)) { copy(node, to); continue; }
97
103
  const type = node.getAttribute("type")?.toLowerCase();
98
104
  if (types[node.localName] && type !== undefined && !types[node.localName].has(type)) continue;
99
- const element = document.createElement(node.localName);
105
+ const element = page.createElement(node.localName);
100
106
  for (const name of [...attributes["*"], ...attributes[node.localName] ?? []]) {
101
107
  if (node.hasAttribute(name)) element.setAttribute(name, node.getAttribute(name));
102
108
  }
109
+ // The browser offers none of the user's saved addresses, cards or past entries: one chosen
110
+ // would fill every field of the program's form, seen or not. Chrome offers a saved address
111
+ // under "off" and none under a token it does not know; a control's unknown token defers to
112
+ // its form's "off", which Firefox takes (measured: tasks/20261112-074507-page-providers).
113
+ if (node.localName === "form") element.setAttribute("autocomplete", "off");
114
+ else if (autofilled.has(node.localName)) element.setAttribute("autocomplete", "dolly-program-field");
103
115
  copy(node, element);
104
116
  to.append(element);
105
117
  }
106
118
  };
107
- document.currentScript.remove();
108
- copy(new DOMParser().parseFromString(source, "text/html").body, document.body);
109
- for (const [name, value] of Object.entries(theme)) document.documentElement.style.setProperty(name, value);
110
- if (font) document.fonts.add(new FontFace(theme["--dolly-html-font"].split(",")[0].trim().replace(/^(["'])(.*)\1$/, "$2"),
111
- Uint8Array.from(atob(font), character => character.charCodeAt(0))));
112
- document.addEventListener("submit", event => {
119
+ const look = new view.CSSStyleSheet();
120
+ look.replaceSync(popupStyle);
121
+ page.adoptedStyleSheets = [look];
122
+ for (const [name, value] of Object.entries(theme)) page.documentElement.style.setProperty(name, value);
123
+ if (font) page.fonts.add(new view.FontFace(theme["--dolly-html-font"].split(",")[0].trim().replace(/^(["'])(.*)\1$/, "$2"), font));
124
+ copy(new DOMParser().parseFromString(source, "text/html").body, page.body);
125
+ page.addEventListener("submit", event => {
113
126
  event.preventDefault();
114
- parent.postMessage({ fields: String(new URLSearchParams(new FormData(event.target, event.submitter))) }, origin);
127
+ const fields = String(new URLSearchParams(new view.FormData(event.target, event.submitter)));
128
+ if (fields.length <= DOLLY_HTML_FIELDS_MAX) answer(fields);
129
+ else answer.tooLarge();
115
130
  });
116
- addEventListener("keydown", event => { if (event.key === "Escape") parent.postMessage({ close: true }, origin); });
117
- document.querySelector("input:not([type=hidden]), select, textarea, button")?.focus();
118
- }
119
-
120
- // The frame: an opaque origin (no allow-same-origin) that may run the page's
121
- // script (its nonce) and submit forms to it, and fetches nothing, as its
122
- // policy says. Without allow-popups, allow-top-navigation or allow-modals it
123
- // opens nothing and navigates no other page.
124
- function frameSource({ source, theme, font }) {
125
- const nonce = crypto.randomUUID();
126
- const policy = `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; form-action 'none'; base-uri 'none'`;
127
- const literal = JSON.stringify({ source, theme, font, origin: location.origin }).replaceAll("<", "\\u003c");
128
- return `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="${policy}">` +
129
- `<style nonce="${nonce}">${popupStyle}</style></head><body><script nonce="${nonce}">(${popup})(${literal})</script></body></html>`;
131
+ page.addEventListener("keydown", event => { if (event.key === "Escape") answer(null); });
132
+ page.querySelector("input:not([type=hidden]), select, textarea, button")?.focus();
130
133
  }
131
134
 
132
135
  const pageStyle = `
@@ -139,21 +142,17 @@ const pageStyle = `
139
142
  color: var(--dolly-color, ${themeProperties["--dolly-color"]}); background: transparent;
140
143
  border: 1px solid currentColor; font: 14px/1.5 var(--dolly-html-font, ${themeProperties["--dolly-html-font"]}); cursor: pointer; }
141
144
  #html-popup iframe { flex: 1; width: 100%; border: 0; }
145
+ #html-popup .html-popup-caption { margin: 0.3rem 0.6rem 0; font-size: 0.8rem; opacity: 0.75; }
146
+ #html-popup .html-popup-notice { margin: 0 0.6rem 0.3rem; color: #ff9b84; }
142
147
  `;
143
148
 
144
- // The embedding's font file, as base64, fetched once by the page: the popup fetches nothing.
149
+ // The embedding's font file, fetched once by the page: the popup fetches nothing.
145
150
  const fontFiles = new Map();
146
151
  function fontFile(style) {
147
152
  const location = /^url\(\s*(["']?)(.+)\1\s*\)$/.exec(style.getPropertyValue("--dolly-html-font-file").trim())?.[2];
148
153
  if (!location) return null;
149
154
  const url = new URL(location, document.baseURI).href;
150
- if (!fontFiles.has(url)) fontFiles.set(url, fetch(url).then(response => response.ok ? response.arrayBuffer() : null).then(buffer => {
151
- if (!buffer) return null;
152
- const bytes = new Uint8Array(buffer);
153
- let text = "";
154
- for (let offset = 0; offset < bytes.length; offset += 32768) text += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
155
- return btoa(text);
156
- }).catch(() => null));
155
+ if (!fontFiles.has(url)) fontFiles.set(url, fetch(url).then(response => response.ok ? response.arrayBuffer() : null).catch(() => null));
157
156
  return fontFiles.get(url);
158
157
  }
159
158
 
@@ -161,7 +160,10 @@ function fontFile(style) {
161
160
  // user submits a form of it or closes it (its Close button, or Escape), or
162
161
  // the program withdraws it.
163
162
  export function browser({ mount, keyboard, send }) {
164
- const look = document.head.appendChild(Object.assign(document.createElement("style"), { textContent: pageStyle }));
163
+ // An adopted style sheet, which a page's style-src without 'unsafe-inline' allows.
164
+ const look = new CSSStyleSheet();
165
+ look.replaceSync(pageStyle);
166
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, look];
165
167
  let shown = null;
166
168
  function remove() {
167
169
  shown?.element?.remove();
@@ -172,13 +174,6 @@ export function browser({ mount, keyboard, send }) {
172
174
  send({ type: "html-answer", popup: shown.popup, fields });
173
175
  remove();
174
176
  }
175
- function received(event) {
176
- if (!shown?.frame || event.source !== shown.frame.contentWindow) return;
177
- const { fields, close } = event.data ?? {};
178
- if (close === true) answer(null);
179
- else if (typeof fields === "string" && fields.length <= DOLLY_HTML_FIELDS_MAX) answer(fields);
180
- }
181
- addEventListener("message", received);
182
177
  return {
183
178
  messages: {
184
179
  async "html-show"({ popup, document: source }) {
@@ -193,15 +188,26 @@ export function browser({ mount, keyboard, send }) {
193
188
  if (shown?.popup !== popup) return;
194
189
  const element = document.createElement("div"), box = document.createElement("div");
195
190
  const close = Object.assign(document.createElement("button"), { type: "button", textContent: "Close" });
191
+ // What the page says itself, outside the program's frame: whose page it is, and
192
+ // why an answer did not go.
193
+ const caption = Object.assign(document.createElement("p"), { className: "html-popup-caption",
194
+ textContent: "A page a program in Dolly shows" });
195
+ const notice = Object.assign(document.createElement("p"), { className: "html-popup-notice", hidden: true });
196
+ notice.setAttribute("role", "alert");
196
197
  const frame = Object.assign(document.createElement("iframe"), { title: "A page a program in Dolly shows" });
197
198
  element.id = "html-popup";
198
199
  element.setAttribute("role", "dialog");
199
200
  element.setAttribute("aria-label", "A page a program in Dolly shows");
200
- frame.setAttribute("sandbox", "allow-scripts allow-forms");
201
- frame.srcdoc = frameSource({ source, theme, font });
201
+ frame.setAttribute("sandbox", frameSandbox);
202
+ frame.srcdoc = "<!doctype html>";
203
+ const reply = Object.assign(fields => answer(fields), { tooLarge() {
204
+ notice.textContent = `This answer is over ${DOLLY_HTML_FIELDS_MAX / 1024} KiB, more than a program takes: shorten it.`;
205
+ notice.hidden = false;
206
+ } });
207
+ frame.addEventListener("load", () => { if (shown?.frame === frame) build(frame, { source, theme, font }, reply); }, { once: true });
202
208
  close.addEventListener("click", () => answer(null));
203
209
  element.addEventListener("keydown", event => { if (event.key === "Escape") answer(null); });
204
- box.append(close, frame);
210
+ box.append(caption, close, notice, frame);
205
211
  element.append(box);
206
212
  shown = { popup, element, frame };
207
213
  mount.append(element);
@@ -211,7 +217,11 @@ export function browser({ mount, keyboard, send }) {
211
217
  },
212
218
  // The popup's keys are its own; Ctrl+C there still interrupts the program.
213
219
  claimsKey: event => shown?.element?.contains(event.target) ? "interrupt" : false,
214
- dispose() { shown?.element?.remove(); shown = null; look.remove(); removeEventListener("message", received); },
220
+ dispose() {
221
+ shown?.element?.remove();
222
+ shown = null;
223
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter(sheet => sheet !== look);
224
+ },
215
225
  };
216
226
  }
217
227
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
- "version": "0.1.5-rc.0",
2
+ "version": "0.1.6",
3
3
  "type": "module",
4
4
  "name": "@daugasauron/dolly-host-html",
5
5
  "license": "MIT",
6
6
  "dependencies": {
7
- "@daugasauron/dolly": "0.1.5-rc.0"
7
+ "@daugasauron/dolly": "0.1.6"
8
8
  },
9
9
  "description": "Dolly's html@0 host module: show the user an HTML page or form; the user answers or closes it.",
10
10
  "repository": {