-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathdocs.html
More file actions
471 lines (452 loc) · 191 KB
/
docs.html
File metadata and controls
471 lines (452 loc) · 191 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>EmbedPress Free — Developer Docs</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/atom-one-light.min.css" id="hljs-light">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/atom-one-dark.min.css" id="hljs-dark" disabled>
<style>
:root {
--bg: #fcfcfc;
--bg-sidebar: #fafafa;
--bg-code: #f5f5f5;
--bg-pre: #fafafa;
--bg-hover: #f0f0f0;
--bg-active: #f5f0ff;
--border: #ebebeb;
--border-strong: #e0e0e0;
--text: #1a1a1a;
--text-muted: #6b6b6b;
--text-faint: #9b9b9b;
--accent: #6f4cff;
--accent-soft: #f1ecff;
--radius: 8px;
--radius-sm: 6px;
--shadow: 0 1px 2px rgba(0,0,0,0.04);
}
[data-theme="dark"] {
--bg: #0f0f10;
--bg-sidebar: #131315;
--bg-code: #1c1c1f;
--bg-pre: #18181b;
--bg-hover: #1f1f22;
--bg-active: #1f1733;
--border: #25252a;
--border-strong: #2e2e34;
--text: #ededed;
--text-muted: #9ca3af;
--text-faint: #6b7280;
--accent: #a78bfa;
--accent-soft: #1f1733;
--shadow: 0 1px 2px rgba(0,0,0,0.4);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-feature-settings: "cv11", "ss01", "ss03";
color: var(--text);
background: var(--bg);
font-size: 15px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#layout {
display: flex;
height: 100vh;
}
/* Sidebar */
#sidebar {
width: 290px;
min-width: 290px;
border-right: 1px solid var(--border);
background: var(--bg-sidebar);
overflow-y: auto;
padding: 0 0 40px;
display: flex;
flex-direction: column;
}
#sidebar::-webkit-scrollbar { width: 6px; }
#sidebar::-webkit-scrollbar-track { background: transparent; }
#sidebar::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
.sidebar-top {
position: sticky;
top: 0;
background: var(--bg-sidebar);
padding-top: 24px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
z-index: 5;
}
.sidebar-header {
padding: 0 24px 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-header h1 {
font-size: 14px;
font-weight: 600;
margin: 0;
color: var(--text);
letter-spacing: -0.01em;
line-height: 1.4;
}
.theme-toggle {
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 4px 7px;
cursor: pointer;
color: var(--text-muted);
font-size: 13px;
line-height: 1;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.theme-toggle:hover { background: var(--bg-hover); color: var(--text); border-color: var(--border-strong); }
#search {
margin: 0 16px;
width: calc(100% - 32px);
padding: 8px 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font-size: 13px;
font-family: inherit;
background: var(--bg);
color: var(--text);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
#search:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
#search::placeholder { color: var(--text-faint); }
#nav { padding-bottom: 20px; }
#nav h2 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
margin: 22px 24px 6px;
font-weight: 600;
}
#nav a {
display: block;
padding: 6px 24px;
font-size: 13.5px;
color: var(--text-muted);
text-decoration: none;
border-left: 2px solid transparent;
transition: background 0.12s, color 0.12s, border-color 0.12s;
line-height: 1.5;
}
#nav a:hover { background: var(--bg-hover); color: var(--text); }
#nav a.active {
background: var(--bg-active);
border-left-color: var(--accent);
color: var(--accent);
font-weight: 500;
}
/* Content */
#content {
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
}
#content::-webkit-scrollbar { width: 8px; }
#content::-webkit-scrollbar-track { background: transparent; }
#content::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
#topbar-inner, #rendered {
max-width: 980px;
margin: 0 auto;
}
#topbar {
border-bottom: 1px solid var(--border);
font-size: 12px;
color: var(--text-faint);
font-family: 'JetBrains Mono', ui-monospace, monospace;
position: sticky;
top: 0;
background: var(--bg);
backdrop-filter: blur(8px);
z-index: 10;
}
#topbar-inner {
padding: 12px 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
#topbar-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
#rendered {
padding: 48px 48px 120px;
font-size: 15px;
}
/* Typography */
#rendered h1, #rendered h2, #rendered h3, #rendered h4, #rendered h5 {
letter-spacing: -0.015em;
line-height: 1.25;
color: var(--text);
font-weight: 600;
}
#rendered h1 {
font-size: 32px;
margin: 0 0 0.4em;
font-weight: 700;
letter-spacing: -0.025em;
}
#rendered h2 {
font-size: 22px;
margin: 2.2em 0 0.6em;
padding-top: 0.4em;
}
#rendered h3 {
font-size: 17px;
margin: 1.8em 0 0.5em;
}
#rendered h4 {
font-size: 15px;
margin: 1.4em 0 0.4em;
color: var(--text-muted);
}
#rendered > h1:first-child { margin-top: 0; }
#rendered p { margin: 0.8em 0; color: var(--text); }
#rendered p, #rendered li { line-height: 1.65; }
#rendered ul, #rendered ol { padding-left: 22px; margin: 0.8em 0; }
#rendered li { margin: 0.3em 0; }
#rendered strong { font-weight: 600; }
/* Inline code */
#rendered code {
background: var(--bg-code);
border: 1px solid var(--border);
padding: 1px 6px;
border-radius: 4px;
font-size: 13px;
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
color: var(--text);
word-break: break-word;
}
/* Code blocks */
#rendered pre {
background: var(--bg-pre);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
overflow-x: auto;
margin: 1.2em 0;
font-size: 13px;
line-height: 1.55;
box-shadow: var(--shadow);
}
#rendered pre code {
background: transparent;
border: none;
padding: 0;
font-size: 13px;
color: var(--text);
}
#rendered pre::-webkit-scrollbar { height: 8px; }
#rendered pre::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
/* Tables */
#rendered table {
border-collapse: collapse;
margin: 1.4em 0;
font-size: 13.5px;
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
#rendered th, #rendered td {
border-bottom: 1px solid var(--border);
padding: 9px 14px;
text-align: left;
vertical-align: top;
}
#rendered tr:last-child td { border-bottom: none; }
#rendered th {
background: var(--bg-pre);
font-weight: 600;
font-size: 12.5px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
#rendered tr:hover td { background: var(--bg-pre); }
/* Blockquote */
#rendered blockquote {
border-left: 3px solid var(--accent);
padding: 4px 18px;
margin: 1.2em 0;
background: var(--accent-soft);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
color: var(--text);
}
#rendered blockquote p { margin: 0.5em 0; }
/* Links */
#rendered a {
color: var(--accent);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.15s;
}
#rendered a:hover { border-bottom-color: var(--accent); }
/* HR */
#rendered hr {
border: none;
border-top: 1px solid var(--border);
margin: 2.5em 0;
}
@media (max-width: 768px) {
#sidebar { width: 240px; min-width: 240px; }
#rendered { padding: 32px 24px 80px; }
#topbar-inner { padding: 12px 24px; }
}
</style>
</head>
<body>
<div id="layout">
<aside id="sidebar">
<div class="sidebar-top">
<div class="sidebar-header">
<h1>EmbedPress Free — Developer Docs</h1>
</div>
<input id="search" type="text" placeholder="Filter docs…" />
</div>
<div id="nav"></div>
</aside>
<main id="content">
<div id="topbar">
<div id="topbar-inner">
<span id="topbar-path"></span>
<button class="theme-toggle" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">◐</button>
</div>
</div>
<article id="rendered">Loading…</article>
</main>
</div>
<script>
const DOCS = {"README.md": "# EmbedPress Developer Documentation\n\nWelcome to the EmbedPress developer documentation. This is the **free** plugin \u2014 for the Pro extension, see the corresponding `docs/` folder in the `embedpress-pro` repo.\n\nEmbedPress is a WordPress plugin that turns 250+ external sources (videos, audio, PDFs, calendars, social feeds, maps, documents\u2026) into clean, configurable embeds available as **Gutenberg blocks**, **Elementor widgets**, and the classic **`[embed]` / `[embedpress]` shortcode**.\n\nThis documentation explains **how the plugin is built**, **why it's designed the way it is**, and **how to extend it safely**.\n\n---\n\n## Table of contents\n\n### Architecture\n- [Architecture Overview](architecture/overview.md) \u2014 the 30,000-ft view of the codebase\n- [Data Flow](architecture/data-flow.md) \u2014 how a URL becomes a rendered embed\n- [WordPress Integration](architecture/wordpress-integration.md) \u2014 hooks, lifecycle, autoloading\n- [Free + Pro Coupling](architecture/free-pro-coupling.md) \u2014 how Pro extends the free plugin\n\n### Folder Structure\n- [Folder Reference](architecture/folders.md) \u2014 what every top-level folder does\n\n### Editors\n- [Gutenberg Architecture](gutenberg/README.md)\n- [Elementor Architecture](elementor/README.md)\n- [Classic Shortcode](architecture/shortcode.md)\n\n### Providers\n- [Provider System](providers/README.md) \u2014 adapter pattern, URL routing, embed generation\n- [Adding a New Provider](providers/adding-a-provider.md) \u2014 step-by-step\n- [Provider Catalog](providers/catalog.md) \u2014 every shipped provider, what it does\n\n### Features\n\n**The core feature is the embed itself.** EmbedPress ships **250+ sources** \u2014 YouTube, Vimeo, Wistia, Twitch, Spotify, SoundCloud, PDFs, Google Docs/Sheets/Slides/Maps/Forms/Calendar, Instagram, X (Twitter), LinkedIn, GitHub, Calendly, Canva, AirTable, OpenSea, Gumroad, and many more \u2014 usable as **Gutenberg blocks**, **Elementor widgets**, the classic **`[embedpress]` shortcode**, or **auto-embed** (URL on a line). Anything not in the catalog falls back to the [Universal Wrapper](features/wrapper.md). See:\n\n- [Provider System](providers/README.md) \u2014 how URL \u2192 embed routing works\n- [Provider Catalog](providers/catalog.md) \u2014 every shipped provider\n- [Adding a New Provider](providers/adding-a-provider.md)\n\nLayered on top of the embed, these features decorate or extend it:\n\n**Content blocks**\n- [PDF Embedder + 3D Flipbook](features/pdf.md)\n- [PDF Gallery](features/pdf-gallery.md)\n- [Document Block (DOC/PPT/XLS)](features/document.md)\n- [Custom Video Player](features/custom-player.md) \u2014 Plyr-based branded player (free + Pro tiers)\n\n**Engagement & tracking**\n- [Social Share](features/social-share.md) \u2014 Facebook / X / Pinterest / LinkedIn buttons around any embed\n- [Analytics](features/analytics.md) \u2014 view / click / impression tracking + dashboard\n\n**Infrastructure & admin**\n- [Universal Wrapper / Auto-embed](features/wrapper.md) \u2014 the catch-all fallback for unknown URLs\n- [Onboarding Wizard](features/onboarding.md) \u2014 first-run admin setup\n- [Feature Enhancer](features/feature-enhancer.md) \u2014 the cross-provider decoration pipeline (`embedpress:onAfterEmbed`)\n\n### Pro features\n\nMany have free-side scaffolding (filter slots, upsell UI, Elementor traits) covered in the feature pages above. Full Pro implementation is in the **`embedpress-pro` repo**'s [`docs/features/`](../../embedpress-pro/docs/features/) tree.\n\n- **Cinematic Preview** \u2014 Netflix / Prime Video / Disney+ / Apple TV+ style hero overlay with 6 presets\n- **Custom Branding** \u2014 per-provider logo + clickable CTA overlay on YouTube, Vimeo, Wistia, Twitch, Dailymotion, PDF, and Document embeds\n- **Lazy Load** \u2014 native `loading=\"lazy\"` on every iframe / image, per-block or global\n- **Content Protection** \u2014 password gate (AES-128-CBC, 1-hour cookie unlock) or user-role gate, per embed\n- **Showcase Ads** \u2014 image / video pre-roll overlay on top of any embed, with skip timing\n- **Broken Embeds Detector** \u2014 daily scanner flags dead embed URLs (404 / 410 only \u2014 bot-hostile statuses \u2192 \"inconclusive\")\n- **Analytics Pro tier** \u2014 per-embed breakdown, geo / device / browser / referrer + UTM, advanced charts, email reports (weekly / monthly), PDF + Excel export, unlimited retention\n- **Custom Player engagement sub-features** \u2014 12 modules layered over the free Plyr player:\n - **Email Capture** \u2014 pause at time \u2192 modal email form \u2192 resume\n - **Action Lock** \u2014 full-cover overlay (share / form / link / login) before unlock\n - **Timed CTA** \u2014 call-to-action overlays at chosen seconds, multi-stack\n - **Chapters** \u2014 clickable timeline segments (manual or YouTube auto-detect)\n - **Auto Resume** \u2014 localStorage seek persistence + Resume prompt\n - **End Screen** \u2014 replay / next / countdown-redirect on `ended`\n - **Drop-Off Heatmap** \u2014 anonymous 5s-bucket retention chart per video\n - **Adaptive Streaming** \u2014 auto-loads hls.js / dash.js for `.m3u8` / `.mpd`\n - **Country Restriction** \u2014 server-side GeoIP gate (with IP-API fallback)\n - **Privacy Mode** \u2014 static thumb + click-to-load; `youtube-nocookie.com`\n - **LMS Completion** \u2014 fires on threshold-cross to LearnDash / TutorLMS / LifterLMS, with anti-skip guard\n - **CDN Offloading** \u2014 BunnyCDN / Cloudflare Stream URL rewrite at upload\n\n### APIs & External Integrations\n- [REST API](api/rest.md)\n- [oEmbed Integration](api/oembed.md)\n- [Provider HTTP calls](api/external.md)\n\n### Developer Guides\n- [Coding Standards](guides/coding-standards.md)\n- [Hooks & Filters Reference](guides/hooks-and-filters.md)\n- [Extending EmbedPress](guides/extending.md)\n- [Debugging & Troubleshooting](guides/debugging.md)\n- [Build & Asset Pipeline](guides/build-pipeline.md)\n- [Testing](guides/testing.md)\n\n### Contributing\n- [Local Development Setup](contributing/setup.md)\n- [Pull Request Guidelines](contributing/pull-requests.md)\n- [Release Process](contributing/release.md)\n\n---\n\n## Quick orientation\n\nIf you have **15 minutes** and want to grok the plugin:\n\n1. Read [Architecture Overview](architecture/overview.md) \u2014 explains the four entry points (shortcode, block, widget, auto-embed) and how they all funnel into the same renderer.\n2. Skim [Data Flow](architecture/data-flow.md) \u2014 follow a YouTube URL from paste to rendered iframe.\n3. Open [Provider System](providers/README.md) \u2014 the heart of the plugin. Almost every customer-facing feature lives in or hooks into a Provider.\n\nIf you're **adding a feature**, start with [Extending EmbedPress](guides/extending.md). If you're **fixing a bug**, start with [Debugging](guides/debugging.md).\n\n---\n\n## Conventions used in these docs\n\n- **File paths** are relative to the plugin root (`/Applications/Workspace/GitHub/embedpress/`).\n- **Code snippets** are illustrative \u2014 always cross-check against the actual file before relying on signatures.\n- **\"Pro\"** in italics or in a callout means the behavior described requires the Pro plugin to be active.\n- **PHP namespaces** are written `EmbedPress\\Foo\\Bar` (the leading `\\` from `EMBEDPRESS_NAMESPACE` is omitted in prose for readability).\n\n## Where this documentation lives in the codebase\n\n```\ndocs/\n\u251c\u2500\u2500 README.md \u2190 you are here\n\u251c\u2500\u2500 architecture/ \u2190 high-level design\n\u251c\u2500\u2500 providers/ \u2190 provider system\n\u251c\u2500\u2500 elementor/ \u2190 Elementor widget internals\n\u251c\u2500\u2500 gutenberg/ \u2190 Gutenberg block internals\n\u251c\u2500\u2500 features/ \u2190 per-feature deep dives\n\u251c\u2500\u2500 api/ \u2190 REST + external HTTP\n\u251c\u2500\u2500 guides/ \u2190 cross-cutting how-tos\n\u2514\u2500\u2500 contributing/ \u2190 local dev, PRs, release\n```\n\nWhen you ship a feature, **update the matching doc in the same PR**. Documentation that drifts is worse than no documentation.\n", "api/external.md": "\n# External Service Calls\n\nMost providers don't make network calls \u2014 they construct an iframe URL from the user-supplied URL and let the browser do the loading. A few do need server-side fetches.\n\n## Providers that fetch\n\n| Provider | Why it fetches |\n|---|---|\n| **GooglePhotos** | Albums require scraping the share URL to derive the embeddable HTML. |\n| **OpenSea** | Server-side resolves NFT metadata for the widget. |\n| **GettyImages** | Resolves image metadata. |\n| **Wrapper (rare)** | Some Wrapper variants probe the URL to determine content type. |\n\n## Pro-only fetches\n\n| Module | Why |\n|---|---|\n| **Broken Embeds Detector** | HEAD / oEmbed probe to determine if an embedded URL is dead. |\n| **Country Restriction (Custom Player)** | GeoIP lookup for visitor (or via JS) |\n\n## How we make HTTP calls\n\nAlways via `wp_remote_get` / `wp_remote_post` \u2014 never `curl_init` directly. This:\n\n- Respects WP's HTTP API filters.\n- Honors site proxy settings.\n- Lets test suites mock cleanly.\n\n```php\n$response = wp_remote_get($url, [\n 'timeout' => 5,\n 'redirection'=> 3,\n 'user-agent' => 'Mozilla/5.0 (compatible; EmbedPress/' . EMBEDPRESS_PLUGIN_VERSION . ')',\n]);\n\nif (is_wp_error($response)) {\n return /* fallback */;\n}\n\n$code = wp_remote_retrieve_response_code($response);\n$body = wp_remote_retrieve_body($response);\n```\n\n## User-Agent string\n\nWe lead with `Mozilla/5.0 (compatible; \u2026)` because some hosts (Cloudflare-fronted, Google) actively block obvious bot UAs. Without the Mozilla prefix you get spurious 403s on URLs that work fine in a browser.\n\n## Error handling\n\nTreat anything except a clean 200 as inconclusive, not broken. The Broken Embeds Detector explicitly maps 401/403/405/406/429/5xx to `STATUS_UNKNOWN` \u2014 only 404 and 410 are confidently \"broken.\" See the [feature notes](../features/) for the rationale.\n\n## Same-origin special-case\n\nSelf-hosted URLs (host matches `home_url()` or `site_url()`) should never be probed. PHP can't reliably reach `localhost:8080` from inside a Docker container, and many production setups have no hairpin NAT. The detector short-circuits same-origin URLs to \"OK / self-hosted-skipped.\"\n\n## Caching\n\nProvider fetches that are deterministic (e.g., Google Photos album HTML) are cached with WP transients. Cache TTL defaults to 12 hours; configurable per provider.\n", "api/oembed.md": "\n# oEmbed Integration\n\nEmbedPress hooks WordPress's native oEmbed system so that pasted URLs auto-embed in post content **and** so that the editor's oEmbed preview agrees with what the frontend will render.\n\n## How WP's oEmbed works (recap)\n\n1. A URL on its own line in post content triggers WP's auto-embed pass.\n2. WP's `WP_oEmbed` checks the URL against its registered providers list.\n3. If a match exists, WP fetches the oEmbed JSON from that provider and uses the returned HTML.\n\n## How EmbedPress changes that\n\nEmbedPress filters `oembed_providers` (and related filters) to:\n\n- Register every host in `providers.php` as known to WP's oEmbed.\n- Short-circuit the upstream HTTP fetch \u2014 instead, route the URL through our own provider chain (`Core::parseContent`) and return the HTML directly.\n\nThis gives us:\n\n- **Consistency**: a YouTube URL renders the same via auto-embed, shortcode, block, and widget.\n- **No extra HTTP**: we don't double-fetch when our provider already builds the embed locally.\n- **Custom controls applied**: the auto-embedded URL still goes through `Feature_Enhancer`, so site-wide settings (lazy load, share strip, etc.) apply.\n\n## Filters used\n\n| Filter | What we do |\n|---|---|\n| `oembed_providers` | Add EmbedPress-known hosts |\n| `pre_oembed_result` | Short-circuit fetch, return our HTML |\n| `embed_oembed_html` | Final HTML decoration |\n\n## When upstream oEmbed actually runs\n\nFor URLs that don't match any EmbedPress provider, we fall through to WP's normal oEmbed behavior. The Wrapper provider also doesn't go through oEmbed \u2014 it just builds an iframe directly.\n", "api/rest.md": "\n# REST API\n\nEmbedPress exposes REST endpoints under the `embedpress/v1` namespace, registered across `Core.php` and `EmbedPress/Includes/Classes/Analytics/REST_API.php`.\n\n> The list below was verified by `grep -rn register_rest_route EmbedPress/`. If you add a new route, update this doc in the same PR.\n\n## Authentication\n\n- **Admin endpoints** require capability + nonce (`X-WP-Nonce`).\n- **Public endpoints** (analytics tracker, oembed proxy) accept anonymous calls but are scoped/rate-limited where appropriate.\n\n## Free endpoints\n\n### Embed proxy\n\n| Route | Method | Purpose | Defined in |\n|---|---|---|---|\n| `/embedpress/v1/oembed/{provider}` | GET / POST | Resolve a URL through `Shortcode::parseContent` and return embed HTML \u2014 used by editor previews and as the auto-embed routing target | `EmbedPress/Core.php` ~line 410 |\n\n### Feedback\n\n| Route | Method | Purpose |\n|---|---|---|\n| `/embedpress/v1/send-feedback` | POST | Submit user feedback / rating | `Core.php` ~line 663 |\n\n### Analytics\n\nAll in `EmbedPress/Includes/Classes/Analytics/REST_API.php`. The full list:\n\n| Route | Method | Purpose |\n|---|---|---|\n| `/embedpress/v1/analytics/track` | POST | Frontend tracker writes events |\n| `/embedpress/v1/analytics/data` | GET | Aggregate data for the dashboard |\n| `/embedpress/v1/analytics/content` | GET | Content table (per-post breakdown) |\n| `/embedpress/v1/analytics/views` | GET | View counts |\n| `/embedpress/v1/analytics/spline-chart` | GET | Time-series chart data |\n| `/embedpress/v1/analytics/overview` | GET | Overview cards |\n| `/embedpress/v1/analytics/embed-details` | GET | Per-embed detail panel |\n| `/embedpress/v1/analytics/milestones` | GET / POST | List + persist milestone notifications |\n| `/embedpress/v1/analytics/milestones/read` | POST | Mark milestone read |\n| `/embedpress/v1/analytics/features` | GET / POST | Feature toggle state |\n| `/embedpress/v1/analytics/geo` | GET | Country breakdown (Pro tier extends) |\n| `/embedpress/v1/analytics/device` | GET | Device breakdown |\n| `/embedpress/v1/analytics/browser` | GET | Browser breakdown |\n| `/embedpress/v1/analytics/browser-info` | GET | Detailed browser info |\n| `/embedpress/v1/analytics/unique-viewers-per-embed` | GET | Unique viewers (Pro tier) |\n| `/embedpress/v1/analytics/referral` | GET | Referrer breakdown (Pro tier) |\n| `/embedpress/v1/analytics/export` | GET | CSV / Excel / PDF export |\n| `/embedpress/v1/analytics/email-settings` | GET / POST | Email report config |\n| `/embedpress/v1/analytics/sync-counters` | POST | Reconcile counter rows |\n| `/embedpress/v1/analytics/tracking-setting` | GET / POST | Tracking preferences |\n| `/embedpress/v1/analytics/cleanup-redundant-data` | POST | Maintenance cleanup |\n| `/embedpress/v1/analytics/performance-stats` | GET | Internal performance dashboard |\n\nSeveral routes branch on Pro activation \u2014 Pro-tier capabilities (unique viewers per embed, referral, advanced geo) are gated inside the same handler, not separate Pro routes. See [Analytics](../features/analytics.md).\n\n## Pro endpoints\n\nPro currently does **not** register new feature routes under `embedpress-pro/v1` or anywhere else. The only Pro-side `register_rest_route` call lives in the bundled licensing dependency:\n\n- `includes/Dependencies/WPDeveloper/Licensing/RESTApi.php` \u2014 license activation, deactivation, status. Namespace is dynamic (constructed by `LicenseManager`).\n\nAdding a Pro REST namespace is a real architectural decision; if you need it, raise it explicitly in a design doc rather than just bolting one on.\n\n## Patterns we follow\n\n- **Validate args via `args` schema in `register_rest_route`** \u2014 don't validate in the handler.\n- **Return `WP_Error` with explicit status codes** for failure.\n- **Sanitize input on the way in, escape on the way out**, even for admin-only endpoints.\n- **Nonce check via `permission_callback` only** \u2014 don't sprinkle nonce checks inside handlers.\n\n## Example handler skeleton\n\n```php\nregister_rest_route('embedpress/v1', '/foo', [\n 'methods' => WP_REST_Server::READABLE,\n 'permission_callback' => function () {\n return current_user_can('manage_options');\n },\n 'args' => [\n 'id' => [\n 'required' => true,\n 'sanitize_callback' => 'absint',\n ],\n ],\n 'callback' => function (WP_REST_Request $req) {\n $id = $req->get_param('id');\n // \u2026\n return rest_ensure_response(['ok' => true]);\n },\n]);\n```\n", "architecture/data-flow.md": "\n# Data Flow\n\nHow does a URL become a rendered embed? Let's follow one.\n\n> The dispatch entry point you'll see referenced everywhere is **`Shortcode::parseContent`** (in `EmbedPress/Shortcode.php`, ~line 265). Despite the file name, it's not just for shortcodes \u2014 blocks and Elementor widgets call it too.\n\n## Example: a YouTube URL pasted into the EmbedPress block\n\n```\nUser pastes https://www.youtube.com/watch?v=abc123 into the EmbedPress block.\n```\n\n### 1. Block captures intent (browser)\n\nThe user adds the generic `embedpress/embedpress` block (handles 250+ providers) and types/pastes the URL. Block attributes (autoplay, controls, custom player options, etc.) are stored in post content.\n\n### 2. Server-side render (PHP)\n\nWhen the post renders, WordPress invokes the block's `render_callback`, which is `EmbedPressBlockRenderer::render` for the generic block (per-block callbacks exist for the specialized blocks \u2014 see [Gutenberg](../gutenberg/README.md)).\n\nInside `render`:\n\n1. Read attributes (URL, client ID, options).\n2. Run content-protection gate (`extract_protection_data` + `should_display_content`).\n3. Either render the cached `embedHTML` attribute via `render_embed_html`, or fall through to dynamic rendering.\n4. Dynamic rendering ultimately calls `Shortcode::parseContent($url, true, $atts)`.\n\n### 3. The `parseContent` pipeline\n\n`EmbedPress\\Shortcode::parseContent` is the central URL\u2192HTML transformer.\n\n```\nparseContent($subject, $stripNewLine, $customAttributes)\n \u2502\n \u251c\u2500 extract URL from $subject (preg_replace)\n \u251c\u2500 parseContentAttributes() \u2014 normalize $atts\n \u251c\u2500 set_embera_settings() \u2014 configure the Embera oEmbed lib\n \u2502\n \u251c\u2500 apply_filters('embedpress:isEmbra', false, $url, $atts)\n \u2502 \u2514\u2500 Feature_Enhancer::isEmbra returns true if YouTube/TikTok/Spreaker/GooglePhotos/Wrapper match\n \u2502\n \u251c\u2500 if isEmbra:\n \u2502 route to Embera \u2192 an EmbedPress\\Providers\\* class builds the iframe\n \u2502 else:\n \u2502 route to WP_oEmbed::fetch \u2192 upstream oEmbed endpoint\n \u2502\n \u251c\u2500 apply_filters('embedpress:onBeforeEmbed', \u2026) \u2014 pre-process\n \u251c\u2500 get_url_data() / get_content_from_template() \u2014 produce HTML, wrap in\n \u2502 <div class=\"ose-{provider} ose-embedpress-responsive\" \u2026>{html}</div>\n \u2502\n \u251c\u2500 apply_filters('pp_embed_parsed_content', \u2026) \u2014 AMP compat\n \u251c\u2500 apply_filters('embedpress:onAfterEmbed', \u2026) \u2014 Feature_Enhancer + Pro decorate\n \u2502\n \u2514\u2500 return wrapper + decorated HTML\n```\n\n### 4. Frontend runtime (browser)\n\n`Core/AssetManager.php` enqueues bundles only when an embed marker is present in the page. The `data-options` attribute on `.ep-embed-content-wraper` carries Custom Player JSON that the Plyr runtime parses on `DOMContentLoaded`.\n\n## The other entry points, condensed\n\n### Elementor widget\n\nThe main Elementor widget (`Embedpress_Elementor`, in `EmbedPress/Elementor/Widgets/Embedpress_Elementor.php`, ~4900 lines) collects controls into shortcode-style attributes via `convert_settings`, then in `render` (around line 4533) calls:\n\n```php\n$embed_content = Shortcode::parseContent($settings['embedpress_embeded_link'], true, $_settings);\n```\n\nSo Elementor and Gutenberg both funnel through the same `parseContent`.\n\n### Classic shortcode\n\n`[embedpress]` / `[embed]` are registered to `Shortcode::do_shortcode`, which calls `parseContent` directly.\n\n### Auto-embed\n\nFiltered through WP's oEmbed \u2014 EmbedPress registers itself as the oEmbed provider for every host in `providers.php` and routes the response back through `parseContent` via the REST endpoint `/embedpress/v1/oembed/{provider}` (handled by `RestAPI::oembed`).\n\n## Where Pro hooks in\n\nPro never reaches into Core. It registers filter callbacks during `Bootstrap::__construct()` and lets the free pipeline call them. The most important hook points:\n\n| Hook | Style | Pro use |\n|---|---|---|\n| `embedpress:onAfterEmbed` | colon | Per-provider extenders (`Providers\\Youtube::onAfterEmbed`, etc.) |\n| `embedpress/pro_class`, `embedpress/pro_text` | slash | Drop locked-state styling |\n| `embedpress/is_allow_rander` | slash | License / protection gating |\n| `embedpress/generate_ad_template` | slash | Showcase Ads injection |\n| `embedpress/display_password_form` | slash | Content Protection form |\n| `embedpress/elementor_enhancer_<provider>` | slash | Elementor widget extra controls |\n| `embedpress_enhance_<provider>` | underscore | Provider-specific Pro params |\n\nSee [Free + Pro Coupling](free-pro-coupling.md) and [Hooks Reference](../guides/hooks-and-filters.md) for the full list.\n\n## Mental model: layers, not files\n\nDon't think *\"where does this live?\"* \u2014 think *\"which layer is this?\"* Then the file location follows:\n\n- **User intent** \u2192 editor source (`src/Blocks/...`, `EmbedPress/Elementor/...`)\n- **URL \u2192 provider routing** \u2192 `providers.php` + `Core::getAdditionalServiceProviders` + `Feature_Enhancer::isEmbra`\n- **The actual URL\u2192HTML pipeline** \u2192 **`Shortcode::parseContent`** (regardless of caller)\n- **Provider-specific embed HTML** \u2192 `EmbedPress/Providers/<Name>.php`\n- **Cross-provider decoration** \u2192 `Feature_Enhancer` (free) and `Filters/Feature_Enhancer_Pro` + per-provider Pro extenders\n- **Browser-side behavior** \u2192 `src/Frontend/...` \u2192 `assets/js/...` (enqueued by `Core/AssetManager.php`)\n\nWhen you can't find something, identify the layer first.\n", "architecture/folders.md": "\n# Folder Reference\n\nWhat every top-level directory contains, why it exists, and where new code belongs.\n\n```\nembedpress/\n\u251c\u2500\u2500 embedpress.php \u2190 plugin header + bootstrap\n\u251c\u2500\u2500 includes.php \u2190 constants\n\u251c\u2500\u2500 autoloader.php \u2190 PSR-style autoload glue\n\u251c\u2500\u2500 providers.php \u2190 URL host \u2192 Provider class registry\n\u2502\n\u251c\u2500\u2500 EmbedPress/ \u2190 all plugin PHP (root namespace)\n\u2502 \u251c\u2500\u2500 Core.php \u2190 modern dispatch\n\u2502 \u251c\u2500\u2500 CoreLegacy.php \u2190 legacy dispatch (don't add here)\n\u2502 \u251c\u2500\u2500 Shortcode.php \u2190 [embedpress] / [embed] handler\n\u2502 \u251c\u2500\u2500 RestAPI.php \u2190 REST endpoints\n\u2502 \u251c\u2500\u2500 Compatibility.php \u2190 misc compat layer\n\u2502 \u251c\u2500\u2500 Providers/ \u2190 one file per upstream source (YouTube, Vimeo\u2026)\n\u2502 \u251c\u2500\u2500 Gutenberg/ \u2190 block server-side render\n\u2502 \u251c\u2500\u2500 Elementor/ \u2190 Elementor integration + widgets\n\u2502 \u251c\u2500\u2500 Ends/ \u2190 Front + Back loaders\n\u2502 \u251c\u2500\u2500 Analytics/ \u2190 analytics module\n\u2502 \u251c\u2500\u2500 AMP/ \u2190 AMP plugin compat\n\u2502 \u251c\u2500\u2500 Plugins/ \u2190 compat with other plugins\n\u2502 \u251c\u2500\u2500 ThirdParty/ \u2190 compat shims for popular tools\n\u2502 \u2514\u2500\u2500 Includes/Classes/ \u2190 shared helpers + Feature_Enhancer\n\u2502\n\u251c\u2500\u2500 Core/ \u2190 legacy \"core\" (pre-EmbedPress namespace era)\n\u2502 \u251c\u2500\u2500 AutoLoader.php \u2190 used by autoloader.php\n\u2502 \u251c\u2500\u2500 AssetManager.php \u2190 enqueue manager\n\u2502 \u251c\u2500\u2500 LocalizationManager.php\n\u2502 \u2514\u2500\u2500 init.php\n\u2502\n\u251c\u2500\u2500 src/ \u2190 all JS/TS source (Vite)\n\u2502 \u251c\u2500\u2500 Blocks/ \u2190 Gutenberg block sources (one folder per block)\n\u2502 \u251c\u2500\u2500 AdminUI/ \u2190 React admin / settings UI\n\u2502 \u251c\u2500\u2500 Frontend/ \u2190 public frontend runtime\n\u2502 \u251c\u2500\u2500 Analytics/ \u2190 analytics tracking JS\n\u2502 \u251c\u2500\u2500 Shared/ \u2190 shared React components\n\u2502 \u2514\u2500\u2500 utils/ \u2190 cross-cutting JS helpers\n\u2502\n\u251c\u2500\u2500 assets/ \u2190 built bundles (Vite output, COMMITTED)\n\u251c\u2500\u2500 static/ \u2190 static assets (CSS, vendored libs like PDF.js, Plyr)\n\u251c\u2500\u2500 languages/ \u2190 .pot + translations\n\u251c\u2500\u2500 vendor/ \u2190 Composer (COMMITTED for WP.org)\n\u251c\u2500\u2500 node_modules/ \u2190 (NOT committed)\n\u251c\u2500\u2500 tests/ \u2190 Playwright E2E + PHPUnit\n\u2514\u2500\u2500 docs/ \u2190 this documentation\n```\n\n## Major folders, expanded\n\n### `EmbedPress/Providers/`\nEach file is one upstream service. Files extend `\\EmbedPress\\Providers\\Wrapper` (the abstract base) or `Embera\\Adapters\\Service` directly. **Add a new provider here**, then register its host patterns in `providers.php`.\n\nSee [Provider System](../providers/README.md).\n\n### `EmbedPress/Gutenberg/`\n| File | Role |\n|---|---|\n| `BlockManager.php` | Registers blocks with WP |\n| `EmbedPressBlockRenderer.php` | Server-side render callbacks |\n| `FallbackHandler.php` | Renders unknown URLs via Wrapper |\n| `InitBlocks.php` | Boot entry point |\n\n### `EmbedPress/Elementor/`\n| File | Role |\n|---|---|\n| `Embedpress_Elementor_Integration.php` | Registers widgets with Elementor |\n| `Elementor_Upsale.php` | Pro upsell helpers for inactive Pro |\n| `Widgets/Embedpress_Elementor.php` | The main widget (handles every provider) |\n\n### `EmbedPress/Includes/Classes/`\nShared helpers used everywhere. Most important:\n- **`Feature_Enhancer.php`** \u2014 the filter pipeline that decorates provider output. New cross-provider features hook in here.\n- **`Helper.php`** \u2014 utility functions (URL parsing, format detection, escaping helpers).\n\n### `src/Blocks/`\nOne folder per block. Each block folder typically contains:\n```\n<block-name>/\n\u251c\u2500\u2500 index.js \u2190 register block\n\u251c\u2500\u2500 edit.js \u2190 editor UI\n\u251c\u2500\u2500 save.js \u2190 save() \u2014 change with care, see Gutenberg deprecation\n\u251c\u2500\u2500 inspector.js \u2190 <InspectorControls>\n\u251c\u2500\u2500 block.json \u2190 block metadata\n\u2514\u2500\u2500 style.scss\n```\n\n### `src/Frontend/`\nThe bundle that runs on visitor pages: player initialization, analytics tracker, lightboxes, etc. Each entry point is built into a separate `assets/*.build.js`.\n\n### `Core/`\nOlder bootstrap helpers. Kept because they're loaded before `EmbedPress\\` autoloading is fully wired (chicken-and-egg: `AutoLoader` itself can't autoload). Don't add new code here unless it absolutely must run before the autoloader registers.\n\n### `static/`\nVendored third-party assets shipped as-is: PDF.js viewer, 3D flipbook, Plyr, polyfills. Updates to these come from upstream \u2014 don't hand-edit.\n\n### `tests/`\n- `tests/playwright/` \u2014 E2E suite split by surface (gutenberg, elementor, classic, dashboard, ui)\n- `tests/phpunit/` \u2014 PHP unit + integration\n\nThe standalone repo `embedpress-playwright-automation` mirrors customer-facing scenarios; the inline suite is for daily dev.\n\n## Where do I put a new feature?\n\n| Feature touches\u2026 | Add code in\u2026 |\n|---|---|\n| One specific provider's URL/embed logic | `EmbedPress/Providers/<Name>.php` |\n| All providers (player, share, lazy load) | `EmbedPress/Includes/Classes/Feature_Enhancer.php` + filter |\n| New Gutenberg block | `src/Blocks/<name>/` + `EmbedPress/Gutenberg/EmbedPressBlockRenderer.php` |\n| New Elementor widget | `EmbedPress/Elementor/Widgets/<Name>.php` (most cases extend the main widget instead) |\n| New REST endpoint | `EmbedPress/RestAPI.php` (or per-feature module) |\n| Frontend behavior on visitor pages | `src/Frontend/` + enqueue in `Core/AssetManager.php` |\n| Admin settings UI | `src/AdminUI/` |\n| Analytics tracking | `src/Analytics/` (JS) + `EmbedPress/Analytics/` (PHP) |\n\n## Build outputs\n\n`npm run build` writes to `assets/`. The mapping from `src/` entry to `assets/` output is defined in `vite.config.js` at the repo root. **Both `src/` and `assets/` are committed** so end users (and WP.org) can install without running Node.\n\n## What's not committed\n\n- `node_modules/`\n- `.env`, `.env.local`\n- `.DS_Store`, IDE folders\n- Vite cache (`.vite/`)\n\nComposer's `vendor/` *is* committed \u2014 required by WP.org because the directory is published as-is.\n", "architecture/free-pro-coupling.md": "\n# Free + Pro Coupling\n\nPro is a *separate plugin* that requires the free plugin to be active. This document explains the contract that keeps them in sync.\n\n## The rule\n\n> Pro extends free behavior **through filters**. It can ship Provider classes with the same names as free (`Youtube`, `Vimeo`, `Wistia`, \u2026) \u2014 they're not forks; they're extenders that register on `embedpress:onAfterEmbed` and friends via a `featureExtend()` static.\n\n## How Pro discovers free\n\n`embedpress-pro.php` checks for free's classes / constants before booting. Without free, Pro's bootstrap shows an admin notice and bails.\n\n## Boot order\n\n```\nWordPress loads embedpress.php (free; alphabetical activation order)\n \u2514\u2500 Core, providers registered, blocks/widgets registered, Feature_Enhancer instantiated\nWordPress loads embedpress-pro.php\n \u2514\u2500 Embedpress\\Pro\\Classes\\Bootstrap::instance()\n \u251c\u2500 load_provider() \u2014 instantiates Pro provider extenders, each calling featureExtend()\n \u2502 which add_filter('embedpress:onAfterEmbed', \u2026)\n \u251c\u2500 instantiates Pro Filters (Feature_Enhancer_Pro, Utility, Calendar, Calendly,\n \u2502 GooglePhotos, Elementor_Enhancer_Pro, Youtube)\n \u251c\u2500 embedpress_plugin_licensing() \u2014 instantiates LicenseManager\n \u251c\u2500 Pro Elementor extension classes register\n \u2514\u2500 Pro Gutenberg dist bundles enqueue\n```\n\n## Standard hook slots free exposes\n\nFree defines these so Pro can extend without forking. **Do not remove them** \u2014 Pro silently breaks.\n\n| Hook | Style | Pro callback |\n|---|---|---|\n| `embedpress:isEmbra` | colon | Free's Feature_Enhancer hook returns true for custom-provider URLs |\n| `embedpress:onBeforeEmbed` | colon | Pro pre-processes payloads |\n| `embedpress:onAfterEmbed` | colon | Pro provider extenders + Feature_Enhancer_Pro decorate HTML |\n| `embedpress/pro_class` | slash | Free returns `'pro_class'`, Pro returns `''` |\n| `embedpress/pro_text` | slash | Free returns `'Pro'`, Pro returns `''` |\n| `embedpress/pro_label` | slash | Pro returns `''` |\n| `embedpress/is_allow_rander` | slash | Pro evaluates content-protection / license gates |\n| `embedpress/generate_ad_template` | slash | Pro emits Showcase Ads template |\n| `embedpress/display_password_form` | slash | Pro emits Content Protection form |\n| `embedpress/content_protection_content` | slash | Pro AES-decrypts protected payload |\n| `embedpress/instafeed_*` | slash | Pro Instagram feed fields |\n| `embedpress/calendly_*` | slash | Pro Calendly integration |\n| `embedpress_google_photos_attributes` | underscore | Pro Google Photos attribute mapping |\n| `embedpress_google_helper_shortcode` | underscore | Pro Google Calendar shortcode |\n| `embedpress/elementor_enhancer_<provider>` | slash | Pro Elementor controls per provider |\n| `embedpress_enhance_<provider>` | underscore | Pro provider-specific decoration |\n\nJS-side hooks (`@wordpress/hooks`) follow similar split styles. Free emits placeholder controls via `applyFilters('embedpress.selectPlaceholder', \u2026)` and Pro substitutes real options. Without Pro, the placeholder shows upsell copy.\n\n## Upsell pattern\n\nFree ships UI for many Pro features rendered with a `pro_class` wrapper:\n\n1. Free renders the control inside an element whose class comes from `apply_filters('embedpress/pro_class', 'pro_class')`.\n2. CSS tints the locked control and overlays a \"Pro\" badge.\n3. Free's filter callback returns `'pro_class'`. Pro's filter callback returns `''`.\n4. With Pro active, the wrapper class is empty; styling drops; the control becomes interactive.\n\nSame UI source code powers both the locked and unlocked states.\n\n## What Pro is allowed to do that may surprise you\n\n- **Ship Provider classes named like free's.** `embedpress-pro/includes/Providers/Youtube.php` exists; it doesn't conflict with free's `EmbedPress/Providers/Youtube.php` because the namespaces differ (`Embedpress\\Pro\\Providers\\Youtube` vs `EmbedPress\\Providers\\Youtube`) and the Pro one only registers filter callbacks \u2014 it never gets routed to by Embera.\n- **Hook the same filter from multiple Pro callbacks.** Pro's per-provider extenders + Feature_Enhancer_Pro can both hook `embedpress:onAfterEmbed`. Priorities determine order.\n\n## Anti-patterns\n\n- **Don't `require` a free file from Pro.** Use class lookups + filter hooks.\n- **Don't redefine free constants in Pro.** Free is loaded first and owns them.\n- **Don't write to free option keys from Pro.** All Pro state goes under `embedpress_pro_*`.\n- **Don't add a Pro REST namespace for features.** Pro currently only ships license routes via the bundled `WPDeveloper\\Licensing\\RESTApi`. New routes should be discussed before adding a separate namespace.\n\n## When you change a free hook signature\n\nAdding parameters is safe. Removing or reordering breaks Pro silently. If you must change a signature:\n\n1. Keep the old hook firing with the old signature.\n2. Add a new hook with the new signature.\n3. Open a tracking ticket to migrate Pro to the new hook.\n4. After Pro ships the migration, remove the old hook in a major version.\n", "architecture/overview.md": "# Architecture Overview\n\nThis document is the 30,000-ft view. Read it first.\n\n## What EmbedPress is, in one paragraph\n\nEmbedPress is a **provider-driven embed engine** for WordPress. Users paste a URL (or pick a block / widget / shortcode), and EmbedPress figures out which \"provider\" the URL belongs to, asks that provider for the embed HTML (via the bundled Embera oEmbed library or WP's native `WP_oEmbed::fetch`), decorates it with optional UI (custom player, social share, lazy-load, branding\u2026), and returns the result. The same `Shortcode::parseContent` pipeline answers all four surfaces (Gutenberg, Elementor, classic shortcode, auto-embed), so a YouTube embed renders identically regardless of how you placed it.\n\n## Core goals\n\n1. **One source of truth per provider.** The logic that turns a YouTube URL into an iframe lives in **one** place \u2014 `EmbedPress/Providers/Youtube.php`. Every editor surface delegates to it through `Shortcode::parseContent`.\n2. **Editor-agnostic rendering.** A block, widget, and shortcode all produce the same final HTML.\n3. **Safe extensibility for Pro.** Pro doesn't fork free providers \u2014 it ships matching extender classes (in `embedpress-pro/includes/Providers/*.php`) that hook the free filter pipeline (`embedpress:onAfterEmbed`, etc.).\n4. **WordPress.org-friendly distribution.** `vendor/`, `assets/`, and built `Gutenberg/dist/` are committed.\n\n## The four entry points\n\nA user can produce an embed in four ways. They all funnel into the same pipeline.\n\n```\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n Editor \u2192 \u2502 Gutenberg blocks \u2502\n \u2502 (7 server-rendered + 7 client-only,\u2502 \u2500\u2500\u2510\n \u2502 see Gutenberg docs) \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\n Editor \u2192 \u2502 Elementor widgets (5) \u2502 \u2502\n \u2502 embedpres_elementor / pdf / doc / \u2502 \u2500\u2500\u2524\n \u2502 pdf_gallery / calendar \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\n Content \u2192 \u2502 Shortcodes: \u2502 \u251c\u2500\u2500\u2192 EmbedPress\\Shortcode::parseContent\n \u2502 [embed] [embedpress] \u2502 \u2500\u2500\u2524 \u251c\u2500 extract URL + atts\n \u2502 [embedpress_pdf] [embedpress_doc] \u2502 \u2502 \u251c\u2500 embedpress:isEmbra dispatch\n \u2502 [embedpress_pdf_gallery] \u2502 \u2502 \u251c\u2500 Embera or WP_oEmbed\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502 \u251c\u2500 embedpress:onBeforeEmbed\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502 \u251c\u2500 provider builds HTML\n Content \u2192 \u2502 REST /embedpress/v1/oembed/{prov.} \u2502 \u2502 \u251c\u2500 embedpress:onAfterEmbed\n \u2502 (called by WP auto-embed) \u2502 \u2500\u2500\u2518 \u2514\u2500 wrapped HTML returned\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2193\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 EmbedPress\\Providers \u2502\n \u2502 selected via Embera \u2502\n \u2502 + isEmbra filter \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2193\n Frontend JS (Plyr, PDF.js,\n flipbook, analytics, etc.)\n```\n\n## Layers\n\n| Layer | Lives in | Responsibility |\n|---|---|---|\n| **Editor surfaces** | `src/Blocks/`, `EmbedPress/Elementor/`, `EmbedPress/Shortcode.php` | Capture user intent, attributes, and URL |\n| **URL\u2192HTML pipeline** | `EmbedPress/Shortcode.php::parseContent` | Resolve URL \u2192 provider, run filters, return HTML |\n| **Provider classes** | `EmbedPress/Providers/*` | Provider-specific URL parsing + embed generation (called via Embera) |\n| **Decoration pipeline** | `EmbedPress/Includes/Classes/Feature_Enhancer.php` (+ Pro extenders) | Decorate provider output via `embedpress:onAfterEmbed` |\n| **Frontend runtime** | `assets/js/*` (built from `src/Frontend/` and `src/Blocks/`) | Player init, analytics ping, lightboxes, lazy load |\n\nEditor surfaces never call Provider classes directly \u2014 they call `Shortcode::parseContent`. Provider classes don't know which editor surface called them \u2014 they just take a URL and return HTML.\n\n## Why two `Core` classes?\n\n`EmbedPress/Core.php` is the **modern (WP 5+ block editor)** path. `EmbedPress/CoreLegacy.php` is the **WP < 5.0 (TinyMCE)** path. The bootstrap (`embedpress.php` ~line 115) detects the WP version and the active editor and instantiates one or the other. CoreLegacy adds a `configureTinyMCE` action and disables some default rewrite rules; it does not exist for \"old shortcodes\" \u2014 both Cores delegate the actual URL\u2192HTML work to `Shortcode::parseContent`.\n\n## Free vs. Pro\n\nPro is a separate plugin (`embedpress-pro`) that **requires the free plugin to be active**. Pro:\n\n- Boots via `Embedpress\\Pro\\Classes\\Bootstrap::instance()` (singleton).\n- Ships its own Provider classes in `includes/Providers/` (Youtube, Vimeo, Wistia, Twitch, Dailymotion, Soundcloud, Document, Meetup) that **extend free providers' behavior** by registering callbacks on `embedpress:onAfterEmbed` / `embedpress:onBeforeEmbed` via a `featureExtend()` static.\n- Registers cross-cutting filters in `includes/Filters/*` (`Feature_Enhancer_Pro`, `Utility`, `Calendar`, `Calendly`, `GooglePhotos`, `Elementor_Enhancer_Pro`, `Youtube`).\n- Does **not** add a custom REST namespace \u2014 only the bundled `WPDeveloper\\Licensing\\RESTApi` for license operations.\n- Does **not** create custom DB tables \u2014 Pro state lives in `wp_options` under the `embedpress_pro_*` prefix.\n\nSee [Free + Pro Coupling](free-pro-coupling.md) for the contract.\n\n## Frontend asset philosophy\n\nSource code lives in `src/`, built with **Vite** to `assets/`. Both source and build outputs are committed because WP.org users install the zip and never run `npm`. `Core/AssetManager.php` enqueues conditionally based on the embed types detected on the page.\n\n## What you should never do\n\n- **Never call a Provider class from a block or widget directly.** Go through `Shortcode::parseContent`.\n- **Never copy free Provider logic into Pro.** Use `featureExtend()` + the `embedpress:onAfterEmbed` filter (this is what Pro's existing extenders already do).\n- **Never change a Gutenberg block's `save()` output without a `deprecated[]` entry.** Old posts will refuse to render. See [Gutenberg](../gutenberg/README.md#deprecation-discipline).\n- **Never widen a provider URL regex without testing other providers.** Greedy regexes silently steal embeds from neighbors.\n\n## Continue reading\n\n- [Data Flow](data-flow.md) \u2014 trace one URL from paste to render\n- [Folder Reference](folders.md) \u2014 what each directory holds\n- [Provider System](../providers/README.md) \u2014 the layer most contributions touch\n", "architecture/shortcode.md": "\n# Classic Shortcodes\n\nShortcodes are the original entry point. Five are registered.\n\n## Registered shortcodes\n\nIn `EmbedPress\\Shortcode::register()` (around line 79):\n\n| Shortcode | Handler | Purpose |\n|---|---|---|\n| `[embed]` | `Shortcode::do_shortcode` | WP-core compatibility \u2014 overrides core's `[embed]` so EmbedPress's pipeline runs |\n| `[embed_oembed_html]` | `Shortcode::do_shortcode` | Internal alias used during oEmbed result processing |\n| `[embedpress]` | `Shortcode::do_shortcode` | Primary shortcode \u2014 generic for any provider |\n| `[embedpress_pdf]` | `Shortcode::do_shortcode_pdf` | Dedicated PDF viewer (PDF.js or 3D flipbook) |\n| `[embedpress_doc]` | `Shortcode::do_shortcode_doc` | DOC / DOCX / PPT / PPTX / XLS / XLSX viewer |\n| `[embedpress_pdf_gallery]` | `Shortcode::do_shortcode_pdf_gallery` | Multi-PDF gallery with carousel/grid layout |\n\n## Forms\n\n```\n[embedpress]https://www.youtube.com/watch?v=abc123[/embedpress]\n\n[embedpress width=\"640\" height=\"360\"]https://vimeo.com/123[/embedpress]\n\n[embed]https://youtu.be/abc123[/embed]\n\n[embedpress_pdf src=\"https://example.com/file.pdf\" width=\"600\" height=\"700\"]\n\n[embedpress_doc src=\"https://example.com/file.pptx\"]\n\n[embedpress_pdf_gallery ...]\n```\n\n## Generic dispatch flow\n\n`do_shortcode($atts, $subject)` (around line 172):\n\n1. Parse ACF dynamic fields if any (lines 193\u2013206).\n2. Call `parseContent($subject, $stripNewLine, $atts)`.\n3. If content protection is on, short-circuit early (lines 231\u2013242).\n4. Return wrapped HTML.\n\n`parseContent` is the central pipeline \u2014 see [Data Flow](data-flow.md).\n\n## Specialized shortcode flows\n\n`do_shortcode_pdf` (lines 1312\u20131494) and `do_shortcode_doc` (lines 1496\u20131606) bypass `parseContent` and emit dedicated viewer iframes directly (PDF.js viewer URL, Google Docs Viewer URL, Office Online when Pro is active). They handle their own attribute set (toolbar position, theme color, watermark, draw, copy, download toggles\u2026).\n\n`do_shortcode_pdf_gallery` (lines 1608\u20131812) renders a list of PDFs in a carousel/grid wrapper with a lightbox modal \u2014 same approach.\n\n## Why we still ship classic shortcodes\n\nCustomer posts written in 2017 still embed via shortcode. Removing the handler would silently break those posts. The maintenance cost is low because the generic path (`[embedpress]` / `[embed]`) reuses the same `parseContent` that blocks and Elementor use.\n", "architecture/wordpress-integration.md": "\n# WordPress Integration\n\nHow EmbedPress plugs into the WordPress lifecycle.\n\n## Plugin bootstrap order\n\n`embedpress.php` is the only file WordPress directly loads. From there:\n\n```\nembedpress.php\n \u251c\u2500 defines EMBEDPRESS_PLUGIN_VERSION (4.5.1), EMBEDPRESS_FILE,\n \u2502 EMBEDPRESS_PLUGIN_BASENAME, EMBEDPRESS_PLUGIN_DIR_PATH,\n \u2502 EMBEDPRESS_PLUGIN_URL, EMBEDPRESS_GUTENBERG_DIR_URL,\n \u2502 EMBEDPRESS_SETTINGS_ASSETS_URL\n \u251c\u2500 require_once includes.php\n \u2502 \u2514\u2500 defines EMBEDPRESS, EMBEDPRESS_PLG_NAME, EMBEDPRESS_VERSION,\n \u2502 EMBEDPRESS_PATH_BASE, EMBEDPRESS_PATH_CORE, EMBEDPRESS_URL_ASSETS,\n \u2502 EMBEDPRESS_URL_STATIC, EMBEDPRESS_SHORTCODE, EMBEDPRESS_LICENSES_API_HOST\n \u2502 \u2514\u2500 Composer autoload, plus utility functions:\n \u2502 embedpress_cache_cleanup, embedpress_schedule_cache_cleanup,\n \u2502 is_embedpress_pro_active, get_embedpress_pro_version, stringToBoolean\n \u251c\u2500 require_once autoloader.php\n \u2502 \u2514\u2500 AutoLoader::register('EmbedPress', EMBEDPRESS_PATH_CORE) \u2192 EmbedPress/\n \u2502 \u2514\u2500 AutoLoader::register('EmbedPress\\\\Src', EMBEDPRESS_PATH_BASE.'src/')\n \u251c\u2500 require_once providers.php\n \u2502 \u2514\u2500 builds $additionalServiceProviders (26 providers)\n \u2514\u2500 around line 115 \u2014 branch by WP version + active editor:\n \u251c\u2500 WP 5+ with block editor \u2192 Core::initialize()\n \u2514\u2500 WP < 5.0 (TinyMCE) \u2192 CoreLegacy::initialize()\n both then:\n \u251c\u2500 Feature_Enhancer instantiated\n \u251c\u2500 Helper instantiated\n \u251c\u2500 Analytics_Manager instantiated (\u2248line 152)\n \u251c\u2500 Shortcode::register() (\u2248line 160)\n \u2514\u2500 Embedpress_Elementor_Integration if Elementor active (\u2248line 156)\n```\n\n## Hooks EmbedPress relies on\n\n| WP hook | What EmbedPress does |\n|---|---|\n| `plugins_loaded` | Boot Core / CoreLegacy + Feature_Enhancer |\n| `init` | Register Gutenberg blocks (via `BlockManager`) |\n| `rest_api_init` | Register REST routes (oembed proxy, analytics, send-feedback) |\n| `enqueue_block_editor_assets` | Editor JS for blocks |\n| `wp_enqueue_scripts` | Frontend runtime (player, analytics, viewer assets) |\n| `admin_enqueue_scripts` | Admin UI (settings, analytics dashboard) |\n| `oembed_providers` | Register EmbedPress-known hosts as oEmbed providers |\n| `pre_oembed_result` (effectively, via REST) | Route to EmbedPress's pipeline |\n| `embedpress_cache_cleanup_action` | Daily cron \u2014 clean `/wp-content/uploads/embedpress/` |\n| `pp_embed_parsed_content` | AMP plugin compatibility |\n| `fl_builder_before_render_shortcodes` | Beaver Builder support |\n\n## Constants you'll see everywhere\n\n| Constant | Defined in | Meaning |\n|---|---|---|\n| `EMBEDPRESS_PATH_BASE` | `includes.php` | Plugin root |\n| `EMBEDPRESS_PATH_CORE` | `includes.php` | `EMBEDPRESS_PATH_BASE . 'EmbedPress/'` |\n| `EMBEDPRESS_URL_ASSETS` | `includes.php` | URL to `assets/` |\n| `EMBEDPRESS_URL_STATIC` | `includes.php` | URL to `static/` |\n| `EMBEDPRESS_NAMESPACE` | (older code uses `'\\\\EmbedPress'`) | Used in providers.php |\n| `EMBEDPRESS_PLUGIN_VERSION` | `embedpress.php` | The real version (cache-bust source) |\n| `EMBEDPRESS_FILE` | `embedpress.php` | Bootstrap file path |\n| `EMBEDPRESS_LICENSES_API_HOST` | `includes.php` | `embedpress.com` |\n\n## Autoloading\n\n`Core/AutoLoader.php` is a small PSR-style loader. `autoloader.php` registers two namespace roots:\n\n- `EmbedPress\\` \u2192 `EmbedPress/`\n- `EmbedPress\\Src\\` \u2192 `src/`\n\nClass `EmbedPress\\Providers\\Youtube` resolves to `EmbedPress/Providers/Youtube.php`. **File names must match class names exactly** \u2014 autoloading is case-sensitive.\n\nComposer autoload also runs (via `includes.php`) for vendor libs (Embera, license SDK, etc.). Both autoloaders coexist.\n\n## Settings storage\n\nEmbedPress options live in `wp_options`:\n\n- `embedpress` \u2014 main settings (read by `Core::getSettings`)\n- `embedpress_settings` \u2014 secondary settings keys (used by some features)\n- per-feature keys (analytics email settings, tracking settings, etc.)\n\nDefaults from `Core::getSettings`:\n\n```\nenablePluginInAdmin, enablePluginInFront,\nenableGlobalEmbedResize, default sizes 600 \u00d7 550\n```\n\n## Database\n\nFree EmbedPress's **Analytics module** creates custom tables (in `EmbedPress/Includes/Classes/Analytics/`). Other features use options or post meta. Pro adds **no** custom tables.\n\n## Multisite\n\nPer-site options. Activation hook runs per blog. Tested on subsite, subdirectory, and subdomain.\n\n## Compatibility shims\n\n`EmbedPress/Plugins/`, `EmbedPress/ThirdParty/`, and `EmbedPress/AMP/` hold conditional compatibility code for popular plugins / frameworks (Elementor's loop builder, AMP, Beaver Builder, page builders). Loaded on-demand.\n", "contributing/pull-requests.md": "\n# Pull Request Guidelines\n\nWhat we expect in a PR before review.\n\n## Before opening\n\n- [ ] Branch named `fix/...` (bug), `feat/...` (feature), `chore/...` (maintenance), or `docs/...` (docs only).\n- [ ] Commit messages explain the *why*, not just the *what*. One change per commit when practical.\n- [ ] `npm run type-check && npm run lint && npm run test` passes locally.\n- [ ] If the PR touches a block's `save()`, a `deprecated[]` entry is included.\n- [ ] If the PR adds a feature, the relevant doc in `docs/` is updated **in the same PR**.\n- [ ] If the PR touches anything provider-related, the provider E2E suite passes.\n\n## PR description should include\n\n1. **What** changed (one-paragraph summary).\n2. **Why** \u2014 the user-visible problem or motivation.\n3. **How to test** \u2014 explicit reproduction steps.\n4. **Risk surface** \u2014 what could break, and what we tested to confirm it doesn't.\n\nIf a card exists in FluentBoards (or the Zoobbe board for legacy work), link it.\n\n## Code review checklist\n\nReviewers will check:\n\n- Does it follow the layer model? (Editor \u2192 Core \u2192 Provider \u2192 Feature_Enhancer \u2192 Frontend)\n- Does it avoid duplicating logic between free and Pro?\n- Is the security checklist met? (escape on output, sanitize on input, nonce on REST)\n- Are user-facing strings translatable?\n- Does it have tests proportional to the risk?\n- Does it touch all five `isSelfHostedVideo` copies if relevant?\n- Are deprecations honored on block save() changes?\n\n## Size guidance\n\n- **< 200 LOC** changes: usually fine to ship as a single PR.\n- **200\u2013600 LOC**: consider splitting into setup + behavior + UI.\n- **> 600 LOC**: split. Reviewers will ask anyway.\n\n## After merge\n\n- The release manager picks up the change. Don't bump version numbers in feature PRs unless instructed \u2014 release happens in batched bumps.\n- If your change requires a doc-update in `docs/`, ensure it's in this PR; don't leave it for later.\n\n## What gets a PR rejected outright\n\n- Forking free code into Pro (instead of using a filter).\n- Block `save()` change without a deprecation entry.\n- Hand-modified bundles in `assets/` (always rebuild from source).\n- New `vendor/` libs without provenance.\n- Translatable strings that aren't translatable.\n- Bypassed nonce checks \"because it's admin only.\"\n", "contributing/release.md": "\n# Release Process\n\nHow a free EmbedPress release ships to WP.org.\n\n## When\n\nWe cut **patch releases (4.x.y)** as needed for bug fixes, **minor (4.x.0)** for new features. Pro versions independently.\n\n## Release plan source\n\nThere's an `embedpress-release-plan.txt` at the docker repo root capturing the current scope. Read it before doing anything.\n\n## Steps (free)\n\nThere's a guided skill for this \u2014 invoke `/embedpress-release` and it'll walk through:\n\n1. Bump version in:\n - `embedpress.php` (the `Version:` header)\n - `includes.php` (the `EMBEDPRESS_PLUGIN_VERSION` constant)\n - `package.json`\n - `readme.txt` (`Stable tag:`)\n2. Generate the changelog from git history (commits + merges + FluentBoards card titles).\n3. Regenerate `languages/embedpress.pot` (`npm run pot`).\n4. Run the full test suite.\n\nThe skill **stops before** any shared / destructive action (commit, tag, push, SVN). Those are humans-only.\n\n## Manual checklist after the skill stops\n\n- [ ] Eyeball the changelog \u2014 does it read right for customers?\n- [ ] Smoke-test on a fresh WP install: `make destroy && make setup`.\n- [ ] Verify `vendor/` is production (`composer install --no-dev`).\n- [ ] Verify `assets/` is freshly built (`npm run build`).\n- [ ] Tag the release: `git tag v4.x.y && git push origin v4.x.y`.\n- [ ] Push to SVN (WP.org) \u2014 manual, by the release manager.\n\n## Pro release\n\n`/embedpress-pro-release` is the equivalent skill. Pro versions independently and ships via EDD, not WP.org.\n\n## Why we have so many version locations\n\nWP.org reads:\n- `Stable tag` from `readme.txt` to decide which folder to publish.\n- `Version:` from `embedpress.php` for the plugin header.\n- The internal `EMBEDPRESS_PLUGIN_VERSION` constant is what we use for asset cache-busting and version checks.\n- `package.json` is the JS world's source of truth.\n\nIf any one of these drifts, customers see weird behavior \u2014 old bundles, mismatched displayed version, or WP.org refusing to publish. Always bump them together.\n\n## Asset cache gotcha\n\nIf `EMBEDPRESS_PLUGIN_VERSION` doesn't bump, browsers cache the previous bundle and Pro features may silently break for users who already loaded the prior version. Bump diligently.\n", "contributing/setup.md": "\n# Local Development Setup\n\nUse the docker stack \u2014 it gives you WP + WP-CLI + Elementor + the plugin bind-mounted.\n\n## Requirements\n\n- Docker Desktop (or compatible)\n- Node \u2265 20, npm \u2265 10\n- Composer 2\n\n## First-time setup\n\n```bash\ngit clone git@github.com:wpdevteam/embedpress.git\ngit clone git@github.com:wpdevteam/embedpress-pro.git\ngit clone git@github.com:wpdevteam/embedpress-docker.git\n\ncd embedpress-docker\ncp .env.example .env\n# Edit .env: set PLUGIN_PATH=/Applications/Workspace/GitHub/embedpress (or wherever you cloned)\n\nmake setup # builds containers, installs WP, activates plugin + Elementor\n```\n\nWordPress will be running at `http://localhost:8080` with admin / admin.\n\n## Day-to-day\n\n```bash\nmake up # start\nmake down # stop\nmake restart\nmake logs # tail\nmake shell # bash inside wordpress container\nmake wp ARG=\"plugin list\"\n\n# JS dev\nmake npm-install\nmake npm-start # vite watch in container\n# or run directly on host if you prefer:\ncd /path/to/embedpress\nnpm install\nnpm run start\n\n# Tests\nmake test\nmake test-js / test-js-ui / test-php / test-php-unit / test-php-integration\nmake lint / lint-fix\n\n# DB\nmake db-export\nmake db-import\nmake db-reset\nmake seed # reload seed data\n```\n\n## Activating Pro\n\nThe docker stack symlinks both plugins (free + pro) into the WP plugins folder. `make setup` activates free. Activate Pro from `wp-admin \u2192 Plugins` or:\n\n```bash\nmake wp ARG=\"plugin activate embedpress-pro\"\n```\n\n## Working on JS\n\n`npm run start` (Vite watch) rebuilds on save. The wordpress container bind-mounts the plugin source, so the built files in `assets/` are immediately picked up.\n\nFor unminified output during dev, set `define('SCRIPT_DEBUG', true)` in `wp-config.php`.\n\n## Working on PHP\n\nJust edit. PHP is loaded fresh on every request \u2014 no restart needed.\n\n## Sample data\n\n`make seed` loads a set of fixtures: posts with each provider, a sample analytics dataset, etc. Use this to smoke-test changes against realistic content.\n", "elementor/README.md": "# Elementor Architecture\n\nHow EmbedPress integrates with the Elementor page builder.\n\n## What ships\n\nFive Elementor widgets, all registered via `Embedpress_Elementor_Integration::register_widget()` (in `EmbedPress/Elementor/Embedpress_Elementor_Integration.php`). All extend `\\Elementor\\Widget_Base` directly \u2014 there is **no inheritance** between them.\n\n| Widget file | `get_name()` | Role |\n|---|---|---|\n| `Widgets/Embedpress_Elementor.php` (~4900 lines) | `embedpres_elementor` | Multi-provider main widget (16+ providers) |\n| `Widgets/Embedpress_Document.php` | `embedpres_document` | DOC / PPT / XLS via Google Docs Viewer |\n| `Widgets/Embedpress_Pdf.php` | `embedpress_pdf` | Dedicated PDF (PDF.js / flipbook) |\n| `Widgets/Embedpress_Pdf_Gallery.php` | `embedpress_pdf_gallery` | PDF gallery with thumbnail generation |\n| `Widgets/Embedpress_Calendar.php` | `embedpress_calendar` | Google Calendar via FullCalendar |\n\n> Note the `embedpres_elementor` and `embedpres_document` typos (missing 's'). Preserved for back-compat \u2014 do not \"fix.\"\n\nRegistration is gated per-widget by the `embedpress:elements[elementor]` option, so users can disable individual widgets from settings.\n\n## Anatomy of the main widget\n\n`Embedpress_Elementor.php` is the largest file in the plugin (~4900 lines) because **every supported provider's controls live in it**. It does not delegate per-provider widgets; it has one big `register_controls()` with **provider-centric sections** organized roughly:\n\n```\nregister_controls()\n embedpress_elementor_content_settings (general \u2014 source dropdown, URL)\n embedpress_yt_channel_section (YouTube)\n embedpress_yt_subscription_section (YouTube subscriber button)\n embedpress_yt_livechat_section (YouTube live chat \u2014 Pro)\n embedpress_opensea_control_section (OpenSea)\n (Vimeo / SoundCloud / Instagram / Twitch / Dailymotion / Wistia / Meetup\n / Calendly / Spreaker / Google Photos / self-hosted / Spotify / Apple Podcasts)\n embedpress_protect_content_section (Content Protection)\n embedpress_share_content_section (Social Share)\n embedpress_custom_player_settings (Plyr Custom Player)\n embedpress_gallery_setting_section (Gallery / grid)\n embedpress_responsive_design_section (Mobile / tablet sizing)\n embedpress_advanced_setting_section\n (style sections \u2014 borders, shadows, etc.)\n```\n\nMost provider sections show only when the user-pasted URL matches that provider. Elementor renders this with `condition` arrays per control.\n\n### Render\n\n`Embedpress_Elementor::render()` (around line 4414) is a thin presentation layer over `Shortcode::parseContent`:\n\n```php\n$_settings = $this->convert_settings($settings);\n$source_data = Helper::get_source_data(/* \u2026 */);\n$embed_content = Shortcode::parseContent($settings['embedpress_embeded_link'], true, $_settings);\n$embed_content = $this->onAfterEmbedSpotify($embed_content, $settings);\n$embed = apply_filters('embedpress_elementor_embed', $embed_content, $settings);\n```\n\nThe widget converts Elementor controls to shortcode-style attributes via `convert_settings`, then hands off to the same `parseContent` pipeline that powers blocks and shortcodes.\n\n### Pro upsell wrapping\n\nPro-only controls are rendered with a CSS class derived from `apply_filters('embedpress/pro_class', 'pro_class')`. Free's filter returns `'pro_class'`; Pro's returns `''`. Same control source, different visual state based on whether Pro is active.\n\n## Specialized widgets\n\nThe four specialized widgets are independent \u2014 they don't share code with the main widget:\n\n- **`Embedpress_Document`** \u2014 multi-format docs via iframe (Google Docs Viewer or Office Online when Pro is active).\n- **`Embedpress_Pdf`** \u2014 PDF.js + 3D flipbook mode; supports media library upload + external URL.\n- **`Embedpress_Pdf_Gallery`** \u2014 multi-PDF gallery with thumbnail generation (Imagick / WP preview) and grid/carousel layout.\n- **`Embedpress_Calendar`** \u2014 FullCalendar4 + Google Calendar ICS parsing.\n\nEach defines its own `register_controls()` and `render()` \u2014 they don't go through `Shortcode::parseContent` because they don't need provider routing.\n\n## Pro extension\n\nPro's Elementor extension lives in `embedpress-pro/includes/Elementor/` and `embedpress-pro/includes/Filters/Elementor_Enhancer_Pro.php`. Pro hooks per-widget Elementor extension points (`elementor/element/embedpres_elementor/section_*/before_section_end`) plus `embedpress/elementor_enhancer_<provider>` to inject Pro controls \u2014 it never edits the free widget files.\n\n## Elementor_Upsale\n\n`EmbedPress/Elementor/Elementor_Upsale.php` injects an upsell sidebar in the Elementor editor: 5-star rating, mini analytics pie-chart, \"Upgrade to Pro\" CTA, \"Let's Chat\" support button. Loaded on `elementor/editor/after_enqueue_scripts`.\n\n## Adding a new control\n\n1. Decide which `register_*_section` it belongs to in `Embedpress_Elementor.php`.\n2. Add `$this->add_control(...)` with conditions for when it should show.\n3. Map it in `convert_settings()` so it ends up in the shortcode-style attribute array.\n4. Implement the actual behavior in `Feature_Enhancer` (free) or as a Pro filter callback.\n5. If it's Pro-only, set `'classes' => apply_filters('embedpress/pro_class', 'pro_class')` and ship the live implementation in Pro.\n\n## Common gotchas\n\n- **Widget names use the typo'd `embedpres_*` form.** Don't silently rename \u2014 existing customer pages reference the typo'd names.\n- **Elementor control key `custom_payer_preset`** is also misspelled (in the YouTube preset section, ~line 378). Same back-compat reason.\n- **OpenSea `ep-preset-1` / `-2` classes** at ~line 1915 are unrelated to the Custom Player presets. Don't conflate.\n- **Specialized widgets don't extend the main one.** If you're tempted to share code, factor into a trait under `EmbedPress/Elementor/Traits/`.\n", "features/analytics.md": "\n# Analytics\n\nA first-party analytics system that records every embed interaction (impression, click, view, play, pause, complete) and rolls it up into a React dashboard with overview cards, time-series chart, world map, browser/device pies, top-referrers list, milestone notifications, and CSV/PDF/Excel export.\n\nBuilt entirely on `wpdb` (no third-party services). Tracker fingerprints clients via canvas + WebGL hashes (no PII). Cookies: `ep_user_id` (30-day) + `ep_session_id` (session).\n\n## Free vs Pro\n\n| Capability | Free | Pro |\n|---|---|---|\n| Total views / clicks / impressions | \u2713 | \u2713 |\n| Total unique viewers (aggregate) | \u2713 | \u2713 |\n| Per-embed breakdown | \u2013 | \u2713 |\n| Unique viewers per embed | \u2013 | \u2713 |\n| Geo (country / region) | \u2013 | \u2713 |\n| Device + browser breakdown | \u2013 | \u2713 |\n| Referrer + UTM | \u2013 | \u2713 |\n| Milestones (100, 500, 1K, 5K, 10K+) | \u2713 | \u2713 |\n| Email reports (weekly / monthly) | \u2013 | \u2713 |\n| Advanced charts | \u2013 | \u2713 |\n| CSV export | \u2713 | \u2713 |\n| Excel export | \u2013 | \u2713 |\n| PDF export | \u2013 | \u2713 |\n| Tracking limit | 100 content items | unlimited |\n| Retention | 90 days | 365 days |\n\nLicense gating happens at REST registration time **and** inside `Pro_Data_Collector` instantiation. If `License_Manager::has_pro_license()` returns false, Pro endpoints are simply not registered, and the React dashboard renders a `ProOverlay` upsell over the gated panels. This means a license bug silently strips Pro features without errors.\n\n## Architecture\n\n```\n Frontend visitor wp-admin (analytics page)\n \u2502 \u2502\n \u25bc \u25bc\n assets/js/analytics-tracker.js React AnalyticsDashboard\n (IntersectionObserver + click (src/Analytics/)\n delegates + fingerprint) \u2502\n \u2502 \u2502\n \u25bc \u25bc\n POST /embedpress/v1/analytics/track \u25c4\u2500\u2500 REST_API.php router \u2500\u2500\u25ba GET /data, /content, /views,\n /spline-chart, /overview,\n /embed-details, /milestones,\n /features, /geo, /device,\n /browser, /export, \u2026\n \u2502\n \u25bc\n Data_Collector \u2500\u2500 extends \u2500\u2500\u25ba Pro_Data_Collector\n (only if licensed)\n \u2502\n \u25bc\n wpdb \u2192 5 analytics tables\n + 3 wp_options keys\n + cron (weekly/monthly reports + milestones)\n```\n\n## Database schema (`Analytics_Schema.php` \u2014 version `1.0.8`)\n\n| Table | Purpose | Key columns |\n|---|---|---|\n| `{prefix}embedpress_analytics_content` | Per-embed metadata + rollup counters | `content_id`, `embed_type`, `post_id`, `total_views`, `total_clicks`, `total_impressions` |\n| `{prefix}embedpress_analytics_views` | Each interaction row | `content_id`, `user_id`, `session_id`, `interaction_type`, `user_ip`, `referrer_url`, `view_duration` |\n| `{prefix}embedpress_analytics_browser_info` | Per-session client metadata | `user_id`, `session_id`, `browser_fingerprint`, `browser_name`, `device_type`, `country`, `city` |\n| `{prefix}embedpress_analytics_milestones` | Achievement records | `milestone_type`, `milestone_value`, `achieved_value`, `is_notified` |\n| `{prefix}embedpress_analytics_referrers` | Referrer + UTM aggregation | `referrer_url`, `referrer_domain`, `utm_source`, `utm_medium`, `utm_campaign`, `total_views`, `unique_visitors` |\n\nBump `Analytics_Schema::DB_VERSION` (~L23) when adding columns; `dbDelta` runs on activation/upgrade and reads this option.\n\n## Tracker payload (frontend \u2192 server)\n\n```js\n{\n content_id, // stable per-embed hash\n interaction_type, // 'impression' | 'click' | 'view' | 'play' | 'pause' | 'complete'\n user_id, // 'ep-user-{ts}-{rand}', 30-day cookie\n session_id, // 'ep-sess-{ts}-{rand}', session cookie\n page_url,\n interaction_data: {\n embed_type, // 'YouTube' | 'Vimeo' | 'PDF' | \u2026\n embed_url,\n viewport_percentage,\n location_data, // client-sourced geo (IP-based fallback server-side)\n browser_fingerprint // canvas + WebGL hash\n }\n}\n```\n\n## Tracking semantics\n\n| Interaction | Trigger | Cooldown / threshold |\n|---|---|---|\n| `impression` | Element enters viewport \u226549% | 5 s cooldown per content_id |\n| `click` | Click event delegated on embed | 2 s cooldown |\n| `view` | \u226549% visible for \u22653 s | One-shot per session per content |\n| `play` | Custom Player play event | \u2013 |\n| `pause` | Custom Player pause | \u2013 |\n| `complete` | Custom Player \u226585% watched | Pro only |\n\nDefaults from `analytics-tracker.js` (~L57-69): `viewThreshold: 49`, `viewDuration: 3000`, `impressionCooldown: 5000`, `clickCooldown: 2000`.\n\n## Code paths\n\n### Free PHP\n\n| File | Role |\n|---|---|\n| `EmbedPress/Analytics.php` | Admin menu registration, page render, enqueues `analytics.build.js`. Sets `embedpress_analytics_tracking_enabled` (default true). |\n| `EmbedPress/Includes/Classes/Analytics/REST_API.php` | All 20+ REST routes. Pro_Data_Collector instantiated at constructor (~L31). License gate at ~L201. |\n| `EmbedPress/Includes/Classes/Analytics/Data_Collector.php` | Free-tier collector. `track_interaction`, `get_views_analytics`, `get_content_analytics`, `get_browser_analytics`, `track_referrer_from_interaction_data`. |\n| `EmbedPress/Includes/Classes/Analytics/Pro_Data_Collector.php` | Extends Data_Collector. Adds `get_unique_viewers_per_embed`, `get_geo_analytics`, `get_device_analytics`, `get_referral_analytics`. |\n| `EmbedPress/Includes/Classes/Analytics/License_Manager.php` | `has_pro_license()` (~L23). Free vs Pro feature arrays (~L78-97). Limits (~L167-186). |\n| `EmbedPress/Includes/Classes/Analytics/Export_Manager.php` | CSV / XLSX / PDF generation. Outputs to `/wp-content/uploads/embedpress-analytics/`. |\n| `EmbedPress/Includes/Classes/Analytics/Milestone_Manager.php` | Tracks 100, 500, 1K, 5K, 10K+ thresholds. Fires `embedpress_milestone_achieved` (~L240). |\n| `EmbedPress/Includes/Classes/Analytics/Email_Reports.php` | Cron handlers `embedpress_weekly_analytics_report`, `embedpress_monthly_analytics_report` (~L45-46). |\n| `EmbedPress/Includes/Classes/Analytics/Browser_Detector.php` | Parses User-Agent \u2192 browser/OS/device-type. |\n| `EmbedPress/Includes/Classes/Analytics/Content_Cache_Manager.php` | Cache invalidation. Filter `embedpress_cache_clear_post_types` (~L58). |\n| `EmbedPress/Includes/Database/Analytics_Schema.php` | `dbDelta` schema for 5 tables. Schema version `1.0.8`. |\n\n### Free JS\n\n| File | Role |\n|---|---|\n| `assets/js/analytics-tracker.js` | 1200+ LOC tracker. IntersectionObserver, fingerprint, payload builder. |\n| `src/Analytics/index.js` | Mounts React `AnalyticsDashboard`. |\n| `src/Analytics/components/AnalyticsDashboard.js` | Container \u2014 date range, custom date range, active tabs. |\n| `src/Analytics/components/Header.js` | Date-range picker. |\n| `src/Analytics/components/Overview.js` | Totals cards. |\n| `src/Analytics/components/SplineChart.js` | amCharts5 time-series. |\n| `src/Analytics/components/WorldMap.js` | amCharts5 geo heatmap (Pro). |\n| `src/Analytics/components/PieChart.js` | Browser/device. |\n| `src/Analytics/components/EmbedDetailsModal.js` | Per-embed modal. |\n| `src/Analytics/components/ExportDropdown.js` | CSV / Excel / PDF export. |\n| `src/Analytics/services/AnalyticsDataProvider.js` | REST client wrapper. |\n\n### Pro\n\n| File | Role |\n|---|---|\n| `embedpress-pro/includes/Classes/CustomPlayer/Completion_Tracker.php` | `POST /completion`, `GET /completions`. Fires `embedpress_video_completed` (~L33). 85% watch-time guard (~L23). |\n| `embedpress-pro/includes/Classes/CustomPlayer/Heatmap_Tracker.php` | `POST /heatmap/sample`, `GET /heatmap/data`, `GET /heatmap/list`. 100-bucket anonymous drop-off via `wp_options` keyed `embedpress_heatmap_<md5(url)>` (~L12). |\n\nPro doesn't add its own analytics tables \u2014 it writes into the free schema via `Pro_Data_Collector`, plus heatmap/completion sidecars in `wp_options`.\n\n## REST endpoints\n\nSee [api/rest.md](../api/rest.md) for the full route list.\n\n## Hooks\n\n| Hook | Type | Where | Use |\n|---|---|---|---|\n| `embedpress_milestone_achieved` | action | `Milestone_Manager.php:240` | Args: `(type, value, achieved)`. Hook for custom notifications. |\n| `embedpress_weekly_analytics_report` | action | `Email_Reports.php:45` | Cron-fired. |\n| `embedpress_monthly_analytics_report` | action | `Email_Reports.php:46` | Cron-fired. |\n| `embedpress_video_completed` | action | `Completion_Tracker.php:33` | Pro. Fires when \u226585% completion arrives \u2014 LMS integrations hook here. |\n| `embedpress_cache_clear_post_types` | filter | `Content_Cache_Manager.php:58` | Allow extra post types. |\n| `embedpress_content_count_post_types` | filter | `Data_Collector.php:1295` | Post types counted as \"content\". |\n\n## License gating\n\n`License_Manager` defines feature arrays:\n\n**Free**: `total_views`, `total_clicks`, `total_impressions`, `total_unique_viewers`, `views_over_time_basic`, `basic_charts`.\n\n**Pro**: `per_embed_analytics`, `unique_viewers_per_embed`, `views_over_time_advanced`, `geo_tracking`, `device_analytics`, `email_reports`, `referral_tracking`, `advanced_charts`, `export_pdf`.\n\nDetection: `is_plugin_active('embedpress-pro/embedpress-pro.php')` AND license active for `EMBEDPRESS_SL_ITEM_SLUG`.\n\n## Common pitfalls\n\n- **Schema bumps require both** the `Analytics_Schema::DB_VERSION` constant change AND a `dbDelta` re-run path. Adding columns without bumping the version means existing installs never get the column.\n- **License-gated REST routes are silent**. If `has_pro_license()` returns false, the route doesn't exist \u2014 the React dashboard sees a 404, not a \"Pro required\" payload. When debugging \"feature missing\" reports, check license first.\n- **Tracking can be globally disabled** via `embedpress_analytics_tracking_enabled` option. Front-end JS is still enqueued; the server just rejects writes. Useful for GDPR consent tooling.\n- **Fingerprint is best-effort only** \u2014 Safari ITP / Brave / Firefox-strict block canvas + WebGL fingerprinting. Treat `user_id`/`session_id` cookies as the primary identity, fingerprint as a tiebreaker.\n- **`embedpress_heatmap_<md5(url)>` options can grow unbounded** if many videos are embedded. The Pro Heatmap_Tracker doesn't currently prune; long-running sites should manually clear.\n- **Free retention is 90 days** but pruning is a cron, not a query-time filter. Sites with disabled cron will accumulate beyond the limit.\n- **`embed_type` strings are case-sensitive** in queries. The tracker emits `'YouTube'`, `'Vimeo'`, etc. Don't compare lowercase server-side.\n- **Pro Heatmap & Completion are separate from analytics tables**. They live in `wp_options` to avoid yet-another-table on activation; keep them out of the `Analytics_Schema` migrations.\n\n## Testing\n\n- **Smoke**: `make wp ARG=\"user create test test@x.test --role=author\"`, visit any embed-bearing post in incognito. Confirm an `impression` row appears in `{prefix}embedpress_analytics_views`.\n- **Forcing a milestone**: insert rows directly into the views table to push `total_views` above 100, then run cron via `make wp ARG=\"cron event run embedpress_daily_milestone_check\"`.\n- **License toggle**: deactivate Pro plugin, refresh dashboard \u2014 Pro panels should render the upsell, not 500.\n- **Export**: trigger `/analytics/export?format=csv&date_range=30` and verify file in `/wp-content/uploads/embedpress-analytics/`.\n- **E2E**: `npm run test:e2e:dashboard`.\n", "features/custom-player.md": "\n# Custom Video Player\n\nPlyr-based branded player layered over YouTube, Vimeo, Wistia, Twitch, Dailymotion, and self-hosted MP4/MP3 sources. Free + Pro split: control toggles, presets, theming, autoplay, volume, playback speed, loop, restart/rewind/fast-forward live in **free**. Pro layers on advanced theming and 12 engagement / delivery sub-features.\n\n## Independent of Cinematic Preview\n\nThe Custom Player and the [Cinematic Preview](../../../embedpress-pro/docs/features/cinematic-preview.md) overlay share the same `.ep-embed-content-wraper` element and the same `data-options` JSON envelope, but they are independent. A wrapper can have:\n\n- only Cinematic Preview (overlay \u2192 bare iframe)\n- only Custom Player (Plyr controls, no overlay)\n- both (overlay \u2192 Plyr)\n- neither (raw embed)\n\n## Supported sources\n\n| Source | Player |\n|---|---|\n| YouTube | Plyr (YouTube provider) |\n| Vimeo | Plyr (Vimeo provider) |\n| Wistia | Plyr (HTML5 + Wistia API) |\n| Twitch | Plyr |\n| Dailymotion | Plyr |\n| Self-hosted MP4 / WebM | Plyr (HTML5) |\n| Self-hosted MP3 / OGG | Plyr (HTML5 audio) |\n| HLS (`.m3u8`) | Plyr + hls.js |\n| DASH (`.mpd`) | Plyr + dash.js |\n\nThe \"is this self-hosted video?\" decision is made by **five regex copies** in PHP and JS \u2014 when extending self-hosted format support (e.g., adding `.av1`), all five must be updated. Plyr is vendored at `assets/js/vendor/plyr.js`.\n\n## Architecture\n\n```\n Editor settings surfaces\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 Gutenberg InspectorControls \u2502\n \u2502 Elementor panel \u2502\n \u2502 Shortcode attributes \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502 block attributes / widget settings\n \u25bc\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 Renderer emits \u2502\n \u2502 .ep-embed-content-wraper \u2502\n \u2502 carrying data-options=\"{JSON}\" \u2502\n \u2502 + data-playerid \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u25bc\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 Frontend initplyr.js \u2502\n \u2502 - DOMContentLoaded scan \u2502\n \u2502 - MutationObserver pickup \u2502\n \u2502 - Builds controls[] array \u2502\n \u2502 - new Plyr(selector, opts) \u2502\n \u2502 - epSafeInit() per Pro feature \u2502\n \u2502 - PiP icon injection \u2502\n \u2502 - iOS YT fullscreen fallback \u2502\n \u2502 - Poster fade-in \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u25bc\n Pro REST endpoints +\n per-feature classes (Pro)\n```\n\nThe single contract: every renderer must produce a `.ep-embed-content-wraper` carrying a unique `data-playerid` and a JSON `data-options`. `initplyr.js` is the only consumer \u2014 it does not care which renderer produced the markup.\n\n## Data contract \u2014 `data-options` JSON\n\nBuilt by `EmbedPressBlockRenderer::build_player_options` (~line 919) and by the Elementor / shortcode equivalents. The free fields:\n\n```js\n{\n customPlayer: bool, // master enable\n autoplay: bool,\n volume: number, // 0\u20131\n playbackSpeed: number, // 0.5\u20132\n posterThumbnail: string, // URL\n playerColor: string, // \u2192 --plyr-color-main\n playerPreset: string, // 'default' | 'preset-1' | 'preset-3'\n playerTooltip: bool,\n playerHideControls: bool,\n playerLoop: bool,\n playerRestart: bool,\n playerRewind: bool,\n playerFastForward: bool,\n playerPip: bool, // non-YouTube\n playerDownload: bool,\n fullscreen: bool|value,\n start: timestamp,\n end: timestamp,\n rel: bool, // YouTube related-videos\n mute: bool,\n t: timestamp, // Vimeo\n vautoplay: bool,\n autopause: bool,\n dnt: bool, // Vimeo do-not-track\n self_hosted: bool,\n hosted_format: string, // mp4 | webm | audio | hls | dash\n show: { // per-control visibility\n progress, current_time, duration, mute, volume, captions,\n fullscreen, pip, settings, playback_speed, restart, seek, loop\n }\n}\n```\n\nPro extends the contract with top-level keys per sub-feature (only emitted when enabled):\n\n| Key | Sub-feature |\n|---|---|\n| `email_capture` | Pause at time \u2192 modal email form \u2192 submit/skip \u2192 resume |\n| `action_lock` | Full-cover overlay (share / form / link / login required) |\n| `timed_cta` | `[{time, headline, button_text, button_url, dismissible}]` |\n| `chapters` | `{items: [{start_time, title}], source: 'manual' \\| 'youtube'}` |\n| `auto_resume` | localStorage seek persistence + Resume prompt |\n| `end_screen` | Replay / next / countdown-redirect overlay on `ended` |\n| `heatmap` | 5s sample \u2192 REST \u2192 per-video bucket array |\n| `adaptive_streaming` | Lazy-loads hls.js / dash.js |\n| `country_restriction` | Server-side GeoIP gate (replaces markup, not JSON) |\n| `privacy_mode` | Static thumb + click-to-load; `youtube-nocookie.com` |\n| `lms_tracking` | Threshold-cross fires `embedpress:video-completed` (LearnDash / TutorLMS / LifterLMS) |\n| `cdn_offloading` | BunnyCDN / Cloudflare Stream URL rewrite at save |\n\n## Presets\n\nVisual styles applied via CSS class:\n\n| UI label | CSS class | Status |\n|---|---|---|\n| Default | (no class) | Free + Pro |\n| Preset 1 | `custom-player-preset-1` | Pro |\n| Preset 2 | `custom-player-preset-3` \u26a0 | Pro |\n\n> The \"Preset 2\" UI label maps to CSS class `custom-player-preset-3`, **not** `-2`. Classes `-2` and `-4` exist in `static/css/embedpress.css` but no UI exposes them. Don't introduce them without product direction.\n\nCSS lives in `static/css/embedpress.css` lines ~1859\u20132019.\n\n## Code paths \u2014 exhaustive map\n\n### Free plugin\n\n**Block attribute schema (must keep both in sync):**\n\n| Where | Why |\n|---|---|\n| `src/Blocks/EmbedPress/src/components/attributes.js` | JS-side definitions used by `registerBlockType` |\n| `EmbedPress/Gutenberg/BlockManager.php::get_embedpress_block_attributes()` | **Server-side mirror** \u2014 any attribute missing here is stripped before `render_callback` runs. Both Free and Pro Custom Player attrs must be registered. |\n\n**Inspector UI:**\n- `src/Blocks/EmbedPress/src/components/InspectorControl/customplayer.js` \u2014 base toggles (free)\n- Pro panels filter in via `applyFilters('embedpress.customPlayerControls', ...)`\n\n**Render path:**\n- Editor: `src/Blocks/EmbedPress/src/components/edit.js` \u2014 wrapper renders `data-options={getPlayerOptions({attributes})}` whenever `customPlayer || cinematicPreview`\n- Save: `src/Blocks/EmbedPress/src/components/save.js` \u2014 same condition\n- Helper: `src/Blocks/EmbedPress/src/components/helper.js::getPlayerOptions` \u2014 single source of truth for the JSON shape\n\n**Elementor:**\n- `EmbedPress/Elementor/Widgets/Embedpress_Elementor.php` \u2014 fires `extend_customplayer_controls` action (Pro hooks here)\n- `Embedpress_Elementor::render()` builds the wrapper, injects `data-options` JSON\n\n**Shortcode:**\n- `EmbedPress/Includes/Classes/Extend_CustomPlayer_Controls.php` \u2014 registers Custom Player on the shortcode pipeline (`emberpress_custom_player => 'yes'` is the legacy enable key \u2014 note the typo `emberpress`, kept for back-compat)\n\n**Frontend init \u2014 `static/js/initplyr.js` (~1466 lines):**\n\n| Section | Role |\n|---|---|\n| L1\u2013L139 | `init()`: scan `[data-playerid]`, parse `data-options`, apply poster |\n| L147\u2013L166 | controls array builder |\n| L181\u2013L219 | `new Plyr(selector, {...})` \u2014 `seekTime: 10` hardcoded |\n| L226\u2013L235 | Privacy Mode auto-resume after consent |\n| L240\u2013L247 | `epSafeInit(name, fn)` \u2014 failure isolation per Pro feature |\n| L249\u2013L272 | Per-feature dispatch: auto_resume \u2192 end_screen \u2192 timed_cta \u2192 chapters \u2192 email_capture \u2192 action_lock \u2192 lms_tracking \u2192 heatmap |\n| L276\u2013L307 | iOS YouTube fullscreen workaround |\n| L314\u2013L333 | Poster fade-in (5s safety timeout) |\n| L338+ | PiP icon injection (MutationObserver \u2014 Plyr renders PiP late) |\n| L410+ | Auto-Resume |\n| L525+ | Heatmap |\n| L565+ | LMS tracking |\n| L645+ | Adaptive streaming (lazy-loads hls.js / dash.js) |\n| L705+ | Action Lock |\n| L836+ | Email Capture (localStorage dedupe per video URL) |\n| L984+ | Chapters |\n| L1198+ | Timed CTA |\n| L1287+ | Privacy Mode |\n| L1346+ | End Screen |\n\n### Self-hosted video detection \u2014 five regex copies\n\nThe five places that decide \"this URL is a self-hosted `<video>`\":\n\n1. `EmbedPress/Providers/SelfHosted.php::validateSelfHostedVideo` \u2014 classic shortcode\n2. `EmbedPress/Includes/Classes/Helper.php::check_media_format` \u2014 server-side, sets `self_hosted` / `hosted_format`\n3. `src/Blocks/EmbedPress/src/components/helper.js` \u2014 TWO copies: `isSelfHostedVideo()` and `checkMediaFormat()`\n4. `src/utils/functions.js::isSelfHostedVideo` \u2014 **gates whether the Custom Player panel appears** in the inspector. Missing this is the easiest way to \"add HLS support\" but still see no player UI.\n5. `src/utils/helper.js::isSelfHostedVideo`\n\nAll five strip `?\u2026` and `#\u2026` before extension match so signed-URL streaming sources (Mux, CloudFront tokens) work.\n\n## Pro sub-feature playbook\n\nFor each new Pro sub-feature:\n\n1. Add attribute(s) to `attributes.js` AND `BlockManager.php::get_embedpress_block_attributes()` (server-side mirror is mandatory).\n2. Surface in inspector via `applyFilters('embedpress.customPlayerControls', ...)` (Pro panel).\n3. Mirror in Elementor \u2014 Pro section in the Elementor widget hook.\n4. Mirror in shortcode \u2014 extend `Extend_CustomPlayer_Controls.php` defaults.\n5. Emit JSON \u2014 extend `helper.js::getPlayerOptions` with the new top-level key. Only emit when feature is enabled.\n6. Server-side render \u2014 if feature gates rendering (e.g. Country Restriction), short-circuit the markup; otherwise the wrapper is unchanged.\n7. Frontend init \u2014 add `epInitXxxx` function to `initplyr.js` and dispatch via `epSafeInit('xxxx', ...)` so a failure in one sub-feature doesn't break the next.\n8. Pro back-end class (if needed) \u2014 REST endpoint + DB writes.\n\n## Editor live-preview architecture (must preserve)\n\nThese invariants are easy to break and the symptoms (stale preview, double oEmbed, Pro features dropped) only appear when toggles happen in a specific order.\n\n- **`Core/AssetManager.php::is_custom_player_enabled()`** must return true unconditionally inside the Gutenberg iframed canvas (`is_admin() && get_current_screen()->is_block_editor()`). Without this short-circuit, `plyr.js` never enqueues and `Plyr` is undefined.\n- **`opacity: 0` belongs inline in `save.js`, NEVER as a global CSS rule.** `save.js` emits `style=\"opacity:0\"` only when `customPlayer` is on; init code flips `.plyr-initialized` after init, and the only CSS rule is `[data-playerid].plyr-initialized { opacity: 1 }`.\n- **`<EmbedWrap key={\u2026}>` is the live-preview backbone.** Its key composes three independent triggers:\n 1. `customPlayer ? 'cp' : 'raw'` \u2014 flips on the master toggle\n 2. `(embedHTML || '').length` \u2014 flips when iframe URL changes\n 3. `JSON.stringify(customPlayerParams || {})` \u2014 flips on any Custom Player option change\n\n Any change unmounts and remounts the wrapper; `dangerouslySetInnerHTML` re-emits the embed; `initplyr.js`'s MutationObserver in the canvas iframe fires `initPlayer` on the fresh node. **Never** drop one of these from the key.\n\n- **`customPlayerParams` must NOT live in the `embed` useEffect's dependency list.** Custom Player options never change the iframe URL. Including it there causes a single toggle to bump *both* `youtubeParams` AND `customPlayerParams` ref-equality across separate render passes, scheduling the embed-fetch debounce twice; the second response runs `getCustomPlayerParams` against stale attributes and drops the freshly enabled Pro flag.\n- **`helper.js::initCustomPlayer` bails when the wrapper has `.plyr-initialized` already.** In the editor's iframed canvas, `initplyr.js`'s MutationObserver beats the 300ms `setTimeout` from `edit.js`'s `useEffect` to the punch. Without the bail, `helper.js` runs `new Plyr(element, ...)` *again*, creating a second instance that orphans the first one's Pro-feature listeners.\n- **Color-migration `setAttributes(...)` calls must live inside a `useEffect`, never in the render body** \u2014 otherwise React's \"Cannot update a component while rendering\" warning fires whenever the user pastes a URL.\n\n## Files cheatsheet\n\n| Concern | File |\n|---|---|\n| Frontend init (single source of runtime truth) | `static/js/initplyr.js` |\n| Block JS attributes | `src/Blocks/EmbedPress/src/components/attributes.js` |\n| Block PHP attribute mirror | `EmbedPress/Gutenberg/BlockManager.php` |\n| Inspector (free toggles) | `src/Blocks/EmbedPress/src/components/InspectorControl/customplayer.js` |\n| Render helpers (JSON shape) | `src/Blocks/EmbedPress/src/components/helper.js` (`getPlayerOptions`) |\n| Block edit / save | `src/Blocks/EmbedPress/src/components/{edit.js,save.js}` |\n| Shortcode + base controls | `EmbedPress/Includes/Classes/Extend_CustomPlayer_Controls.php` |\n| Elementor widget | `EmbedPress/Elementor/Widgets/Embedpress_Elementor.php` |\n| Presets CSS | `static/css/embedpress.css` (~L1859\u20132019) |\n| Plyr vendor | `assets/js/vendor/plyr.js` |\n\n## Common pitfalls\n\n- **`data-options` JSON malformed** \u2014 frontend logs a parse error and falls back to a plain iframe. Use `htmlspecialchars($json, ENT_QUOTES, 'UTF-8')` server-side.\n- **Player UI doesn't appear for a new self-hosted format** \u2014 usually means one of the five `isSelfHostedVideo` copies wasn't updated.\n- **YouTube `videoId` extraction** \u2014 supports `youtube.com/watch?v=ID`, `youtu.be/ID`, `/embed/ID`, `/shorts/ID`, channel/playlist URLs (Pro). Test all forms when changing the regex.\n- **Misspelled `custom_payer_preset` Elementor key** \u2014 preserve the typo for back-compat.\n- **`emberpress_custom_player`** (sic) \u2014 legacy shortcode key has a typo. Don't fix \u2014 load-bearing for old posts.\n- **iOS YouTube fullscreen** modifies `<meta name=\"viewport\">` directly. Make sure both `enter` and `exit` handlers fire.\n- **PiP icon via MutationObserver** because Plyr renders PiP late on some sources. Don't replace with a one-shot \u2014 it'll race.\n- **Privacy Mode + autoplay**: autoplay is force-disabled (the click IS the autoplay). After consent, the wrapper carries `data-ep-autoplay-after-init=\"1\"` and `initplyr.js` calls `player.play()` once on `ready` / `canplay`.\n- **Email Capture localStorage key**: per video URL. Strip query for the key \u2014 otherwise adding `?t=10` re-prompts.\n- **Heatmap batching**: \u22641 request per 30s per viewer (acceptance criterion). Buffer; don't sample-and-send on every interval.\n- **Anti-skip on LMS completion**: requires `watched_seconds \u2265 min(0.85, threshold \u00d7 0.9) \u00d7 total_seconds`. Without the 0.9 multiplier, a 50% threshold was unsatisfiable because `watched/dur` lagged `currentTime/dur`.\n- **Completion is server-idempotent**, NOT sessionStorage-gated. Per-(user, video_url) state lives in user meta `embedpress_video_progress` keyed by `md5(video_url)`.\n- **Two distinct POSTs hit `/completion`**: (1) threshold-cross (full payload, fires `embedpress_video_completed` once), (2) progress beacons with `progress_only=1` (sent on `pause` / `ended` / `beforeunload` / `pagehide` via `navigator.sendBeacon`, throttled to \u22651s of new watched time). Beacons let the dashboard surface real watch depth for non-completers. `sendBeacon` survives unload but can't set `X-WP-Nonce` \u2014 `_wpnonce` is appended to the FormData body instead.\n- **Cinematic Preview coexistence**: when both skins are on, Cinematic Preview's overlay handles the first click and starts Plyr. Email Capture's pause-at-time fires AFTER overlay dismissal \u2014 confirm timer starts from `play`, not page load.\n\n## Testing\n\nMaster test plan: `embedpress-docker/test-plan-custom-player-4.5.2.txt`.\n\nPre-flight:\n- Hard-refresh editor (asset cache key needs a version bump).\n- DevTools Console open \u2014 any red error = Fail.\n- Test BOTH (a) YouTube `https://www.youtube.com/watch?v=dQw4w9WgXcQ` and (b) self-hosted `.mp4`. Pass only if BOTH work (unless feature is provider-specific).\n- Re-test in BOTH Gutenberg AND Elementor \u2014 historical JSON-quoting bugs only manifested in one of them.\n- Incognito for any feature involving `localStorage` (auto-resume, email capture dedupe, action-lock unlock state).\n\nPlaywright automation: `/Applications/Workspace/GitHub/embedpress-playwright-automation`.\n", "features/document.md": "\n# Document Block\n\nBlock name `embedpress/document`. Embeds DOC / DOCX / PPT / PPTX / XLS / XLSX / PDF using one of three viewers depending on format and license.\n\n## Viewer routing\n\n`docViewer` modes:\n\n| Mode | Renderer | When |\n|---|---|---|\n| `'custom'` | EmbedPress PDF.js | PDFs only |\n| `'office'` | **Office Online** (Pro) | Office formats (DOC, DOCX, PPT, PPTX, XLS, XLSX) |\n| `'google'` | Google Viewer | Fallback for non-PDF without Pro |\n\nThe routing logic lives in `Shortcode.php::do_shortcode_doc` (~L1496+):\n\n1. Detect MIME type / extension from `href`.\n2. If PDF \u2192 defer to PDF block render path.\n3. Else if `docViewer='office'` and Pro active \u2192 Office Online URL: `https://view.officeapps.live.com/op/embed.aspx?src=<url>`.\n4. Else \u2192 Google Viewer URL: `https://docs.google.com/gview?url=<url>&embedded=true`.\n\n> **Google Viewer is officially deprecated** by Google for new sources. Treat it as best-effort fallback only \u2014 don't rely on it for SLA-critical embeds.\n\n## Free vs Pro\n\n| Capability | Free | Pro |\n|---|---|---|\n| PDF via PDF.js or flipbook | \u2713 | \u2713 |\n| Office Online viewer (clean Office rendering) | \u2013 | \u2713 |\n| Google Viewer fallback | \u2713 | \u2713 |\n| Custom logo overlay | \u2013 | \u2713 |\n| Content protection | \u2013 | \u2713 |\n\n## Code paths\n\n| File | Role |\n|---|---|\n| `src/Blocks/document/block.json` | Block manifest \u2014 \"Embed documents like PDF, DOC, PPT, XLS\" |\n| `src/Blocks/document/src/components/attributes.js` | Unified attributes |\n| `src/Blocks/document/src/components/PDFViewer.js` | Editor preview |\n| `EmbedPress/Elementor/Widgets/Embedpress_Pdf.php` | Elementor widget for PDFs (free) |\n| `EmbedPress/Elementor/Widgets/Embedpress_Document.php` | Elementor widget for non-PDF docs (Pro) |\n| `EmbedPress/Gutenberg/EmbedPressBlockRenderer.php::render_document` (~L443) | Server render |\n| `EmbedPress/Shortcode.php::do_shortcode_doc` (~L1496+) | MIME detection + viewer routing |\n\n## Block attributes\n\n| Attribute | Type | Purpose |\n|---|---|---|\n| `href` | string | File URL |\n| `docViewer` | `'custom' \\| 'google' \\| 'office'` | Viewer selector |\n| `presentation` | bool | Enables 3D flipbook for PDFs |\n| `themeMode`, `customColor`, `toolbar`, `position`, `download`, `copy_text`, `draw`, `doc_rotation` | mixed | Shared with PDF block |\n| **Pro:** `customlogo`, `logoX`, `logoY`, `customlogoUrl`, `logoOpacity` | mixed | Custom logo overlay |\n\n## Common pitfalls\n\n- **Google Viewer is officially deprecated.** Customer-facing SLA-critical embeds should use Pro + Office Online.\n- **Office Online URL must be public.** Office Online fetches the file server-side, so behind-auth URLs return blank. For private docs, use Pro + a public asset URL.\n- **PPTX in Office Online doesn't always honour `allowfullscreen`** \u2014 common cases work after the 4.4.10 fix, but not all. If a customer reports fullscreen failing, check the host's CSP / Permissions-Policy first.\n- **`docViewer='custom'` + non-PDF** is invalid but not validated. The block lets you save it, then renders blank. Validate at attribute set time.\n- **Office Online has a 10 MB limit** for direct embed. Larger files won't render; fall back to download link.\n- **PDF path is shared** with the [PDF feature](pdf.md) \u2014 don't duplicate fixes.\n- **Cross-origin restrictions**. If the document URL is on a host with `X-Frame-Options: DENY`, none of the three viewers will work \u2014 that's outside EmbedPress's control.\n\n## Testing\n\n- **PDF**: embed a PDF URL \u2192 routes to PDF.js viewer.\n- **PPTX (Pro)**: embed a public `.pptx` URL with `docViewer='office'` \u2192 Office Online iframe.\n- **PPTX (free)**: same URL with `docViewer='google'` \u2192 Google Viewer iframe (may flake).\n- **Fullscreen**: PPTX in Office Online \u2192 click fullscreen, verify it works.\n- **Custom logo**: with Pro, set logo \u2192 overlay appears bottom-right of the iframe.\n", "features/feature-enhancer.md": "\n# Feature_Enhancer \u2014 the cross-provider decoration pipeline\n\n`EmbedPress/Includes/Classes/Feature_Enhancer.php` is the layer that decorates **all** provider output. It's where most cross-cutting logic (custom player, social share, provider routing, AJAX handlers for PDF / video popup) is wired up.\n\n## Why a pipeline\n\nProviders do one job: turn a URL into base HTML. But customers want all sorts of decoration on top \u2014 autoplay flags, share buttons, branding, lazy loading. Putting that into each provider would mean N copies. Instead, providers stay simple and `Feature_Enhancer` runs over their output via the `embedpress:onAfterEmbed` filter.\n\n## How it runs\n\n`Feature_Enhancer` is instantiated in `embedpress.php` (around line 133). Its constructor adds these hooks:\n\n| Hook | Type | Callback |\n|---|---|---|\n| `embedpress:isEmbra` | filter | `isEmbra()` \u2014 decides whether YouTube/TikTok/Spreaker/GooglePhotos/Wrapper should handle the URL |\n| `embedpress:onAfterEmbed` | filter (priority 90) | `enhance_youtube`, `enhance_vimeo`, `enhance_twitch`, `enhance_dailymotion`, `enhance_soundcloud`, `enhance_missing_title` |\n| `embedpress_gutenberg_youtube_params` | filter | YouTube param injection for blocks |\n| `init` | action | Registers the Vimeo Gutenberg block |\n| `embedpress_gutenberg_wistia_block_after_embed` | action | Wistia block post-render |\n| `elementor/widget/embedpres_elementor/skins_init` | action | `elementor_setting_init` |\n| `wp_ajax_youtube_rest_api`, `wp_ajax_nopriv_youtube_rest_api` | action | AJAX handler for YouTube data |\n| `embedpress_gutenberg_embed` | action | `gutenberg_embed` |\n| `wp_ajax_save_source_data` | action | Caches per-embed metadata to post meta |\n| `save_post`, `load-post.php` | action | Source data lifecycle |\n| `elementor/editor/after_save` | action | Source data caching for Elementor |\n| `wp_head` | action | `embedpress_generate_social_share_meta` |\n| (additional `wp_ajax_*` for PDF / flipbook viewers) | action | various |\n\n## isEmbra \u2014 the routing decision\n\n`Feature_Enhancer::isEmbra($isEmbra, $url, $atts)` (around line 132) instantiates `Youtube`, `TikTok`, `Spreaker`, `Wrapper`, and `GooglePhotos` provider classes and calls `validateUrl()` on each. If any returns truthy, `isEmbra` returns true and `Shortcode::parseContent` routes through Embera + a custom Provider class. Otherwise, the URL goes through `WP_oEmbed::fetch`.\n\nThis means **Feature_Enhancer is the gatekeeper** between \"use our Provider\" and \"use WP's native oEmbed.\"\n\n## Adding a new cross-provider feature\n\n1. Decide which stage it touches: pre-embed (`embedpress:onBeforeEmbed`), post-embed (`embedpress:onAfterEmbed`), or block-attribute (`embedpress_gutenberg_*`).\n2. Add a callback in `Feature_Enhancer` (or as a separate class registered from the same place).\n3. Inside the callback, read the relevant attributes; mutate appropriately.\n4. Add the corresponding control in the block / widget so users can opt in.\n5. If Pro-only: ship the actual mutation in `embedpress-pro/includes/Filters/*` (typically `Feature_Enhancer_Pro` or one of the per-provider extenders); in free, ship a no-op + the upsell UI.\n\n## Pattern: provider-aware vs provider-agnostic\n\n- **Provider-aware** (e.g., YouTube subscriber button): the per-provider `enhance_youtube` method, conditional on URL host or the Embera-resolved provider name.\n- **Provider-agnostic** (e.g., share buttons, lazy load): hook `embedpress:onAfterEmbed` and run unconditionally; mutate the wrapper, not the provider HTML.\n\n## Pro counterpart\n\nPro ships `Embedpress\\Pro\\Filters\\Feature_Enhancer_Pro` (in `embedpress-pro/includes/Filters/Feature_Enhancer_Pro.php`) which hooks complementary actions:\n\n- `embedpress_enhance_soundcloud`\n- `embedpress_enhance_dailymotion`\n- `embedpress_wistia_block_attributes`\n\nPro's per-provider extenders (`includes/Providers/Youtube.php`, `Vimeo.php`, etc.) each implement `featureExtend()` that hooks `embedpress:onAfterEmbed` / `:onBeforeEmbed` for their respective providers.\n", "features/onboarding.md": "\n# Onboarding Setup Wizard\n\n3-step React wizard at `?page=embedpress-onboarding` that walks new users through enabling features and editor surfaces. Free, added in 4.5.1.\n\n## Trigger\n\nAuto-redirect fires when:\n\n- Plugin freshly activated, AND\n- `!get_option('embedpress_onboarding_complete')`, AND\n- not already on the wizard page.\n\nAfter completion, `embedpress_onboarding_complete=1` and the wizard never auto-redirects again. Manually accessible any time via wp-admin submenu **\"Setup Wizard\"**.\n\n## Steps\n\n1. **Get Started** \u2014 welcome with 250+ source icons, consent modal, \"Personalize\" / \"Skip\" buttons.\n2. **Customize Setup** \u2014 9 toggle cards: Gutenberg Block, Elementor Widget, Powered By, Analytics, Social Share (free) + Lazy Load, Custom Branding, Custom Ads, Content Protection (Pro, crown-badged).\n3. **Ready to Embed** \u2014 feature summary + upsell checklist + Pro feature links.\n\nPro features show a crown icon + ProPopup upsell modal on click \u2192 upgrade link from `embedpressOnboardingData.upgradeUrl`.\n\n## Architecture\n\n```\n Plugin activation\n \u2502\n \u25bc\n EmbedpressSettings.php (~L215) checks:\n !$pro_active && !get_option('embedpress_onboarding_complete')\n \u2502\n \u25bc\n Auto-redirect to wp-admin?page=embedpress-onboarding\n \u2502\n \u25bc\n Wizard page renders <div id=\"embedpress-onboarding-root\"></div>\n \u2502\n \u25bc\n src/AdminUI/onboarding-entry.js mounts <Onboarding/>\n (compiled to assets/js/onboarding.build.js + .css)\n \u2502\n \u25bc\n 3-step React UI; toggles bound to step-2 state\n \u2502\n \u25bc\n On finish \u2192 AJAX `embedpress_save_onboarding`\n \u2192 update_option(EMBEDPRESS_PLG_NAME) with toggle values\n \u2192 update_option(EMBEDPRESS_PLG_NAME . ':elements') for editor enablements\n \u2192 update_option('embedpress_onboarding_complete', 1)\n \u2502\n \u25bc\n Redirect to dashboard\n```\n\n## Code paths\n\n| File | Role |\n|---|---|\n| `src/AdminUI/Onboarding.js` (~652 LOC, single component) | The whole wizard |\n| Inside Onboarding.js (~L362-408) | Step 1 (Get Started + consent) |\n| Inside Onboarding.js (~L412-479) | Step 2 (toggle cards) |\n| Inside Onboarding.js (~L482-576) | Step 3 (summary + upsell) |\n| Inside Onboarding.js (~L256-260) | `PRO_KEYS = ['g_lazyload', 'custom_branding', 'custom_ads', 'content_protection']` |\n| Inside Onboarding.js (~L100-126) | `ProPopup` modal \u2014 shown when locked toggle clicked |\n| Inside Onboarding.js (~L313) | Reads `data?.proActive` |\n| `src/AdminUI/onboarding-entry.js` | React mount point \u2014 `#embedpress-onboarding-root` |\n| `assets/js/onboarding.build.js` | Compiled bundle |\n| `assets/css/onboarding.build.css` | Styles |\n| `EmbedPress/Ends/Back/Settings/EmbedpressSettings.php` (~L215) | Auto-redirect trigger |\n| Same file (~L338-351) | `wp_localize_script('embedpress-onboarding', 'embedpressOnboardingData', {...})` |\n| Same file (~L500-600) | AJAX handler `embedpress_save_onboarding` \u2014 writes options |\n\n## Persisted options\n\n| Option key | Shape | Set by |\n|---|---|---|\n| `EMBEDPRESS_PLG_NAME` (the global settings array) | `{gutenberg_block, elementor_widget, embedpress_document_powered_by, analytics_tracking, social_share, g_lazyload, custom_branding, custom_ads, content_protection}` | Step 2 toggles |\n| `EMBEDPRESS_PLG_NAME . ':elements'` | per-element enablement (Gutenberg block keys, Elementor widget keys) | Step 2 editor toggles |\n| `embedpress_onboarding_complete` | `1` | Set on finish; suppresses re-run |\n\n## Localization (`embedpressOnboardingData`)\n\nPassed via `wp_localize_script`:\n\n- `ajaxUrl`, `nonce` \u2014 for the save AJAX\n- `dashboardUrl` \u2014 where to redirect after finish\n- `proActive` \u2014 license check result\n- `upgradeUrl` \u2014 `wpdeveloper.com/in/upgrade-embedpress`\n- `assetsUrl` \u2014 for icon assets\n- `analyticsTracking` \u2014 current state of the analytics toggle (so re-running preserves the user's earlier choice)\n\n## Common pitfalls\n\n- **`PRO_KEYS` is hardcoded.** Adding a 5th Pro feature toggle requires editing this array AND adding the toggle card AND the option write.\n- **Auto-redirect only fires once per install.** To re-trigger for testing: `wp option delete embedpress_onboarding_complete`.\n- **Pro detection happens server-side** (`proActive` localized once). If the user activates Pro mid-wizard, they need to refresh \u2014 the React state won't update.\n- **Step 2 writes a unified array** to `EMBEDPRESS_PLG_NAME` \u2014 be careful if other code paths also write that option, you can race-overwrite. The wizard merges, but if you skip the merge code path you lose other settings.\n- **Element enablement (`:elements`)** is a separate option key. Don't put block/widget enablement in the main settings array.\n- **Skipping the wizard** still sets `embedpress_onboarding_complete=1` \u2014 by design (so it doesn't keep nagging). But it means skipped users never wrote default option values \u2014 relying code should fall back gracefully.\n- **Localization data is only passed when the user is on the onboarding page.** Don't reference `embedpressOnboardingData` from other admin pages.\n- **`embedpress-onboarding-root` div is the only mount point** \u2014 if the page template doesn't render it, the React app silently fails.\n\n## Testing\n\n- **Fresh install**: `make destroy && make setup` \u2192 activate plugin \u2192 should auto-redirect to wizard.\n- **Skip path**: click Skip \u2192 `embedpress_onboarding_complete=1`, redirect to dashboard.\n- **Toggle path**: enable some toggles \u2192 finish \u2192 verify options written via `wp option get embedpress`.\n- **Pro toggle without license**: click Custom Branding (no Pro) \u2192 ProPopup opens with upgrade link.\n- **Re-trigger**: `wp option delete embedpress_onboarding_complete` \u2192 reload admin \u2192 wizard re-shows.\n- **With Pro active**: all 9 toggles unlocked, no crown badges.\n", "features/pdf-gallery.md": "\n# PDF Gallery\n\nMulti-PDF gallery with four layouts (grid, masonry, carousel, bookshelf) and per-item custom thumbnails. Free, shipped 4.5.0. Block name `embedpress/pdf-gallery`.\n\nThe gallery is essentially a **chooser around the existing PDF viewer** \u2014 clicking a card opens the PDF in a lightbox modal that uses the same PDF.js / 3D flipbook pipeline as the standalone [PDF block](pdf.md).\n\n## Architecture\n\n```\n Block: embedpress/pdf-gallery\n pdfItems: [ { id, url, fileName, customThumbnailId, customThumbnailUrl } ]\n \u2502\n \u25bc\n Server render: render_pdf_gallery()\n EmbedPressBlockRenderer.php:74\n \u2502\n \u25bc\n For each item:\n - Generate or reuse thumbnail (Pdf_Thumbnail_Handler.php)\n \u251c\u2500\u2500 WP-managed preview (if attachment exists)\n \u2514\u2500\u2500 Imagick fallback (server-side render of page 1)\n \u2502\n \u25bc\n Layout shell (grid / masonry / carousel / bookshelf) wraps cards\n \u2502\n \u25bc\n Frontend: static/js/pdf-gallery.js\n Click \u2192 static/js/ep-pdf-lightbox.js\n \u2502\n \u25bc\n Lightbox modal opens chosen PDF using PDF.js or 3D flipbook\n (viewer settings inherited from gallery block attrs)\n```\n\n## Code paths\n\n| File | Role |\n|---|---|\n| `src/Blocks/pdf-gallery/block.json` | Block manifest |\n| `src/Blocks/pdf-gallery/src/index.js` | Block registration |\n| `src/Blocks/pdf-gallery/src/components/attributes.js` | Full schema |\n| `src/Blocks/pdf-gallery/src/components/edit.js` | Gallery builder UI |\n| `src/Blocks/pdf-gallery/src/components/save.js` | Save HTML |\n| `src/Blocks/pdf-gallery/src/inspector.js` | Inspector |\n| `EmbedPress/Elementor/Widgets/Embedpress_Pdf_Gallery.php` | Elementor widget |\n| `EmbedPress/Gutenberg/EmbedPressBlockRenderer.php::render_pdf_gallery` (~L74) | Server render |\n| `EmbedPress/Includes/Classes/Pdf_Thumbnail_Handler.php` | AJAX thumbnail generator + Imagick fallback |\n| `EmbedPress/Gutenberg/BlockManager.php` (~L115-116) | Registers AJAX actions: `ep_generate_pdf_thumbnail`, `ep_upload_pdf_thumbnail` |\n| `static/js/pdf-gallery.js` | Frontend gallery init |\n| `static/js/pdf-gallery-elementor-editor.js` | Elementor editor multiselect handler |\n| `static/js/ep-pdf-lightbox.js` | Lightbox modal \u2014 opens picked PDF |\n\n## Block attributes\n\n| Attribute | Type | Purpose |\n|---|---|---|\n| `pdfItems` | `[{id, url, fileName, customThumbnailId, customThumbnailUrl}]` | The PDFs |\n| `layout` | `'grid' \\| 'masonry' \\| 'carousel' \\| 'bookshelf'` | Layout |\n| `columns`, `columnsTablet`, `columnsMobile` | number | Responsive grid |\n| `gap` | number | Spacing |\n| `thumbnailAspectRatio` | string | E.g. `'4:3'` |\n| `playButtonIcon` | `'play' \\| 'eye' \\| 'document' \\| 'none'` | Hover icon |\n| `playButtonColor`, `playButtonSize`, `playButtonBg`, `playButtonShape` | mixed | Play button styling |\n| `hoverOverlayColor` | hex | Hover overlay |\n| `playButtonAlwaysShow` | bool | Don't require hover |\n| `carouselAutoplay`, `carouselAutoplaySpeed`, `carouselLoop`, `carouselArrows`, `carouselDots`, `slidesPerView` | mixed | Carousel options (Glider) |\n| Viewer settings (`viewerStyle`, `themeMode`, `customColor`, `toolbar`, `position`, `presentation`, `download`, `copy_text`, \u2026) | mixed | Same as PDF block \u2014 applied to lightbox |\n| **Pro:** `watermarkText`, `watermarkFontSize`, `watermarkColor`, `watermarkOpacity` | mixed | Watermark in lightbox |\n| `powered_by` | bool | Show \"Powered by EmbedPress\" credit |\n\n## Thumbnail pipeline\n\n`Pdf_Thumbnail_Handler.php` exposes two AJAX actions registered in `BlockManager.php`:\n\n- `ep_generate_pdf_thumbnail` \u2014 server-side PDF page 1 \u2192 image:\n 1. Look up WP-managed attachment preview if attachment exists.\n 2. Imagick fallback (`Imagick->setImageFormat('jpeg')`).\n- `ep_upload_pdf_thumbnail` \u2014 accept user-uploaded custom thumbnail \u2192 store as attachment, return URL/id.\n\nIf both fail, the card falls back to the play button icon over the layout's default background.\n\n## Lightbox modal\n\n`static/js/ep-pdf-lightbox.js` listens for clicks on `.ep-pdf-gallery-item`, reads the gallery block's viewer attrs from `data-` attributes, and renders the same iframe the standalone PDF block would. All viewer-side features (watermark, branding, content protection \u2014 Pro) work the same way.\n\n## Free vs Pro\n\n| Capability | Free | Pro |\n|---|---|---|\n| All four layouts | \u2713 | \u2713 |\n| Custom thumbnails | \u2713 | \u2713 |\n| Carousel autoplay/arrows/dots | \u2713 | \u2713 |\n| Watermark (text only) | \u2713 | \u2013 |\n| Watermark (full options) | \u2013 | \u2713 |\n| Custom logo overlay | \u2013 | \u2713 |\n| Content protection | \u2013 | \u2713 |\n\n## Common pitfalls\n\n- **Imagick may not be installed.** Without Imagick, server-side thumbnail fallback fails \u2014 cards show icon-only fallback. Don't add a hard error; degrade silently.\n- **`pdfItems` is an array of objects, not just URLs.** When migrating attributes, preserve `customThumbnailId`/`customThumbnailUrl` to avoid regenerating thumbnails.\n- **Carousel uses Glider.** Glider has known limitations on touch \u2014 test on real mobile, not just dev tools.\n- **Lightbox inherits viewer attrs from the gallery block, not from the picked PDF.** Setting toolbar position on the gallery affects every PDF opened via lightbox uniformly. There's no per-item viewer override.\n- **Open-at-page in the lightbox is fixed at 1** \u2014 `pageNumber` from the gallery block is not passed through.\n- **Bookshelf layout is purely CSS-based** \u2014 no special JS. If books look squished, check `thumbnailAspectRatio`.\n- **Per-item `playButtonIcon='none'`** doesn't exist \u2014 that toggle is gallery-wide. Hide via custom CSS scoped by item id.\n\n## Testing\n\n- **Smoke**: insert PDF Gallery block, add 3 PDFs, layout=grid \u2192 3 cards with thumbnails.\n- **Custom thumbnail**: upload custom image to one item \u2192 that card uses the custom image.\n- **Lightbox**: click any card \u2192 modal opens with PDF.js viewer.\n- **Carousel**: switch layout to carousel \u2192 arrows + dots work; autoplay rotates.\n- **Imagick missing**: simulate by removing imagick extension \u2192 server thumbnails fall back to icon view, no JS error.\n- **Pro lightbox watermark**: with Pro, set watermark \u2192 appears in opened PDF.\n", "features/pdf.md": "\n# PDF Embedder + 3D Flipbook\n\nRenders PDFs via the bundled Mozilla PDF.js viewer or, optionally, a 3D flipbook (Three.js + html2canvas). Both modes share the same Gutenberg block (`embedpress/embedpress-pdf`) and Elementor widget.\n\n## Two render modes inside one block\n\n- **PDF.js modern viewer** \u2014 `viewerStyle='modern'`. EmbedPress's themed wrapper around Mozilla's PDF.js. Color picker, toolbar config (download / copy / draw / etc.), open-at-page support.\n- **3D flipbook** \u2014 `viewerStyle='flip-book'` + `presentation=true`. Uses `3dflipbook.min.js` + Three.js + html2canvas + a separate PDF.js for page rasterization. Iframe-rendered through an admin-ajax endpoint.\n\n## Free vs Pro\n\n| Capability | Free | Pro |\n|---|---|---|\n| PDF.js viewer + theme + color picker | \u2713 | \u2713 |\n| 3D flipbook presentation mode (basic) | \u2713 | \u2713 |\n| Toolbar config + open-at-page | \u2713 | \u2713 |\n| Basic watermark text | \u2713 | \u2713 |\n| Watermark with full options (font size, opacity, color) | \u2013 | \u2713 |\n| Custom branding logo overlay | \u2013 | \u2713 |\n| Content protection (password + role) | \u2013 | \u2713 |\n| Advanced flipbook sound effects | \u2013 | \u2713 |\n\n## Architecture\n\n```\n Block save/Editor \u2192 block.json + components/edit.js + save.js\n \u2502\n \u25bc\n Frontend render \u2014 render_embedpress_pdf()\n EmbedPressBlockRenderer.php:410\n \u2502\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25bc \u25bc \u25bc\n viewerStyle='modern' viewerStyle='flip-book' Pro filters:\n <iframe src= + presentation=true: - watermark\n static/pdf/web/ admin-ajax.php?action= - custom logo overlay\n viewer.html?file=\u2026& get_flipbook_viewer& - content protection gate\n pageNumber=\u2026> key=<base64-params>\n \u2502\n \u25bc\n static/pdf-flip-book/\n js/3dflipbook.min.js\n + js/three.min.js\n + js/html2canvas.min.js\n + js/pdf.min.js\n```\n\n## Code paths\n\n### Block + widget\n\n| File | Role |\n|---|---|\n| `src/Blocks/embedpress-pdf/block.json` | Block manifest |\n| `src/Blocks/embedpress-pdf/src/index.js` | Block registration |\n| `src/Blocks/embedpress-pdf/src/components/attributes.js` | Full attribute schema |\n| `src/Blocks/embedpress-pdf/src/components/edit.js` | Live editor |\n| `src/Blocks/embedpress-pdf/src/components/save.js` | Block save HTML |\n| `src/Blocks/embedpress-pdf/src/inspector.js` | Inspector panel |\n| `EmbedPress/Elementor/Widgets/Embedpress_Pdf.php` | Elementor widget |\n| `EmbedPress/Gutenberg/EmbedPressBlockRenderer.php::render_embedpress_pdf` (~L410) | Server render |\n| `EmbedPress/Gutenberg/EmbedPressBlockRenderer.php` (~L541-548) | Iframe `title` derived from filename (a11y) |\n| `EmbedPress/Shortcode.php` (~L1462-1466) | Flipbook URL assembly |\n| `EmbedPress/Shortcode.php::getParamData` (~L1257) | Base64-encoded `&key=` for flipbook params |\n| `EmbedPress/Includes/Classes/Helper.php::get_flipbook_renderer` | Returns `admin-ajax.php?action=get_flipbook_viewer` |\n\n### Frontend assets\n\n| Path | Role |\n|---|---|\n| `static/pdf/web/` | Bundled Mozilla PDF.js viewer (minified) |\n| `static/pdf/web/ep-scripts.js` (~L22, L48) | Reads `hashParams.get('key')` (flipbook params) and `pageNumber`; sets theme + watermark color |\n| `static/pdf/build/pdf.worker.js` | PDF.js worker |\n| `assets/js/vendor/pdfobject.js` | PDFObject fallback for browsers without iframe PDF support |\n| `static/pdf-flip-book/js/3dflipbook.min.js` | 3D flipbook renderer (minified \u2014 no source) |\n| `static/pdf-flip-book/js/three.min.js` | Three.js |\n| `static/pdf-flip-book/js/html2canvas.min.js` | Page \u2192 canvas |\n| `static/pdf-flip-book/js/pdf.min.js + pdf.worker.js` | Separate PDF.js for flipbook |\n| `static/pdf-flip-book/js/default-book-view.js` | jQuery init for flipbook UI controls |\n\n## Block attributes\n\n| Attribute | Type | Purpose |\n|---|---|---|\n| `href` | string | PDF URL |\n| `mime` | string | File MIME type |\n| `viewerStyle` | `'modern' \\| 'flip-book'` | Render branch |\n| `presentation` | bool (default true) | When `viewerStyle='flip-book'`, enables 3D presentation |\n| `pageNumber` | number (default 1) | **Open-at-page** (added 4.5.0). Passed via URL param to PDF.js viewer |\n| `themeMode` | `'default' \\| 'custom'` | Toolbar theme |\n| `customColor` | hex | Toolbar color |\n| `toolbar` | bool | Toolbar visibility |\n| `position` | `'top' \\| 'bottom'` | PDF.js toolbar position |\n| `flipbook_toolbar_position` | `'top' \\| 'bottom'` | Flipbook-specific toolbar position |\n| `download`, `copy_text`, `add_text`, `draw`, `add_image` | bool | Toolbar action toggles |\n| `zoomIn`, `zoomOut`, `fitView`, `bookmark`, `doc_rotation` | bool | Nav/zoom toggles |\n| `sound` | string | Flipbook page-flip sound |\n| **Pro:** `watermarkText`, `watermarkFontSize`, `watermarkColor`, `watermarkOpacity` | mixed | Watermark |\n| **Pro:** `customlogo`, `customlogoUrl`, `logoX`, `logoY`, `logoOpacity` | mixed | Custom branding overlay |\n| **Pro:** `lockContent`, `protectionType`, `contentPassword`, \u2026 | mixed | Content protection |\n\n## Open-at-page\n\n`pageNumber` block attribute \u2192 URL hash on the viewer iframe \u2192 `static/pdf/web/ep-scripts.js`:\n\n```js\npageNumber: hashParams.get('pageNumber')\n```\n\nPDF.js reads this and jumps the document on initial render. In flipbook mode, the value is included in the base64-encoded `key` payload and decoded by the flipbook renderer.\n\n## Common pitfalls\n\n- **Flipbook minified library is opaque.** `3dflipbook.min.js` ships without a source map. Bug-hunting requires reading the minified source or reproducing on `3dflipbook.net` demo.\n- **Two separate PDF.js bundles.** PDF.js modern viewer at `static/pdf/web/` and a separate PDF.js for flipbook at `static/pdf-flip-book/js/`. Don't unify without testing both modes \u2014 different versions / configs.\n- **Flipbook params are base64-encoded.** Don't dump the `key=` URL param to logs raw.\n- **Flipbook routed through `admin-ajax.php`** with action `get_flipbook_viewer`. If admin-ajax is blocked / aggressively cached, the flipbook iframe won't load.\n- **`viewerStyle='flip-book'` + `presentation=false`** is valid but odd \u2014 falls back to PDF.js with flipbook-toolbar styles. Prefer `presentation=true` for actual 3D mode.\n- **PDFObject fallback** is used by some legacy edge cases. Don't remove `assets/js/vendor/pdfobject.js` even if it looks unused.\n- **Iframe sandbox** for the PDF iframe inherits Shortcode.php's wrapper sandbox \u2014 tightening that sandbox can break PDF.js's drawing/text-selection (`allow-scripts` is required).\n- **Watermark + flipbook together** \u2014 watermark text is decoded from `key` in `ep-scripts.js` and overlaid via CSS. If your custom watermark isn't appearing, check `key` was generated with watermark fields populated.\n- **Block save() changes break old posts** \u2014 see [Gutenberg deprecation discipline](../gutenberg/README.md#deprecation-discipline). Add a `deprecated[]` entry before changing PDF block save().\n\n## Testing\n\n- **Smoke (modern)**: embed PDF, set `viewerStyle='modern'` \u2192 PDF.js viewer renders, toolbar shows configured items.\n- **Open-at-page**: set `pageNumber=5` \u2192 PDF opens at page 5.\n- **Smoke (flipbook)**: set `viewerStyle='flip-book'` + `presentation=true` \u2192 3D book renders.\n- **A11y**: inspect iframe \u2192 `title` attribute present.\n- **Pro watermark**: with Pro active, set watermark text + color \u2192 renders over both modes.\n- **Toolbar position**: switch `position` between top/bottom.\n", "features/social-share.md": "\n# Social Share\n\nPer-block toggleable share buttons (Facebook, X/Twitter, Pinterest, LinkedIn) rendered around any embed. **Fully free**, no Pro gating.\n\nImplemented as inline SVG icons in `target=\"_blank\"` anchors pointing at each platform's intent URL. No JS dependencies, no popup window code, no third-party SDKs.\n\n## Configuration\n\nPer embed: toggle on/off, position (top / right / bottom / left), per-platform toggles, custom title and description fields that override the post's defaults for richer share metadata.\n\n## Architecture\n\n```\n Block save()\n contentShare: bool\n sharePosition: 'top' | 'right' | 'bottom' | 'left'\n customTitle, customDescription\n shareFacebook | shareTwitter | sharePinterest | shareLinkedin: bool\n \u2502\n \u25bc\n src/Blocks/GlobalCoponents/social-share-html.js\n Builds <div class=\"ep-social-share share-position-{pos}\">\n For each enabled platform:\n <a href=\"<intent-url>\" target=\"_blank\">\n <svg .platform-{name}/>\n </a>\n \u2502\n \u25bc\n Frontend renders directly \u2014 no JS event handler beyond default link behavior\n \u2502\n \u25bc\n CSS: assets/css/embedpress.css ~L1390-1470\n```\n\n## Code paths\n\n| File | Role |\n|---|---|\n| `src/Blocks/GlobalCoponents/social-share-control.js` | Inspector controls \u2014 toggle, position, customTitle, customDescription, per-platform toggles |\n| `src/Blocks/GlobalCoponents/social-share-html.js` | HTML builder \u2014 renders the share div with inline SVG icons |\n| `src/Blocks/<provider>/src/components/attributes.js` | Each provider block re-declares the share attributes (defaults vary, e.g. `shareFacebook:true`, `sharePosition:'right'`) |\n| `assets/css/embedpress.css` (~L1390-1470) | Layout + per-platform fill colors |\n\n## Block attributes (per host block)\n\n| Attribute | Type | Default | Purpose |\n|---|---|---|---|\n| `contentShare` | bool | false | Master toggle |\n| `sharePosition` | `'top' \\| 'right' \\| 'bottom' \\| 'left'` | `'right'` | Where buttons appear |\n| `customTitle` | string | \u2014 | Override post title in share |\n| `customDescription` | string | \u2014 | Override description |\n| `shareFacebook` | bool | true | Show FB button |\n| `shareTwitter` | bool | true | Show X button |\n| `sharePinterest` | bool | true | Show Pinterest button |\n| `shareLinkedin` | bool | true | Show LinkedIn button |\n\n## Share URLs\n\n- **Facebook**: `https://www.facebook.com/sharer/sharer.php?u=<post-url>&t=<title>`\n- **X/Twitter**: `https://twitter.com/intent/tweet?url=<post-url>&text=<title>`\n- **Pinterest**: `https://www.pinterest.com/pin/create/button/?url=<post-url>&description=<description>&media=<image>`\n- **LinkedIn**: `https://www.linkedin.com/shareArticle?mini=true&url=<post-url>&title=<title>&summary=<description>`\n\nAll components are URL-encoded inside `social-share-html.js`.\n\n## CSS palette\n\n| Class | Color |\n|---|---|\n| `.facebook { fill: #475a96 }` | FB blue |\n| `.twitter { fill: #1DA1F2 }` | Legacy bird blue (pre-X rebrand) |\n| `.pinterest` | Pinterest red |\n| `.linkedin` | LinkedIn blue |\n\n`.ep-social-share` is a flex row/column depending on `share-position-*`.\n\n## Common pitfalls\n\n- **Twitter color is the legacy bird blue.** Update if/when re-branding to X (black).\n- **No popup window code.** Buttons are plain `target=\"_blank\"` links. Opening a `window.open(...)` style popup is a feature add \u2014 current code does nothing JS-side.\n- **Pinterest needs an image URL.** The `media` param requires the embed thumbnail; without it, Pinterest's modal asks the user to pick. The host block must expose a thumbnail.\n- **Custom title/description fall back to the post's title** if not set. The fallback is in `social-share-html.js`, not server-side \u2014 view source if customers report wrong text being shared.\n- **Position class is rendered into HTML** (`.share-position-right` etc.). Don't change class names without updating the CSS file.\n- **Each provider block redeclares share attributes** in its own `attributes.js`. Adding a 5th platform (e.g., WhatsApp) requires touching every provider block + the global JS module.\n- **No analytics integration.** Clicks are not tracked.\n- **No preview in the editor.** The Inspector shows toggles, but the editor canvas may not render the buttons depending on the host block's render path. Test in the frontend, not just the editor.\n\n## Testing\n\n- **Smoke**: enable share on a YouTube embed, position right \u2192 4 buttons appear right of the iframe.\n- **Click**: each button opens the correct intent URL in a new tab with the post URL pre-filled.\n- **Custom title**: set `customTitle='X'` \u2192 share dialog uses 'X' instead of post title.\n- **Per-platform**: disable Pinterest only \u2192 3 buttons render.\n- **Position**: switch positions, verify CSS rearranges.\n", "features/wrapper.md": "\n# Universal Wrapper\n\nThe fallback provider. Lets users embed any URL when no specific provider matches. Implemented as `EmbedPress\\Providers\\Wrapper` with a permissive URL regex; rendered as a sandboxed iframe.\n\n## Why we ship it\n\nCustomers paste arbitrary URLs constantly. Without a fallback, EmbedPress would say \"we don't support that source\" and the user would leave. Wrapper catches everything and tries to render *something* useful \u2014 usually a sandboxed iframe pointing at the URL.\n\n## Architecture\n\n```\n User pastes URL \u2192 oEmbed pipeline\n \u2502\n \u25bc\n add_filter('oembed_providers', addOEmbedProviders)\n (Core.php:137 \u2014 registers Wrapper LAST)\n \u2502\n \u25bc\n ProviderAdapter::match(url) iterates registered providers\n \u2502\n \u25bc\n No specific provider matches\n \u2502\n \u25bc\n Wrapper.php:43-46 regex matches:\n /^(https?:\\/\\/)?(www\\.)?[a-z0-9]+([\\-\\.]{1}[a-z0-9]+)*\\.[a-z]{2,5}(:[0-9]{1,5})?(\\/.*)?$/i\n \u2502\n \u25bc\n modifyResponse() returns fake oEmbed payload (Wrapper.php:68-71)\n with empty html \u2014 Shortcode.php replaces with sandboxed iframe\n \u2502\n \u25bc\n <div class=\"embedpress-wrapper ose-embedpress-responsive\">\n <iframe src=\"<url>\" sandbox=\"\u2026\" allowFullScreen></iframe>\n </div>\n```\n\n## Code paths\n\n| File | Role |\n|---|---|\n| `EmbedPress/Providers/Wrapper.php` | Provider class. Match regex (~L43-46). Fake oEmbed response (~L68-71). |\n| `EmbedPress/Core.php` (~L137) | `add_filter('oembed_providers', [$this, 'addOEmbedProviders'])` \u2014 registers Wrapper at the end |\n| `EmbedPress/Shortcode.php` (~L300-400) | Renders the iframe with sandbox attributes |\n| `assets/css/embedpress.css` (~L1360+) | `.embedpress-wrapper` + `.ose-embedpress-responsive` responsive container |\n\n## Output\n\nDefault sandbox: `allow-modals allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox`. Plus `allowFullScreen=\"true\"` and a configurable width/height wrapper.\n\n```html\n<div class=\"embedpress-wrapper ose-embedpress-responsive\">\n <iframe src=\"<URL>\"\n width=\"600\" height=\"400\"\n sandbox=\"allow-modals allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox\"\n allowfullscreen></iframe>\n</div>\n```\n\nThe wrapper **does not** strip `X-Frame-Options` headers (it can't \u2014 that's a browser-enforced rule). If the target host responds `DENY` or `SAMEORIGIN`, the iframe renders blank with no useful error.\n\n## Common pitfalls\n\n- **`X-Frame-Options: DENY` / `SAMEORIGIN` headers** are common on news sites, banking, social media. Wrapper can't bypass them. When customers report \"the embed is blank\", check the target's response headers before diagnosing further.\n- **CSP `frame-ancestors`** is the modern equivalent and is just as binding.\n- **The match regex is permissive** (any `http(s)://host/.../...`). Deliberate \u2014 it's the fallback. But malformed URLs sometimes match and produce broken iframes. Don't tighten without checking the test suite.\n- **Wrapper is registered LAST.** If you accidentally register a new specific provider AFTER Wrapper in `Core.php:137`, that provider will never match. Always insert above the Wrapper line.\n- **`modifyResponse()` returns empty HTML** \u2014 the actual iframe is built by `Shortcode.php`. Don't try to inject markup into the oEmbed payload.\n- **No proxy / no SSR.** EmbedPress doesn't fetch and re-serve the URL \u2014 it's pure client-side iframing. Don't recommend Wrapper for sites that require auth.\n- **Sandbox is wrapper-wide.** The same sandbox is used for every Wrapper embed. Tightening it (removing `allow-scripts`) breaks most pages; loosening it (`allow-top-navigation`) is risky. Leave it alone unless responding to a specific report.\n\n## When to graduate to a real provider\n\nIf you find yourself supporting many URLs from one host through Wrapper, that host deserves a real provider. The signs:\n\n- Customers report the iframe doesn't load \u2192 upstream blocks framing \u2192 you need a real provider that uses their official embed endpoint.\n- Customers want platform-specific controls (autoplay, color, \u2026).\n\n## Testing\n\n- **Frame-friendly site**: paste e.g. `https://example.com/` \u2192 renders inside wrapper.\n- **Frame-blocked site**: paste `https://www.google.com/` \u2192 iframe blank (Google sets `X-Frame-Options`). Expected, not a bug.\n- **Custom dimensions**: set wrapper width/height \u2192 iframe respects them.\n- **Sandbox**: inspect rendered iframe \u2192 expected sandbox tokens present.\n", "guides/build-pipeline.md": "\n# Build & Asset Pipeline\n\nHow `src/` becomes `assets/`.\n\n## Tools\n\n- **Vite** for JS/TS/SCSS bundling.\n- **Composer** for PHP autoload + vendor libs.\n- **WordPress build hooks** are NOT used \u2014 we use Vite, not `@wordpress/scripts`, because we have non-block bundles (admin UI, frontend tracker).\n\n## Commands\n\n```bash\nnpm install # install deps (Node \u2265 20, npm \u2265 10)\nnpm run start # vite build --watch\nnpm run build # production build \u2192 assets/\nnpm run type-check # tsc --noEmit\nnpm run lint # eslint src/\nnpm run pot # regenerate languages/embedpress.pot\n```\n\n## What's committed\n\n| Path | Committed? | Why |\n|---|---|---|\n| `src/` | \u2713 | Source of truth |\n| `assets/` | \u2713 | WP.org users don't run `npm` |\n| `static/` | \u2713 | Vendored libs (PDF.js, flipbook, Plyr) |\n| `vendor/` | \u2713 | Composer for WP.org |\n| `node_modules/` | \u2717 | Reproducible from `package-lock.json` |\n\n## Vite entry points\n\n`vite.config.js` defines multiple entry points:\n\n```\nsrc/Blocks/index.js \u2192 assets/blocks.build.js\nsrc/Frontend/index.js \u2192 assets/embedpress.build.js\nsrc/Analytics/tracker.js \u2192 assets/analytics.build.js\nsrc/Analytics/dashboard.js \u2192 assets/analytics-dashboard.build.js\nsrc/AdminUI/index.js \u2192 assets/admin.build.js\n\u2026\n```\n\nEach block's individual editor JS is built into a per-block bundle (`assets/<block-name>.build.js`).\n\n## Asset enqueuing\n\n`Core/AssetManager.php` is the central enqueue point. Most bundles are enqueued **conditionally**:\n\n- Block editor JS \u2014 only on the post edit screen\n- Frontend player runtime \u2014 only when an embed marker class is in the page\n- Analytics tracker \u2014 only when analytics is enabled and embeds exist\n- Admin UI \u2014 only on EmbedPress admin pages\n\nThis keeps page weight low for sites that don't use a particular feature.\n\n## Versioning\n\nBundle URLs include `?ver=EMBEDPRESS_PLUGIN_VERSION` for cache busting. **The version constant must be bumped on release** or browsers will serve cached old bundles.\n\nFor development, `define('EMBEDPRESS_DEV_MODE', true)` in `wp-config.php` swaps the version for `time()`, defeating the cache.\n\n## i18n\n\n`npm run pot` runs `wp i18n make-pot` (or equivalent) to extract translatable strings from PHP and JS. Output: `languages/embedpress.pot`. Translation files (`embedpress-<locale>.po`/`mo`) come from WP.org's translation platform.\n\n## Bundle size\n\nWatch the build output. Bundles over ~200KB raw should be reviewed \u2014 PDF.js and 3D flipbook are bigger because we vendor them. New runtime code should aim small. If a bundle grows substantially, profile imports for tree-shaking opportunities.\n", "guides/coding-standards.md": "\n# Coding Standards\n\nThe conventions we hold ourselves to.\n\n## PHP\n\n- **PSR-style autoload** (`AutoLoader.php`). One class per file, file name matches class name.\n- **Namespace**: `EmbedPress\\\u2026` for the plugin, `Embedpress\\Pro\\\u2026` for Pro.\n- **WPCS** (WordPress Coding Standards) checked via `phpcs.xml`. Run `composer run-script phpcs` (or via `make lint-php`).\n- **No raw `print_r` / `var_dump`** in committed code. Use `error_log` for one-off debugging and remove before merging.\n- **Always escape on output**: `esc_html`, `esc_attr`, `esc_url`, `wp_kses`. Even data you \"trust.\"\n- **Always sanitize on input**: `sanitize_text_field`, `absint`, `esc_url_raw` (for storage).\n- **Translate all user-facing strings**: `__('Text', 'embedpress')`. Don't concatenate translated strings \u2014 use placeholders.\n\n## JavaScript / TypeScript\n\n- **ESLint** config at repo root. Run `npm run lint`.\n- **TypeScript** for new code in `src/`. JS-only files exist for legacy reasons.\n- **React functional components** with hooks. No class components.\n- **No `any`** without an explanatory comment.\n- **Prefer pure functions** over class state for utilities.\n\n## File naming\n\n| What | Convention | Example |\n|---|---|---|\n| PHP class file | PascalCase, matches class | `Feature_Enhancer.php`, `Youtube.php` |\n| JS source | kebab-case | `custom-player-controls.js` |\n| Block folder | kebab-case | `embedpress-pdf/`, `pdf-gallery/` |\n| CSS | kebab-case | `embedpress.css` |\n| Test files | `*.spec.ts` (Playwright) / `*Test.php` (PHPUnit) | `youtube.spec.ts` |\n\n## Class naming\n\n- **Providers**: `EmbedPress\\Providers\\<ProperName>` \u2014 `Youtube`, `GoogleMaps`, `SelfHosted`.\n- **Services**: `EmbedPress\\<Domain>\\<Service>` \u2014 `Analytics\\Analytics`, `Gutenberg\\BlockManager`.\n- **Helpers**: kept under `EmbedPress\\Includes\\Classes\\` with a descriptive suffix \u2014 `Feature_Enhancer`, `Helper`.\n\n## Security checklist (every PR)\n\n- [ ] All user input sanitized at the boundary.\n- [ ] All output escaped at the print site.\n- [ ] All AJAX / REST calls have a nonce check via `permission_callback`.\n- [ ] No raw SQL \u2014 use `$wpdb->prepare` for everything.\n- [ ] No `eval`, no dynamic `include`/`require` of user-controlled paths.\n- [ ] No bundled libs without verified provenance.\n\n## Performance\n\n- **Don't enqueue scripts globally.** Use marker class detection in `AssetManager` so each runtime only loads when needed.\n- **Cache external HTTP calls.** Transients with 12-hour TTL by default.\n- **No N+1 in admin lists.** When iterating embeds for analytics dashboards, use a single aggregated query.\n- **No synchronous network in the request path.** Provider fetches that can be slow should be cached or queued.\n", "guides/debugging.md": "\n# Debugging & Troubleshooting\n\nPractical tactics for hunting EmbedPress bugs.\n\n## Turn on the right knobs\n\n```php\n// wp-config.php\ndefine('WP_DEBUG', true);\ndefine('WP_DEBUG_LOG', true);\ndefine('WP_DEBUG_DISPLAY', false);\ndefine('SCRIPT_DEBUG', true); // load unminified JS\n\n// Force fresh asset URLs every page load (skip browser cache)\ndefine('EMBEDPRESS_DEV_MODE', true);\n```\n\n`SCRIPT_DEBUG` makes WP load unminified bundles \u2014 much easier to set breakpoints. `EMBEDPRESS_DEV_MODE` swaps `EMBEDPRESS_PLUGIN_VERSION` for `time()` so the version query string changes on every request.\n\n## Tail the debug log\n\n```bash\nmake wp-debug-log\n# or\ntail -f wp-content/debug.log\n```\n\n## Common symptoms \u2192 first place to look\n\n### \"Embed shows up as a plain link, not the expected widget\"\n\n1. Did the URL match a provider? Look at `Core::parseContent` flow \u2014 did it fall through to `Wrapper`?\n2. Is the provider's `validateUrl` regex too strict? Test it with `preg_match` in `wp shell`.\n3. Is the URL on its own line (auto-embed requirement) or wrapped in something else?\n\n### \"Block renders fine in editor but blank on frontend\"\n\n1. Check `render_callback` \u2014 is it returning a string? An empty return = blank output.\n2. Open browser console \u2014 JS error in the player runtime?\n3. Does the marker class exist in DOM? If yes but no JS runs, AssetManager didn't enqueue.\n4. Look in the network tab \u2014 is `embedpress.build.js` 200ing?\n\n### \"Block contains unexpected or invalid content\"\n\nYou changed `save()` output without a `deprecated[]` entry. See [Gutenberg deprecation](../gutenberg/README.md#deprecation-discipline).\n\n### \"Pro feature doesn't activate\"\n\n1. Is Pro plugin active? `wp plugin list`.\n2. Did Pro hook the filter slot? Add a `error_log` to confirm Pro's callback fires.\n3. Is the free filter slot still present? `grep apply_filters EmbedPress/...`.\n\n### \"Custom Player UI doesn't appear for self-hosted format X\"\n\nYou forgot one of the **five `isSelfHostedVideo` regex copies**. See [Custom Player](../features/custom-player.md). Update all five.\n\n### \"Analytics counts wrong\"\n\n1. Is the marker class on the embed wrapper?\n2. Is the tracker bundle enqueued? Check network tab.\n3. Is the REST endpoint returning 200? Look in `wp-content/debug.log` for write failures.\n4. Is there an ad-blocker hitting `/wp-json/embedpress/`? Surprisingly common \u2014 some blocklists target `analytics` paths.\n\n### \"Broken Embeds Detector flags working URLs\"\n\n1. Is the URL same-origin? It shouldn't reach the network probe \u2014 verify `home_url()` / `site_url()` host match logic.\n2. Does the host return non-404 codes (403/429/5xx)? Those should map to `STATUS_UNKNOWN`, not `STATUS_BROKEN`.\n3. Is the User-Agent reaching the host? Some CDNs strip non-Mozilla UAs.\n\nSee the skill notes for the detector's full false-positive history.\n\n## Tracing a render\n\nAdd a temporary `error_log` chain:\n\n```php\n// EmbedPress/Core.php, in parseContent\nerror_log('[EP] parseContent url=' . $url);\nerror_log('[EP] matched provider=' . $providerClass);\nerror_log('[EP] final HTML length=' . strlen($html));\n```\n\nRemove before committing.\n\n## Debugging from `wp shell`\n\n```bash\nmake wp ARG=\"shell\"\n```\n\n```php\n$core = \\EmbedPress\\Core::getInstance();\n$html = $core->parseContent('https://www.youtube.com/watch?v=abc123');\necho $html;\n```\n\nThis is the fastest way to confirm a URL routes correctly without the editor or frontend in the loop.\n\n## Provider regex testing\n\n```bash\nmake wp ARG=\"shell\"\n```\n\n```php\n$url = 'https://acme.example.com/watch/abc';\n$provider = new \\EmbedPress\\Providers\\AcmeVideo($url);\nvar_dump($provider->validateUrl(new \\Embera\\Url($url)));\n```\n\n## Frontend JS debugging\n\n- Use the browser's source map navigation \u2014 built bundles include source maps.\n- `window.embedpressDebug = true` enables a verbose log in the player runtime (recent versions).\n\n## When to escalate\n\nIf a bug reproduces on a clean WP install with only EmbedPress free + Pro active, it's an EmbedPress bug. If it only reproduces with a specific theme or plugin combo, suspect a CSS / JS conflict and start with `Twenty Twenty-Four` + minimal plugin set to isolate.\n", "guides/extending.md": "\n# Extending EmbedPress\n\nCommon extension scenarios with concrete recipes.\n\n## \"I want to add a new embed source\"\n\n\u2192 See [Adding a Provider](../providers/adding-a-provider.md).\n\n## \"I want to add an option to the YouTube block\"\n\nThe shortest path:\n\n1. Add the attribute to `src/Blocks/youtube/block.json`.\n2. Add a control in `src/Blocks/youtube/inspector.js`.\n3. Read it in the YouTube block's render callback in `EmbedPressBlockRenderer.php`.\n4. Forward to `Core::parseContent` as part of `$attrs`.\n5. In `Feature_Enhancer`, hook `embedpress_youtube_params` to translate the attribute into a URL param.\n\n## \"I want a feature to apply to all providers\"\n\nHook `embedpress_render` in `Feature_Enhancer::init()`:\n\n```php\nadd_filter('embedpress_render', function ($html, $url, $atts) {\n if (!empty($atts['my_feature_enabled'])) {\n $html = '<div class=\"my-feature\">' . $html . '</div>';\n }\n return $html;\n}, 10, 3);\n```\n\nThen add the `my_feature_enabled` toggle to whichever surfaces you want it on (block + Elementor + shortcode att).\n\n## \"I want a Pro-only feature\"\n\n1. Add the UI to free wrapped in `pro_class` so it shows as a locked upsell.\n2. Add the **filter slot** in free where the feature would attach.\n3. In Pro, hook the filter and implement the feature.\n4. Pro returns `''` for `embedpress_pro_class` so the lock styling drops on its UI when Pro is active.\n\nThis way, the UI source is single-sourced in free, and the implementation is single-sourced in Pro.\n\n## \"I want to register an additional provider from a custom plugin\"\n\n```php\nadd_action('embedpress_after_register_providers', function ($embera) {\n $embera->addProvider('myservice.com', \\My\\Plugin\\Providers\\MyService::class);\n});\n```\n\nYour provider class must follow the same shape as built-in providers (extend `Embera\\Adapters\\Service`, implement `validateUrl` + `modifyResponse`).\n\n## \"I want to track custom analytics events\"\n\nUse the analytics REST endpoint:\n\n```js\nfetch('/wp-json/embedpress/v1/analytics/track', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': window.wpApiSettings.nonce },\n body: JSON.stringify({ event: 'my_event', embed_id: 123, meta: { \u2026 } }),\n});\n```\n\nFor deeper integration, hook the PHP-side Analytics service.\n\n## \"I want to override how a specific block renders\"\n\nDon't fork the block. Instead:\n\n- Hook `embedpress_render` and short-circuit if `$atts['provider'] === 'youtube'` (or whatever).\n- Return your custom HTML.\n\nThis survives plugin updates.\n\n## What you should not do\n\n- Don't `require` files from the EmbedPress folder structure directly. Paths can change.\n- Don't extend EmbedPress's classes from your plugin. Use filters/actions instead \u2014 extension breaks on internal refactors.\n- Don't write to EmbedPress's option keys. Use a prefix of your own.\n", "guides/hooks-and-filters.md": "\n# Hooks & Filters Reference\n\nThe actions and filters EmbedPress fires that you (or Pro) can hook.\n\n> The hook names below were verified against `EmbedPress/Includes/Classes/Feature_Enhancer.php`, `EmbedPress/Shortcode.php`, `EmbedPress/Core.php`, and `embedpress-pro/includes/Filters/*.php`. Note the inconsistent naming style \u2014 historical reasons; do not \"normalize\" without coordinating with Pro.\n\n## Naming style\n\nThree styles coexist:\n\n- **Colon-separated** (oldest): `embedpress:onAfterEmbed`, `embedpress:onBeforeEmbed`, `embedpress:isEmbra`\n- **Slash-separated** (Pro extension hooks): `embedpress/pro_class`, `embedpress/pro_text`, `embedpress/is_allow_rander`, `embedpress/generate_ad_template`, `embedpress/display_password_form`, `embedpress/content_protection_content`, `embedpress/instafeed_reaction_count`, `embedpress/calendly_event_data`, `embedpress/elementor_enhancer_<provider>`\n- **Underscore-separated** (newer feature hooks): `embedpress_excluded_height_sources`, `embedpress_should_modify_spotify`, `embedpress_additional_service_providers`, `embedpress_gutenberg_youtube_params`, `embedpress_elementor_embed`\n\nUse the existing style of the area you're hooking \u2014 don't introduce a fourth.\n\n## Filters \u2014 embed pipeline\n\n### `embedpress:isEmbra`\n**Where:** `Shortcode::parseContent`, line 304.\n**Signature:** `apply_filters('embedpress:isEmbra', $isEmbra, $url, $atts)`\n**Purpose:** Decide whether the URL should be routed to a custom Provider class (returns true) or to WordPress's native `WP_oEmbed::fetch` (returns false).\n**Hooked by:** `Feature_Enhancer::isEmbra()` returns true for YouTube, TikTok, Spreaker, GooglePhotos, and the Wrapper fallback.\n\n### `embedpress:onBeforeEmbed`\n**Where:** `Shortcode::parseContent`, line 336.\n**Purpose:** Pre-embed processing \u2014 mutate the request before the provider builds its HTML.\n\n### `embedpress:onAfterEmbed`\n**Where:** `Shortcode::parseContent`, line 540.\n**Priority:** Feature_Enhancer hooks at priority 90.\n**Purpose:** Decorate the rendered HTML \u2014 the central extension point.\n**Hooked by:** `Feature_Enhancer::enhance_youtube`, `enhance_vimeo`, `enhance_twitch`, `enhance_dailymotion`, `enhance_soundcloud`, `enhance_missing_title`. Pro also hooks here via `Feature_Enhancer_Pro` and per-provider extenders.\n\n```php\nadd_filter('embedpress:onAfterEmbed', function ($html, $url, $atts) {\n return '<div class=\"my-wrap\">' . $html . '</div>';\n}, 100, 3);\n```\n\n## Filters \u2014 provider registry\n\n### `embedpress_additional_service_providers`\n**Where:** `Core::getAdditionalServiceProviders`, line 763.\n**Purpose:** Add or modify the URL\u2192Provider class registry.\n\n```php\nadd_filter('embedpress_additional_service_providers', function ($providers) {\n $providers[\\EmbedPress\\Providers\\My_Provider::class] = ['myhost.com', '*.myhost.com'];\n return $providers;\n});\n```\n\n## Filters \u2014 Pro UI gating\n\nThese return strings that Pro flips to empty when active.\n\n### `embedpress/pro_class`\n**Default (free):** returns `'pro_class'` so the wrapping element gets locked styling.\n**Pro callback:** `Embedpress\\Pro\\Filters\\Utility` returns `''`.\n\n### `embedpress/pro_text`\n**Default:** returns `'Pro'` (the badge text).\n**Pro callback:** returns `''`.\n\n### `embedpress/pro_label`\nSimilar \u2014 Pro replaces with empty.\n\n### `embedpress/is_allow_rander`\n**Purpose:** Should this embed render at all? Used by content protection / license gating to suppress rendering.\n\n## Filters \u2014 Pro feature slots\n\nThese are real hooks Pro implements. Free defines the slot via `apply_filters` so Pro can fill it; without Pro, the default is a no-op or upsell stub.\n\n| Filter | Where applied (free) | Pro callback |\n|---|---|---|\n| `embedpress/generate_ad_template` | Showcase Ads template emission | `Filters\\Utility::generate_ad_template` |\n| `embedpress/display_password_form` | Content Protection password gate | `Filters\\Utility::display_password_form` |\n| `embedpress/content_protection_content` | Content Protection content swap | `Filters\\Utility::content_protection_content` |\n| `embedpress/instafeed_reaction_count` | Instagram feed reactions | `Filters\\Utility` |\n| `embedpress/instafeed_tab_option` | Instagram feed tab option | `Filters\\Utility` |\n| `embedpress/calendly_event_data` | Calendly event sync | `Filters\\Calendly` |\n| `embedpress/calendly_sync_button` | Calendly sync UI | `Filters\\Calendly` |\n| `embedpress/calendly_connect_text_label` | Calendly connect label | `Filters\\Calendly` |\n| `embedpress/connected_text_label` | Calendly connected state | `Filters\\Calendly` |\n| `embedpress_google_photos_attributes` | Google Photos attribute map | `Filters\\GooglePhotos` |\n| `embedpress_google_helper_shortcode` | Google Calendar shortcode | `Filters\\Calendar` |\n| `embedpress/elementor_enhancer_youtube` | Elementor YT enhancements | `Filters\\Elementor_Enhancer_Pro` |\n| `embedpress/elementor_enhancer_vimeo` | Elementor Vimeo | same |\n| `embedpress/elementor_enhancer_wistia` | Elementor Wistia | same |\n| `embedpress/elementor_enhancer_soundcloud` | Elementor SoundCloud | same |\n| `embedpress/elementor_enhancer_dailymotion` | Elementor Dailymotion | same |\n| `embedpress/elementor_enhancer_twitch` | Elementor Twitch | same |\n| `embedpress_enhance_soundcloud` | SoundCloud enhancement | `Filters\\Feature_Enhancer_Pro` |\n| `embedpress_enhance_dailymotion` | Dailymotion enhancement | `Filters\\Feature_Enhancer_Pro` |\n| `embedpress_wistia_block_attributes` | Wistia block attribute extension | `Filters\\Feature_Enhancer_Pro` |\n\n## Filters \u2014 block / shortcode tweaks\n\n### `embedpress_gutenberg_youtube_params`\n**Purpose:** Mutate YouTube URL params for Gutenberg renders.\n\n### `embedpress_elementor_embed`\n**Where:** `Embedpress_Elementor::render`, around line 4535.\n**Purpose:** Decorate the rendered embed object during Elementor render.\n\n### `embedpress_excluded_height_sources`\n**Where:** `embedpress.php`.\n**Purpose:** List of provider aliases for which we should *not* force a height \u2014 defaults include `opensea` and `google-photos`.\n\n### `embedpress_should_modify_spotify`\n**Where:** `Shortcode::parseContent`.\n**Purpose:** Conditionally wrap Spotify embeds with our own theming.\n\n### `embed_apple_podcast`\n**Where:** Shortcode pipeline.\n**Hooked by:** `Embedpress\\Pro\\Filters\\Utility`.\n\n## Actions\n\n### `embedpress_before_init`\n**Where:** Plugin bootstrap.\n**Purpose:** Fires early, before Core/CoreLegacy initialize. Use for very-early registrations.\n\n### `embedpress_cache_cleanup_action`\n**Where:** Scheduled cron event (daily).\n**Purpose:** Trigger cache cleanup of `/wp-content/uploads/embedpress/`.\n\n### `embedpress_gutenberg_embed`\n**Hooked by:** Feature_Enhancer for Gutenberg-specific embed tweaks.\n\n### `embedpress_gutenberg_wistia_block_after_embed`\n**Hooked by:** Feature_Enhancer for Wistia block post-processing.\n\n## REST endpoint (free)\n\nOnly one routes-level hook surface here:\n\n`POST/GET /embedpress/v1/oembed/{provider}` \u2014 `RestAPI::oembed` callback. Internally re-dispatches to `Shortcode::parseContent`.\n\n## How to discover internal hooks\n\n```bash\ngrep -rn \"do_action\\|apply_filters\" EmbedPress/ | grep -v \"^vendor\\|node_modules\"\n```\n\nMany hooks beyond this list exist for narrow purposes; they're not part of the public contract. If you find one you'd like to depend on, file an issue to formalize it.\n\n## Versioning policy\n\n- **Adding a new hook**: never breaks anything.\n- **Adding parameters to an existing hook**: backwards-compatible.\n- **Removing a hook or removing parameters**: breaking change \u2014 only across major versions, and only after Pro and known integrations have migrated.\n", "guides/testing.md": "\n# Testing\n\nThree layers: PHP unit / integration, JS unit, browser E2E.\n\n## PHP\n\n- **Unit**: `make test-php-unit` (PHPUnit, isolated)\n- **Integration**: `make test-php-integration` (boots WP, hits DB)\n\nConfig: `phpunit.xml` at the repo root.\n\n## JS\n\n- **Unit**: `npm run test` (Vitest)\n- **Type-check**: `npm run type-check`\n- **Lint**: `npm run lint`\n\n## Browser E2E (Playwright)\n\nThe big one.\n\n```bash\nnpm run test:e2e # everything\nnpm run test:e2e:gutenberg # block tests\nnpm run test:e2e:elementor # widget tests\nnpm run test:e2e:classic # shortcode + auto-embed tests\nnpm run test:e2e:dashboard # admin / analytics\nnpm run test:e2e:ui # general UI smoke\nnpm run test:e2e:headed # show the browser\n```\n\nSuites are organized under `tests/playwright/<surface>/`. Each provider typically has a `<provider>.spec.ts` per surface.\n\nThere's also a separate repo, `embedpress-playwright-automation`, that mirrors customer-facing scenarios. The inline suite is for daily dev; the standalone repo is for QA / regression sweeps.\n\n## What to test before merging\n\n| Change | Required tests |\n|---|---|\n| New provider | E2E paste in editor + frontend render in **all four** surfaces (block, widget, shortcode, auto-embed) |\n| Block save() change | Gutenberg E2E, including loading a post written before the change to confirm the deprecation entry works |\n| Elementor control | Elementor E2E for that widget |\n| Custom Player change | All Custom Player formats (YT, Vimeo, Wistia, MP4, HLS) |\n| Analytics tweak | Dashboard E2E + at least one tracker write test |\n| Pro feature | Both Pro-active and Pro-inactive states |\n\n## Writing a Playwright test\n\n```ts\nimport { test, expect } from '@playwright/test';\nimport { loginAsAdmin, openNewPost } from '../helpers';\n\ntest('YouTube block renders iframe on frontend', async ({ page }) => {\n await loginAsAdmin(page);\n await openNewPost(page);\n\n // Insert block, set URL\n await page.click('button[aria-label=\"Add block\"]');\n await page.fill('input[placeholder=\"Search blocks\"]', 'YouTube');\n await page.click('button:has-text(\"YouTube\")');\n await page.fill('.ep-url-input', 'https://www.youtube.com/watch?v=abc123');\n\n // Publish + view\n await page.click('button:has-text(\"Publish\")');\n await page.click('button:has-text(\"View Post\")');\n\n // Assert iframe exists\n await expect(page.locator('iframe[src*=\"youtube.com/embed/abc123\"]')).toBeVisible();\n});\n```\n\n## Local-only tests\n\nFor things that need a live external endpoint (Wistia, real YouTube), prefer to skip in CI and mock the HTTP layer. Tests that hit live external services are flaky and slow.\n\n## Regression sweep before release\n\n```bash\nnpm run type-check\nnpm run lint\nnpm run test\nnpm run test:e2e\nmake test-php\n```\n\nIf any of these fail, the release is not ready.\n", "gutenberg/README.md": "# Gutenberg Architecture\n\nHow EmbedPress integrates with the WordPress block editor.\n\n## What ships\n\nEmbedPress contributes a family of blocks under the `embedpress/` namespace, registered via `EmbedPress\\Gutenberg\\BlockManager` (in `EmbedPress/Gutenberg/BlockManager.php`).\n\n### Server-rendered blocks (have a PHP `render_callback`)\n\n| Block name | Render callback (in `EmbedPressBlockRenderer`) | Role |\n|---|---|---|\n| `embedpress/embedpress` | `render` | **Generic / catch-all** for 250+ providers. The main block. |\n| `embedpress/embedpress-pdf` | `render_embedpress_pdf` | PDF.js or 3D flipbook viewer for PDFs |\n| `embedpress/document` | `render_document` | DOC / DOCX / PPT / PPTX / XLS / XLSX viewer |\n| `embedpress/embedpress-calendar` | `render_embedpress_calendar` | Google Calendar |\n| `embedpress/youtube-block` | `render_youtube_block` | Minimal legacy YouTube block |\n| `embedpress/wistia-block` | `render_wistia_block` | Minimal legacy Wistia block |\n| `embedpress/pdf-gallery` | `render_pdf_gallery` | Multi-PDF grid / carousel / bookshelf |\n\n### Client-rendered blocks (no server `render_callback`)\n\nThese have a `block.json` and an `edit.js` but rely on save() output / client iframes:\n\n- `embedpress/google-docs-block`\n- `embedpress/google-sheets-block`\n- `embedpress/google-slides-block`\n- `embedpress/google-forms-block`\n- `embedpress/google-drawings-block`\n- `embedpress/google-maps-block`\n- `embedpress/twitch-block`\n\nThe 250+ providers are not \"one block per provider.\" Most providers are reached through the **generic `embedpress/embedpress` block**. Specialized blocks exist only when the UX needs more than a URL field \u2014 PDFs need viewer toggles + lightbox, documents need format-specific routing, gallery needs multi-item management, calendar needs event display, etc.\n\n## Lifecycle\n\n```\nWordPress init\n \u2514\u2500 EmbedPress\\Gutenberg\\InitBlocks (boot)\n \u2514\u2500 BlockManager\n \u251c\u2500 register every block from src/Blocks/<name>/block.json\n \u251c\u2500 wire each block.json's render_callback to EmbedPressBlockRenderer::<method>\n \u2514\u2500 enqueue editor JS bundles\n```\n\nEditor-side, `edit.js` provides the React UI. On render, WordPress invokes the matching `render_*` callback.\n\n## Block folder layout\n\n```\nsrc/Blocks/<block-name>/\n\u251c\u2500\u2500 block.json \u2190 name, attributes schema, supports\n\u251c\u2500\u2500 index.js \u2190 registerBlockType()\n\u251c\u2500\u2500 edit.js \u2190 editor component\n\u251c\u2500\u2500 save.js \u2190 static save (or null for fully dynamic)\n\u251c\u2500\u2500 inspector.js \u2190 <InspectorControls>\n\u251c\u2500\u2500 style.scss \u2190 frontend styles\n\u2514\u2500\u2500 editor.scss \u2190 editor-only styles\n```\n\n`src/Blocks/EmbedPress/` is the source folder for the generic `embedpress/embedpress` block (note: capital \"EmbedPress\" \u2014 it's not a provider, just the folder name for the catch-all). `src/Blocks/GlobalCoponents/` (misspelled, kept for compat) holds the shared React components reused across blocks: `custom-player-controls.js`, `social-share-control.js`, `lock-control.js`, `custombranding.js`, `ads-template.js`, `ads-control.js`, `embed-wrap.js`, `embed-placeholder.js`, etc.\n\n## Server-side rendering\n\n`EmbedPressBlockRenderer.php` (~28k lines) defines one static method per server-rendered block.\n\nFor the generic block (`render`):\n\n1. Read attributes (URL, client ID, options).\n2. Run protection: `extract_protection_data` + `should_display_content`.\n3. Either render cached `embedHTML` via `render_embed_html`, or:\n4. Fall through to dynamic rendering, which calls **`Shortcode::parseContent($url, true, $atts)`** \u2014 the same pipeline shortcodes and Elementor use.\n\nFor specialized blocks (`render_embedpress_pdf`, `render_document`, etc.), the method builds the iframe directly (PDF.js viewer URL, Google Docs Viewer URL, etc.) without going through `parseContent`, since these don't need provider routing.\n\nAll blocks ultimately converge on `generate_final_html()`, which emits the universal wrapper:\n\n```html\n<div class=\"ep-embed-content-wraper \u2026\" data-options='{\u2026JSON\u2026}'>\n {provider HTML / iframe}\n</div>\n```\n\n## The `data-options` data contract\n\n`build_player_options()` (around line 919 of `EmbedPressBlockRenderer.php`) constructs the JSON consumed by the frontend Custom Player runtime. Real fields include:\n\n```\nrewind, restart, pip, poster_thumbnail, player_color, player_preset,\nfast_forward, player_tooltip, hide_controls, download, fullscreen,\nstart, end, rel, mute, t, vautoplay, autopause, dnt,\nself_hosted, hosted_format\n```\n\nPro extends this contract with engagement / delivery sub-feature fields (chapters, CTA, end screen, country restriction, etc.).\n\n## Frontend rendering\n\nFrontend JS scans for marker classes:\n\n- `.ep-embed-content-wraper[data-options]` \u2014 Custom Player container\n- `.embedpress-pdf` \u2014 PDF viewer\n- `.ep-pdf-gallery` \u2014 Gallery\n- (analytics tracker selectors)\n\nBundles are enqueued conditionally via `Core/AssetManager.php`.\n\n## Inspector hooks (JS)\n\nFree emits placeholder controls via `wp.hooks` so Pro can fill them:\n\n```js\napplyFilters('embedpress.selectPlaceholder', defaultControl, controlName, attributes)\napplyFilters('embedpress.youtubeControls', controls, attributes)\napplyFilters('embedpress.wistiaControls', controls, attributes)\n// ... per provider\n```\n\nWithout Pro, the placeholder is a disabled / upsell control. With Pro, the real control is injected.\n\n## Deprecation discipline\n\n> The single most important rule.\n\nChanging a block's `save()` output requires a `deprecated[]` entry that matches the old output. Otherwise WordPress shows old posts as \"Block contains unexpected or invalid content.\"\n\nPrefer **conditional attribute emission** when possible \u2014 emit a new attribute only when the user explicitly toggles it, so old posts still match the original signature without needing a deprecation.\n\n## Adding a new block\n\n1. `scripts/new-block.sh <block-name>` (from docker repo) scaffolds the folder.\n2. Edit `block.json`.\n3. Implement `edit.js`, `inspector.js`.\n4. Decide: server-rendered (add a `render_*` method to `EmbedPressBlockRenderer` and register in `BlockManager`) or client-rendered (no PHP method needed).\n5. `npm run build`.\n6. E2E test in `tests/playwright/gutenberg/<block>.spec.ts`.\n\n## Common debugging tactics\n\n- **Block won't register**: check `block.json` syntax; check `npm run build` ran. Browser console shows `Invalid block` errors.\n- **Editor preview blank**: render callback returns an empty string. `error_log` inside the callback.\n- **Frontend missing player JS**: marker class not present, or `AssetManager` decided not to enqueue.\n- **\"Block contains unexpected content\"**: missing `deprecated[]` entry.\n", "providers/README.md": "# Provider System\n\nThe Provider system is the heart of EmbedPress. Every embed in the plugin is generated by a Provider class.\n\n## What a Provider is\n\nA **Provider** is a PHP class that knows how to:\n\n1. Recognize that a URL belongs to it (via host pattern matching).\n2. Parse the URL into the parts that matter (video ID, file ID, channel handle\u2026).\n3. Generate the embed HTML \u2014 usually an iframe pointing at the upstream embed endpoint.\n\nEach Provider lives in `EmbedPress/Providers/<Name>.php` and is mapped to one or more host patterns in `providers.php`.\n\n## Why the adapter pattern\n\nEmbedPress originally wrapped the [Embera](https://github.com/mauricius/embera) library, which itself uses an adapter pattern: one adapter per service. We kept that pattern even as we added our own providers because:\n\n- **Isolation**: a bug in the YouTube adapter can't break Vimeo.\n- **Testability**: each adapter is a small unit you can mock.\n- **Clarity**: adding a new source means adding one file, not hunting through a giant switch.\n\n## Anatomy of a Provider\n\nMost providers extend `Embera\\Adapters\\Service`. The minimum interface is:\n\n```php\nnamespace EmbedPress\\Providers;\n\nuse Embera\\Adapters\\Service as EmberaService;\n\nclass ExampleProvider extends EmberaService\n{\n /** Validate this URL belongs here */\n public function validateUrl(\\Embera\\Url $url)\n {\n return preg_match('~example\\.com/watch/[A-Za-z0-9_-]+~', (string) $url);\n }\n\n /** Normalize URL into a canonical embed URL */\n public function normalizeUrl(\\Embera\\Url $url)\n {\n return $url;\n }\n\n /** Build the iframe HTML */\n protected function modifyResponse(array $response = [])\n {\n $response['html'] = sprintf(\n '<iframe src=\"%s\" width=\"%d\" height=\"%d\" frameborder=\"0\" allowfullscreen></iframe>',\n esc_url($this->getEmbedUrl()),\n $this->getWidth(),\n $this->getHeight()\n );\n return $response;\n }\n\n private function getEmbedUrl()\n {\n // \u2026extract ID, build embed URL\u2026\n }\n}\n```\n\nThe `Wrapper` class (`EmbedPress/Providers/Wrapper.php`) is a permissive base used as the **fallback provider** when no specific provider matches. See [Wrapper](../features/wrapper.md).\n\n## How URL \u2192 Provider routing happens\n\n1. User-supplied URL hits `Core::parseContent()`.\n2. Core constructs an Embera engine seeded with `$additionalServiceProviders` from `providers.php`.\n3. Embera iterates registered providers and asks each `validateUrl($url)`.\n4. The first provider that says \"yes\" wins. If none match, the **Wrapper** provider takes the URL (last-resort fallback).\n5. Core calls the matched provider, captures its HTML, and forwards it through the [Feature_Enhancer](../features/feature-enhancer.md) filter pipeline.\n\n> **Greediness warning.** A provider with a too-broad host pattern can steal URLs from neighbors. Always test that your new provider doesn't match `youtube.com/...` or `vimeo.com/...` URLs, and run the E2E provider suite before merging.\n\n## The provider registry: `providers.php`\n\n`providers.php` is a flat associative array:\n\n```php\n$additionalServiceProviders = [\n EMBEDPRESS_NAMESPACE . \"\\\\Providers\\\\Youtube\"\n => [\"youtube.com\"],\n EMBEDPRESS_NAMESPACE . \"\\\\Providers\\\\Wistia\"\n => [\"*.wistia.com\", \"wistia.com\"],\n // \u2026\n];\n```\n\nThe wildcard `*.foo.com` matches any subdomain. EmbedPress also extends Embera's matching to support the wildcard.\n\n`SelfHosted` is special: its host list is the *current site* plus a long list of TLDs (`*.com`, `*.net`, \u2026) so any direct `.mp4` / `.mp3` / `.pdf` URL can be picked up and routed to the self-hosted media renderer.\n\n## What \"providers\" do *not* do\n\n- They don't render UI controls (that's the editor's job).\n- They don't decide whether to apply the Custom Player (Feature_Enhancer does that based on attributes).\n- They don't enqueue assets (Asset Manager does, when a marker class is detected).\n- They don't talk to any analytics or third-party APIs unless that's literally how the provider embeds (e.g., Google Photos requires a fetch to derive the album HTML).\n\nA Provider is a **pure URL \u2192 HTML transformer**. Keep it that way.\n\n## Adding a new provider\n\nSee [Adding a Provider](adding-a-provider.md) for the step-by-step.\n\n## Provider catalog\n\nSee [Provider Catalog](catalog.md) for every shipped provider, the URL patterns it matches, and known quirks.\n", "providers/adding-a-provider.md": "# Adding a New Provider\n\nStep-by-step: ship a new embed source.\n\n## Before you start\n\n1. Confirm the upstream service exposes a public embed URL or oEmbed endpoint. If they require an API key with per-request server calls, you're building a more complex integration \u2014 most providers are pure iframe wrappers.\n2. Check that no existing provider already matches the URLs you'll target. Run `grep -r \"<host>\" EmbedPress/Providers providers.php`.\n3. Decide which **editor surfaces** the new provider needs to appear in \u2014 sometimes you only need shortcode/auto-embed (cheap), sometimes you need a dedicated block (more work).\n\n## The fast path (auto-embed + shortcode only)\n\nUse this when you just want pasted URLs to \"just work\" without a dedicated block or widget.\n\n### Step 1 \u2014 Scaffold\n\nFrom the docker repo:\n\n```bash\nmake shell\nscripts/new-provider.sh AcmeVideo\n```\n\nThis creates `EmbedPress/Providers/AcmeVideo.php` from a template.\n\n(If you don't use the script, copy an existing simple provider like `Calendly.php` or `Boomplay.php` and rename.)\n\n### Step 2 \u2014 Fill in the methods\n\n```php\nnamespace EmbedPress\\Providers;\n\nuse Embera\\Adapters\\Service as EmberaService;\n\nclass AcmeVideo extends EmberaService\n{\n public function validateUrl(\\Embera\\Url $url)\n {\n // Be tight. Reject anything that's not /watch/<id>.\n return preg_match(\n '~^https?://(www\\.)?acmevideo\\.com/watch/(?P<id>[A-Za-z0-9_-]+)~i',\n (string) $url,\n $m\n );\n }\n\n protected function modifyResponse(array $response = [])\n {\n preg_match(\n '~/watch/(?P<id>[A-Za-z0-9_-]+)~',\n (string) $this->url,\n $m\n );\n $id = $m['id'] ?? '';\n\n $embedUrl = sprintf('https://acmevideo.com/embed/%s', $id);\n\n $response['html'] = sprintf(\n '<iframe src=\"%s\" width=\"%d\" height=\"%d\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen></iframe>',\n esc_url($embedUrl),\n $this->getWidth(),\n $this->getHeight()\n );\n $response['type'] = 'video';\n $response['provider_name'] = 'AcmeVideo';\n return $response;\n }\n}\n```\n\n### Step 3 \u2014 Register in `providers.php`\n\n```php\nEMBEDPRESS_NAMESPACE . \"\\\\Providers\\\\AcmeVideo\"\n => [\"acmevideo.com\", \"*.acmevideo.com\"],\n```\n\n### Step 4 \u2014 Smoke test\n\n```bash\nmake wp ARG=\"post create --post_type=post --post_title='Acme test' --post_content='https://acmevideo.com/watch/abc123' --post_status=publish\"\n```\n\nOpen the post in the browser. You should see the iframe.\n\n### Step 5 \u2014 E2E\n\nAdd a test in `tests/playwright/<surface>/providers/acme.spec.ts` covering: paste in editor, verify iframe `src`, verify it loads in frontend.\n\n### Step 6 \u2014 Update docs\n\n- Add an entry to [Provider Catalog](catalog.md).\n- If the provider has unusual quirks, add a section to its catalog entry.\n\nThat's it for the fast path.\n\n## The full path (with a dedicated block)\n\nIf you need a custom block (because the provider needs special controls \u2014 e.g., date pickers for a calendar), additionally:\n\n1. **Scaffold the block**: `scripts/new-block.sh acme-video`\n2. **Implement the block** in `src/Blocks/acme-video/`:\n - `index.js` \u2014 register the block\n - `edit.js` \u2014 editor UI (URL input + provider-specific controls)\n - `save.js` \u2014 output the URL + attributes (use `useBlockProps.save()`)\n - `block.json` \u2014 metadata\n3. **Wire server-side render** in `EmbedPress/Gutenberg/EmbedPressBlockRenderer.php` (add a `case` for the block name; it should call `Core::parseContent` with the block attributes).\n4. **Build**: `npm run build`\n5. **Add an Elementor widget** *only if* customers will use Elementor with this provider. Often the existing `Embedpress_Elementor` widget is enough \u2014 it accepts any URL.\n\n## Pro-only providers\n\nPro providers live in `embedpress-pro/includes/Providers/` and register on `embedpress_pro_register_providers`. Same shape as free, but loaded by Pro's bootstrap.\n\n## Common pitfalls\n\n- **URL regex too greedy.** Use `^https?://` and anchor to the host. Without `^`, your regex will match `https://example.com/?ref=acmevideo.com/watch/x` and steal that URL.\n- **Iframe missing `allowfullscreen`.** Customers will report \"fullscreen doesn't work.\"\n- **Unescaped HTML.** Always wrap user-derived values in `esc_url`, `esc_attr`, `esc_html`. Even if upstream is \"trusted,\" a paste from a phishing URL is not.\n- **Forgetting `provider_name` and `type`** in the response array. Some downstream filters branch on these.\n- **Hardcoded width/height.** Use `$this->getWidth()` / `$this->getHeight()` \u2014 they respect block / widget overrides.\n\n## How to test for greediness\n\nBefore merging, run the full E2E provider suite:\n\n```bash\nnpm run test:e2e -- --grep=\"provider\"\n```\n\nSpecifically watch for failures in YouTube, Vimeo, and Wrapper tests \u2014 those are the most likely victims of an over-broad new regex.\n", "providers/catalog.md": "# Provider Catalog\n\nEvery Provider class shipped with the free plugin and how it's wired in.\n\n> Verified against `EmbedPress/Providers/` (28 PHP files) and `providers.php` (26 active host registrations) on the `4.5.1` baseline. Two files (`Wrapper.php`, `TikTok.php`) exist in the Providers directory but are not registered in `providers.php` \u2014 they're invoked via different paths. **Vimeo is NOT a custom Provider class** \u2014 it's handled by Embera's built-in adapter.\n\n## How to read this catalog\n\nFor each provider:\n\n- **Class** \u2014 the EmbedPress\\Providers\\* file\n- **Hosts in `providers.php`** \u2014 the URL patterns Embera matches against (if registered there)\n- **Notes** \u2014 anything special\n\n## Video / Streaming\n\n| Class | Hosts | Notes |\n|---|---|---|\n| **Youtube** | `youtube.com` | Most-used provider. Routed via `Feature_Enhancer::isEmbra`. Pro extender in `embedpress-pro/includes/Providers/Youtube.php` adds channel/playlist/livechat controls. |\n| **Wistia** | `*.wistia.com`, `wistia.com` | Volume + custom player params via Feature_Enhancer. Pro extender adds advanced controls. |\n| **Twitch** | `twitch.tv`, `clips.twitch.tv` | Channel, clips, VODs all flow through one provider. Pro adds chat parameter. |\n| **FITE** | `fite.tv`, `triller.tv`, `trillertv.com` | Live event streaming. |\n| (TikTok) | (not in providers.php) | File exists at `Providers/TikTok.php`; Feature_Enhancer::isEmbra invokes it directly when host matches. |\n\n> **Vimeo, Dailymotion, SoundCloud, Spotify, Apple Podcasts** etc. are handled via Embera's built-in adapters and EmbedPress's Feature_Enhancer decoration \u2014 no custom Provider class in `EmbedPress/Providers/`.\n\n## Documents / PDFs / Files\n\n| Class | Hosts | Notes |\n|---|---|---|\n| **GoogleDocs** | `docs.google.com` | Docs, Sheets, Slides, Forms all share host but each has its own Gutenberg block. |\n| **GoogleDrive** | `drive.google.com` | Drive file embed. |\n| **OneDrive** | `onedrive.live.com`, `1drv.ms` | Microsoft's Drive equivalent. |\n| **GoogleCalendar** | `calendar.google.com` | Calendar iframe + ICS feed. |\n| **SelfHosted** | site host + long TLD wildcard list | Catches direct file URLs (`.mp4`, `.mp3`, `.pdf`, `.docx`, etc.). The fallback for self-hosted media. **Five regex copies** (PHP + JS) to keep in sync. |\n\n## Maps / Locations\n\n| Class | Hosts | Notes |\n|---|---|---|\n| **GoogleMaps** | `google.com`, `google.com.*`, `maps.google.com`, `goo.gl`, `google.co.*` | Tight regex needed because `google.com` is shared with Google's other services. |\n\n## Social / Feeds\n\n| Class | Hosts | Notes |\n|---|---|---|\n| **InstagramFeed** | `instagram.com` | Single post; multi-post feed UX is Pro. |\n| **X** | `*.x.com`, `x.com` | Renamed from Twitter. Class is `X`. |\n| **LinkedIn** | `*.linkedin.com`, `linkedin.com` | Posts + share embeds. |\n| **GitHub** | `gist.github.com`, `github.com` | Gists and source files. |\n| **OpenSea** | `opensea.io` | NFT widget. Has separate Elementor `ep-preset-1/-2` styling unrelated to Custom Player presets. |\n\n## Audio\n\n| Class | Hosts | Notes |\n|---|---|---|\n| **Boomplay** | `boomplay.com` | African music streaming. |\n| **Spreaker** | `*.spreaker.com`, `spreaker.com` | Podcast hosting. Routed via `isEmbra`. |\n| **NRKRadio** | `radio.nrk.no`, `nrk.no` | Norwegian public radio. |\n\n## Tools / Forms / Misc\n\n| Class | Hosts | Notes |\n|---|---|---|\n| **Calendly** | `*.calendly.com`, `calendly.com` | Booking widget. Pro adds OAuth + sync. |\n| **AirTable** | `*.airtable.com`, `airtable.com` | Database / view embed. |\n| **Canva** | `*.canva.com`, `canva.com` | Design embed. |\n| **Gumroad** | `*.gumroad.com`, `gumroad.com` | Product/store embed. |\n| **GooglePhotos** | `photos.app.goo.gl`, `photos.google.com` | Album embed (server-side fetch to derive). Routed via `isEmbra`. |\n| **GettyImages** | `gettyimages.com` | Stock image embed. |\n| **Giphy** | `giphy.com`, `i.giphy.com` | GIF embed. |\n| **Meetup** | `meetup.com` | Event embed. Pro extender adds advanced controls. |\n\n## The Wrapper\n\n`Providers/Wrapper.php` is the universal fallback. Not registered in `providers.php` \u2014 instead, `Feature_Enhancer::isEmbra` invokes it when no other provider matches. It renders a sandboxed iframe pointing at the URL.\n\nSee [Universal Wrapper](../features/wrapper.md).\n\n## Where the \"250+ providers\" number comes from\n\nThe bundled **Embera library** ships adapters for many additional services (Vimeo, SoundCloud, Spotify, Apple Podcasts, Dailymotion, TED, SlideShare, Codepen, dozens of social and tool sites). Combined with EmbedPress's custom Provider classes here and the Wrapper fallback, the total catalog crosses 250 sources.\n\nWhen you see a provider's controls in the editor that isn't listed above, it's almost certainly an Embera-adapter source decorated by `Feature_Enhancer` rather than a custom Provider class. To confirm, grep for the provider name in `vendor/mauricius/embera/` (Composer).\n\n## How to keep this catalog accurate\n\nWhen you add or rename a Provider class, update:\n\n1. The file in `EmbedPress/Providers/`\n2. The host registration in `providers.php`\n3. The corresponding row in this catalog\n4. (If applicable) the Pro extender in `embedpress-pro/includes/Providers/`\n"};
const ORDER = ["README.md", "architecture/overview.md", "architecture/data-flow.md", "architecture/wordpress-integration.md", "architecture/folders.md", "architecture/free-pro-coupling.md", "architecture/shortcode.md", "providers/README.md", "providers/adding-a-provider.md", "providers/catalog.md", "gutenberg/README.md", "elementor/README.md", "features/pdf.md", "features/pdf-gallery.md", "features/document.md", "features/custom-player.md", "features/social-share.md", "features/analytics.md", "features/wrapper.md", "features/onboarding.md", "features/feature-enhancer.md", "api/rest.md", "api/oembed.md", "api/external.md", "guides/coding-standards.md", "guides/extending.md", "guides/hooks-and-filters.md", "guides/build-pipeline.md", "guides/testing.md", "guides/debugging.md", "contributing/setup.md", "contributing/pull-requests.md", "contributing/release.md"];
const sectionLabels = {
'_root': '',
'architecture': 'Architecture',
'providers': 'Providers',
'gutenberg': 'Gutenberg',
'elementor': 'Elementor',
'features': 'Features',
'integration': 'Integration',
'api': 'API',
'guides': 'Guides',
'contributing': 'Contributing',
'scripts': 'Scripts',
'staging': 'Staging',
'legacy': 'Legacy notes',
};
function buildNav(filter) {
const nav = document.getElementById('nav');
nav.innerHTML = '';
const groups = {};
for (const path of ORDER) {
const parts = path.split('/');
const section = parts.length === 1 ? '_root' : parts[0];
if (!groups[section]) groups[section] = [];
groups[section].push(path);
}
for (const section of Object.keys(groups)) {
const items = groups[section].filter(p => !filter || p.toLowerCase().includes(filter.toLowerCase()) || (DOCS[p] || '').toLowerCase().includes(filter.toLowerCase()));
if (!items.length) continue;
if (sectionLabels[section]) {
const h = document.createElement('h2');
h.textContent = sectionLabels[section];
nav.appendChild(h);
}
for (const path of items) {
const a = document.createElement('a');
a.href = '#' + path;
a.textContent = prettyName(path);
a.dataset.path = path;
a.onclick = (e) => { e.preventDefault(); show(path); };
nav.appendChild(a);
}
}
}
function prettyName(path) {
const file = path.split('/').pop().replace(/\.md$/, '');
if (path === 'README.md') return 'Home';
if (file === 'README') return 'Overview';
return file.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function show(path) {
const md = DOCS[path];
if (!md) { document.getElementById('rendered').innerHTML = '<p>Not found.</p>'; return; }
document.getElementById('topbar-path').textContent = path;
let html = marked.parse(md);
// rewrite relative .md links to in-page hash links
html = html.replace(/href="([^"]+\.md(?:#[^"]*)?)"/g, (m, href) => {
if (/^https?:/.test(href)) return m;
const [hrefPath, anchor] = href.split('#');
const base = path.split('/').slice(0, -1);
const target = resolvePath(base, hrefPath);
if (DOCS[target] !== undefined) {
return `href="#${target}" data-internal="${target}"${anchor ? ' data-anchor="' + anchor + '"' : ''}`;
}
return m;
});
document.getElementById('rendered').innerHTML = html;
document.querySelectorAll('a[data-internal]').forEach(a => {
a.onclick = (e) => { e.preventDefault(); show(a.dataset.internal); };
});
document.querySelectorAll('#sidebar a').forEach(a => a.classList.toggle('active', a.dataset.path === path));
document.querySelectorAll('#rendered pre code').forEach(b => hljs.highlightElement(b));
window.scrollTo(0, 0);
document.getElementById('content').scrollTo(0, 0);
history.replaceState(null, '', '#' + path);
}
function resolvePath(baseParts, href) {
const parts = [...baseParts];
for (const seg of href.split('/')) {
if (seg === '..') parts.pop();
else if (seg !== '.' && seg !== '') parts.push(seg);
}
return parts.join('/');
}
document.getElementById('search').addEventListener('input', (e) => buildNav(e.target.value));
// Theme toggle
function applyTheme(t) {
document.documentElement.setAttribute('data-theme', t);
document.getElementById('hljs-light').disabled = (t === 'dark');
document.getElementById('hljs-dark').disabled = (t !== 'dark');
document.getElementById('theme-toggle').textContent = t === 'dark' ? '☀' : '◐';
try { localStorage.setItem('ep-docs-theme', t); } catch (e) {}
}
const savedTheme = (() => {
try { return localStorage.getItem('ep-docs-theme'); } catch (e) { return null; }
})() || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
applyTheme(savedTheme);
document.getElementById('theme-toggle').addEventListener('click', () => {
const cur = document.documentElement.getAttribute('data-theme') || 'light';
applyTheme(cur === 'dark' ? 'light' : 'dark');
});
buildNav('');
const initial = location.hash ? location.hash.slice(1) : (DOCS['README.md'] ? 'README.md' : ORDER[0]);
show(initial);
</script>
</body>
</html>