overlaify 0.0.0-stage → 0.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/lib/publish.js ADDED
@@ -0,0 +1,255 @@
1
+ // login / logout / whoami / publish / download: move overlays between this machine and My overlays on overlaify.com.
2
+ // The CLI token lives in ~/.overlaify/credentials.json, one per site, so a local copy of the site
3
+ // (--site http://localhost:8788) has its own. OVERLAIFY_TOKEN overrides it, for CI.
4
+ import { createServer } from 'node:http';
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
6
+ import { basename, dirname, join, resolve } from 'node:path';
7
+ import { homedir, hostname } from 'node:os';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { spawn } from 'node:child_process';
10
+ import { createInterface } from 'node:readline/promises';
11
+ import { validateOverlay } from './validate.js';
12
+ import { SDK_TAG } from './commands.js';
13
+
14
+ export const DEFAULT_SITE = 'https://www.overlaify.com';
15
+ const CREDENTIALS = join(homedir(), '.overlaify/credentials.json');
16
+ const KINDS = ['alert', 'chat', 'goal', 'ticker', 'frame', 'custom'];
17
+
18
+ export function siteUrl(site) {
19
+ const raw = site || process.env.OVERLAIFY_SITE || DEFAULT_SITE;
20
+ let url;
21
+ try { url = new URL(raw); } catch { throw new Error(`Not a URL: ${raw}`); }
22
+ return url.origin;
23
+ }
24
+
25
+ const readCredentials = () => {
26
+ try { return JSON.parse(readFileSync(CREDENTIALS, 'utf8')); } catch { return {}; }
27
+ };
28
+
29
+ function writeCredentials(all) {
30
+ mkdirSync(dirname(CREDENTIALS), { recursive: true });
31
+ writeFileSync(CREDENTIALS, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
32
+ chmodSync(CREDENTIALS, 0o600);
33
+ }
34
+
35
+ const tokenFor = (site) => process.env.OVERLAIFY_TOKEN || readCredentials()[site]?.token || null;
36
+
37
+ async function api(site, path, { method = 'GET', body, token = tokenFor(site) } = {}) {
38
+ const headers = { accept: 'application/json' };
39
+ if (token) headers.authorization = `Bearer ${token}`;
40
+ if (body) headers['content-type'] = 'application/json';
41
+ let res;
42
+ try {
43
+ res = await fetch(site + path, { method, headers, body: body && JSON.stringify(body) });
44
+ } catch (err) {
45
+ throw new Error(`Could not reach ${site} (${err.cause?.code || err.message}).`);
46
+ }
47
+ const data = await res.json().catch(() => ({}));
48
+ if (!res.ok) {
49
+ const e = new Error(data.error || `${method} ${path} failed (${res.status}).`);
50
+ e.status = res.status;
51
+ e.code = data.code;
52
+ throw e;
53
+ }
54
+ return data;
55
+ }
56
+
57
+ /** The signed-in user for a site, or null. */
58
+ async function me(site, token) {
59
+ const { user } = await api(site, '/api/me', { token });
60
+ return user;
61
+ }
62
+
63
+ function openBrowser(url) {
64
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
65
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
66
+ try {
67
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
68
+ } catch {}
69
+ }
70
+
71
+ async function ask(question) {
72
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
73
+ try { return (await rl.question(question)).trim(); } finally { rl.close(); }
74
+ }
75
+
76
+ /**
77
+ * overlaify login [--site url] [--token t] [--no-browser]
78
+ * Opens <site>/cli, where the signed-in user allows the CLI; the page hands the token back to a one-off
79
+ * server on 127.0.0.1. With --no-browser (or over SSH) the page shows the token to paste instead.
80
+ */
81
+ export async function login({ site, token, browser = true, log = console.log } = {}) {
82
+ site = siteUrl(site);
83
+ if (!token) token = browser ? await tokenFromBrowser(site, log) : await tokenFromPaste(site, log);
84
+ token = token.trim();
85
+ const user = await me(site, token).catch(() => null);
86
+ if (!user) throw new Error('That token was not accepted. Run overlaify login again.');
87
+ const all = readCredentials();
88
+ all[site] = { token, user: { id: user.id, name: user.name } };
89
+ writeCredentials(all);
90
+ return { site, user };
91
+ }
92
+
93
+ function tokenFromBrowser(site, log) {
94
+ const state = randomBytes(16).toString('hex');
95
+ return new Promise((resolveToken, reject) => {
96
+ const server = createServer((req, res) => {
97
+ const url = new URL(req.url, 'http://127.0.0.1');
98
+ if (url.pathname !== '/callback') { res.writeHead(404).end(); return; }
99
+ const token = url.searchParams.get('token');
100
+ if (url.searchParams.get('state') !== state || !/^ovl_[0-9a-f]{64}$/.test(token || '')) {
101
+ res.writeHead(400, { 'content-type': 'text/plain' }).end('This sign-in link does not match. Run overlaify login again.');
102
+ return;
103
+ }
104
+ res.writeHead(302, { location: `${site}/my` }).end();
105
+ clearTimeout(timer);
106
+ server.close();
107
+ resolveToken(token);
108
+ });
109
+ const timer = setTimeout(() => { server.close(); reject(new Error('Timed out waiting for the browser. Run overlaify login again, or use --no-browser.')); }, 5 * 60 * 1000);
110
+ server.on('error', reject);
111
+ server.listen(0, '127.0.0.1', () => {
112
+ const q = new URLSearchParams({ port: String(server.address().port), state, name: hostname() });
113
+ const url = `${site}/cli?${q}`;
114
+ log(`Opening ${url}\nAllow the CLI there (sign in first if asked). Waiting…`);
115
+ openBrowser(url);
116
+ });
117
+ });
118
+ }
119
+
120
+ async function tokenFromPaste(site, log) {
121
+ if (!process.stdin.isTTY) throw new Error('Pass the token with --token, or set OVERLAIFY_TOKEN.');
122
+ log(`Open ${site}/cli in a browser, allow the CLI and copy the token it shows.`);
123
+ return ask('Token: ');
124
+ }
125
+
126
+ /** overlaify logout [--site url] — revokes the token on the site and forgets it here. */
127
+ export async function logout({ site } = {}) {
128
+ site = siteUrl(site);
129
+ const all = readCredentials();
130
+ if (!all[site]) return false;
131
+ await api(site, '/api/cli/token', { method: 'DELETE', token: all[site].token }).catch(() => {});
132
+ delete all[site];
133
+ writeCredentials(all);
134
+ return true;
135
+ }
136
+
137
+ /** overlaify whoami [--site url] → the user, or null when not logged in. */
138
+ export async function whoami({ site } = {}) {
139
+ site = siteUrl(site);
140
+ if (!tokenFor(site)) return null;
141
+ return me(site);
142
+ }
143
+
144
+ // Where a file was published: .overlaify/<name>.publish.json → { [site]: { projectId, id, url } },
145
+ // next to the settings `overlaify dev` keeps in .overlaify/<name>.json.
146
+ const stateFile = (file, ext) => join(dirname(file), '.overlaify', basename(file).replace(/\.html?$/i, '') + ext);
147
+ const readJsonFile = (f) => {
148
+ try { return JSON.parse(readFileSync(f, 'utf8')); } catch { return null; }
149
+ };
150
+
151
+ const titleOf = (html, file) =>
152
+ (/<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1] || '').replace(/\s+/g, ' ').trim() || basename(file).replace(/\.html?$/i, '');
153
+
154
+ /**
155
+ * overlaify publish [file] [--public | --private] [--name n] [--title t] [--kind k] [--new] [--site url]
156
+ * Uploads the overlay to My overlays. The first publish of a file asks public or private; publishing it
157
+ * again adds a new version to the same overlay (its OBS URL changes to the new version's) unless --new.
158
+ * `name` is its address, overlaify.com/<handle>/<name>; a new overlay is otherwise named after its title.
159
+ */
160
+ export async function publish(file, { site, visibility, name, title, kind, fresh, log = console.log } = {}) {
161
+ site = siteUrl(site);
162
+ file = resolve(file);
163
+ if (!existsSync(file)) throw new Error(`No such file: ${file}`);
164
+ if (kind && !KINDS.includes(kind)) throw new Error(`--kind must be one of ${KINDS.join(', ')}.`);
165
+ if (name != null && !/^[a-z0-9](?:[a-z0-9-]{0,58}[a-z0-9])?$/.test(name)) {
166
+ throw new Error('--name must be 1–60 lowercase letters, digits or dashes, not at the start or end.');
167
+ }
168
+ if (!tokenFor(site)) throw new Error(`Not logged in to ${site}. Run: overlaify login${site === DEFAULT_SITE ? '' : ` --site ${site}`}`);
169
+
170
+ const html = readFileSync(file, 'utf8');
171
+ const check = validateOverlay(html);
172
+ if (!check.ok) {
173
+ throw new Error(`${basename(file)} doesn't pass the spec. Fix these first (overlaify validate ${basename(file)}):\n` +
174
+ check.errors.map((i) => ` [${i.code}] ${i.message}`).join('\n'));
175
+ }
176
+
177
+ const published = readJsonFile(stateFile(file, '.publish.json')) || {};
178
+ const previous = fresh ? null : published[site];
179
+ if (!visibility && !previous) {
180
+ if (!process.stdin.isTTY) throw new Error('Choose who can see it: --public or --private.');
181
+ log('Who can open this overlay on Overlaify?\n 1) Private — only you (its OBS URL still works)\n 2) Public — anyone with a link');
182
+ const a = (await ask('Choose 1 or 2 [1]: ')).toLowerCase();
183
+ visibility = a === '2' || a.startsWith('pub') ? 'public' : a === '' || a === '1' || a.startsWith('pri') ? 'private' : null;
184
+ if (!visibility) throw new Error('Publishing cancelled.');
185
+ }
186
+
187
+ const body = { html, title: title || titleOf(html, file), name, kind, visibility, config: readJsonFile(stateFile(file, '.json')) || undefined };
188
+ let out;
189
+ try {
190
+ out = await api(site, '/api/my/overlays/publish', { method: 'POST', body: { ...body, projectId: previous?.projectId } });
191
+ } catch (err) {
192
+ // The overlay was deleted on the site: publish it as a new one, which needs a visibility.
193
+ if (err.code !== 'project_not_found' || !previous) throw err;
194
+ log(`The overlay published from ${basename(file)} is no longer in your account; publishing it as a new one.`);
195
+ out = await api(site, '/api/my/overlays/publish', { method: 'POST', body: { ...body, visibility: visibility || 'private' } });
196
+ }
197
+
198
+ published[site] = { projectId: out.projectId, id: out.id, url: out.url };
199
+ mkdirSync(dirname(stateFile(file, '.publish.json')), { recursive: true });
200
+ writeFileSync(stateFile(file, '.publish.json'), JSON.stringify(published, null, 2) + '\n');
201
+ return out;
202
+ }
203
+
204
+ // An overlay on the site: handle/name, @handle/name, or its page URL (whose origin is then the site).
205
+ function parseRef(ref, site) {
206
+ let path = String(ref || '');
207
+ if (/^https?:\/\//i.test(path)) {
208
+ const url = new URL(path);
209
+ site ||= url.origin;
210
+ path = url.pathname;
211
+ }
212
+ const m = /^\/?@?([a-z0-9_-]+)\/([a-z0-9-]+)\/?$/i.exec(path);
213
+ if (!m) throw new Error('Name an overlay as <user>/<name>, e.g. overlaify download alice/neon-alert (or paste its page URL).');
214
+ return { site: siteUrl(site), handle: m[1].toLowerCase(), name: m[2].toLowerCase() };
215
+ }
216
+
217
+ /**
218
+ * overlaify download <user>/<name> [file] [--version n] [--force] [--site url]
219
+ * Saves an overlay from overlaify.com/<user>/<name> as a local file (default <name>.html), with the SDK tag
220
+ * put back so it runs on its own. A private overlay needs its owner to be logged in. For your own overlay the
221
+ * saved settings come along for `overlaify dev`, and `overlaify publish` then adds versions to that overlay.
222
+ */
223
+ export async function download(ref, file, { site, version, force } = {}) {
224
+ const { site: origin, handle, name } = parseRef(ref, site);
225
+ site = origin;
226
+ if (version != null && !/^[1-9]\d*$/.test(String(version))) throw new Error('--version must be a version number: 1 is the first.');
227
+ let out;
228
+ try {
229
+ out = await api(site, `/api/u/${handle}/${name}${version != null ? `?version=${version}` : ''}`);
230
+ } catch (err) {
231
+ if (err.status === 401) {
232
+ const how = `overlaify login${site === DEFAULT_SITE ? '' : ` --site ${site}`}`;
233
+ throw new Error(`${handle}/${name} is private. ${tokenFor(site) ? 'Your login has expired; log in again' : 'If it is yours, log in first'}: ${how}`);
234
+ }
235
+ throw err;
236
+ }
237
+ if (typeof out.html !== 'string') throw new Error(`${site} doesn't support downloads yet (no /api/u/${handle}/${name}).`);
238
+
239
+ file = resolve(file || `${name}.html`);
240
+ if (existsSync(file) && !force) throw new Error(`${basename(file)} already exists (use --force to overwrite).`);
241
+ const html = /<head[^>]*>/i.test(out.html)
242
+ ? out.html.replace(/<head[^>]*>/i, (m) => `${m}\n${SDK_TAG}`)
243
+ : `${SDK_TAG}\n${out.html}`;
244
+ mkdirSync(dirname(file), { recursive: true });
245
+ writeFileSync(file, html);
246
+
247
+ if (out.owned) {
248
+ mkdirSync(dirname(stateFile(file, '.json')), { recursive: true });
249
+ if (out.config && Object.keys(out.config).length) writeFileSync(stateFile(file, '.json'), JSON.stringify(out.config, null, 2) + '\n');
250
+ const published = readJsonFile(stateFile(file, '.publish.json')) || {};
251
+ published[site] = { projectId: out.projectId, id: out.id, url: `${site}/o/${out.id}` };
252
+ writeFileSync(stateFile(file, '.publish.json'), JSON.stringify(published, null, 2) + '\n');
253
+ }
254
+ return { ...out, file, page: `${site}/${handle}/${name}` };
255
+ }
@@ -0,0 +1,51 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>My overlay</title>
6
+ <script src="https://www.overlaify.com/sdk/v1/overlaify.js"></script>
7
+ <!-- Follows Overlay Spec v1; check it with: npx overlaify validate <file> -->
8
+ <script type="application/json" id="overlaify-manifest">
9
+ {
10
+ "spec": 1,
11
+ "settings": [
12
+ { "key": "color", "label": "Accent color", "type": "color", "default": "#c6ff3d", "swatches": ["#c6ff3d", "#ff4f8b", "#5ce1ff", "#ffb547", "#b48bff"] },
13
+ { "key": "duration", "label": "On screen", "type": "range", "min": 2, "max": 15, "step": 0.5, "unit": "s", "default": 5 }
14
+ ],
15
+ "actions": [
16
+ { "name": "test", "label": "Show a test alert" }
17
+ ]
18
+ }
19
+ </script>
20
+ <style>
21
+ html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: transparent; }
22
+ #card { position: absolute; left: 50%; top: 120px; transform: translate(-50%, -30px); opacity: 0; transition: transform .45s, opacity .45s;
23
+ padding: 28px 48px; border-radius: 20px; background: rgba(11, 10, 16, .85); border: 3px solid var(--c); box-shadow: 0 0 40px var(--c);
24
+ color: #fff; font: 800 54px/1.1 system-ui, sans-serif; text-align: center; text-shadow: 0 3px 10px rgba(0, 0, 0, .6); }
25
+ #card.on { opacity: 1; transform: translate(-50%, 0); }
26
+ #card small { display: block; font-size: 24px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--c); }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <div id="card"><small id="kicker"></small><span id="name"></span></div>
31
+ <script>
32
+ const card = document.getElementById('card');
33
+ document.documentElement.style.setProperty('--c', Overlaify.param('color', '#c6ff3d'));
34
+ const DURATION = Overlaify.param('duration', 5) * 1000;
35
+
36
+ const WORDS = { follow: 'New follower', sub: 'New subscriber', gift: 'Gifted a sub', giftbomb: 'Gift bomb', tip: 'Tipped', raid: 'Raid' };
37
+ const show = Overlaify.queue(async (e) => {
38
+ document.getElementById('kicker').textContent = WORDS[e.type] || e.type;
39
+ document.getElementById('name').textContent = e.type === 'tip' ? `${e.name} · ${e.display}` : e.name;
40
+ card.classList.add('on');
41
+ Overlaify.chime('alert');
42
+ await Overlaify.sleep(DURATION);
43
+ card.classList.remove('on');
44
+ await Overlaify.sleep(500);
45
+ });
46
+
47
+ Object.keys(WORDS).forEach((type) => Overlaify.on(type, show));
48
+ Overlaify.action('test', () => Overlaify.simulate('sub'));
49
+ </script>
50
+ </body>
51
+ </html>
@@ -0,0 +1,362 @@
1
+ // Checks an overlay's HTML against the Overlaify Overlay Spec (docs/overlay-spec.md).
2
+ // Plain string scanning, no DOM, so it runs the same in Pages Functions, Node scripts and a future CLI.
3
+
4
+ export const SPEC_VERSION = 1;
5
+ export const MAX_BYTES = 80000;
6
+ export const SETTING_TYPES = ['text', 'number', 'toggle', 'color', 'select', 'segmented', 'range'];
7
+ export const ACTION_STYLES = ['primary', 'secondary', 'danger'];
8
+ export const INPUT_TYPES = ['number', 'text', 'select'];
9
+ export const RESERVED_KEYS = ['channel', 'sources', 'volume', 'mute', 'test'];
10
+ export const EVENTS = ['chat', 'follow', 'sub', 'gift', 'giftbomb', 'cheer', 'tip', 'raid'];
11
+ const LIFECYCLE = ['connected', 'disconnected', '*'];
12
+ const ALLOWED_HOSTS = ['fonts.googleapis.com', 'fonts.gstatic.com', 'static-cdn.jtvnw.net'];
13
+ const SDK_HOSTS = ['www.overlaify.com', 'overlaify.com'];
14
+ // Namespaces, not requests.
15
+ const NAMESPACE_HOSTS = ['www.w3.org'];
16
+ const NAME = /^[A-Za-z][A-Za-z0-9_-]{0,39}$/;
17
+ const HEX = /^#[0-9a-f]{6}$/i;
18
+
19
+ /**
20
+ * validateOverlay(html) → { ok, errors, warnings, manifest }
21
+ * Each issue is { code, message }. `ok` means no errors; warnings don't block hosting.
22
+ */
23
+ export function validateOverlay(html) {
24
+ const errors = [];
25
+ const warnings = [];
26
+ const err = (code, message) => errors.push({ code, message });
27
+ const warn = (code, message) => warnings.push({ code, message });
28
+ html = String(html || '');
29
+
30
+ checkDocument(html, err, warn);
31
+
32
+ const blocks = manifestBlocks(html);
33
+ let manifest = null;
34
+ if (blocks.length > 1) err('manifest_duplicate', `Found ${blocks.length} overlaify-manifest blocks; keep one.`);
35
+ if (blocks.length) {
36
+ const b = blocks[0];
37
+ if (!/\btype\s*=\s*["']?application\/json\b/i.test(b.attrs)) {
38
+ err('manifest_type', 'The overlaify-manifest <script> needs type="application/json".');
39
+ }
40
+ try {
41
+ manifest = JSON.parse(b.body);
42
+ } catch (e) {
43
+ err('manifest_json', `The manifest is not valid JSON (${e.message}). Use double quotes, no comments, no trailing commas.`);
44
+ }
45
+ if (manifest !== null && (typeof manifest !== 'object' || Array.isArray(manifest))) {
46
+ err('manifest_json', 'The manifest must be a JSON object: { "spec": 1, "settings": [...], "actions": [...] }.');
47
+ manifest = null;
48
+ }
49
+ if (manifest) checkManifest(manifest, err, warn);
50
+ }
51
+
52
+ checkCode(stripManifest(html), manifest, blocks.length > 0, err, warn);
53
+
54
+ return { ok: errors.length === 0, errors, warnings, manifest };
55
+ }
56
+
57
+ /** The issues as a bulleted list, for showing a person or asking a model to fix them. */
58
+ export function formatIssues(issues) {
59
+ return issues.map((i) => `- [${i.code}] ${i.message}`).join('\n');
60
+ }
61
+
62
+ /* ------------------------------------------------------------------ */
63
+ /* Document */
64
+ /* ------------------------------------------------------------------ */
65
+
66
+ function checkDocument(html, err, warn) {
67
+ const bytes = new TextEncoder().encode(html).length;
68
+ if (bytes > MAX_BYTES) err('too_large', `The overlay is ${Math.round(bytes / 1000)} KB; the limit is ${MAX_BYTES / 1000} KB.`);
69
+ if (!/<html[\s>]/i.test(html)) warn('not_html', 'Not a complete HTML document; wrap it in <!doctype html><html>…</html>.');
70
+ if (!/<title>\s*[^<\s][^<]*<\/title>/i.test(html)) warn('title_missing', 'Add a short overlay name in <title>.');
71
+
72
+ const tags = html.match(/<[a-z][^>]*>/gi) || [];
73
+ for (const tag of tags) {
74
+ const name = tag.match(/^<([a-z0-9-]+)/i)[1].toLowerCase();
75
+ if (name === 'iframe' || name === 'object' || name === 'embed') {
76
+ err('embedded_frame', `<${name}> is not allowed; overlays can't embed other pages.`);
77
+ continue;
78
+ }
79
+ if (name === 'script') {
80
+ const src = attr(tag, 'src');
81
+ // The SDK tag is fine (local previews need it); Overlaify swaps it for its own when hosting.
82
+ if (src !== null && !isSdkUrl(src)) {
83
+ err('external_script', `<script src="${short(src)}"> is not allowed; put all JS inline.`);
84
+ }
85
+ continue;
86
+ }
87
+ if (name === 'link') {
88
+ const href = attr(tag, 'href');
89
+ const rel = (attr(tag, 'rel') || '').toLowerCase();
90
+ // preconnect/dns-prefetch only warm up a connection; they load nothing.
91
+ if (href !== null && !isDynamic(href) && !/\b(preconnect|dns-prefetch)\b/.test(rel) && !allowedUrl(href)) {
92
+ err('external_resource', `<link href="${short(href)}"> loads an external file; inline it (only Google Fonts are allowed).`);
93
+ }
94
+ continue;
95
+ }
96
+ for (const a of ['src', 'poster', 'data']) {
97
+ const v = attr(tag, a);
98
+ if (v !== null && !isDynamic(v) && !allowedUrl(v)) {
99
+ err('external_resource', `<${name} ${a}="${short(v)}"> loads an external file; use a data: URL, inline SVG, canvas or emoji.`);
100
+ }
101
+ }
102
+ if (name === 'image' || name === 'use' || name === 'feimage') {
103
+ const v = attr(tag, 'href') ?? attr(tag, 'xlink:href');
104
+ if (v !== null && !v.startsWith('#') && !isDynamic(v) && !allowedUrl(v)) {
105
+ err('external_resource', `<${name} href="${short(v)}"> loads an external file.`);
106
+ }
107
+ }
108
+ }
109
+
110
+ // CSS only: <style> blocks and style="" attributes (in JS, url(...) is usually a function call).
111
+ const css = [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)].map((m) => m[1])
112
+ .concat(tags.map((t) => attr(t, 'style')).filter(Boolean)).join('\n');
113
+ for (const m of css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi)) {
114
+ const u = m[2].trim();
115
+ if (u && !u.startsWith('#') && !isDynamic(u) && !allowedUrl(u)) err('external_resource', `CSS url(${short(u)}) loads an external file; use a data: URL or a gradient.`);
116
+ }
117
+ // @import url(...) was covered above.
118
+ for (const m of css.matchAll(/@import\s+['"]([^'"]+)/gi)) {
119
+ if (!allowedUrl(m[1])) err('external_resource', `@import ${short(m[1])} loads an external stylesheet; inline it (only Google Fonts are allowed).`);
120
+ }
121
+ }
122
+
123
+ // data:/blob: and the spec's allowed hosts. Relative URLs have nothing to load from on Overlaify.
124
+ function allowedUrl(u) {
125
+ u = u.trim();
126
+ if (!u || /^(data|blob):/i.test(u)) return true;
127
+ const host = hostOf(u);
128
+ return host !== null && ALLOWED_HOSTS.includes(host);
129
+ }
130
+
131
+ // Markup built in JS (`<img src="${url}">`, 'url(' + x + ')') is filled in at run time; the scan can't judge it.
132
+ function isDynamic(v) {
133
+ return /\$\{|['"`]|\s\+|\+\s/.test(v);
134
+ }
135
+
136
+ // /sdk/v1/overlaify.js, on Overlaify or relative to wherever the overlay is served (overlaify dev).
137
+ function isSdkUrl(u) {
138
+ const host = hostOf(u);
139
+ return (host === null || SDK_HOSTS.includes(host)) && /(^|\/)sdk\/(v\d+\/)?overlaify(\.min)?\.js(\?|$)/i.test(u);
140
+ }
141
+
142
+ function hostOf(u) {
143
+ const m = u.match(/^(?:https?:)?\/\/([^/?#:\s]+)/i);
144
+ return m ? m[1].toLowerCase() : null;
145
+ }
146
+
147
+ function attr(tag, name) {
148
+ const re = new RegExp(`\\s${name.replace(':', '\\:')}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i');
149
+ const m = tag.match(re);
150
+ return m ? (m[1] ?? m[2] ?? m[3]) : null;
151
+ }
152
+
153
+ function short(s) {
154
+ return s.length > 60 ? s.slice(0, 57) + '...' : s;
155
+ }
156
+
157
+ /* ------------------------------------------------------------------ */
158
+ /* Manifest */
159
+ /* ------------------------------------------------------------------ */
160
+
161
+ const SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
162
+ const isManifest = (attrs) => /\bid\s*=\s*["']?overlaify-manifest\b/i.test(attrs);
163
+
164
+ function manifestBlocks(html) {
165
+ return [...html.matchAll(SCRIPT)].filter((m) => isManifest(m[1])).map((m) => ({ attrs: m[1], body: m[2] }));
166
+ }
167
+
168
+ function stripManifest(html) {
169
+ return html.replace(SCRIPT, (all, attrs) => (isManifest(attrs) ? '' : all));
170
+ }
171
+
172
+ function checkManifest(m, err, warn) {
173
+ if (!('spec' in m)) warn('spec_missing', `Add "spec": ${SPEC_VERSION} to the manifest.`);
174
+ else if (m.spec !== SPEC_VERSION) err('spec_version', `Unknown spec version ${JSON.stringify(m.spec)}; this validator knows spec ${SPEC_VERSION}.`);
175
+ for (const k of Object.keys(m)) {
176
+ if (!['spec', 'settings', 'actions'].includes(k)) warn('manifest_unknown_key', `Unknown manifest key "${k}"; only spec, settings and actions are read.`);
177
+ }
178
+ if ('settings' in m && !Array.isArray(m.settings)) err('manifest_shape', '"settings" must be an array.');
179
+ if ('actions' in m && !Array.isArray(m.actions)) err('manifest_shape', '"actions" must be an array.');
180
+
181
+ const keys = new Set();
182
+ (Array.isArray(m.settings) ? m.settings : []).forEach((s, i) => checkSetting(s, i, keys, err, warn));
183
+ const names = new Set();
184
+ (Array.isArray(m.actions) ? m.actions : []).forEach((a, i) => checkAction(a, i, names, err, warn));
185
+ }
186
+
187
+ function checkSetting(s, i, keys, err, warn) {
188
+ if (!s || typeof s !== 'object' || Array.isArray(s)) return err('setting_key', `settings[${i}] must be an object.`);
189
+ const at = typeof s.key === 'string' ? `Setting "${s.key}"` : `settings[${i}]`;
190
+ if (typeof s.key !== 'string' || !NAME.test(s.key)) {
191
+ err('setting_key', `${at}: "key" must be letters, digits, _ or -, starting with a letter (max 40).`);
192
+ } else if (RESERVED_KEYS.includes(s.key)) {
193
+ err('setting_reserved', `${at}: "${s.key}" is reserved; the platform always provides ${RESERVED_KEYS.join(', ')}.`);
194
+ } else if (keys.has(s.key)) {
195
+ err('setting_duplicate', `${at} is declared twice.`);
196
+ } else {
197
+ keys.add(s.key);
198
+ }
199
+ if (typeof s.label !== 'string' || !s.label.trim()) warn('setting_label', `${at}: add a short "label".`);
200
+ for (const f of ['desc', 'group']) {
201
+ if (f in s && typeof s[f] !== 'string') warn('setting_field', `${at}: "${f}" should be a string.`);
202
+ }
203
+ if (!SETTING_TYPES.includes(s.type)) {
204
+ return err('setting_type', `${at}: "type" must be one of ${SETTING_TYPES.join(', ')}.`);
205
+ }
206
+
207
+ const has = 'default' in s;
208
+ const d = s.default;
209
+ if (!has) warn('setting_default_missing', `${at}: add a "default" (the value the overlay starts with).`);
210
+ const bad = (want) => err('setting_default', `${at}: "default" must be ${want}, got ${JSON.stringify(d)}.`);
211
+
212
+ switch (s.type) {
213
+ case 'text':
214
+ if (has && typeof d !== 'string') bad('a string');
215
+ break;
216
+ case 'number':
217
+ if (has && !isNum(d)) bad('a number');
218
+ break;
219
+ case 'toggle':
220
+ if (has && typeof d !== 'boolean') bad('true or false');
221
+ break;
222
+ case 'color':
223
+ if (has && !(typeof d === 'string' && HEX.test(d))) bad('a #rrggbb color');
224
+ if ('swatches' in s && !(Array.isArray(s.swatches) && s.swatches.every((c) => typeof c === 'string' && HEX.test(c)))) {
225
+ err('setting_swatches', `${at}: "swatches" must be an array of #rrggbb colors.`);
226
+ }
227
+ break;
228
+ case 'select':
229
+ case 'segmented': {
230
+ const values = optionValues(s.options);
231
+ if (!values) {
232
+ err('setting_options', `${at}: "options" must be a non-empty array of values or { "value", "label" }.`);
233
+ break;
234
+ }
235
+ if (s.type === 'segmented' && values.length > 4) warn('setting_options', `${at}: segmented fits 2-4 choices; use "select" for ${values.length}.`);
236
+ if (has && !values.includes(String(d))) bad(`one of the options (${values.join(', ')})`);
237
+ break;
238
+ }
239
+ case 'range':
240
+ if (!isNum(s.min) || !isNum(s.max) || s.min >= s.max) {
241
+ err('setting_range', `${at}: a range needs numeric "min" < "max".`);
242
+ } else if (has && (!isNum(d) || d < s.min || d > s.max)) {
243
+ bad(`a number from ${s.min} to ${s.max}`);
244
+ }
245
+ if ('step' in s && !(isNum(s.step) && s.step > 0)) err('setting_range', `${at}: "step" must be a number above 0.`);
246
+ break;
247
+ }
248
+ }
249
+
250
+ function checkAction(a, i, names, err, warn) {
251
+ if (!a || typeof a !== 'object' || Array.isArray(a)) return err('action_name', `actions[${i}] must be an object.`);
252
+ const at = typeof a.name === 'string' ? `Action "${a.name}"` : `actions[${i}]`;
253
+ if (typeof a.name !== 'string' || !NAME.test(a.name)) {
254
+ err('action_name', `${at}: "name" must be letters, digits, _ or -, starting with a letter (max 40).`);
255
+ } else if (names.has(a.name)) {
256
+ err('action_duplicate', `${at} is declared twice.`);
257
+ } else {
258
+ names.add(a.name);
259
+ }
260
+ if (typeof a.label !== 'string' || !a.label.trim()) warn('action_label', `${at}: add a "label" for its key.`);
261
+ if ('group' in a && typeof a.group !== 'string') warn('action_field', `${at}: "group" should be a string.`);
262
+ if ('wide' in a && typeof a.wide !== 'boolean') warn('action_field', `${at}: "wide" should be true or false.`);
263
+ if ('style' in a && !ACTION_STYLES.includes(a.style)) err('action_style', `${at}: "style" must be one of ${ACTION_STYLES.join(', ')}.`);
264
+ if ('confirm' in a && typeof a.confirm !== 'boolean' && typeof a.confirm !== 'string') {
265
+ err('action_confirm', `${at}: "confirm" must be true or a short question.`);
266
+ }
267
+ if (!('input' in a)) return;
268
+ const p = a.input;
269
+ if (!p || typeof p !== 'object' || !INPUT_TYPES.includes(p.type)) {
270
+ return err('action_input', `${at}: "input" must be { "type": ${INPUT_TYPES.join(' | ')}, "label", "default", ... }.`);
271
+ }
272
+ if (p.type === 'number') {
273
+ for (const f of ['default', 'min', 'max', 'step']) {
274
+ if (f in p && !isNum(p[f])) err('action_input', `${at}: input "${f}" must be a number.`);
275
+ }
276
+ }
277
+ if (p.type === 'select') {
278
+ const values = optionValues(p.options);
279
+ if (!values) err('action_input', `${at}: a select input needs non-empty "options".`);
280
+ else if ('default' in p && !values.includes(String(p.default))) err('action_input', `${at}: input "default" must be one of the options.`);
281
+ }
282
+ }
283
+
284
+ function optionValues(options) {
285
+ if (!Array.isArray(options) || !options.length) return null;
286
+ const out = [];
287
+ for (const o of options) {
288
+ if (typeof o === 'string' || isNum(o)) out.push(String(o));
289
+ else if (o && typeof o === 'object' && (typeof o.value === 'string' || isNum(o.value))) out.push(String(o.value));
290
+ else return null;
291
+ }
292
+ return out;
293
+ }
294
+
295
+ const isNum = (n) => typeof n === 'number' && Number.isFinite(n);
296
+
297
+ /* ------------------------------------------------------------------ */
298
+ /* Code */
299
+ /* ------------------------------------------------------------------ */
300
+
301
+ // Literal first arguments of Overlaify.<fn>('...'), e.g. calls('param') → Set of setting keys read.
302
+ function calls(code, fn) {
303
+ const re = new RegExp(`\\bOverlaify\\s*\\.\\s*${fn}\\s*\\(\\s*(['"\`])([^'"\`]+)\\1`, 'g');
304
+ return new Set([...code.matchAll(re)].map((m) => m[2]));
305
+ }
306
+
307
+ function checkCode(code, manifest, hasManifest, err, warn) {
308
+ const params = calls(code, 'param');
309
+ const handlers = calls(code, 'action');
310
+ const settings = new Set((Array.isArray(manifest?.settings) ? manifest.settings : []).map((s) => s?.key)
311
+ .filter((k) => typeof k === 'string' && NAME.test(k) && !RESERVED_KEYS.includes(k)));
312
+ const actions = new Set((Array.isArray(manifest?.actions) ? manifest.actions : []).map((a) => a?.name).filter(Boolean));
313
+ const ownParams = [...params].filter((k) => !RESERVED_KEYS.includes(k));
314
+
315
+ if (!hasManifest && (ownParams.length || handlers.size)) {
316
+ warn('manifest_missing', 'The overlay reads settings or handles actions but has no overlaify-manifest, so the streamer gets no settings panel or keys.');
317
+ }
318
+ if (manifest) {
319
+ // Overlaify.params.get(...) or a computed key reads settings this scan can't see.
320
+ const dynamic = /\bOverlaify\s*\.\s*params\b/.test(code) || /\bOverlaify\s*\.\s*param\s*\(\s*[^'"`\s)]/.test(code);
321
+ if (!dynamic) {
322
+ for (const k of settings) {
323
+ if (!params.has(k)) warn('setting_unused', `Setting "${k}" is never read; use Overlaify.param('${k}', <default>) or remove it.`);
324
+ }
325
+ }
326
+ for (const k of ownParams) {
327
+ if (!settings.has(k)) warn('param_undeclared', `Overlaify.param('${k}') is not declared in the manifest's settings, so the panel can't set it.`);
328
+ }
329
+ const dynamicActions = /\bOverlaify\s*\.\s*action\s*\(\s*[^'"`\s)]/.test(code);
330
+ if (!dynamicActions) {
331
+ for (const n of actions) {
332
+ if (!handlers.has(n)) err('action_unhandled', `Action "${n}" has no handler; add Overlaify.action('${n}', fn) or remove it, or its key does nothing.`);
333
+ }
334
+ }
335
+ for (const n of handlers) {
336
+ if (!actions.has(n)) warn('action_undeclared', `Overlaify.action('${n}') is not in the manifest, so the streamer has no key for it.`);
337
+ }
338
+ }
339
+
340
+ const events = calls(code, 'on');
341
+ for (const t of events) {
342
+ if (!EVENTS.includes(t) && !LIFECYCLE.includes(t) && !t.startsWith('action:')) {
343
+ warn('unknown_event', `Overlaify.on('${t}') never fires; events are ${EVENTS.join(', ')} (plus connected, disconnected, '*').`);
344
+ }
345
+ }
346
+ if (events.has('cheer') && events.has('tip')) {
347
+ warn('cheer_and_tip', `Twitch bits fire both 'cheer' and 'tip', so handling both counts them twice; handle 'tip' alone.`);
348
+ }
349
+
350
+ const scripts = [...code.matchAll(SCRIPT)].map((m) => m[2]).join('\n');
351
+ const net = scripts.match(/\b(fetch|XMLHttpRequest|WebSocket|EventSource)\s*\(/);
352
+ if (net) warn('network_access', `${net[1]}() makes network calls; overlays get events from the SDK.`);
353
+ const dialog = scripts.match(/(?:^|[^\w$.]|\bwindow\.)(alert|confirm|prompt)\s*\(/);
354
+ if (dialog) warn('blocking_dialog', `${dialog[1]}() needs a person to click; overlays must just run.`);
355
+
356
+ const urls = new Set();
357
+ for (const m of code.matchAll(/\bhttps?:\/\/[^\s"'`)<>]+/gi)) {
358
+ const host = hostOf(m[0]);
359
+ if (host && !ALLOWED_HOSTS.includes(host) && !NAMESPACE_HOSTS.includes(host) && !SDK_HOSTS.includes(host)) urls.add(host);
360
+ }
361
+ if (urls.size) warn('external_url', `The code mentions ${[...urls].join(', ')}; overlays can only load from Google Fonts and Twitch emotes.`);
362
+ }