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 +21 -0
- package/README.md +170 -0
- package/dist/embedManager.js +537 -0
- package/dist/embedManager.min.js +1 -0
- package/package.json +70 -0
- package/src/lib/embedManager.js +537 -0
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
|
+
}
|