diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6897dcc --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Copy to .env (git-ignored) to use a real Iframely API key with the demo +# pages served by `pnpm serve`. scripts/serve.mjs substitutes it into the +# demo HTML at request time; without it the pages use the TEST placeholder. +IFRAMELY_API_KEY= diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index a6f957a..0000000 --- a/.eslintrc +++ /dev/null @@ -1,51 +0,0 @@ -{ - "env": { - "browser": true, - "commonjs": true, - "es6": true, - "jest": true - }, - "extends": "eslint:recommended", - "parserOptions": { - "sourceType": "module" - }, - "globals": { - "ActiveXObject": true - }, - "rules": { - "indent": [ - "error", - 4, - { - "ignoreComments": true - } - ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "single", - { - "avoidEscape": true - } - ], - "semi": [ - "error", - "always" - ], - "no-console": [ - "warn", - { - "allow": ["warn", "error"] - } - ], - "no-empty": [ - "error", - { - "allowEmptyCatch": true - } - ] - } -} \ No newline at end of file diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..2768f61 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,47 @@ +name: Audit + +# Compares pnpm audit against the committed audit.log snapshot, so a new +# advisory fails the run until someone reviews it and refreshes the +# snapshot. Every branch audits its own lockfile: on push, and on PRs +# against the merge result. The schedule matters too: advisories change +# over time without any code changes. GitHub only runs schedules on the +# default branch, so a stale snapshot there fails that run, not your +# branch's. +on: + workflow_dispatch: + push: + branches: ['**'] + pull_request: + branches: [dev, main] + schedule: + - cron: '0 6 * * 1' # Mondays 06:00 UTC + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Keep in sync with the local pnpm version (and ci.yml). Audit + # works off pnpm-lock.yaml, no install needed. + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 12.14.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + + - name: Verify audit.log matches pnpm audit + run: | + pnpm audit > audit-current.log || true + if ! diff -u --strip-trailing-cr audit.log audit-current.log; then + echo "::error::audit.log is stale. The diff above shows what changed. Review the advisories, then refresh the snapshot with: pnpm audit > audit.log" + exit 1 + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1e8d53a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Actions are pinned to commit SHAs; the comment names the release. + +jobs: + check: + name: Typecheck, lint, unit tests, build + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Keep in sync with the local pnpm version. + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 12.14.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + cache: pnpm + + # Also enforces the lockfile's supply-chain policies. + - run: pnpm install --frozen-lockfile + + # Vulnerabilities: see audit.yml. + - run: pnpm typecheck + - run: pnpm lint + - run: pnpm format:check + - run: pnpm test + + - run: pnpm build + + # dist/ is committed: it must be exactly what the sources build to. + - name: Check that the committed dist/ is up to date + run: | + if [ -n "$(git status --porcelain -- dist)" ]; then + git status --short -- dist + echo "::error::dist/ is out of date. Run 'pnpm build' and commit the result." + exit 1 + fi + + - run: pnpm test:pkg + + e2e: + name: Browser tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 12.14.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm exec playwright install --with-deps chromium firefox webkit + + - run: pnpm test:e2e + + - if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: playwright-test-results + path: test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 02ba458..80c5ca2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules/ package-lock.json .npmrc +test-results/ +playwright-report/ +.env diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..ca5c350 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.18.0 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..608cff3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,190 @@ +# Changelog + +## 2.0.0-next.5 (2026-11-11) + +Fifth pre-release of 2.0, under the `next` dist-tag (`npm install @iframely/embed.js@next`); `latest` stays on 1.x. +The `iframe-sized` event and the `height-changed` event name, on top of 2.0.0-next.4. + +### Added + +- An `iframe-sized` event, `iframely.on('iframe-sized', (iframe, media) => ...)`, fires each time an embed reports + its size and the library has applied it. `height-changed` stays silent when the reported size equals the current + one and arrives a frame later, so a page could not tell when a new embed had its real size without reading the + `message` protocol itself. + +### Changed + +- The `heightChanged` event is now `height-changed`, in line with the other event names (`options-changed`, + `iframe-ready`). `heightChanged` is deprecated and still fires, right after `height-changed` and with the same + arguments, so existing listeners keep working. + +## 2.0.0-next.4 (2026-11-10) + +Fourth pre-release of 2.0, under the `next` dist-tag (`npm install @iframely/embed.js@next`); `latest` stays on 1.x. +Removable event listeners, on top of 2.0.0-next.3. + +### Added + +- `iframely.off(event, cb)` removes every subscription of `cb` to an event, and `iframely.on(event, cb)` now returns + a function that removes that one subscription. A component can thus drop its listeners when it unmounts; they used + to stay for the life of the page. A removed listener is not called any more, not even by a `triggerAsync` that was + already waiting for its animation frame. + +## 2.0.0-next.3 (2026-11-09) + +Third pre-release of 2.0, under the `next` dist-tag (`npm install @iframely/embed.js@next`); `latest` stays on 1.x. +Fixes from a review of the 2.0 code, on top of 2.0.0-next.2. + +### Changed + +- The npm module keeps its "no autorun" default to itself instead of writing `autorun: false` into the shared + `window.iframely.config`. Another `embed.js` on the page (a script tag a CMS adds, or the 1.x cloud script that API + embed codes include) therefore keeps scanning the page on `DOMContentLoaded`; it used to be switched off by the + import, and a 1.x copy could not be switched back on. `configure()` consequently treats `autorun` like any other + key: `configure({ autorun: true })` still turns it on, but not after it was explicitly set to `false`. + +### Fixed + +- A pre-set `window.iframely = { config: { cdn } }` is no longer replaced by the script's own host when `embed.js` is + served from the root of a host (`https://static.example.com/embed.js`). A `?cdn=` in the script `src` still wins. +- A document-wide `load()` (the `DOMContentLoaded` autorun included) that arrives while the import request of a + `load(container)` is pending is replayed when that import ends; it used to be dropped, leaving the links outside the + container unbuilt. `unload(container)` now cancels a pending import that the container's own `load()` started. +- Links queued for an import from a shadow root, a document fragment or a detached container are released when the + import fails, does not cover them or is cancelled. They used to keep `data-import-uri`, and every later `load()` + skipped them. +- `unload(container)` with the `parent` option removes a parent wrapper only inside the container, never the + container itself or an element above it. +- Two or more `` links with neither a URL nor an `href` no longer end `load()` in + "Maximum call stack size exceeded". +- A `postMessage` whose `src` or `context` is not a string no longer makes the message listener throw. +- `unload()` keeps the intersection observers of the iframes it leaves on the page (widgets inside shadow roots), + which used to stop receiving intersection updates. +- USAGE.md: the React recipe rendered nothing under StrictMode (the Next.js default in development), where the + effect runs, is cleaned up and runs again on the same element. Its cleanup now restores the markup after + `unload(el)`. +- The development server also refuses the other names Windows accepts for a dotfile (8.3 short names such as + `ENV~1`, a trailing dot or space, `name:stream`), and answers only requests whose `Host` is a loopback name, so a + DNS-rebinding page cannot read the demo pages that carry the API key. + +## 2.0.0-next.2 (2026-11-08) + +Second pre-release of 2.0, under the `next` dist-tag (`npm install @iframely/embed.js@next`); `latest` stays on 1.x. +2.0.0-next.1 was tagged in this changelog but never published to npm, so this is the first 2.0 release there, and the +changes below come on top of those listed for 2.0.0-next.1. + +### Added + +- `iframely.LIB_VERSION`: the embed.js release that booted the page, e.g. `'2.0.0'`, injected from `package.json` at + build time and also readable on the server stub. It is not the API protocol version sent as `v=`, see `API_VERSION` + below. +- Every bundle in `dist/`, minified ones included, starts with `/*! @iframely/embed.js | MIT | ... */`, so + a copy (for example in a CMS, or fetched from the cloud) can be identified without running it. +- A console warning when another copy of embed.js initialised `window.iframely` first and its core lacks methods this + copy provides (typically the 1.x cloud script that API embed codes include, which has no `unload()` or + `configure()`; or an older release missing a newer method), or reports a different major version. This copy then + does not start; the warning names both copies, the missing methods and how to load a single copy. Copies that share + a complete core (`embed.js` with `embed-options.js`, or the cloud script with the npm module) stay silent, as + before. +- `iframely.load(container)` builds only the embeds inside an element, document fragment or shadow root: links, + lazy iframes, and a size request to iframes already there; in import mode the container's links are batched into one + request. It pairs with `unload(container)`. Previously any element other than an `` or `'; + const kept = shadow.querySelector('iframe')!; + sendIntersections({ iframe: kept }, { margin: 10 }); + // Same observer, but this iframe has left the document. + const gone = document.createElement('iframe'); + sendIntersections({ iframe: gone }, { margin: 10 }); + // An observer whose only iframe is gone. + sendIntersections({ iframe: document.createElement('iframe') }, { margin: 20 }); + + iframely.unload(); + + expect(shadow.querySelector('iframe')).toBe(kept); + expect(unobserve).toHaveBeenCalledWith(gone); + expect(unobserve).not.toHaveBeenCalledWith(kept); + // Only the observer with nothing left to observe is dropped. + expect(disconnect).toHaveBeenCalledTimes(1); + sendIntersections({ iframe: document.createElement('iframe') }, { margin: 10 }); + expect(instances).toHaveLength(2); + }); + + // Frameworks often detach the DOM before running cleanup (Svelte teardown, + // React effect cleanup after unmount); unload(container) must still + // release what the widgets hold. + it('releases a widget whose container is already detached', () => { + const container = document.createElement('div'); + container.innerHTML = + '
' + + '
'; + document.body.appendChild(container); + const iframe = container.querySelector('iframe')!; + sendIntersections({ iframe: iframe }); + expect(observe).toHaveBeenCalledWith(iframe); + + container.remove(); + const perWidget = vi.fn(); + iframely.on('unload-widget', perWidget); + + expect(() => iframely.unload(container)).not.toThrow(); + expect(perWidget).toHaveBeenCalledTimes(1); + expect(unobserve).toHaveBeenCalledWith(iframe); + expect(container.querySelector('.iframely-embed')).toBeNull(); + }); +}); diff --git a/tests/unit/lazy-iframe.test.ts b/tests/unit/lazy-iframe.test.ts new file mode 100644 index 0000000..e3f51d8 --- /dev/null +++ b/tests/unit/lazy-iframe.test.ts @@ -0,0 +1,72 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import iframely from '../../src/iframely'; +import { boot as bootConst } from '../../src/const'; +import { boot as bootEvents } from '../../src/events'; +import { boot as bootUtils } from '../../src/utils'; +import { boot as bootLazyIframe } from '../../src/lazy-iframe'; + +beforeAll(() => { + bootConst(); + bootEvents(); + bootUtils(); + bootLazyIframe(); +}); + +beforeEach(() => { + // intersection.ts switches this on at 'init'. + iframely.config = { intersection: 1 }; + document.body.innerHTML = + '
' + + '' + + '
'; +}); + +function iframe(): HTMLIFrameElement { + return document.querySelector('iframe')!; +} + +describe('lazy iframes', () => { + it('builds the src from data-iframely-url and uses native lazy loading when supported', () => { + iframely.SUPPORT_IFRAME_LOADING_ATTR = true; + const ready = vi.fn(); + iframely.on('iframe-ready', ready); + + iframely.trigger('load'); + + const src = decodeURIComponent(iframe().getAttribute('src')!); + expect(src).toContain('cdn.iframe.ly/abc123'); + expect(src).toContain('url=https://x.example'); + expect(src).toContain('v=1'); + expect(src).toContain('app=1'); + expect(src).not.toContain('lazy=1'); + expect(iframe().getAttribute('loading')).toBe('lazy'); + expect(iframe().hasAttribute('data-iframely-url')).toBe(false); + expect(ready).toHaveBeenCalledWith(iframe()); + }); + + it('falls back to the server-side lazy mode without native support', () => { + iframely.SUPPORT_IFRAME_LOADING_ATTR = false; + + iframely.trigger('load'); + + expect(iframe().getAttribute('src')).toContain('lazy=1'); + expect(iframe().hasAttribute('loading')).toBe(false); + }); + + it('passes the configured theme to the iframe', () => { + iframely.config = { intersection: 1, theme: 'dark' }; + + iframely.trigger('load'); + + expect(iframe().getAttribute('src')).toContain('theme=dark'); + }); + + it('leaves iframes that already have a src alone', () => { + iframe().setAttribute('src', 'https://cdn.iframe.ly/already'); + + iframely.trigger('load'); + + expect(iframe().getAttribute('src')).toBe('https://cdn.iframe.ly/already'); + expect(iframe().hasAttribute('data-iframely-url')).toBe(true); + }); +}); diff --git a/tests/unit/load-container.test.ts b/tests/unit/load-container.test.ts new file mode 100644 index 0000000..8ca650c --- /dev/null +++ b/tests/unit/load-container.test.ts @@ -0,0 +1,332 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import iframely from '../../src/iframely'; +import { boot as bootConst } from '../../src/const'; +import { boot as bootEvents } from '../../src/events'; +import { boot as bootUtils } from '../../src/utils'; +import { boot as bootImport } from '../../src/import'; +import { boot as bootAhref } from '../../src/ahref'; +import { boot as bootLazyIframe } from '../../src/lazy-iframe'; + +// load(container) builds what is inside the container and nothing else: +// the component-scoped counterpart of unload(container). +beforeAll(() => { + bootConst(); + bootEvents(); + bootUtils(); + bootImport(); + bootAhref(); + bootLazyIframe(); +}); + +beforeEach(() => { + // intersection.ts switches `intersection` on at 'init'. + iframely.config = { api_key: 'k', intersection: 1 }; + iframely.SUPPORT_IFRAME_LOADING_ATTR = true; + document.body.innerHTML = ''; + // Drops a pending import of the previous test. + iframely.unload(); +}); + +function wrap(inner: string): string { + return '
' + inner + '
'; +} + +const anchor = (url: string) => wrap('
'); +const lazyIframe = (id: string) => + wrap( + '' + ); +const directIframe = (id: string) => + wrap(''); + +describe('load(container)', () => { + it('builds the anchors and lazy iframes inside the container only', () => { + document.body.innerHTML = + '
' + + anchor('https://in.example/1') + + lazyIframe('lazyin') + + '
' + + '
' + + anchor('https://out.example/1') + + lazyIframe('lazyout') + + '
'; + const inside = document.getElementById('in')!; + const outside = document.getElementById('out')!; + + iframely.load(inside); + + expect(inside.querySelector('a[data-iframely-url]')).toBeNull(); + expect(inside.querySelector('iframe[src*="in.example"]')).not.toBeNull(); + expect(inside.querySelector('iframe[src*="lazyin"]')).not.toBeNull(); + expect(outside.querySelector('a[data-iframely-url]')).not.toBeNull(); + expect(outside.querySelector('iframe[src]')).toBeNull(); + }); + + it('asks the iframes already in the container, and only those, for their size', () => { + document.body.innerHTML = + '
' + + directIframe('abc') + + '
' + + directIframe('def') + + '
'; + const [inIframe, outIframe] = Array.from(document.querySelectorAll('iframe')); + const inPost = vi.spyOn(inIframe.contentWindow!, 'postMessage'); + const outPost = vi.spyOn(outIframe.contentWindow!, 'postMessage'); + + iframely.load(document.getElementById('in')!); + + expect(inPost).toHaveBeenCalledTimes(1); + expect(JSON.parse(inPost.mock.calls[0][0] as string).method).toBe('getSize'); + expect(outPost).not.toHaveBeenCalled(); + }); + + it('also sizes existing iframes on a document-wide load()', () => { + document.body.innerHTML = directIframe('abc'); + const post = vi.spyOn(document.querySelector('iframe')!.contentWindow!, 'postMessage'); + + iframely.load(); + + expect(post).toHaveBeenCalledTimes(1); + }); + + it('leaves nothing behind when paired with unload(container)', () => { + document.body.innerHTML = + '
' + anchor('https://in.example/1') + lazyIframe('lazyin') + '
'; + const inside = document.getElementById('in')!; + + iframely.load(inside); + expect(inside.querySelectorAll('iframe')).toHaveLength(2); + iframely.unload(inside); + + expect(inside.querySelectorAll('.iframely-embed')).toHaveLength(0); + }); + + it('scans a shadow root', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = anchor('https://in.example/1'); + + iframely.load(shadow as unknown as Element); + + expect(shadow.querySelector('iframe[src*="in.example"]')).not.toBeNull(); + }); + + it('keeps load(container, url) appending and unfurling one link', () => { + document.body.innerHTML = '
' + anchor('https://out.example/1'); + const inside = document.getElementById('in')!; + + iframely.load(inside, 'https://in.example/1'); + + expect(inside.querySelector('iframe[src*="in.example"]')).not.toBeNull(); + expect(document.querySelector('a[href="https://out.example/1"]')).not.toBeNull(); + }); + + it('batches the container anchors, and only those, into one import', () => { + document.body.innerHTML = + '
' + + anchor('https://in.example/1') + + anchor('https://in.example/2') + + '
' + + anchor('https://out.example/1'); + // Keep the JSONP