@emplugins/emprivacy 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -50
- package/dist/index.d.mts +205 -30
- package/dist/index.mjs +1746 -290
- package/docs/DEVELOPMENT.md +2 -2
- package/docs/GETTING_STARTED.md +81 -77
- package/docs/PLUGIN_SETTINGS.md +121 -117
- package/docs/RELEASE_NOTES.md +13 -0
- package/docs/TESTING.md +19 -21
- package/package.json +10 -8
- package/src/astro/GatedEmbed.astro +70 -0
- package/src/astro/embed-resolve.ts +227 -0
- package/src/astro/index.ts +24 -0
package/docs/TESTING.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Testing EmPrivacy before deploy
|
|
4
4
|
|
|
5
|
-
Testing is split into **automated** checks (CI-friendly) and **manual** checks that require a running EmDash site
|
|
5
|
+
Testing is split into **automated** checks (CI-friendly) and **manual** checks that require a running EmDash site.
|
|
6
6
|
|
|
7
7
|
## Automated (in this repo)
|
|
8
8
|
|
|
@@ -10,30 +10,34 @@ Testing is split into **automated** checks (CI-friendly) and **manual** checks t
|
|
|
10
10
|
|--------|-------------------|
|
|
11
11
|
| `pnpm run typecheck` | TypeScript types against `emdash` APIs |
|
|
12
12
|
| `pnpm run build` | Bundles ESM + declarations (`dist/`) |
|
|
13
|
-
| `pnpm test` |
|
|
13
|
+
| `pnpm test` | Config validation, security helpers (embed/script allowlists, CSRF Origin/Sec-Fetch-Site, cookie TTL, SRI), i18n, theme hex, vendor list (no token leak; GTM=marketing), JSON escaping |
|
|
14
14
|
|
|
15
|
-
These scripts do **not** start EmDash or
|
|
15
|
+
These scripts do **not** start EmDash or drive a browser.
|
|
16
16
|
|
|
17
17
|
## Manual integration QA (required before production)
|
|
18
18
|
|
|
19
|
-
Use a **staging** EmDash Astro app on **`emdash@^0.38.0`** with `emprivacyPlugin()` in **`plugins: []
|
|
20
|
-
|
|
21
|
-
Work through the checklist from the main [README](../README.md) (also copied below). Record pass/fail and browser + EmDash versions.
|
|
19
|
+
Use a **staging** EmDash Astro app on **`emdash@^0.38.0`** with `emprivacyPlugin()` in **`plugins: []`**, layout wires (`EmDashHead` / body components with page context), and the same EmDash version you plan to ship.
|
|
22
20
|
|
|
23
21
|
### Core flows
|
|
24
22
|
|
|
25
|
-
1. **Banner** — Incognito
|
|
26
|
-
2. **
|
|
27
|
-
3. **
|
|
28
|
-
4. **
|
|
29
|
-
5. **
|
|
30
|
-
6. **
|
|
31
|
-
7. **
|
|
23
|
+
1. **Banner** — Incognito: banner appears with no `emprivacy_cc` cookie, when **Policy / consent version** does not match `v`, or when a legacy cookie lacks `f`.
|
|
24
|
+
2. **Categories** — Customize shows Essential (locked on), Functional, Analytics, Marketing. Reject non-essential: no configured analytics/marketing scripts in Network; embeds stay placeholders.
|
|
25
|
+
3. **Re-open** — Cookie button (bottom-left). Save reloads. Turning a category off removes those scripts after reload.
|
|
26
|
+
4. **Presets** — For the platform you ship, confirm the expected host loads only after the correct category (Analytics for most presets; **Marketing** for GTM). Custom/marketing scripts must respect any host allowlist.
|
|
27
|
+
5. **Evil embed plant** — In DevTools, add a placeholder with `data-src="https://evil.example/x"` and grant marketing; assert no iframe is created.
|
|
28
|
+
6. **Embed unlock** — “Allow embeds and load” opens preferences; it must not silently flip categories.
|
|
29
|
+
7. **Embeds** — With `@emdash-cms/plugin-embeds` and EmPrivacy last (or site-level `blockComponents`): a YouTube block is a placeholder first; after allowing the embed category, the iframe `src` is `https://www.youtube-nocookie.com/embed/…`. A junk/javascript URL never becomes an iframe.
|
|
30
|
+
8. **API** — After a choice, `window.emprivacy.get()` returns state; `window.emprivacy.has("analytics")` matches Network.
|
|
31
|
+
9. **i18n** — A `de` (or `fr`) page shows translated buttons when that locale is active.
|
|
32
|
+
10. **Policy pages** — `/privacy` does not show the first-visit overlay when that setting is on; the cookie button still works.
|
|
33
|
+
11. **Admin** — Save persists; **What this site uses** matches vendors; tokens do not appear there or at `/_emdash/api/plugins/emprivacy/vendors`.
|
|
34
|
+
12. **Optional server log** — POST requires matching Origin or `Sec-Fetch-Site: same-origin`; Origin mismatch is 403. Body includes `functional`.
|
|
35
|
+
13. **Optional Google Consent Mode** — Denied defaults in `<head>` before interaction, including `functionality_storage`.
|
|
32
36
|
|
|
33
37
|
### Regression triggers
|
|
34
38
|
|
|
35
|
-
- Bump EmDash minor: re-run manual QA
|
|
36
|
-
-
|
|
39
|
+
- Bump EmDash minor: re-run manual QA.
|
|
40
|
+
- Cookie JSON or embed allowlist changes: re-check banner, `window.emprivacy`, and a YouTube block.
|
|
37
41
|
|
|
38
42
|
## CI suggestion
|
|
39
43
|
|
|
@@ -44,10 +48,4 @@ pnpm run build
|
|
|
44
48
|
pnpm test
|
|
45
49
|
```
|
|
46
50
|
|
|
47
|
-
## Future improvements (not in v1)
|
|
48
|
-
|
|
49
|
-
- **Integration tests** against EmDash’s native plugin loader + mock `PluginContext` (heavy; depends on EmDash test utilities if exposed).
|
|
50
|
-
- **E2E** (Playwright) against a minimal EmDash fixture app in CI (slower, higher maintenance).
|
|
51
|
-
|
|
52
51
|
For most teams, **automated unit tests + staging manual QA** is the right balance.
|
|
53
|
-
|
package/package.json
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "SPDX-License-Identifier: MIT",
|
|
3
3
|
"name": "@emplugins/emprivacy",
|
|
4
|
-
"version": "2.
|
|
5
|
-
"description": "Cookie consent and privacy notices for EmDash (GDPR, CCPA/CIPA-oriented configuration)",
|
|
4
|
+
"version": "2.1.0",
|
|
5
|
+
"description": "Cookie consent, gated embeds, and privacy notices for EmDash (GDPR, CCPA/CIPA-oriented configuration)",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.mjs",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
10
|
"import": "./dist/index.mjs",
|
|
11
11
|
"types": "./dist/index.d.mts"
|
|
12
|
-
}
|
|
12
|
+
},
|
|
13
|
+
"./astro": "./src/astro/index.ts"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"dist",
|
|
17
|
+
"src/astro",
|
|
16
18
|
"README.md",
|
|
17
19
|
"LICENSE",
|
|
18
20
|
"docs/DEVELOPMENT.md",
|
|
@@ -40,14 +42,14 @@
|
|
|
40
42
|
"emdash": "^0.38.0"
|
|
41
43
|
},
|
|
42
44
|
"dependencies": {
|
|
43
|
-
"zod": "4.5
|
|
45
|
+
"zod": "4.6.5"
|
|
44
46
|
},
|
|
45
47
|
"devDependencies": {
|
|
46
|
-
"@changesets/cli": "^
|
|
48
|
+
"@changesets/cli": "^3.0.3",
|
|
47
49
|
"emdash": "0.38.0",
|
|
48
|
-
"tsdown": "^0.
|
|
50
|
+
"tsdown": "^0.23.0",
|
|
49
51
|
"typescript": "^5.9.3",
|
|
50
|
-
"vitest": "^
|
|
52
|
+
"vitest": "^5.0.1"
|
|
51
53
|
},
|
|
52
54
|
"repository": {
|
|
53
55
|
"type": "git",
|
|
@@ -64,7 +66,7 @@
|
|
|
64
66
|
"pnpm": {
|
|
65
67
|
"overrides": {
|
|
66
68
|
"kysely": "0.29.6",
|
|
67
|
-
"zod": "4.5
|
|
69
|
+
"zod": "4.6.5"
|
|
68
70
|
}
|
|
69
71
|
},
|
|
70
72
|
"scripts": {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { resolveEmbed } from "./embed-resolve.js";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
node: {
|
|
7
|
+
_type?: string;
|
|
8
|
+
id?: string;
|
|
9
|
+
poster?: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { node } = Astro.props;
|
|
15
|
+
const resolved = resolveEmbed(node ?? {});
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
resolved ? (
|
|
20
|
+
<div
|
|
21
|
+
class="emprivacy-embed"
|
|
22
|
+
data-emprivacy-embed={resolved.kind}
|
|
23
|
+
data-src={resolved.src}
|
|
24
|
+
data-mode={resolved.mode}
|
|
25
|
+
data-label={resolved.label}
|
|
26
|
+
>
|
|
27
|
+
{resolved.poster ? (
|
|
28
|
+
<img class="emprivacy-embed-poster" src={resolved.poster} alt="" width="480" height="270" />
|
|
29
|
+
) : null}
|
|
30
|
+
<p class="emprivacy-embed-copy">{resolved.label} is blocked until you allow the matching cookie category.</p>
|
|
31
|
+
<button type="button" class="emprivacy-embed-btn" data-emprivacy-embed-load>
|
|
32
|
+
Allow embeds and load
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
) : (
|
|
36
|
+
<p class="emprivacy-embed-invalid">This embed could not be displayed.</p>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
<style>
|
|
41
|
+
.emprivacy-embed {
|
|
42
|
+
display: grid;
|
|
43
|
+
gap: 0.75rem;
|
|
44
|
+
place-items: center;
|
|
45
|
+
padding: 1.25rem;
|
|
46
|
+
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
|
47
|
+
border-radius: 8px;
|
|
48
|
+
background: color-mix(in srgb, currentColor 6%, transparent);
|
|
49
|
+
text-align: center;
|
|
50
|
+
aspect-ratio: 16 / 9;
|
|
51
|
+
}
|
|
52
|
+
.emprivacy-embed-poster {
|
|
53
|
+
max-width: 100%;
|
|
54
|
+
height: auto;
|
|
55
|
+
border-radius: 6px;
|
|
56
|
+
}
|
|
57
|
+
.emprivacy-embed-copy,
|
|
58
|
+
.emprivacy-embed-invalid {
|
|
59
|
+
margin: 0;
|
|
60
|
+
max-width: 36rem;
|
|
61
|
+
}
|
|
62
|
+
.emprivacy-embed-btn {
|
|
63
|
+
border-radius: 6px;
|
|
64
|
+
border: 1px solid currentColor;
|
|
65
|
+
background: transparent;
|
|
66
|
+
color: inherit;
|
|
67
|
+
padding: 0.5rem 0.85rem;
|
|
68
|
+
cursor: pointer;
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate official EmDash embed block payloads and produce a first-party
|
|
5
|
+
* placeholder plan. Never returns a URL we did not construct or allowlist.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const EMBED_BLOCK_TYPES = [
|
|
9
|
+
"youtube",
|
|
10
|
+
"vimeo",
|
|
11
|
+
"tweet",
|
|
12
|
+
"bluesky",
|
|
13
|
+
"mastodon",
|
|
14
|
+
"linkPreview",
|
|
15
|
+
"gist",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type EmbedBlockType = (typeof EMBED_BLOCK_TYPES)[number];
|
|
19
|
+
|
|
20
|
+
export type EmbedRenderMode = "iframe" | "link";
|
|
21
|
+
|
|
22
|
+
export interface ResolvedEmbed {
|
|
23
|
+
kind: EmbedBlockType;
|
|
24
|
+
mode: EmbedRenderMode;
|
|
25
|
+
/** https iframe src or link href — always produced by this module */
|
|
26
|
+
src: string;
|
|
27
|
+
label: string;
|
|
28
|
+
poster: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const YT_ID = /^[A-Za-z0-9_-]{11}$/;
|
|
32
|
+
const VIMEO_ID = /^\d{6,12}$/;
|
|
33
|
+
const TWEET_ID = /^\d{5,20}$/;
|
|
34
|
+
const BSKY_RKEY = /^[a-z0-9]{1,32}$/i;
|
|
35
|
+
const BSKY_HANDLE = /^[a-z0-9.-]{1,253}$/i;
|
|
36
|
+
const GIST_ID = /^[a-f0-9]{8,64}$/i;
|
|
37
|
+
const GIST_USER = /^[A-Za-z0-9-]{1,39}$/;
|
|
38
|
+
|
|
39
|
+
const YT_HOSTS = new Set(["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtube-nocookie.com", "youtube-nocookie.com"]);
|
|
40
|
+
const VIMEO_HOSTS = new Set(["vimeo.com", "www.vimeo.com", "player.vimeo.com"]);
|
|
41
|
+
const TWEET_HOSTS = new Set(["twitter.com", "www.twitter.com", "x.com", "www.x.com", "mobile.twitter.com"]);
|
|
42
|
+
const BSKY_HOSTS = new Set(["bsky.app", "www.bsky.app"]);
|
|
43
|
+
const GIST_HOSTS = new Set(["gist.github.com"]);
|
|
44
|
+
const POSTER_HOSTS = new Set(["i.ytimg.com", "img.youtube.com", "i.vimeocdn.com"]);
|
|
45
|
+
|
|
46
|
+
function hasUnsafeChars(s: string): boolean {
|
|
47
|
+
return /[\u0000-\u001F\u007F\s]/.test(s);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseHttpsUrl(raw: string): URL | null {
|
|
51
|
+
const t = raw.trim();
|
|
52
|
+
if (!t || t.length > 2048 || hasUnsafeChars(t)) return null;
|
|
53
|
+
if (/%0d|%0a|%09|%0b|%0c|%20/i.test(t)) return null;
|
|
54
|
+
try {
|
|
55
|
+
const u = new URL(t);
|
|
56
|
+
if (u.protocol !== "https:") return null;
|
|
57
|
+
if (u.username || u.password) return null;
|
|
58
|
+
if (u.hostname === "localhost" || u.hostname.endsWith(".localhost")) return null;
|
|
59
|
+
return u;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hostOf(u: URL): string {
|
|
66
|
+
return u.hostname.toLowerCase();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function youtubeId(raw: string): string | null {
|
|
70
|
+
const t = raw.trim();
|
|
71
|
+
if (YT_ID.test(t)) return t;
|
|
72
|
+
const u = parseHttpsUrl(t);
|
|
73
|
+
if (!u || !YT_HOSTS.has(hostOf(u))) return null;
|
|
74
|
+
if (hostOf(u) === "youtu.be") {
|
|
75
|
+
const id = u.pathname.replace(/^\//, "").split("/")[0] ?? "";
|
|
76
|
+
return YT_ID.test(id) ? id : null;
|
|
77
|
+
}
|
|
78
|
+
const v = u.searchParams.get("v");
|
|
79
|
+
if (v && YT_ID.test(v)) return v;
|
|
80
|
+
const parts = u.pathname.split("/").filter(Boolean);
|
|
81
|
+
const embedIdx = parts.indexOf("embed");
|
|
82
|
+
if (embedIdx >= 0) {
|
|
83
|
+
const id = parts[embedIdx + 1] ?? "";
|
|
84
|
+
return YT_ID.test(id) ? id : null;
|
|
85
|
+
}
|
|
86
|
+
const shortsIdx = parts.indexOf("shorts");
|
|
87
|
+
if (shortsIdx >= 0) {
|
|
88
|
+
const id = parts[shortsIdx + 1] ?? "";
|
|
89
|
+
return YT_ID.test(id) ? id : null;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function vimeoId(raw: string): string | null {
|
|
95
|
+
const t = raw.trim();
|
|
96
|
+
if (VIMEO_ID.test(t)) return t;
|
|
97
|
+
const u = parseHttpsUrl(t);
|
|
98
|
+
if (!u || !VIMEO_HOSTS.has(hostOf(u))) return null;
|
|
99
|
+
const parts = u.pathname.split("/").filter(Boolean);
|
|
100
|
+
const last = parts[parts.length - 1] ?? "";
|
|
101
|
+
const videoIdx = parts.indexOf("video");
|
|
102
|
+
if (videoIdx >= 0) {
|
|
103
|
+
const id = parts[videoIdx + 1] ?? "";
|
|
104
|
+
return VIMEO_ID.test(id) ? id : null;
|
|
105
|
+
}
|
|
106
|
+
return VIMEO_ID.test(last) ? last : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function tweetId(raw: string): string | null {
|
|
110
|
+
const t = raw.trim();
|
|
111
|
+
if (TWEET_ID.test(t)) return t;
|
|
112
|
+
const u = parseHttpsUrl(t);
|
|
113
|
+
if (!u || !TWEET_HOSTS.has(hostOf(u))) return null;
|
|
114
|
+
const m = /\/status(?:es)?\/(\d{5,20})(?:\/|$)/i.exec(u.pathname);
|
|
115
|
+
return m?.[1] ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function blueskyHref(raw: string): string | null {
|
|
119
|
+
const u = parseHttpsUrl(raw);
|
|
120
|
+
if (!u || !BSKY_HOSTS.has(hostOf(u))) return null;
|
|
121
|
+
const m = /^\/profile\/([^/]+)\/post\/([^/]+)\/?$/.exec(u.pathname);
|
|
122
|
+
if (!m) return null;
|
|
123
|
+
const handle = m[1] ?? "";
|
|
124
|
+
const rkey = m[2] ?? "";
|
|
125
|
+
if (!BSKY_HANDLE.test(handle) || !BSKY_RKEY.test(rkey)) return null;
|
|
126
|
+
return `https://bsky.app/profile/${handle}/post/${rkey}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function gistHref(raw: string): string | null {
|
|
130
|
+
const u = parseHttpsUrl(raw);
|
|
131
|
+
if (!u || !GIST_HOSTS.has(hostOf(u))) return null;
|
|
132
|
+
const parts = u.pathname.split("/").filter(Boolean);
|
|
133
|
+
if (parts.length < 2) return null;
|
|
134
|
+
const user = parts[0] ?? "";
|
|
135
|
+
const id = (parts[1] ?? "").replace(/\.js$/i, "");
|
|
136
|
+
if (!GIST_USER.test(user) || !GIST_ID.test(id)) return null;
|
|
137
|
+
return `https://gist.github.com/${user}/${id}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function mastodonHref(raw: string): string | null {
|
|
141
|
+
const u = parseHttpsUrl(raw);
|
|
142
|
+
if (!u) return null;
|
|
143
|
+
// Any Mastodon instance is an arbitrary host — never iframe it.
|
|
144
|
+
if (!/^\/@[^/]+\/\d+\/?$/.test(u.pathname) && !/^\/users\/[^/]+\/statuses\/\d+\/?$/.test(u.pathname)) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return `https://${hostOf(u)}${u.pathname.replace(/\/$/, "")}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function posterUrl(raw: unknown, kind: EmbedBlockType, mediaId: string | null): string | null {
|
|
151
|
+
if (kind === "youtube" && mediaId && YT_ID.test(mediaId)) {
|
|
152
|
+
return `https://i.ytimg.com/vi/${mediaId}/hqdefault.jpg`;
|
|
153
|
+
}
|
|
154
|
+
if (typeof raw !== "string") return null;
|
|
155
|
+
const u = parseHttpsUrl(raw);
|
|
156
|
+
if (!u || !POSTER_HOSTS.has(hostOf(u))) return null;
|
|
157
|
+
return u.href;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isEmbedBlockType(s: string): s is EmbedBlockType {
|
|
161
|
+
return (EMBED_BLOCK_TYPES as readonly string[]).includes(s);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function resolveEmbed(node: {
|
|
165
|
+
_type?: unknown;
|
|
166
|
+
id?: unknown;
|
|
167
|
+
poster?: unknown;
|
|
168
|
+
title?: unknown;
|
|
169
|
+
}): ResolvedEmbed | null {
|
|
170
|
+
if (!node || typeof node._type !== "string" || !isEmbedBlockType(node._type)) return null;
|
|
171
|
+
if (typeof node.id !== "string") return null;
|
|
172
|
+
const kind = node._type;
|
|
173
|
+
const rawId = node.id.trim();
|
|
174
|
+
if (!rawId || rawId.length > 2048) return null;
|
|
175
|
+
|
|
176
|
+
if (kind === "youtube") {
|
|
177
|
+
const id = youtubeId(rawId);
|
|
178
|
+
if (!id) return null;
|
|
179
|
+
return {
|
|
180
|
+
kind,
|
|
181
|
+
mode: "iframe",
|
|
182
|
+
src: `https://www.youtube-nocookie.com/embed/${id}`,
|
|
183
|
+
label: "YouTube",
|
|
184
|
+
poster: posterUrl(node.poster, kind, id),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (kind === "vimeo") {
|
|
188
|
+
const id = vimeoId(rawId);
|
|
189
|
+
if (!id) return null;
|
|
190
|
+
return {
|
|
191
|
+
kind,
|
|
192
|
+
mode: "iframe",
|
|
193
|
+
src: `https://player.vimeo.com/video/${id}`,
|
|
194
|
+
label: "Vimeo",
|
|
195
|
+
poster: posterUrl(node.poster, kind, id),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (kind === "tweet") {
|
|
199
|
+
const id = tweetId(rawId);
|
|
200
|
+
if (!id) return null;
|
|
201
|
+
return {
|
|
202
|
+
kind,
|
|
203
|
+
mode: "link",
|
|
204
|
+
src: `https://x.com/i/web/status/${id}`,
|
|
205
|
+
label: "Post on X",
|
|
206
|
+
poster: null,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (kind === "bluesky") {
|
|
210
|
+
const href = blueskyHref(rawId);
|
|
211
|
+
if (!href) return null;
|
|
212
|
+
return { kind, mode: "link", src: href, label: "Bluesky post", poster: null };
|
|
213
|
+
}
|
|
214
|
+
if (kind === "mastodon") {
|
|
215
|
+
const href = mastodonHref(rawId);
|
|
216
|
+
if (!href) return null;
|
|
217
|
+
return { kind, mode: "link", src: href, label: "Mastodon post", poster: null };
|
|
218
|
+
}
|
|
219
|
+
if (kind === "gist") {
|
|
220
|
+
const href = gistHref(rawId);
|
|
221
|
+
if (!href) return null;
|
|
222
|
+
return { kind, mode: "link", src: href, label: "GitHub Gist", poster: null };
|
|
223
|
+
}
|
|
224
|
+
const href = parseHttpsUrl(rawId);
|
|
225
|
+
if (!href) return null;
|
|
226
|
+
return { kind: "linkPreview", mode: "link", src: href.href, label: href.hostname, poster: null };
|
|
227
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Portable Text renderers that replace official EmDash embed blocks with
|
|
5
|
+
* consent-gated placeholders. Import from `@emplugins/emprivacy/astro`.
|
|
6
|
+
*
|
|
7
|
+
* Later plugins in `plugins: []` win. To force these wrappers regardless of
|
|
8
|
+
* registration order, pass `blockComponents` to `<PortableText />`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import GatedEmbed from "./GatedEmbed.astro";
|
|
12
|
+
|
|
13
|
+
export const blockComponents = {
|
|
14
|
+
youtube: GatedEmbed,
|
|
15
|
+
vimeo: GatedEmbed,
|
|
16
|
+
tweet: GatedEmbed,
|
|
17
|
+
bluesky: GatedEmbed,
|
|
18
|
+
mastodon: GatedEmbed,
|
|
19
|
+
linkPreview: GatedEmbed,
|
|
20
|
+
gist: GatedEmbed,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export { GatedEmbed };
|
|
24
|
+
export { blockComponents as emprivacyEmbedComponents };
|