@emplugins/emprivacy 1.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 +119 -63
- package/dist/index.d.mts +257 -6
- package/dist/index.mjs +2335 -12
- package/docs/DEVELOPMENT.md +6 -10
- package/docs/GETTING_STARTED.md +83 -75
- package/docs/PLUGIN_SETTINGS.md +121 -117
- package/docs/RELEASE_NOTES.md +10 -12
- package/docs/TESTING.md +25 -27
- package/package.json +16 -14
- package/src/astro/GatedEmbed.astro +70 -0
- package/src/astro/embed-resolve.ts +227 -0
- package/src/astro/index.ts +24 -0
- package/dist/config-BJ1LHuri.mjs +0 -166
- package/dist/config-CqOtRoQR.d.mts +0 -63
- package/dist/sandbox-entry.d.mts +0 -283
- package/dist/sandbox-entry.mjs +0 -693
package/docs/TESTING.md
CHANGED
|
@@ -2,52 +2,50 @@
|
|
|
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
|
|
|
9
9
|
| Command | What it verifies |
|
|
10
10
|
|--------|-------------------|
|
|
11
|
-
| `
|
|
12
|
-
| `
|
|
13
|
-
| `
|
|
11
|
+
| `pnpm run typecheck` | TypeScript types against `emdash` APIs |
|
|
12
|
+
| `pnpm run build` | Bundles ESM + declarations (`dist/`) |
|
|
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 **`0.
|
|
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
|
|
|
40
44
|
```bash
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
pnpm install --frozen-lockfile
|
|
46
|
+
pnpm run typecheck
|
|
47
|
+
pnpm run build
|
|
48
|
+
pnpm test
|
|
45
49
|
```
|
|
46
50
|
|
|
47
|
-
## Future improvements (not in v1)
|
|
48
|
-
|
|
49
|
-
- **Integration tests** against EmDash’s `adaptSandboxEntry` + 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,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "SPDX-License-Identifier: MIT",
|
|
3
3
|
"name": "@emplugins/emprivacy",
|
|
4
|
-
"version": "1.0
|
|
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": {
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
"import": "./dist/index.mjs",
|
|
11
11
|
"types": "./dist/index.d.mts"
|
|
12
12
|
},
|
|
13
|
-
"./
|
|
13
|
+
"./astro": "./src/astro/index.ts"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
+
"src/astro",
|
|
17
18
|
"README.md",
|
|
18
19
|
"LICENSE",
|
|
19
20
|
"docs/DEVELOPMENT.md",
|
|
@@ -38,17 +39,17 @@
|
|
|
38
39
|
"license": "MIT",
|
|
39
40
|
"packageManager": "pnpm@9.15.4",
|
|
40
41
|
"peerDependencies": {
|
|
41
|
-
"emdash": "
|
|
42
|
+
"emdash": "^0.38.0"
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
44
|
-
"zod": "
|
|
45
|
+
"zod": "4.6.5"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
|
-
"@changesets/cli": "^
|
|
48
|
-
"emdash": "
|
|
49
|
-
"tsdown": "^0.
|
|
48
|
+
"@changesets/cli": "^3.0.3",
|
|
49
|
+
"emdash": "0.38.0",
|
|
50
|
+
"tsdown": "^0.23.0",
|
|
50
51
|
"typescript": "^5.9.3",
|
|
51
|
-
"vitest": "^
|
|
52
|
+
"vitest": "^5.0.1"
|
|
52
53
|
},
|
|
53
54
|
"repository": {
|
|
54
55
|
"type": "git",
|
|
@@ -64,16 +65,17 @@
|
|
|
64
65
|
},
|
|
65
66
|
"pnpm": {
|
|
66
67
|
"overrides": {
|
|
67
|
-
"kysely": "0.29.
|
|
68
|
+
"kysely": "0.29.6",
|
|
69
|
+
"zod": "4.6.5"
|
|
68
70
|
}
|
|
69
71
|
},
|
|
70
72
|
"scripts": {
|
|
71
73
|
"sync:version": "node scripts/write-version.mjs",
|
|
72
74
|
"verify:exports": "node scripts/verify-exports.mjs",
|
|
73
|
-
"build": "tsdown src/index.ts
|
|
74
|
-
"dev": "tsdown src/index.ts
|
|
75
|
+
"build": "tsdown src/index.ts --format esm --dts --clean",
|
|
76
|
+
"dev": "tsdown src/index.ts --format esm --dts --watch",
|
|
75
77
|
"typecheck": "tsc --noEmit",
|
|
76
|
-
"audit": "pnpm audit",
|
|
78
|
+
"audit": "pnpm audit --prod",
|
|
77
79
|
"test": "vitest run",
|
|
78
80
|
"test:watch": "vitest",
|
|
79
81
|
"pack:check": "npm pack --dry-run",
|
|
@@ -83,6 +85,6 @@
|
|
|
83
85
|
"prepublishOnly": "pnpm run sync:version && pnpm run typecheck && pnpm run build && pnpm test && pnpm run verify:exports && pnpm run audit"
|
|
84
86
|
},
|
|
85
87
|
"engines": {
|
|
86
|
-
"node": ">=
|
|
88
|
+
"node": ">=22.16"
|
|
87
89
|
}
|
|
88
90
|
}
|
|
@@ -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 };
|
package/dist/config-BJ1LHuri.mjs
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
//#region src/config.ts
|
|
2
|
-
/**
|
|
3
|
-
* EmPrivacy KV document (`consent:config`) and shared helpers.
|
|
4
|
-
*/
|
|
5
|
-
const KV_KEY = "consent:config";
|
|
6
|
-
const COOKIE_NAME = "emprivacy_cc";
|
|
7
|
-
const PLUGIN_ID = "emprivacy";
|
|
8
|
-
const DEFAULT_CONFIG = {
|
|
9
|
-
bannerTitle: "Cookies & privacy",
|
|
10
|
-
bannerMessage: "We use cookies to run this site and optionally for analytics and marketing. You can accept or customize your choices.",
|
|
11
|
-
privacyPolicyUrl: "/privacy",
|
|
12
|
-
cookiePolicyUrl: "",
|
|
13
|
-
strictDefaults: true,
|
|
14
|
-
policyVersion: "1",
|
|
15
|
-
cloudflareWebAnalyticsToken: "",
|
|
16
|
-
analyticsProvider: "cloudflare",
|
|
17
|
-
analyticsScriptUrls: [],
|
|
18
|
-
marketingScriptUrls: [],
|
|
19
|
-
googleConsentMode: false,
|
|
20
|
-
logConsentToServer: false
|
|
21
|
-
};
|
|
22
|
-
function hasUnsafeUrlChars(s) {
|
|
23
|
-
return /[\u0000-\u001F\u007F\s]/.test(s);
|
|
24
|
-
}
|
|
25
|
-
function isHttpsUrl(s) {
|
|
26
|
-
try {
|
|
27
|
-
const t = s.trim();
|
|
28
|
-
if (!t) return false;
|
|
29
|
-
if (hasUnsafeUrlChars(t)) return false;
|
|
30
|
-
if (/%0d|%0a|%09|%0b|%0c|%20/i.test(t)) return false;
|
|
31
|
-
const u = new URL(t);
|
|
32
|
-
if (u.protocol !== "https:") return false;
|
|
33
|
-
if (u.username || u.password) return false;
|
|
34
|
-
return true;
|
|
35
|
-
} catch {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
function invalidScriptUrlError(kind, u) {
|
|
40
|
-
if (u.includes("<") || u.includes(">")) return `Invalid ${kind} script URL (https only). Paste one full https:// URL per line (the script src only), not a <script> tag or HTML comment. Example: https://static.cloudflareinsights.com/beacon.min.js. The invalid line was: ${u}`;
|
|
41
|
-
return `Invalid ${kind} script URL (https only): ${u}`;
|
|
42
|
-
}
|
|
43
|
-
function isAnalyticsProvider(s) {
|
|
44
|
-
return s === "cloudflare" || s === "none" || s === "custom";
|
|
45
|
-
}
|
|
46
|
-
/** Reject characters that would break JSON or attributes; allow typical Cloudflare site token strings. */
|
|
47
|
-
function assertValidCloudflareToken(t) {
|
|
48
|
-
const s = t.trim();
|
|
49
|
-
if (s.length === 0) return;
|
|
50
|
-
if (s.length > 256) throw new Error("Cloudflare site token is too long.");
|
|
51
|
-
if (/[\s<>'"&]/.test(s)) throw new Error("Cloudflare site token contains invalid characters.");
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Root-relative public path (EmDash page route), e.g. `/privacy`.
|
|
55
|
-
* Rejects protocol-relative URLs (`//…`) and whitespace.
|
|
56
|
-
*/
|
|
57
|
-
function isRootRelativeSitePath(s) {
|
|
58
|
-
const t = s.trim();
|
|
59
|
-
if (t.length === 0) return false;
|
|
60
|
-
if (!t.startsWith("/")) return false;
|
|
61
|
-
if (t.startsWith("//")) return false;
|
|
62
|
-
if (hasUnsafeUrlChars(t)) return false;
|
|
63
|
-
return true;
|
|
64
|
-
}
|
|
65
|
-
/** For hooks: turn stored path or absolute URL into a public absolute URL. */
|
|
66
|
-
function resolvePolicyHref(stored, ctx) {
|
|
67
|
-
const t = stored.trim();
|
|
68
|
-
if (!t) return t;
|
|
69
|
-
if (isRootRelativeSitePath(t)) return ctx.url(t);
|
|
70
|
-
return t;
|
|
71
|
-
}
|
|
72
|
-
function isValidPolicyHrefInput(s) {
|
|
73
|
-
const t = s.trim();
|
|
74
|
-
if (!t) return false;
|
|
75
|
-
return isHttpsUrl(t) || isRootRelativeSitePath(t);
|
|
76
|
-
}
|
|
77
|
-
function parseUrlList(text) {
|
|
78
|
-
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
79
|
-
return [...new Set(lines)];
|
|
80
|
-
}
|
|
81
|
-
function assertLength(label, s, min, max) {
|
|
82
|
-
const t = s.trim();
|
|
83
|
-
if (t.length < min) throw new Error(`${label} is required.`);
|
|
84
|
-
if (t.length > max) throw new Error(`${label} is too long.`);
|
|
85
|
-
}
|
|
86
|
-
function assertOptionalLength(label, s, max) {
|
|
87
|
-
if (s.trim().length > max) throw new Error(`${label} is too long.`);
|
|
88
|
-
}
|
|
89
|
-
function normalizeConfig(raw) {
|
|
90
|
-
if (typeof raw !== "object" || raw === null) return { ...DEFAULT_CONFIG };
|
|
91
|
-
const o = raw;
|
|
92
|
-
const analytics = Array.isArray(o.analyticsScriptUrls) ? o.analyticsScriptUrls.filter((x) => typeof x === "string") : DEFAULT_CONFIG.analyticsScriptUrls;
|
|
93
|
-
const marketing = Array.isArray(o.marketingScriptUrls) ? o.marketingScriptUrls.filter((x) => typeof x === "string") : DEFAULT_CONFIG.marketingScriptUrls;
|
|
94
|
-
const cfTok = typeof o.cloudflareWebAnalyticsToken === "string" ? o.cloudflareWebAnalyticsToken : "";
|
|
95
|
-
const apCandidate = typeof o.analyticsProvider === "string" ? o.analyticsProvider.trim() : "";
|
|
96
|
-
let analyticsProvider;
|
|
97
|
-
if (isAnalyticsProvider(apCandidate)) analyticsProvider = apCandidate;
|
|
98
|
-
else if (analytics.length > 0) analyticsProvider = "custom";
|
|
99
|
-
else analyticsProvider = DEFAULT_CONFIG.analyticsProvider;
|
|
100
|
-
return {
|
|
101
|
-
bannerTitle: typeof o.bannerTitle === "string" ? o.bannerTitle : DEFAULT_CONFIG.bannerTitle,
|
|
102
|
-
bannerMessage: typeof o.bannerMessage === "string" ? o.bannerMessage : DEFAULT_CONFIG.bannerMessage,
|
|
103
|
-
privacyPolicyUrl: typeof o.privacyPolicyUrl === "string" ? o.privacyPolicyUrl : DEFAULT_CONFIG.privacyPolicyUrl,
|
|
104
|
-
cookiePolicyUrl: typeof o.cookiePolicyUrl === "string" ? o.cookiePolicyUrl : DEFAULT_CONFIG.cookiePolicyUrl,
|
|
105
|
-
strictDefaults: typeof o.strictDefaults === "boolean" ? o.strictDefaults : DEFAULT_CONFIG.strictDefaults,
|
|
106
|
-
policyVersion: typeof o.policyVersion === "string" && o.policyVersion.length > 0 ? o.policyVersion : DEFAULT_CONFIG.policyVersion,
|
|
107
|
-
cloudflareWebAnalyticsToken: cfTok,
|
|
108
|
-
analyticsProvider,
|
|
109
|
-
analyticsScriptUrls: analytics,
|
|
110
|
-
marketingScriptUrls: marketing,
|
|
111
|
-
googleConsentMode: typeof o.googleConsentMode === "boolean" ? o.googleConsentMode : DEFAULT_CONFIG.googleConsentMode,
|
|
112
|
-
logConsentToServer: typeof o.logConsentToServer === "boolean" ? o.logConsentToServer : DEFAULT_CONFIG.logConsentToServer
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
/** Validate admin-submitted fields; throws an Error with a short message on failure */
|
|
116
|
-
function assertValidSavedConfig(input) {
|
|
117
|
-
assertLength("Policy version", input.policyVersion, 1, 64);
|
|
118
|
-
assertOptionalLength("Banner title", input.bannerTitle, 120);
|
|
119
|
-
assertOptionalLength("Short notice", input.bannerMessage, 600);
|
|
120
|
-
const privacyPolicyUrl = input.privacyPolicyUrl.trim();
|
|
121
|
-
if (!isValidPolicyHrefInput(privacyPolicyUrl)) throw new Error("Privacy policy must be a full https:// URL or a site path to your EmDash Page (e.g. /privacy).");
|
|
122
|
-
if (privacyPolicyUrl.length > 2048) throw new Error("Privacy policy URL/path is too long.");
|
|
123
|
-
let cookiePolicyUrl = input.cookiePolicyUrl.trim();
|
|
124
|
-
if (cookiePolicyUrl && !isValidPolicyHrefInput(cookiePolicyUrl)) throw new Error("Cookie policy must be a valid https:// URL or a root-relative path (e.g. /cookies), or empty.");
|
|
125
|
-
if (cookiePolicyUrl.length > 2048) throw new Error("Cookie policy URL/path is too long.");
|
|
126
|
-
if (!cookiePolicyUrl) cookiePolicyUrl = DEFAULT_CONFIG.cookiePolicyUrl;
|
|
127
|
-
const ap = input.analyticsPlatform.trim() || "cloudflare";
|
|
128
|
-
if (!isAnalyticsProvider(ap)) throw new Error("Analytics: choose a supported option (Cloudflare, None, or Custom).");
|
|
129
|
-
const tokenTrim = input.cloudflareToken.trim();
|
|
130
|
-
if (ap === "cloudflare") assertValidCloudflareToken(tokenTrim);
|
|
131
|
-
const analyticsScriptUrls = [];
|
|
132
|
-
if (ap === "custom") {
|
|
133
|
-
const list = parseUrlList(input.analyticsUrlsText);
|
|
134
|
-
if (list.length > 50) throw new Error("Analytics script URL list is too long (max 50).");
|
|
135
|
-
for (const u of list) {
|
|
136
|
-
if (u.length > 2048) throw new Error(invalidScriptUrlError("analytics", u));
|
|
137
|
-
if (!isHttpsUrl(u)) throw new Error(invalidScriptUrlError("analytics", u));
|
|
138
|
-
analyticsScriptUrls.push(u);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
const marketingScriptUrls = [];
|
|
142
|
-
const mlist = parseUrlList(input.marketingUrlsText);
|
|
143
|
-
if (mlist.length > 50) throw new Error("Marketing script URL list is too long (max 50).");
|
|
144
|
-
for (const u of mlist) {
|
|
145
|
-
if (u.length > 2048) throw new Error(invalidScriptUrlError("marketing", u));
|
|
146
|
-
if (!isHttpsUrl(u)) throw new Error(invalidScriptUrlError("marketing", u));
|
|
147
|
-
marketingScriptUrls.push(u);
|
|
148
|
-
}
|
|
149
|
-
return normalizeConfig({
|
|
150
|
-
bannerTitle: input.bannerTitle.trim() || DEFAULT_CONFIG.bannerTitle,
|
|
151
|
-
bannerMessage: input.bannerMessage.trim() || DEFAULT_CONFIG.bannerMessage,
|
|
152
|
-
privacyPolicyUrl,
|
|
153
|
-
cookiePolicyUrl,
|
|
154
|
-
strictDefaults: input.strictDefaults,
|
|
155
|
-
policyVersion: input.policyVersion.trim(),
|
|
156
|
-
analyticsProvider: ap,
|
|
157
|
-
cloudflareWebAnalyticsToken: ap === "cloudflare" ? tokenTrim : "",
|
|
158
|
-
analyticsScriptUrls: ap === "custom" ? analyticsScriptUrls : [],
|
|
159
|
-
marketingScriptUrls,
|
|
160
|
-
googleConsentMode: input.googleConsentMode,
|
|
161
|
-
logConsentToServer: input.logConsentToServer
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
//#endregion
|
|
166
|
-
export { assertValidCloudflareToken as a, isValidPolicyHrefInput as c, PLUGIN_ID as i, normalizeConfig as l, DEFAULT_CONFIG as n, assertValidSavedConfig as o, KV_KEY as r, isRootRelativeSitePath as s, COOKIE_NAME as t, resolvePolicyHref as u };
|