resize-iframe 0.2.0 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # resize-iframe
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e0996b5: `srcdoc` support, for inline HTML the host owns.
8
+
9
+ `withResizeChild(html)` from `resize-iframe/inject` inlines the child script into
10
+ HTML you generate — report embeds, quizzes, model output — so the document you hand
11
+ to `srcdoc` stays self-contained: no CDN, no relative path back to the package. It
12
+ is idempotent, and wraps a fragment in a minimal document with a measurable box, so
13
+ anything from a bare string of text upwards sizes correctly. `childScriptTag()` and
14
+ `childScriptSource` are exported too, for hosts that place the script themselves.
15
+
16
+ The `<resize-iframe>` element now accepts `srcdoc` alongside `src`, applies
17
+ `sandbox` and `allow` before either so the first load carries them, and takes a
18
+ `warning-timeout` attribute (`0` silences the missing-child warning).
19
+
3
20
  ## 0.2.0
4
21
 
5
22
  ### Minor Changes
package/README.md CHANGED
@@ -7,8 +7,11 @@ each page, no build step, no dependencies, MIT.
7
7
  - ↔️ Vertical, horizontal, or both
8
8
  - 🔒 Messages matched against the frame's own window, which cannot be forged
9
9
  - 🍪 Storage Access API support for third-party embeds
10
- - 🧩 Use `iframeResize()` or the `<resize-iframe>` element
11
- - 📦 ~5KB unminified, across both files
10
+ - 🧩 Use `iframeResize()`, `<resize-iframe>`, or `withResizeChild` for srcdoc
11
+ - 📦 ~6KB unminified, across the files you ship
12
+
13
+ **[Live demo →](https://jagreehal.github.io/resize-iframe/)** — sandboxed, opaque-origin frames
14
+ resizing, messaging, and refusing to be read from the parent.
12
15
 
13
16
  ## Installation
14
17
 
@@ -66,6 +69,35 @@ That alone auto-resizes the frame. A cross-origin parent cannot measure your
66
69
  content, so without this script nothing happens — the parent logs a warning after
67
70
  five seconds saying exactly that.
68
71
 
72
+ ### Inline HTML (`srcdoc`) the host owns
73
+
74
+ When you build the framed HTML yourself — a report embed, a quiz, skill output —
75
+ put the child script inside it with `withResizeChild` and pass the result as
76
+ `srcdoc`. No CDN, no relative path, the document stays self-contained:
77
+
78
+ ```html
79
+ <script type="module">
80
+ import 'resize-iframe';
81
+ import { withResizeChild } from 'resize-iframe/inject';
82
+
83
+ const frame = document.createElement('resize-iframe');
84
+ frame.setAttribute('sandbox', 'allow-scripts'); // opaque origin; keep it
85
+ frame.setAttribute('min-h', '200px');
86
+ frame.setAttribute('srcdoc', withResizeChild(generatedHtml));
87
+ document.body.append(frame);
88
+ </script>
89
+ ```
90
+
91
+ `withResizeChild` is idempotent, inserts the script before `</body>`, and wraps
92
+ fragments in a minimal document — margin zeroed, content in a box — so even a bare
93
+ string of text has something measurable around it. The same module exports
94
+ `childScriptTag()` and `childScriptSource` if you would rather place the script
95
+ yourself. Remote `src` pages are unchanged: only that page's author can include the
96
+ child script.
97
+
98
+ Working with an AI agent? `.claude/skills/resize-iframe/` teaches it which of these
99
+ shapes to reach for.
100
+
69
101
  The script tag takes two optional attributes:
70
102
 
71
103
  | Attribute | Default | Description |
@@ -162,11 +194,13 @@ turned off for.
162
194
  | Attribute | Default | Description |
163
195
  | ------------- | ------------------ | ---------------------------------------- |
164
196
  | `src` | — | URL to embed |
197
+ | `srcdoc` | — | Inline HTML (prefer `withResizeChild`) |
165
198
  | `title` | `Embedded content` | Accessible name for the iframe |
166
199
  | `direction` | `vertical` | As above |
167
200
  | `offset-size` | `0` | As above |
168
201
  | `min-h` | — | Minimum height constraint |
169
202
  | `max-h` | — | Maximum height constraint |
203
+ | `warning-timeout` | `5000` | Ms before missing-child warning; `0` off |
170
204
  | `allow` | — | Passed through, e.g. `storage-access` |
171
205
  | `sandbox` | — | Passed through |
172
206
 
@@ -233,7 +267,7 @@ pnpm exec playwright install
233
267
  pnpm test
234
268
  ```
235
269
 
236
- 34 Playwright specs across Chromium, Firefox and WebKit. The suite serves the
270
+ 42 Playwright specs across Chromium, Firefox and WebKit. The suite serves the
237
271
  parent page and the child pages from two different origins — `localhost` and
238
272
  `127.0.0.1`, which are cross-*site*, not merely cross-origin — so every test runs
239
273
  against the same partitioning and `contentDocument` restrictions as production.
@@ -0,0 +1,2 @@
1
+ // Generated by scripts/embed-child.mjs — do not edit.
2
+ export const childScriptSource = "// Load this inside the framed page as a classic script, e.g.\n// <script src=\".../resize-iframe-child.js\"><\\/script>\n// (Written with an escaped closer so the file can also be inlined into a\n// parent <script> without the HTML parser cutting the tag short.)\n// Optional attributes on that script tag:\n// data-parent-origin=\"https://parent.example\" report size only to that embedder\n// data-size-selector=\".content\" measure these elements instead\n// Or mark elements in the page with data-iframe-size to the same effect.\nconst config = document.currentScript?.dataset ?? {};\nconst targetOrigin = config.parentOrigin || '*';\nconst sizeSelector = config.sizeSelector || '[data-iframe-size]';\n\nconst start = () => {\n let last = { height: 0, width: 0 };\n let queued = false;\n let auto = true;\n let inbound = null;\n\n // Measure the bottom and right edges of the marked elements, or of body's\n // children. Never body itself: it stretches to fill the frame in quirks mode\n // or under `body { height: 100% }`, which ratchets the frame larger and never\n // lets it shrink back.\n // Body's own bottom/right padding and margin fall outside the measurement —\n // zero them if that gap matters, or mark a wrapper with data-iframe-size.\n const measure = () => {\n const marked = document.querySelectorAll(sizeSelector);\n const elements = marked.length ? marked : document.body.children;\n if (!elements.length) {\n const body = document.body.getBoundingClientRect();\n return { height: Math.ceil(body.bottom), width: Math.ceil(body.right) };\n }\n let height = 0;\n let width = 0;\n for (const element of elements) {\n const box = element.getBoundingClientRect();\n height = Math.max(height, box.bottom);\n width = Math.max(width, box.right);\n }\n return { height: Math.ceil(height), width: Math.ceil(width) };\n };\n\n const post = () => {\n queued = false;\n const size = measure();\n if (size.height === last.height && size.width === last.width) return;\n last = size;\n parent.postMessage({ 'resize-iframe': size }, targetOrigin);\n };\n\n // Batch to one measurement per frame: mutations arrive in bursts and every\n // measure() forces a layout.\n const schedule = () => {\n if (queued || !auto) return;\n queued = true;\n requestAnimationFrame(post);\n };\n\n // ResizeObserver catches reflow (images, fonts, viewport). MutationObserver\n // catches DOM and style changes, which the observer misses entirely whenever\n // body is stretched to the frame and so never changes size itself.\n new ResizeObserver(schedule).observe(document.body);\n new MutationObserver(schedule).observe(document.body, {\n subtree: true,\n childList: true,\n attributes: true,\n characterData: true,\n });\n addEventListener('load', schedule); // images and fonts landing late\n\n // Messages from the embedder. event.source is set by the browser and cannot be\n // forged, so this is the same guard the parent uses in the other direction, and\n // the envelope keeps unrelated postMessage traffic (analytics, wallets, other\n // embeds) out of the callback.\n addEventListener('message', (event) => {\n if (event.source !== parent) return;\n const data = event.data;\n if (data && typeof data === 'object' && 'resize-iframe-message' in data) {\n inbound?.(data['resize-iframe-message']);\n }\n });\n\n // Third-party cookies: an embedded page gets partitioned storage until the user\n // grants access. Probe the API rather than sniffing the browser — Safari and\n // Chrome both partition, and which browsers do is not a stable list.\n const hasStorageAccess = () =>\n document.hasStorageAccess?.().catch(() => false) ?? Promise.resolve(true);\n\n const reportStorageAccess = async () => {\n const access = await hasStorageAccess();\n parent.postMessage({ 'resize-iframe-storage': { hasAccess: access } }, targetOrigin);\n return access;\n };\n\n window.parentIframe = {\n // Assign a function to receive what the parent sends with sendMessage().\n get onMessage() {\n return inbound;\n },\n set onMessage(fn) {\n inbound = fn;\n },\n autoResize(state) {\n if (state !== undefined) auto = state;\n if (auto) schedule();\n return auto;\n },\n resize() {\n post(); // nudge, for the rare change neither observer sees\n },\n sendMessage(message, origin = targetOrigin) {\n parent.postMessage({ 'resize-iframe-message': message }, origin);\n },\n hasStorageAccess,\n // MUST be called from a click or tap handler in this page: the browser denies\n // the request without a user gesture here, and the parent cannot supply one.\n // Reload afterwards if your page reads cookies during startup.\n async requestStorageAccess() {\n if (!document.requestStorageAccess) return true;\n try {\n await document.requestStorageAccess();\n } catch {\n // Denied, or the user has never visited this site first-party.\n }\n return reportStorageAccess();\n },\n };\n\n reportStorageAccess();\n post();\n};\n\nif (parent !== window) {\n if (document.body) start();\n else addEventListener('DOMContentLoaded', start);\n}\n";
package/inject.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /** Source of resize-iframe-child.js, for hosts that want to inject it themselves. */
2
+ export const childScriptSource: string;
3
+
4
+ /** The inline `<script data-resize-iframe-child>…</script>` tag. */
5
+ export function childScriptTag(): string;
6
+
7
+ /**
8
+ * Returns HTML that includes the child script exactly once, ready for srcdoc.
9
+ * Fragments are wrapped in a minimal document with a measurable box to sit in.
10
+ */
11
+ export function withResizeChild(html: string): string;
package/inject.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Prepare HTML so a parent using resize-iframe can size a srcdoc frame.
3
+ *
4
+ * A cross-origin (or opaque-origin sandboxed) parent cannot measure the framed
5
+ * page. The child script has to run inside it. For HTML the host owns — skill
6
+ * output, quizzes, inlined report fragments — call this before setting srcdoc
7
+ * and the script is inlined, so the report stays self-contained: no CDN, no
8
+ * relative path to the package.
9
+ *
10
+ * Remote `src` pages are not covered: only that page's author can include
11
+ * resize-iframe-child.js.
12
+ */
13
+ import { childScriptSource } from "./child-source.js";
14
+
15
+ export { childScriptSource };
16
+
17
+ const MARKER = "data-resize-iframe-child";
18
+
19
+ /** The inline script tag `withResizeChild` inserts. */
20
+ export function childScriptTag() {
21
+ // A literal </script> anywhere in the source — even inside a JS comment —
22
+ // closes an HTML <script> element. Break the sequence for the parser; JS
23
+ // still sees <\/script> as </script>.
24
+ const safe = childScriptSource.replace(/<\/(script)/gi, "<\\/$1");
25
+ return `<script ${MARKER}>${safe}</script>`;
26
+ }
27
+
28
+ /**
29
+ * Returns HTML that includes the child script exactly once.
30
+ *
31
+ * - Full documents: script inserted before `</body>` (or `</html>`).
32
+ * - Fragments: wrapped in a minimal document so `document.body` exists for
33
+ * the child (it waits on body before measuring).
34
+ * - Idempotent: HTML that already carries the marker is returned unchanged.
35
+ */
36
+ export function withResizeChild(html) {
37
+ const source = typeof html === "string" ? html : String(html ?? "");
38
+ if (source.includes(MARKER)) return source;
39
+
40
+ const tag = childScriptTag();
41
+ if (/<\/body>/i.test(source)) return source.replace(/<\/body>/i, `${tag}</body>`);
42
+ if (/<\/html>/i.test(source)) return source.replace(/<\/html>/i, `${tag}</html>`);
43
+ // The child measures body's element children, so bare text needs a box around
44
+ // it, and the default body margin would land inside the measured top edge
45
+ // while its counterpart fell outside the bottom one.
46
+ return `<!doctype html><html><head><style>body{margin:0}</style></head><body><div>${source}</div>${tag}</body></html>`;
47
+ }
package/package.json CHANGED
@@ -1,15 +1,31 @@
1
1
  {
2
2
  "name": "resize-iframe",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Iframes that size themselves to their content, cross-origin included. No build step, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "resize-iframe.js",
7
7
  "module": "resize-iframe.js",
8
8
  "types": "resize-iframe.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./resize-iframe.d.ts",
12
+ "import": "./resize-iframe.js",
13
+ "default": "./resize-iframe.js"
14
+ },
15
+ "./inject": {
16
+ "types": "./inject.d.ts",
17
+ "import": "./inject.js",
18
+ "default": "./inject.js"
19
+ },
20
+ "./resize-iframe-child.js": "./resize-iframe-child.js"
21
+ },
9
22
  "files": [
10
23
  "resize-iframe.js",
11
24
  "resize-iframe-child.js",
12
25
  "resize-iframe.d.ts",
26
+ "inject.js",
27
+ "inject.d.ts",
28
+ "child-source.js",
13
29
  "CHANGELOG.md"
14
30
  ],
15
31
  "publishConfig": {
@@ -41,9 +57,11 @@
41
57
  "typescript": "6.0.3"
42
58
  },
43
59
  "scripts": {
60
+ "embed-child": "node scripts/embed-child.mjs",
61
+ "pretest": "pnpm embed-child",
44
62
  "test": "playwright test",
45
63
  "test:ui": "playwright test --ui",
46
- "typecheck": "tsc --noEmit --strict --lib es2022,dom resize-iframe.d.ts",
64
+ "typecheck": "tsc --noEmit --strict --lib es2022,dom resize-iframe.d.ts inject.d.ts",
47
65
  "changeset": "changeset",
48
66
  "version-packages": "changeset version",
49
67
  "release": "changeset publish"
@@ -1,5 +1,7 @@
1
- // Load this inside the framed page:
2
- // <script src=".../resize-iframe-child.js"></script>
1
+ // Load this inside the framed page as a classic script, e.g.
2
+ // <script src=".../resize-iframe-child.js"><\/script>
3
+ // (Written with an escaped closer so the file can also be inlined into a
4
+ // parent <script> without the HTML parser cutting the tag short.)
3
5
  // Optional attributes on that script tag:
4
6
  // data-parent-origin="https://parent.example" report size only to that embedder
5
7
  // data-size-selector=".content" measure these elements instead
@@ -18,8 +20,8 @@ const start = () => {
18
20
  // children. Never body itself: it stretches to fill the frame in quirks mode
19
21
  // or under `body { height: 100% }`, which ratchets the frame larger and never
20
22
  // lets it shrink back.
21
- // ponytail: body's own bottom/right padding and margin are ignored zero them
22
- // if that gap matters, or wrap your content and mark it with data-iframe-size.
23
+ // Body's own bottom/right padding and margin fall outside the measurement
24
+ // zero them if that gap matters, or mark a wrapper with data-iframe-size.
23
25
  const measure = () => {
24
26
  const marked = document.querySelectorAll(sizeSelector);
25
27
  const elements = marked.length ? marked : document.body.children;
@@ -45,11 +45,15 @@ export interface ParentIframe {
45
45
  /** Attributes of the <resize-iframe> element. */
46
46
  export interface ResizeIframeAttributes {
47
47
  src?: string;
48
+ /** Inline HTML. Prefer `withResizeChild(html)` from `resize-iframe/inject` so the frame can report its size. */
49
+ srcdoc?: string;
48
50
  title?: string;
49
51
  direction?: 'vertical' | 'horizontal' | 'both' | 'none';
50
52
  'offset-size'?: number | string;
51
53
  'min-h'?: string;
52
54
  'max-h'?: string;
55
+ /** Ms to wait before warning that the child script is missing. `0` silences. */
56
+ 'warning-timeout'?: number | string;
53
57
  allow?: string;
54
58
  sandbox?: string;
55
59
  /** Whatever else the host framework puts on an element: class, ref, key. */
package/resize-iframe.js CHANGED
@@ -99,7 +99,9 @@ function connect(iframe, settings) {
99
99
 
100
100
  class ResizeIframe extends HTMLElement {
101
101
  static get observedAttributes() {
102
- return ['src', 'min-h', 'max-h', 'allow', 'sandbox'];
102
+ // src / srcdoc last: setting either starts the navigation, and sandbox/allow
103
+ // have to be on the element before that or the first load runs without them.
104
+ return ['min-h', 'max-h', 'allow', 'sandbox', 'warning-timeout', 'srcdoc', 'src'];
103
105
  }
104
106
 
105
107
  connectedCallback() {
@@ -117,10 +119,14 @@ class ResizeIframe extends HTMLElement {
117
119
  this.attributeChangedCallback(name, null, this.getAttribute(name));
118
120
  }
119
121
  }
122
+ const warningTimeoutAttr = this.getAttribute('warning-timeout');
120
123
  iframeResize(
121
124
  {
122
125
  direction: this.getAttribute('direction') || DEFAULTS.direction,
123
126
  offsetSize: Number(this.getAttribute('offset-size')) || 0,
127
+ ...(warningTimeoutAttr !== null
128
+ ? { warningTimeout: Number(warningTimeoutAttr) }
129
+ : {}),
124
130
  },
125
131
  this.iframe
126
132
  );
@@ -132,6 +138,8 @@ class ResizeIframe extends HTMLElement {
132
138
 
133
139
  attributeChangedCallback(name, oldValue, newValue) {
134
140
  if (!this.iframe || newValue === null) return;
141
+ // srcdoc wins over src when both are set (HTML behaviour). Prefer one.
142
+ if (name === 'srcdoc') this.iframe.srcdoc = newValue;
135
143
  if (name === 'src') this.iframe.src = newValue;
136
144
  if (name === 'min-h') this.iframe.style.minHeight = newValue;
137
145
  if (name === 'max-h') this.iframe.style.maxHeight = newValue;
@@ -139,6 +147,7 @@ class ResizeIframe extends HTMLElement {
139
147
  // denied outright unless the frame carries allow="storage-access", and a
140
148
  // sandboxed frame also needs allow-storage-access-by-user-activation.
141
149
  if (name === 'allow' || name === 'sandbox') this.iframe.setAttribute(name, newValue);
150
+ // warning-timeout is read once, in connectedCallback.
142
151
  }
143
152
 
144
153
  get height() {