embed-manager 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peter Benoit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # EmbedManager
2
+
3
+ A lightweight, dependency-free JavaScript library for embedding content from YouTube, Vimeo, Twitch, CodePen, TikTok, Spotify, SoundCloud, GitHub Gists, Google Maps, Twitter/X, and Instagram — with **lazy loading** via the Intersection Observer API.
4
+
5
+ ## Features
6
+
7
+ - Lazy loads iframes only when they scroll into view
8
+ - Supports 14+ embed types out of the box
9
+ - Privacy-enhanced YouTube (`youtube-nocookie.com`) by default
10
+ - Vimeo `dnt=1` (Do Not Track) by default
11
+ - Sandboxed `<iframe>` for raw website embeds
12
+ - Accessible (`aria-label`, `role="alert"`, `aria-live`)
13
+ - Zero dependencies — pure vanilla JS (~8.6 KB minified)
14
+
15
+ ## CDN Usage
16
+
17
+ ### jsDelivr (recommended)
18
+
19
+ ```html
20
+ <!-- Pinned to a specific version -->
21
+ <script src="https://cdn.jsdelivr.net/npm/embed-manager@1.0.0/dist/embedManager.min.js"></script>
22
+
23
+ <!-- Always latest -->
24
+ <script src="https://cdn.jsdelivr.net/npm/embed-manager/dist/embedManager.min.js"></script>
25
+ ```
26
+
27
+ ### unpkg
28
+
29
+ ```html
30
+ <!-- Pinned to a specific version -->
31
+ <script src="https://unpkg.com/embed-manager@1.0.0/dist/embedManager.min.js"></script>
32
+
33
+ <!-- Always latest -->
34
+ <script src="https://unpkg.com/embed-manager/dist/embedManager.min.js"></script>
35
+ ```
36
+
37
+ Loading the script auto-initializes `window.EmbedManager` and begins observing any `.embed-container` elements on the page.
38
+
39
+ ## npm
40
+
41
+ ```sh
42
+ npm install embed-manager
43
+ ```
44
+
45
+ ```js
46
+ const EmbedManager = require('embed-manager');
47
+ const mgr = new EmbedManager({ rootMargin: '300px 0px' });
48
+ ```
49
+
50
+ ## Quick Start
51
+
52
+ Add a container with `data-type` and `data-src`:
53
+
54
+ ```html
55
+ <!-- YouTube -->
56
+ <div class="embed-container"
57
+ data-type="youtube"
58
+ data-src="https://www.youtube.com/embed/dQw4w9WgXcQ"
59
+ data-title="Never Gonna Give You Up">
60
+ </div>
61
+
62
+ <!-- Vimeo with privacy hash -->
63
+ <div class="embed-container"
64
+ data-type="vimeo"
65
+ data-src="https://player.vimeo.com/video/123456789"
66
+ data-hash="myPrivateHash"
67
+ data-title="My Vimeo Video">
68
+ </div>
69
+
70
+ <!-- CodePen -->
71
+ <div class="embed-container"
72
+ data-type="codepen"
73
+ data-src="https://codepen.io/user/pen/abcdef"
74
+ data-default-tab="result"
75
+ data-title="My Pen">
76
+ </div>
77
+
78
+ <!-- Spotify track -->
79
+ <div class="embed-container"
80
+ data-type="spotify"
81
+ data-src="https://open.spotify.com/track/abc123"
82
+ data-title="My Track"
83
+ data-aspect-ratio="unset"
84
+ data-height="152px">
85
+ </div>
86
+ ```
87
+
88
+ Then include EmbedManager via CDN (or npm) — no further setup needed.
89
+
90
+ ## Supported Types
91
+
92
+ | `data-type` | Description |
93
+ |-------------|-------------|
94
+ | `youtube` | YouTube (auto-upgraded to `youtube-nocookie.com`) |
95
+ | `vimeo` | Vimeo (supports `data-hash` for private videos) |
96
+ | `twitch` | Twitch player/chat |
97
+ | `codepen` | CodePen pens & embeds |
98
+ | `spotify` | Spotify tracks, albums, playlists, episodes |
99
+ | `soundcloud` | SoundCloud tracks |
100
+ | `tiktok` | TikTok videos |
101
+ | `twitter` / `x` | Tweets (via Twitter widget.js blockquote) |
102
+ | `instagram` | Instagram posts & reels |
103
+ | `gist` / `github` | GitHub Gists (via script embed) |
104
+ | `maps` / `google-maps` | Google Maps embed |
105
+ | `website` | Any URL (sandboxed iframe) |
106
+
107
+ ## Options
108
+
109
+ ```js
110
+ new EmbedManager({
111
+ rootMargin: '200px 0px' // IntersectionObserver margin (default)
112
+ });
113
+ ```
114
+
115
+ ## Data Attributes
116
+
117
+ | Attribute | Default | Description |
118
+ |-----------|---------|-------------|
119
+ | `data-type` | — | Embed type (required) |
120
+ | `data-src` | — | Source URL (required) |
121
+ | `data-title` | `"Untitled Embed"` | iframe/aria title |
122
+ | `data-width` | `100%` | Container width |
123
+ | `data-height` | — | Explicit height (disables aspect-ratio) |
124
+ | `data-aspect-ratio` | `16/9` | CSS aspect-ratio |
125
+ | `data-autoplay` | — | Set `"true"` to autoplay (YouTube, Vimeo) |
126
+ | `data-hash` | — | Vimeo privacy hash |
127
+ | `data-app-id` | — | Vimeo app_id |
128
+ | `data-theme-id` | — | CodePen theme ID |
129
+ | `data-default-tab` | `result` | CodePen default tab |
130
+ | `data-editable` | — | Set `"true"` for editable CodePen |
131
+ | `data-preview` | — | Set `"true"` for CodePen preview mode |
132
+ | `data-color` | `ff5500` | SoundCloud player color |
133
+ | `data-show-comments` | — | Set `"true"` for SoundCloud comments |
134
+ | `data-api-key` | — | Google Maps API key |
135
+ | `data-lang` | `en` | Twitter embed language |
136
+ | `data-theme` | `light` | Twitter embed theme |
137
+
138
+ ## API
139
+
140
+ ```js
141
+ // Auto-initialized on page load as window.EmbedManager
142
+ // Or create your own instance:
143
+ const mgr = new EmbedManager();
144
+
145
+ // Process a container immediately (bypasses IntersectionObserver)
146
+ mgr.processContainer(document.querySelector('.embed-container'));
147
+
148
+ // Add a dynamically created container to the lazy-load queue
149
+ mgr.addEmbed(document.querySelector('.new-embed'));
150
+ ```
151
+
152
+ ## Release Steps
153
+
154
+ ```sh
155
+ # 1. Bump version
156
+ npm version patch # or minor / major
157
+
158
+ # 2. Preview what will be published
159
+ npm run pack:check
160
+
161
+ # 3. Publish (runs tests + build automatically via prepublishOnly)
162
+ npm publish
163
+
164
+ # 4. Push tag to GitHub
165
+ git push origin main --tags
166
+ ```
167
+
168
+ ## License
169
+
170
+ MIT © [Peter Benoit](https://github.com/peterbenoit)
@@ -0,0 +1,537 @@
1
+ /**
2
+ * EmbedManager.js
3
+ *
4
+ * This library handles the embedding of various content types
5
+ * (YouTube, Vimeo, Twitch, CodePen, Twitter, Instragram and more) into a webpage with
6
+ * lazy loading to improve performance. It automatically injects the necessary
7
+ * CSS and sets up an Intersection Observer to load iframes only when they are
8
+ * in view, optimizing page load times and resource management.
9
+ *
10
+ * Features:
11
+ * - Lazy loading of iframes using the Intersection Observer API
12
+ * - Support for multiple content types (YouTube, Vimeo, Twitch, CodePen, Twitter, Instragram and more)
13
+ * - Sandbox attribute for website embeds to enhance security
14
+ * - Configurable iframe titles for accessibility
15
+ * - Handles Vimeo unlisted/private privacy hash via optional `data-hash` attribute
16
+ * - Enhanced accessibility with aria attributes
17
+ * - Improved security with referrer policy and stricter sandbox settings
18
+ * - Responsive design support with aspect ratio preservation
19
+ * - Error handling for failed embeds
20
+ *
21
+ * Usage:
22
+ * - Include the HTML structure with the 'embed-container' class and appropriate data attributes.
23
+ * - The class will handle iframe creation, and lazy loading automatically.
24
+ */
25
+ class EmbedManager {
26
+ constructor(options = {}) {
27
+ this.options = {
28
+ rootMargin: '200px 0px',
29
+ ...options
30
+ };
31
+ this.injectCSS();
32
+ this.init();
33
+ }
34
+
35
+ // Inject CSS into the document head
36
+ injectCSS() {
37
+ const style = document.createElement('style');
38
+ style.innerHTML = `
39
+ .embed-container {
40
+ margin: 20px auto;
41
+ background: #f4f4f4;
42
+ position: relative;
43
+ overflow: hidden;
44
+ display: flex;
45
+ justify-content: center;
46
+ align-items: center;
47
+ /* Default aspect ratio wrapper */
48
+ aspect-ratio: 16/9;
49
+ }
50
+ .embed-container iframe {
51
+ width: 100%;
52
+ height: 100%;
53
+ border: none;
54
+ display: block;
55
+ position: absolute;
56
+ top: 0;
57
+ left: 0;
58
+ }
59
+ .embed-container p {
60
+ margin: 0;
61
+ font-size: 1em;
62
+ color: #555;
63
+ }
64
+ .embed-container .embed-placeholder {
65
+ display: flex;
66
+ flex-direction: column;
67
+ align-items: center;
68
+ justify-content: center;
69
+ width: 100%;
70
+ height: 100%;
71
+ text-align: center;
72
+ padding: 1rem;
73
+ }
74
+ .embed-container .embed-error {
75
+ color: #721c24;
76
+ background-color: #f8d7da;
77
+ padding: 0.75rem;
78
+ border-radius: 0.25rem;
79
+ margin: 0.5rem 0;
80
+ width: 100%;
81
+ text-align: center;
82
+ }
83
+ `;
84
+ document.head.appendChild(style);
85
+ }
86
+
87
+ // Initialize lazy loading after DOM is loaded
88
+ init() {
89
+ if (document.readyState === 'loading') {
90
+ document.addEventListener('DOMContentLoaded', () => this.setupEmbeds());
91
+ } else {
92
+ this.setupEmbeds();
93
+ }
94
+ }
95
+
96
+ setupEmbeds() {
97
+ const embeds = document.querySelectorAll('.embed-container');
98
+ this.setupObserver(embeds);
99
+ }
100
+
101
+ // Set up Intersection Observer for lazy loading
102
+ setupObserver(embeds) {
103
+ const observer = new IntersectionObserver((entries) => {
104
+ entries.forEach(entry => {
105
+ if (entry.isIntersecting) {
106
+ this.lazyLoadEmbed(entry.target);
107
+ observer.unobserve(entry.target); // Stop observing once loaded
108
+ }
109
+ });
110
+ }, {
111
+ rootMargin: this.options.rootMargin
112
+ });
113
+
114
+ // Observe each embed container
115
+ embeds.forEach(embed => {
116
+ // Add placeholder content
117
+ if (!embed.innerHTML.trim()) {
118
+ const type = embed.getAttribute('data-type') || 'content';
119
+ const placeholder = document.createElement('div');
120
+ placeholder.className = 'embed-placeholder';
121
+ placeholder.innerHTML = `<p>Loading ${type} content when visible</p>`;
122
+ embed.appendChild(placeholder);
123
+ }
124
+ observer.observe(embed);
125
+ });
126
+ }
127
+
128
+ // Helper method to show errors
129
+ showError(embed, message) {
130
+ console.error(`EmbedManager Error: ${message}`);
131
+ embed.innerHTML = `<div class="embed-error" role="alert">${message}</div>`;
132
+ }
133
+
134
+ // Validate URL format — only accepts https/http to prevent javascript: URI injection
135
+ isValidUrl(url) {
136
+ try {
137
+ const parsed = new URL(url);
138
+ return ['https:', 'http:'].includes(parsed.protocol);
139
+ } catch (e) {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ // Build the embed source URL with appropriate parameters
145
+ buildEmbedSrc(embed, src, type) {
146
+ let finalSrc = src;
147
+
148
+ switch (type) {
149
+ case 'codepen': {
150
+ const themeId = embed.getAttribute('data-theme-id') || '';
151
+ const defaultTab = embed.getAttribute('data-default-tab') || 'result';
152
+ const editable = embed.getAttribute('data-editable') === 'true' ? 'true' : 'false';
153
+ const usePreview = embed.getAttribute('data-preview') === 'true';
154
+ // Convert /pen/ URL to embed URL and insert /preview/ when requested
155
+ if (finalSrc.includes('/pen/')) {
156
+ finalSrc = finalSrc.replace('/pen/', usePreview ? '/embed/preview/' : '/embed/');
157
+ } else if (usePreview && finalSrc.includes('/embed/') && !finalSrc.includes('/embed/preview/')) {
158
+ finalSrc = finalSrc.replace('/embed/', '/embed/preview/');
159
+ }
160
+ const cSep = finalSrc.includes('?') ? '&' : '?';
161
+ finalSrc = `${finalSrc}${cSep}theme-id=${themeId}&default-tab=${defaultTab}&editable=${editable}`;
162
+ break;
163
+ }
164
+
165
+ case 'vimeo': {
166
+ // Handle Vimeo privacy hash
167
+ const hash = embed.getAttribute('data-hash');
168
+ if (hash && !src.includes('h=')) {
169
+ const vSep = src.includes('?') ? '&' : '?';
170
+ finalSrc = `${src}${vSep}h=${hash}`;
171
+ }
172
+
173
+ // Add common Vimeo parameters
174
+ const vimeoParams = [
175
+ 'badge=0',
176
+ 'autopause=0',
177
+ 'player_id=0',
178
+ 'dnt=1' // Do Not Track for privacy
179
+ ];
180
+
181
+ // Add app_id if provided
182
+ const appId = embed.getAttribute('data-app-id');
183
+ if (appId) {
184
+ vimeoParams.push(`app_id=${appId}`);
185
+ }
186
+
187
+ // Add user preferences
188
+ if (embed.getAttribute('data-autoplay') === 'true') {
189
+ vimeoParams.push('autoplay=1');
190
+ }
191
+
192
+ // Append all parameters
193
+ vimeoParams.forEach(param => {
194
+ const paramName = param.split('=')[0];
195
+ if (!finalSrc.includes(paramName + '=')) {
196
+ const sep = finalSrc.includes('?') ? '&' : '?';
197
+ finalSrc = `${finalSrc}${sep}${param}`;
198
+ }
199
+ });
200
+ break;
201
+ }
202
+
203
+ case 'youtube': {
204
+ // Add YouTube parameters for better privacy and user experience
205
+ const ytParams = [];
206
+
207
+ // Add user preferences
208
+ if (embed.getAttribute('data-autoplay') === 'true') {
209
+ ytParams.push('autoplay=1');
210
+ }
211
+
212
+ // Add privacy-enhanced mode
213
+ if (!src.includes('youtube-nocookie.com')) {
214
+ // Replace with privacy-enhanced version if not already using it
215
+ finalSrc = finalSrc.replace('youtube.com', 'youtube-nocookie.com');
216
+ }
217
+
218
+ // Add other common parameters
219
+ ytParams.push('rel=0', 'modestbranding=1');
220
+
221
+ // Append all parameters
222
+ const ytSep = finalSrc.includes('?') ? '&' : '?';
223
+ finalSrc = `${finalSrc}${ytSep}${ytParams.join('&')}`;
224
+ break;
225
+ }
226
+
227
+ case 'twitch': {
228
+ const parentDomain = window.location.hostname;
229
+ finalSrc = `${finalSrc}&parent=${parentDomain}`;
230
+ break;
231
+ }
232
+
233
+ case 'twitter':
234
+ case 'x':
235
+ // Twitter/X embeds need special handling with their widget.js
236
+ // We'll return the src as is, but we need to load their script
237
+ this.loadExternalScript('https://platform.twitter.com/widgets.js', 'twitter-widget');
238
+
239
+ // If the source is just a tweet ID, construct the proper URL
240
+ if (/^\d+$/.test(src)) {
241
+ finalSrc = `https://twitter.com/i/status/${src}`;
242
+ }
243
+ break;
244
+
245
+ case 'instagram':
246
+ // Instagram embeds require their script
247
+ this.loadExternalScript('https://www.instagram.com/embed.js', 'instagram-embed');
248
+
249
+ // Handle different Instagram URL formats
250
+ if (finalSrc.includes('instagram.com/p/') || finalSrc.includes('instagram.com/reel/')) {
251
+ // Add query parameters if not already present
252
+ if (!finalSrc.includes('?')) {
253
+ finalSrc = `${finalSrc}?utm_source=ig_embed&utm_campaign=loading`;
254
+ } else if (!finalSrc.includes('utm_source=ig_embed')) {
255
+ finalSrc = `${finalSrc}&utm_source=ig_embed&utm_campaign=loading`;
256
+ }
257
+ }
258
+ break;
259
+
260
+ case 'tiktok': {
261
+ // TikTok embeds require their script
262
+ this.loadExternalScript('https://www.tiktok.com/embed.js', 'tiktok-embed');
263
+
264
+ // Handle both video URLs and direct embed URLs
265
+ if (!finalSrc.includes('embed')) {
266
+ // Strip trailing slash and query string before extracting ID
267
+ const tiktokPath = finalSrc.replace(/\?.*$/, '').replace(/\/$/, '');
268
+ const tiktokId = tiktokPath.split('/').pop();
269
+ finalSrc = `https://www.tiktok.com/embed/v2/${tiktokId}`;
270
+ }
271
+ break;
272
+ }
273
+
274
+ case 'soundcloud': {
275
+ // If only the track URL is provided, convert to embed URL
276
+ if (!finalSrc.includes('api.soundcloud.com')) {
277
+ // We'll use color and auto_play from data attributes
278
+ const color = embed.getAttribute('data-color') || 'ff5500';
279
+ const autoPlay = embed.getAttribute('data-autoplay') === 'true' ? 'true' : 'false';
280
+ const showComments = embed.getAttribute('data-show-comments') === 'true' ? 'true' : 'false';
281
+
282
+ finalSrc = `https://w.soundcloud.com/player/?url=${encodeURIComponent(src)}&color=${color}&auto_play=${autoPlay}&hide_related=false&show_comments=${showComments}&show_user=true&show_reposts=false&show_teaser=true`;
283
+ }
284
+ break;
285
+ }
286
+
287
+ case 'spotify': {
288
+ // Handle different Spotify embed types (track, album, playlist, podcast)
289
+ if (finalSrc.includes('spotify.com')) {
290
+ // Convert regular Spotify URL to embed URL
291
+ const spotifyType = finalSrc.includes('/track/') ? 'track' :
292
+ finalSrc.includes('/album/') ? 'album' :
293
+ finalSrc.includes('/playlist/') ? 'playlist' :
294
+ finalSrc.includes('/episode/') ? 'episode' : 'track';
295
+
296
+ const spotifyId = finalSrc.split('/').pop().split('?')[0];
297
+ finalSrc = `https://open.spotify.com/embed/${spotifyType}/${spotifyId}`;
298
+ }
299
+ break;
300
+ }
301
+
302
+ case 'github':
303
+ case 'gist':
304
+ // GitHub Gists are embedded via script, not iframe
305
+ // Convert gist.github.com/user/gistid to the .js script URL
306
+ if (finalSrc.includes('gist.github.com') && !finalSrc.endsWith('.js')) {
307
+ const gistId = finalSrc.split('/').pop();
308
+ finalSrc = `https://gist.github.com/${gistId}.js`;
309
+ }
310
+ break;
311
+
312
+ case 'maps':
313
+ case 'google-maps': {
314
+ // Handle Google Maps embeds
315
+ if (!finalSrc.includes('google.com/maps/embed')) {
316
+ // If it's a regular maps URL, convert to embed URL
317
+ // Extract location query or coordinates
318
+ let query = '';
319
+
320
+ if (finalSrc.includes('maps/place/')) {
321
+ query = finalSrc.split('maps/place/')[1].split('/')[0];
322
+ } else if (finalSrc.includes('maps?q=')) {
323
+ query = finalSrc.split('maps?q=')[1].split('&')[0];
324
+ }
325
+
326
+ if (query) {
327
+ finalSrc = `https://www.google.com/maps/embed/v1/place?key=${embed.getAttribute('data-api-key') || ''}&q=${query}`;
328
+ }
329
+ }
330
+ break;
331
+ }
332
+ }
333
+
334
+ return finalSrc;
335
+ }
336
+
337
+ // Method to load external scripts needed for some embeds
338
+ loadExternalScript(src, id) {
339
+ if (!document.getElementById(id)) {
340
+ const script = document.createElement('script');
341
+ script.id = id;
342
+ script.src = src;
343
+ script.async = true;
344
+ script.defer = true;
345
+ document.body.appendChild(script);
346
+ }
347
+ }
348
+
349
+ // Override lazyLoadEmbed to handle special embed types
350
+ lazyLoadEmbed(embed) {
351
+ const type = embed.getAttribute('data-type');
352
+ const src = embed.getAttribute('data-src');
353
+
354
+ // Special handling for embeds that don't use iframes
355
+ if (type === 'twitter' || type === 'x' || type === 'gist' || type === 'github' || type === 'instagram') {
356
+ this.handleSpecialEmbed(embed, type);
357
+ return;
358
+ }
359
+
360
+ const title = embed.getAttribute('data-title') || 'Untitled Embed';
361
+ const width = embed.getAttribute('data-width') || '100%';
362
+ const height = embed.getAttribute('data-height');
363
+ const aspectRatio = embed.getAttribute('data-aspect-ratio') || '16/9';
364
+
365
+ // Validate source URL
366
+ if (!src || !this.isValidUrl(src)) {
367
+ this.showError(embed, 'Invalid embed source URL');
368
+ return;
369
+ }
370
+
371
+ // Set dimensions or aspect ratio
372
+ if (height) {
373
+ embed.style.height = height;
374
+ embed.style.width = width;
375
+ // Remove default aspect ratio if explicit dimensions are provided
376
+ embed.style.aspectRatio = 'unset';
377
+ } else {
378
+ embed.style.width = width;
379
+ embed.style.aspectRatio = aspectRatio;
380
+ }
381
+
382
+ // Show loading state for accessibility
383
+ const loadingMessage = document.createElement('div');
384
+ loadingMessage.className = 'embed-placeholder';
385
+ loadingMessage.setAttribute('aria-live', 'polite');
386
+ loadingMessage.innerHTML = `<p>Loading ${type} content...</p>`;
387
+ embed.innerHTML = '';
388
+ embed.appendChild(loadingMessage);
389
+
390
+ // Create iframe element with enhanced attributes
391
+ const iframe = document.createElement('iframe');
392
+ iframe.allow = 'autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media';
393
+ iframe.loading = 'lazy';
394
+ iframe.title = title;
395
+ iframe.setAttribute('allowfullscreen', '');
396
+ iframe.setAttribute('aria-label', title);
397
+ iframe.referrerPolicy = 'no-referrer-when-downgrade';
398
+
399
+ // Set source based on type
400
+ try {
401
+ let finalSrc = this.buildEmbedSrc(embed, src, type);
402
+ iframe.src = finalSrc;
403
+
404
+ // Handle load and error events
405
+ iframe.addEventListener('load', () => {
406
+ embed.querySelector('.embed-placeholder')?.remove();
407
+ });
408
+
409
+ iframe.addEventListener('error', () => {
410
+ this.showError(embed, `Failed to load ${type} content`);
411
+ });
412
+
413
+ // For website embeds, set enhanced sandbox attributes for security
414
+ if (type === 'website') {
415
+ iframe.sandbox = 'allow-scripts allow-same-origin allow-forms allow-popups';
416
+ }
417
+
418
+ // Replace placeholder with iframe
419
+ embed.appendChild(iframe);
420
+
421
+ } catch (error) {
422
+ this.showError(embed, error.message);
423
+ }
424
+ }
425
+
426
+ // Handle embeds that don't use traditional iframes
427
+ handleSpecialEmbed(embed, type) {
428
+ const src = embed.getAttribute('data-src');
429
+ const title = embed.getAttribute('data-title') || 'Untitled Embed';
430
+
431
+ // Show loading state
432
+ const loadingMessage = document.createElement('div');
433
+ loadingMessage.className = 'embed-placeholder';
434
+ loadingMessage.setAttribute('aria-live', 'polite');
435
+ loadingMessage.innerHTML = `<p>Loading ${type} content...</p>`;
436
+ embed.innerHTML = '';
437
+ embed.appendChild(loadingMessage);
438
+
439
+ try {
440
+ switch (type) {
441
+ case 'twitter':
442
+ case 'x':
443
+ // Create a blockquote for Twitter to transform
444
+ const tweetUrl = this.buildEmbedSrc(embed, src, type);
445
+ const tweetContainer = document.createElement('blockquote');
446
+ tweetContainer.className = 'twitter-tweet';
447
+ tweetContainer.setAttribute('data-lang', embed.getAttribute('data-lang') || 'en');
448
+ tweetContainer.setAttribute('data-theme', embed.getAttribute('data-theme') || 'light');
449
+
450
+ const tweetlink = document.createElement('a');
451
+ tweetlink.href = tweetUrl;
452
+ tweetlink.textContent = title;
453
+ tweetContainer.appendChild(tweetlink);
454
+
455
+ embed.innerHTML = '';
456
+ embed.appendChild(tweetContainer);
457
+
458
+ // Initialize Twitter widgets
459
+ if (window.twttr && window.twttr.widgets) {
460
+ window.twttr.widgets.load(embed);
461
+ } else {
462
+ // The script will auto-process when loaded
463
+ this.loadExternalScript('https://platform.twitter.com/widgets.js', 'twitter-widget');
464
+ }
465
+ break;
466
+
467
+ case 'instagram':
468
+ // Create an Instagram embed using blockquote format
469
+ const instagramUrl = this.buildEmbedSrc(embed, src, type);
470
+ const instagramContainer = document.createElement('blockquote');
471
+ instagramContainer.className = 'instagram-media';
472
+ instagramContainer.setAttribute('data-instgrm-captioned', '');
473
+ instagramContainer.setAttribute('data-instgrm-permalink', instagramUrl);
474
+ instagramContainer.setAttribute('data-instgrm-version', '14');
475
+ instagramContainer.style.margin = '0 auto';
476
+ instagramContainer.style.width = '100%';
477
+ instagramContainer.style.maxWidth = '540px';
478
+
479
+ // Add a link inside the blockquote (required for Instagram's script)
480
+ const link = document.createElement('a');
481
+ link.href = instagramUrl;
482
+ link.textContent = title || 'View this post on Instagram';
483
+ link.target = '_blank';
484
+ instagramContainer.appendChild(link);
485
+
486
+ embed.innerHTML = '';
487
+ embed.appendChild(instagramContainer);
488
+
489
+ // Load Instagram's embed script and process this container
490
+ this.loadExternalScript('https://www.instagram.com/embed.js', 'instagram-embed');
491
+
492
+ // Need to tell instgrm to process this embed
493
+ if (window.instgrm) {
494
+ window.instgrm.Embeds.process();
495
+ }
496
+ break;
497
+
498
+ case 'gist':
499
+ case 'github':
500
+ // GitHub Gists use script tags
501
+ const gistUrl = this.buildEmbedSrc(embed, src, type);
502
+ const gistScript = document.createElement('script');
503
+ gistScript.src = gistUrl;
504
+
505
+ embed.innerHTML = '';
506
+ embed.appendChild(gistScript);
507
+ break;
508
+ }
509
+ } catch (error) {
510
+ this.showError(embed, error.message);
511
+ }
512
+ }
513
+
514
+ // Process a single container immediately (for demo functionality)
515
+ processContainer(container) {
516
+ if (container && container.classList.contains('embed-container')) {
517
+ this.lazyLoadEmbed(container);
518
+ }
519
+ }
520
+
521
+ // Utility method to add new embeds dynamically after page load
522
+ addEmbed(container) {
523
+ if (container && container.classList.contains('embed-container')) {
524
+ this.setupObserver([container]);
525
+ }
526
+ }
527
+ }
528
+
529
+ // Export for module environments (testing, Node.js, bundlers)
530
+ if (typeof module !== 'undefined' && module.exports) {
531
+ module.exports = EmbedManager;
532
+ }
533
+
534
+ // Auto-initialize for browser environments (non-module script tag usage)
535
+ if (typeof window !== 'undefined' && typeof module === 'undefined') {
536
+ window.EmbedManager = new EmbedManager();
537
+ }
@@ -0,0 +1 @@
1
+ class EmbedManager{constructor(t={}){this.options={rootMargin:"200px 0px",...t},this.injectCSS(),this.init()}injectCSS(){const t=document.createElement("style");t.innerHTML="\n\t\t\t.embed-container {\n\t\t\t\tmargin: 20px auto;\n\t\t\t\tbackground: #f4f4f4;\n\t\t\t\tposition: relative;\n\t\t\t\toverflow: hidden;\n\t\t\t\tdisplay: flex;\n\t\t\t\tjustify-content: center;\n\t\t\t\talign-items: center;\n\t\t\t\t/* Default aspect ratio wrapper */\n\t\t\t\taspect-ratio: 16/9;\n\t\t\t}\n\t\t\t.embed-container iframe {\n\t\t\t\twidth: 100%;\n\t\t\t\theight: 100%;\n\t\t\t\tborder: none;\n\t\t\t\tdisplay: block;\n\t\t\t\tposition: absolute;\n\t\t\t\ttop: 0;\n\t\t\t\tleft: 0;\n\t\t\t}\n\t\t\t.embed-container p {\n\t\t\t\tmargin: 0;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tcolor: #555;\n\t\t\t}\n\t\t\t.embed-container .embed-placeholder {\n\t\t\t\tdisplay: flex;\n\t\t\t\tflex-direction: column;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\twidth: 100%;\n\t\t\t\theight: 100%;\n\t\t\t\ttext-align: center;\n\t\t\t\tpadding: 1rem;\n\t\t\t}\n\t\t\t.embed-container .embed-error {\n\t\t\t\tcolor: #721c24;\n\t\t\t\tbackground-color: #f8d7da;\n\t\t\t\tpadding: 0.75rem;\n\t\t\t\tborder-radius: 0.25rem;\n\t\t\t\tmargin: 0.5rem 0;\n\t\t\t\twidth: 100%;\n\t\t\t\ttext-align: center;\n\t\t\t}\n\t\t",document.head.appendChild(t)}init(){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>this.setupEmbeds()):this.setupEmbeds()}setupEmbeds(){const t=document.querySelectorAll(".embed-container");this.setupObserver(t)}setupObserver(t){const e=new IntersectionObserver(t=>{t.forEach(t=>{t.isIntersecting&&(this.lazyLoadEmbed(t.target),e.unobserve(t.target))})},{rootMargin:this.options.rootMargin});t.forEach(t=>{if(!t.innerHTML.trim()){const e=t.getAttribute("data-type")||"content",a=document.createElement("div");a.className="embed-placeholder",a.innerHTML=`<p>Loading ${e} content when visible</p>`,t.appendChild(a)}e.observe(t)})}showError(t,e){console.error(`EmbedManager Error: ${e}`),t.innerHTML=`<div class="embed-error" role="alert">${e}</div>`}isValidUrl(t){try{const e=new URL(t);return["https:","http:"].includes(e.protocol)}catch(t){return!1}}buildEmbedSrc(t,e,a){let i=e;switch(a){case"codepen":{const e=t.getAttribute("data-theme-id")||"",a=t.getAttribute("data-default-tab")||"result",n="true"===t.getAttribute("data-editable")?"true":"false",r="true"===t.getAttribute("data-preview");i.includes("/pen/")?i=i.replace("/pen/",r?"/embed/preview/":"/embed/"):r&&i.includes("/embed/")&&!i.includes("/embed/preview/")&&(i=i.replace("/embed/","/embed/preview/"));const s=i.includes("?")?"&":"?";i=`${i}${s}theme-id=${e}&default-tab=${a}&editable=${n}`;break}case"vimeo":{const a=t.getAttribute("data-hash");if(a&&!e.includes("h=")){const t=e.includes("?")?"&":"?";i=`${e}${t}h=${a}`}const n=["badge=0","autopause=0","player_id=0","dnt=1"],r=t.getAttribute("data-app-id");r&&n.push(`app_id=${r}`),"true"===t.getAttribute("data-autoplay")&&n.push("autoplay=1"),n.forEach(t=>{const e=t.split("=")[0];if(!i.includes(e+"=")){const e=i.includes("?")?"&":"?";i=`${i}${e}${t}`}});break}case"youtube":{const a=[];"true"===t.getAttribute("data-autoplay")&&a.push("autoplay=1"),e.includes("youtube-nocookie.com")||(i=i.replace("youtube.com","youtube-nocookie.com")),a.push("rel=0","modestbranding=1");const n=i.includes("?")?"&":"?";i=`${i}${n}${a.join("&")}`;break}case"twitch":{const t=window.location.hostname;i=`${i}&parent=${t}`;break}case"twitter":case"x":this.loadExternalScript("https://platform.twitter.com/widgets.js","twitter-widget"),/^\d+$/.test(e)&&(i=`https://twitter.com/i/status/${e}`);break;case"instagram":this.loadExternalScript("https://www.instagram.com/embed.js","instagram-embed"),(i.includes("instagram.com/p/")||i.includes("instagram.com/reel/"))&&(i.includes("?")?i.includes("utm_source=ig_embed")||(i=`${i}&utm_source=ig_embed&utm_campaign=loading`):i=`${i}?utm_source=ig_embed&utm_campaign=loading`);break;case"tiktok":if(this.loadExternalScript("https://www.tiktok.com/embed.js","tiktok-embed"),!i.includes("embed")){const t=i.replace(/\?.*$/,"").replace(/\/$/,"").split("/").pop();i=`https://www.tiktok.com/embed/v2/${t}`}break;case"soundcloud":if(!i.includes("api.soundcloud.com")){const a=t.getAttribute("data-color")||"ff5500",n="true"===t.getAttribute("data-autoplay")?"true":"false",r="true"===t.getAttribute("data-show-comments")?"true":"false";i=`https://w.soundcloud.com/player/?url=${encodeURIComponent(e)}&color=${a}&auto_play=${n}&hide_related=false&show_comments=${r}&show_user=true&show_reposts=false&show_teaser=true`}break;case"spotify":if(i.includes("spotify.com")){const t=i.includes("/track/")?"track":i.includes("/album/")?"album":i.includes("/playlist/")?"playlist":i.includes("/episode/")?"episode":"track",e=i.split("/").pop().split("?")[0];i=`https://open.spotify.com/embed/${t}/${e}`}break;case"github":case"gist":if(i.includes("gist.github.com")&&!i.endsWith(".js")){const t=i.split("/").pop();i=`https://gist.github.com/${t}.js`}break;case"maps":case"google-maps":if(!i.includes("google.com/maps/embed")){let e="";i.includes("maps/place/")?e=i.split("maps/place/")[1].split("/")[0]:i.includes("maps?q=")&&(e=i.split("maps?q=")[1].split("&")[0]),e&&(i=`https://www.google.com/maps/embed/v1/place?key=${t.getAttribute("data-api-key")||""}&q=${e}`)}}return i}loadExternalScript(t,e){if(!document.getElementById(e)){const a=document.createElement("script");a.id=e,a.src=t,a.async=!0,a.defer=!0,document.body.appendChild(a)}}lazyLoadEmbed(t){const e=t.getAttribute("data-type"),a=t.getAttribute("data-src");if("twitter"===e||"x"===e||"gist"===e||"github"===e||"instagram"===e)return void this.handleSpecialEmbed(t,e);const i=t.getAttribute("data-title")||"Untitled Embed",n=t.getAttribute("data-width")||"100%",r=t.getAttribute("data-height"),s=t.getAttribute("data-aspect-ratio")||"16/9";if(!a||!this.isValidUrl(a))return void this.showError(t,"Invalid embed source URL");r?(t.style.height=r,t.style.width=n,t.style.aspectRatio="unset"):(t.style.width=n,t.style.aspectRatio=s);const o=document.createElement("div");o.className="embed-placeholder",o.setAttribute("aria-live","polite"),o.innerHTML=`<p>Loading ${e} content...</p>`,t.innerHTML="",t.appendChild(o);const d=document.createElement("iframe");d.allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media",d.loading="lazy",d.title=i,d.setAttribute("allowfullscreen",""),d.setAttribute("aria-label",i),d.referrerPolicy="no-referrer-when-downgrade";try{let i=this.buildEmbedSrc(t,a,e);d.src=i,d.addEventListener("load",()=>{t.querySelector(".embed-placeholder")?.remove()}),d.addEventListener("error",()=>{this.showError(t,`Failed to load ${e} content`)}),"website"===e&&(d.sandbox="allow-scripts allow-same-origin allow-forms allow-popups"),t.appendChild(d)}catch(e){this.showError(t,e.message)}}handleSpecialEmbed(t,e){const a=t.getAttribute("data-src"),i=t.getAttribute("data-title")||"Untitled Embed",n=document.createElement("div");n.className="embed-placeholder",n.setAttribute("aria-live","polite"),n.innerHTML=`<p>Loading ${e} content...</p>`,t.innerHTML="",t.appendChild(n);try{switch(e){case"twitter":case"x":const n=this.buildEmbedSrc(t,a,e),r=document.createElement("blockquote");r.className="twitter-tweet",r.setAttribute("data-lang",t.getAttribute("data-lang")||"en"),r.setAttribute("data-theme",t.getAttribute("data-theme")||"light");const s=document.createElement("a");s.href=n,s.textContent=i,r.appendChild(s),t.innerHTML="",t.appendChild(r),window.twttr&&window.twttr.widgets?window.twttr.widgets.load(t):this.loadExternalScript("https://platform.twitter.com/widgets.js","twitter-widget");break;case"instagram":const o=this.buildEmbedSrc(t,a,e),d=document.createElement("blockquote");d.className="instagram-media",d.setAttribute("data-instgrm-captioned",""),d.setAttribute("data-instgrm-permalink",o),d.setAttribute("data-instgrm-version","14"),d.style.margin="0 auto",d.style.width="100%",d.style.maxWidth="540px";const c=document.createElement("a");c.href=o,c.textContent=i||"View this post on Instagram",c.target="_blank",d.appendChild(c),t.innerHTML="",t.appendChild(d),this.loadExternalScript("https://www.instagram.com/embed.js","instagram-embed"),window.instgrm&&window.instgrm.Embeds.process();break;case"gist":case"github":const l=this.buildEmbedSrc(t,a,e),m=document.createElement("script");m.src=l,t.innerHTML="",t.appendChild(m)}}catch(e){this.showError(t,e.message)}}processContainer(t){t&&t.classList.contains("embed-container")&&this.lazyLoadEmbed(t)}addEmbed(t){t&&t.classList.contains("embed-container")&&this.setupObserver([t])}}"undefined"!=typeof module&&module.exports&&(module.exports=EmbedManager),"undefined"!=typeof window&&"undefined"==typeof module&&(window.EmbedManager=new EmbedManager);
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "embed-manager",
3
+ "version": "1.0.0",
4
+ "description": "A versatile JavaScript library for embedding various content types (YouTube, Vimeo, Twitch, CodePen, and websites) with lazy loading capabilities",
5
+ "main": "src/lib/embedManager.js",
6
+ "browser": "dist/embedManager.min.js",
7
+ "jsdelivr": "dist/embedManager.min.js",
8
+ "unpkg": "dist/embedManager.min.js",
9
+ "exports": {
10
+ ".": {
11
+ "browser": "./dist/embedManager.min.js",
12
+ "default": "./src/lib/embedManager.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "src/lib/embedManager.js",
17
+ "dist/embedManager.js",
18
+ "dist/embedManager.min.js",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/peterbenoit/EmbedManager.git"
25
+ },
26
+ "keywords": [
27
+ "embed",
28
+ "iframe",
29
+ "lazy-loading",
30
+ "youtube",
31
+ "vimeo",
32
+ "twitch",
33
+ "codepen",
34
+ "tiktok",
35
+ "spotify",
36
+ "soundcloud",
37
+ "website-embed"
38
+ ],
39
+ "author": "Peter Benoit",
40
+ "license": "MIT",
41
+ "scripts": {
42
+ "build": "mkdir -p dist && cp src/lib/embedManager.js dist/embedManager.js && terser src/lib/embedManager.js --compress --mangle --output dist/embedManager.min.js",
43
+ "test": "jest",
44
+ "test:watch": "jest --watch",
45
+ "test:coverage": "jest --coverage",
46
+ "pack:check": "npm pack --dry-run",
47
+ "prepublishOnly": "npm test && npm run build"
48
+ },
49
+ "jest": {
50
+ "testEnvironment": "jsdom",
51
+ "testMatch": [
52
+ "**/*.test.js"
53
+ ],
54
+ "coverageDirectory": "coverage",
55
+ "collectCoverageFrom": [
56
+ "src/lib/**/*.js"
57
+ ]
58
+ },
59
+ "bugs": {
60
+ "url": "https://github.com/peterbenoit/EmbedManager/issues"
61
+ },
62
+ "homepage": "https://github.com/peterbenoit/EmbedManager#readme",
63
+ "devDependencies": {
64
+ "fs-extra": "^11.1.0",
65
+ "http-server": "^14.1.1",
66
+ "jest": "^30.3.0",
67
+ "jest-environment-jsdom": "^30.3.0",
68
+ "terser": "^5.17.1"
69
+ }
70
+ }
@@ -0,0 +1,537 @@
1
+ /**
2
+ * EmbedManager.js
3
+ *
4
+ * This library handles the embedding of various content types
5
+ * (YouTube, Vimeo, Twitch, CodePen, Twitter, Instragram and more) into a webpage with
6
+ * lazy loading to improve performance. It automatically injects the necessary
7
+ * CSS and sets up an Intersection Observer to load iframes only when they are
8
+ * in view, optimizing page load times and resource management.
9
+ *
10
+ * Features:
11
+ * - Lazy loading of iframes using the Intersection Observer API
12
+ * - Support for multiple content types (YouTube, Vimeo, Twitch, CodePen, Twitter, Instragram and more)
13
+ * - Sandbox attribute for website embeds to enhance security
14
+ * - Configurable iframe titles for accessibility
15
+ * - Handles Vimeo unlisted/private privacy hash via optional `data-hash` attribute
16
+ * - Enhanced accessibility with aria attributes
17
+ * - Improved security with referrer policy and stricter sandbox settings
18
+ * - Responsive design support with aspect ratio preservation
19
+ * - Error handling for failed embeds
20
+ *
21
+ * Usage:
22
+ * - Include the HTML structure with the 'embed-container' class and appropriate data attributes.
23
+ * - The class will handle iframe creation, and lazy loading automatically.
24
+ */
25
+ class EmbedManager {
26
+ constructor(options = {}) {
27
+ this.options = {
28
+ rootMargin: '200px 0px',
29
+ ...options
30
+ };
31
+ this.injectCSS();
32
+ this.init();
33
+ }
34
+
35
+ // Inject CSS into the document head
36
+ injectCSS() {
37
+ const style = document.createElement('style');
38
+ style.innerHTML = `
39
+ .embed-container {
40
+ margin: 20px auto;
41
+ background: #f4f4f4;
42
+ position: relative;
43
+ overflow: hidden;
44
+ display: flex;
45
+ justify-content: center;
46
+ align-items: center;
47
+ /* Default aspect ratio wrapper */
48
+ aspect-ratio: 16/9;
49
+ }
50
+ .embed-container iframe {
51
+ width: 100%;
52
+ height: 100%;
53
+ border: none;
54
+ display: block;
55
+ position: absolute;
56
+ top: 0;
57
+ left: 0;
58
+ }
59
+ .embed-container p {
60
+ margin: 0;
61
+ font-size: 1em;
62
+ color: #555;
63
+ }
64
+ .embed-container .embed-placeholder {
65
+ display: flex;
66
+ flex-direction: column;
67
+ align-items: center;
68
+ justify-content: center;
69
+ width: 100%;
70
+ height: 100%;
71
+ text-align: center;
72
+ padding: 1rem;
73
+ }
74
+ .embed-container .embed-error {
75
+ color: #721c24;
76
+ background-color: #f8d7da;
77
+ padding: 0.75rem;
78
+ border-radius: 0.25rem;
79
+ margin: 0.5rem 0;
80
+ width: 100%;
81
+ text-align: center;
82
+ }
83
+ `;
84
+ document.head.appendChild(style);
85
+ }
86
+
87
+ // Initialize lazy loading after DOM is loaded
88
+ init() {
89
+ if (document.readyState === 'loading') {
90
+ document.addEventListener('DOMContentLoaded', () => this.setupEmbeds());
91
+ } else {
92
+ this.setupEmbeds();
93
+ }
94
+ }
95
+
96
+ setupEmbeds() {
97
+ const embeds = document.querySelectorAll('.embed-container');
98
+ this.setupObserver(embeds);
99
+ }
100
+
101
+ // Set up Intersection Observer for lazy loading
102
+ setupObserver(embeds) {
103
+ const observer = new IntersectionObserver((entries) => {
104
+ entries.forEach(entry => {
105
+ if (entry.isIntersecting) {
106
+ this.lazyLoadEmbed(entry.target);
107
+ observer.unobserve(entry.target); // Stop observing once loaded
108
+ }
109
+ });
110
+ }, {
111
+ rootMargin: this.options.rootMargin
112
+ });
113
+
114
+ // Observe each embed container
115
+ embeds.forEach(embed => {
116
+ // Add placeholder content
117
+ if (!embed.innerHTML.trim()) {
118
+ const type = embed.getAttribute('data-type') || 'content';
119
+ const placeholder = document.createElement('div');
120
+ placeholder.className = 'embed-placeholder';
121
+ placeholder.innerHTML = `<p>Loading ${type} content when visible</p>`;
122
+ embed.appendChild(placeholder);
123
+ }
124
+ observer.observe(embed);
125
+ });
126
+ }
127
+
128
+ // Helper method to show errors
129
+ showError(embed, message) {
130
+ console.error(`EmbedManager Error: ${message}`);
131
+ embed.innerHTML = `<div class="embed-error" role="alert">${message}</div>`;
132
+ }
133
+
134
+ // Validate URL format — only accepts https/http to prevent javascript: URI injection
135
+ isValidUrl(url) {
136
+ try {
137
+ const parsed = new URL(url);
138
+ return ['https:', 'http:'].includes(parsed.protocol);
139
+ } catch (e) {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ // Build the embed source URL with appropriate parameters
145
+ buildEmbedSrc(embed, src, type) {
146
+ let finalSrc = src;
147
+
148
+ switch (type) {
149
+ case 'codepen': {
150
+ const themeId = embed.getAttribute('data-theme-id') || '';
151
+ const defaultTab = embed.getAttribute('data-default-tab') || 'result';
152
+ const editable = embed.getAttribute('data-editable') === 'true' ? 'true' : 'false';
153
+ const usePreview = embed.getAttribute('data-preview') === 'true';
154
+ // Convert /pen/ URL to embed URL and insert /preview/ when requested
155
+ if (finalSrc.includes('/pen/')) {
156
+ finalSrc = finalSrc.replace('/pen/', usePreview ? '/embed/preview/' : '/embed/');
157
+ } else if (usePreview && finalSrc.includes('/embed/') && !finalSrc.includes('/embed/preview/')) {
158
+ finalSrc = finalSrc.replace('/embed/', '/embed/preview/');
159
+ }
160
+ const cSep = finalSrc.includes('?') ? '&' : '?';
161
+ finalSrc = `${finalSrc}${cSep}theme-id=${themeId}&default-tab=${defaultTab}&editable=${editable}`;
162
+ break;
163
+ }
164
+
165
+ case 'vimeo': {
166
+ // Handle Vimeo privacy hash
167
+ const hash = embed.getAttribute('data-hash');
168
+ if (hash && !src.includes('h=')) {
169
+ const vSep = src.includes('?') ? '&' : '?';
170
+ finalSrc = `${src}${vSep}h=${hash}`;
171
+ }
172
+
173
+ // Add common Vimeo parameters
174
+ const vimeoParams = [
175
+ 'badge=0',
176
+ 'autopause=0',
177
+ 'player_id=0',
178
+ 'dnt=1' // Do Not Track for privacy
179
+ ];
180
+
181
+ // Add app_id if provided
182
+ const appId = embed.getAttribute('data-app-id');
183
+ if (appId) {
184
+ vimeoParams.push(`app_id=${appId}`);
185
+ }
186
+
187
+ // Add user preferences
188
+ if (embed.getAttribute('data-autoplay') === 'true') {
189
+ vimeoParams.push('autoplay=1');
190
+ }
191
+
192
+ // Append all parameters
193
+ vimeoParams.forEach(param => {
194
+ const paramName = param.split('=')[0];
195
+ if (!finalSrc.includes(paramName + '=')) {
196
+ const sep = finalSrc.includes('?') ? '&' : '?';
197
+ finalSrc = `${finalSrc}${sep}${param}`;
198
+ }
199
+ });
200
+ break;
201
+ }
202
+
203
+ case 'youtube': {
204
+ // Add YouTube parameters for better privacy and user experience
205
+ const ytParams = [];
206
+
207
+ // Add user preferences
208
+ if (embed.getAttribute('data-autoplay') === 'true') {
209
+ ytParams.push('autoplay=1');
210
+ }
211
+
212
+ // Add privacy-enhanced mode
213
+ if (!src.includes('youtube-nocookie.com')) {
214
+ // Replace with privacy-enhanced version if not already using it
215
+ finalSrc = finalSrc.replace('youtube.com', 'youtube-nocookie.com');
216
+ }
217
+
218
+ // Add other common parameters
219
+ ytParams.push('rel=0', 'modestbranding=1');
220
+
221
+ // Append all parameters
222
+ const ytSep = finalSrc.includes('?') ? '&' : '?';
223
+ finalSrc = `${finalSrc}${ytSep}${ytParams.join('&')}`;
224
+ break;
225
+ }
226
+
227
+ case 'twitch': {
228
+ const parentDomain = window.location.hostname;
229
+ finalSrc = `${finalSrc}&parent=${parentDomain}`;
230
+ break;
231
+ }
232
+
233
+ case 'twitter':
234
+ case 'x':
235
+ // Twitter/X embeds need special handling with their widget.js
236
+ // We'll return the src as is, but we need to load their script
237
+ this.loadExternalScript('https://platform.twitter.com/widgets.js', 'twitter-widget');
238
+
239
+ // If the source is just a tweet ID, construct the proper URL
240
+ if (/^\d+$/.test(src)) {
241
+ finalSrc = `https://twitter.com/i/status/${src}`;
242
+ }
243
+ break;
244
+
245
+ case 'instagram':
246
+ // Instagram embeds require their script
247
+ this.loadExternalScript('https://www.instagram.com/embed.js', 'instagram-embed');
248
+
249
+ // Handle different Instagram URL formats
250
+ if (finalSrc.includes('instagram.com/p/') || finalSrc.includes('instagram.com/reel/')) {
251
+ // Add query parameters if not already present
252
+ if (!finalSrc.includes('?')) {
253
+ finalSrc = `${finalSrc}?utm_source=ig_embed&utm_campaign=loading`;
254
+ } else if (!finalSrc.includes('utm_source=ig_embed')) {
255
+ finalSrc = `${finalSrc}&utm_source=ig_embed&utm_campaign=loading`;
256
+ }
257
+ }
258
+ break;
259
+
260
+ case 'tiktok': {
261
+ // TikTok embeds require their script
262
+ this.loadExternalScript('https://www.tiktok.com/embed.js', 'tiktok-embed');
263
+
264
+ // Handle both video URLs and direct embed URLs
265
+ if (!finalSrc.includes('embed')) {
266
+ // Strip trailing slash and query string before extracting ID
267
+ const tiktokPath = finalSrc.replace(/\?.*$/, '').replace(/\/$/, '');
268
+ const tiktokId = tiktokPath.split('/').pop();
269
+ finalSrc = `https://www.tiktok.com/embed/v2/${tiktokId}`;
270
+ }
271
+ break;
272
+ }
273
+
274
+ case 'soundcloud': {
275
+ // If only the track URL is provided, convert to embed URL
276
+ if (!finalSrc.includes('api.soundcloud.com')) {
277
+ // We'll use color and auto_play from data attributes
278
+ const color = embed.getAttribute('data-color') || 'ff5500';
279
+ const autoPlay = embed.getAttribute('data-autoplay') === 'true' ? 'true' : 'false';
280
+ const showComments = embed.getAttribute('data-show-comments') === 'true' ? 'true' : 'false';
281
+
282
+ finalSrc = `https://w.soundcloud.com/player/?url=${encodeURIComponent(src)}&color=${color}&auto_play=${autoPlay}&hide_related=false&show_comments=${showComments}&show_user=true&show_reposts=false&show_teaser=true`;
283
+ }
284
+ break;
285
+ }
286
+
287
+ case 'spotify': {
288
+ // Handle different Spotify embed types (track, album, playlist, podcast)
289
+ if (finalSrc.includes('spotify.com')) {
290
+ // Convert regular Spotify URL to embed URL
291
+ const spotifyType = finalSrc.includes('/track/') ? 'track' :
292
+ finalSrc.includes('/album/') ? 'album' :
293
+ finalSrc.includes('/playlist/') ? 'playlist' :
294
+ finalSrc.includes('/episode/') ? 'episode' : 'track';
295
+
296
+ const spotifyId = finalSrc.split('/').pop().split('?')[0];
297
+ finalSrc = `https://open.spotify.com/embed/${spotifyType}/${spotifyId}`;
298
+ }
299
+ break;
300
+ }
301
+
302
+ case 'github':
303
+ case 'gist':
304
+ // GitHub Gists are embedded via script, not iframe
305
+ // Convert gist.github.com/user/gistid to the .js script URL
306
+ if (finalSrc.includes('gist.github.com') && !finalSrc.endsWith('.js')) {
307
+ const gistId = finalSrc.split('/').pop();
308
+ finalSrc = `https://gist.github.com/${gistId}.js`;
309
+ }
310
+ break;
311
+
312
+ case 'maps':
313
+ case 'google-maps': {
314
+ // Handle Google Maps embeds
315
+ if (!finalSrc.includes('google.com/maps/embed')) {
316
+ // If it's a regular maps URL, convert to embed URL
317
+ // Extract location query or coordinates
318
+ let query = '';
319
+
320
+ if (finalSrc.includes('maps/place/')) {
321
+ query = finalSrc.split('maps/place/')[1].split('/')[0];
322
+ } else if (finalSrc.includes('maps?q=')) {
323
+ query = finalSrc.split('maps?q=')[1].split('&')[0];
324
+ }
325
+
326
+ if (query) {
327
+ finalSrc = `https://www.google.com/maps/embed/v1/place?key=${embed.getAttribute('data-api-key') || ''}&q=${query}`;
328
+ }
329
+ }
330
+ break;
331
+ }
332
+ }
333
+
334
+ return finalSrc;
335
+ }
336
+
337
+ // Method to load external scripts needed for some embeds
338
+ loadExternalScript(src, id) {
339
+ if (!document.getElementById(id)) {
340
+ const script = document.createElement('script');
341
+ script.id = id;
342
+ script.src = src;
343
+ script.async = true;
344
+ script.defer = true;
345
+ document.body.appendChild(script);
346
+ }
347
+ }
348
+
349
+ // Override lazyLoadEmbed to handle special embed types
350
+ lazyLoadEmbed(embed) {
351
+ const type = embed.getAttribute('data-type');
352
+ const src = embed.getAttribute('data-src');
353
+
354
+ // Special handling for embeds that don't use iframes
355
+ if (type === 'twitter' || type === 'x' || type === 'gist' || type === 'github' || type === 'instagram') {
356
+ this.handleSpecialEmbed(embed, type);
357
+ return;
358
+ }
359
+
360
+ const title = embed.getAttribute('data-title') || 'Untitled Embed';
361
+ const width = embed.getAttribute('data-width') || '100%';
362
+ const height = embed.getAttribute('data-height');
363
+ const aspectRatio = embed.getAttribute('data-aspect-ratio') || '16/9';
364
+
365
+ // Validate source URL
366
+ if (!src || !this.isValidUrl(src)) {
367
+ this.showError(embed, 'Invalid embed source URL');
368
+ return;
369
+ }
370
+
371
+ // Set dimensions or aspect ratio
372
+ if (height) {
373
+ embed.style.height = height;
374
+ embed.style.width = width;
375
+ // Remove default aspect ratio if explicit dimensions are provided
376
+ embed.style.aspectRatio = 'unset';
377
+ } else {
378
+ embed.style.width = width;
379
+ embed.style.aspectRatio = aspectRatio;
380
+ }
381
+
382
+ // Show loading state for accessibility
383
+ const loadingMessage = document.createElement('div');
384
+ loadingMessage.className = 'embed-placeholder';
385
+ loadingMessage.setAttribute('aria-live', 'polite');
386
+ loadingMessage.innerHTML = `<p>Loading ${type} content...</p>`;
387
+ embed.innerHTML = '';
388
+ embed.appendChild(loadingMessage);
389
+
390
+ // Create iframe element with enhanced attributes
391
+ const iframe = document.createElement('iframe');
392
+ iframe.allow = 'autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media';
393
+ iframe.loading = 'lazy';
394
+ iframe.title = title;
395
+ iframe.setAttribute('allowfullscreen', '');
396
+ iframe.setAttribute('aria-label', title);
397
+ iframe.referrerPolicy = 'no-referrer-when-downgrade';
398
+
399
+ // Set source based on type
400
+ try {
401
+ let finalSrc = this.buildEmbedSrc(embed, src, type);
402
+ iframe.src = finalSrc;
403
+
404
+ // Handle load and error events
405
+ iframe.addEventListener('load', () => {
406
+ embed.querySelector('.embed-placeholder')?.remove();
407
+ });
408
+
409
+ iframe.addEventListener('error', () => {
410
+ this.showError(embed, `Failed to load ${type} content`);
411
+ });
412
+
413
+ // For website embeds, set enhanced sandbox attributes for security
414
+ if (type === 'website') {
415
+ iframe.sandbox = 'allow-scripts allow-same-origin allow-forms allow-popups';
416
+ }
417
+
418
+ // Replace placeholder with iframe
419
+ embed.appendChild(iframe);
420
+
421
+ } catch (error) {
422
+ this.showError(embed, error.message);
423
+ }
424
+ }
425
+
426
+ // Handle embeds that don't use traditional iframes
427
+ handleSpecialEmbed(embed, type) {
428
+ const src = embed.getAttribute('data-src');
429
+ const title = embed.getAttribute('data-title') || 'Untitled Embed';
430
+
431
+ // Show loading state
432
+ const loadingMessage = document.createElement('div');
433
+ loadingMessage.className = 'embed-placeholder';
434
+ loadingMessage.setAttribute('aria-live', 'polite');
435
+ loadingMessage.innerHTML = `<p>Loading ${type} content...</p>`;
436
+ embed.innerHTML = '';
437
+ embed.appendChild(loadingMessage);
438
+
439
+ try {
440
+ switch (type) {
441
+ case 'twitter':
442
+ case 'x':
443
+ // Create a blockquote for Twitter to transform
444
+ const tweetUrl = this.buildEmbedSrc(embed, src, type);
445
+ const tweetContainer = document.createElement('blockquote');
446
+ tweetContainer.className = 'twitter-tweet';
447
+ tweetContainer.setAttribute('data-lang', embed.getAttribute('data-lang') || 'en');
448
+ tweetContainer.setAttribute('data-theme', embed.getAttribute('data-theme') || 'light');
449
+
450
+ const tweetlink = document.createElement('a');
451
+ tweetlink.href = tweetUrl;
452
+ tweetlink.textContent = title;
453
+ tweetContainer.appendChild(tweetlink);
454
+
455
+ embed.innerHTML = '';
456
+ embed.appendChild(tweetContainer);
457
+
458
+ // Initialize Twitter widgets
459
+ if (window.twttr && window.twttr.widgets) {
460
+ window.twttr.widgets.load(embed);
461
+ } else {
462
+ // The script will auto-process when loaded
463
+ this.loadExternalScript('https://platform.twitter.com/widgets.js', 'twitter-widget');
464
+ }
465
+ break;
466
+
467
+ case 'instagram':
468
+ // Create an Instagram embed using blockquote format
469
+ const instagramUrl = this.buildEmbedSrc(embed, src, type);
470
+ const instagramContainer = document.createElement('blockquote');
471
+ instagramContainer.className = 'instagram-media';
472
+ instagramContainer.setAttribute('data-instgrm-captioned', '');
473
+ instagramContainer.setAttribute('data-instgrm-permalink', instagramUrl);
474
+ instagramContainer.setAttribute('data-instgrm-version', '14');
475
+ instagramContainer.style.margin = '0 auto';
476
+ instagramContainer.style.width = '100%';
477
+ instagramContainer.style.maxWidth = '540px';
478
+
479
+ // Add a link inside the blockquote (required for Instagram's script)
480
+ const link = document.createElement('a');
481
+ link.href = instagramUrl;
482
+ link.textContent = title || 'View this post on Instagram';
483
+ link.target = '_blank';
484
+ instagramContainer.appendChild(link);
485
+
486
+ embed.innerHTML = '';
487
+ embed.appendChild(instagramContainer);
488
+
489
+ // Load Instagram's embed script and process this container
490
+ this.loadExternalScript('https://www.instagram.com/embed.js', 'instagram-embed');
491
+
492
+ // Need to tell instgrm to process this embed
493
+ if (window.instgrm) {
494
+ window.instgrm.Embeds.process();
495
+ }
496
+ break;
497
+
498
+ case 'gist':
499
+ case 'github':
500
+ // GitHub Gists use script tags
501
+ const gistUrl = this.buildEmbedSrc(embed, src, type);
502
+ const gistScript = document.createElement('script');
503
+ gistScript.src = gistUrl;
504
+
505
+ embed.innerHTML = '';
506
+ embed.appendChild(gistScript);
507
+ break;
508
+ }
509
+ } catch (error) {
510
+ this.showError(embed, error.message);
511
+ }
512
+ }
513
+
514
+ // Process a single container immediately (for demo functionality)
515
+ processContainer(container) {
516
+ if (container && container.classList.contains('embed-container')) {
517
+ this.lazyLoadEmbed(container);
518
+ }
519
+ }
520
+
521
+ // Utility method to add new embeds dynamically after page load
522
+ addEmbed(container) {
523
+ if (container && container.classList.contains('embed-container')) {
524
+ this.setupObserver([container]);
525
+ }
526
+ }
527
+ }
528
+
529
+ // Export for module environments (testing, Node.js, bundlers)
530
+ if (typeof module !== 'undefined' && module.exports) {
531
+ module.exports = EmbedManager;
532
+ }
533
+
534
+ // Auto-initialize for browser environments (non-module script tag usage)
535
+ if (typeof window !== 'undefined' && typeof module === 'undefined') {
536
+ window.EmbedManager = new EmbedManager();
537
+ }