sitedrift 0.1.0 → 0.3.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/src/agent.mjs ADDED
@@ -0,0 +1,73 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import { readSession } from './session.mjs';
4
+
5
+ function output(value) {
6
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
7
+ }
8
+
9
+ function routeFor(command) {
10
+ if (command.name === 'status' || command.name === 'context') return '/api/v1/session';
11
+ return '/api/v1/notes';
12
+ }
13
+
14
+ export async function requestSession(session, pathname, init = {}) {
15
+ const url = new URL(pathname, session.url);
16
+ const transport = url.protocol === 'https:' ? https : http;
17
+ const text = await new Promise((resolve, reject) => {
18
+ const req = transport.request(url, {
19
+ method: init.method || 'GET',
20
+ rejectUnauthorized: false,
21
+ headers: {
22
+ authorization: `Bearer ${session.token}`,
23
+ 'content-type': 'application/json',
24
+ ...(init.headers || {}),
25
+ },
26
+ }, (res) => {
27
+ let data = '';
28
+ res.setEncoding('utf8');
29
+ res.on('data', (chunk) => { data += chunk; });
30
+ res.on('end', () => {
31
+ if ((res.statusCode || 500) >= 400) {
32
+ try { reject(new Error(JSON.parse(data).error)); } catch { reject(new Error(data || `HTTP ${res.statusCode}`)); }
33
+ } else {
34
+ resolve(data);
35
+ }
36
+ });
37
+ });
38
+ req.on('error', reject);
39
+ if (init.body) req.write(init.body);
40
+ req.end();
41
+ });
42
+ return JSON.parse(text);
43
+ }
44
+
45
+ export async function runAgentCommand(command, config) {
46
+ const session = readSession(config.port);
47
+ if (command.name === 'status' || command.name === 'context' || command.action === 'list') {
48
+ output(await requestSession(session, routeFor(command)));
49
+ return 0;
50
+ }
51
+
52
+ const op = command.action === 'add'
53
+ ? {
54
+ op: 'add',
55
+ text: command.text,
56
+ author: command.author || config.author,
57
+ route: command.route || '/',
58
+ side: command.side || null,
59
+ }
60
+ : command.action === 'resolve'
61
+ ? { op: 'resolve', id: command.id }
62
+ : command.action === 'reopen'
63
+ ? { op: 'reopen', id: command.id }
64
+ : command.action === 'remove'
65
+ ? { op: 'remove', id: command.id }
66
+ : { op: 'clear' };
67
+
68
+ output(await requestSession(session, '/api/v1/notes', {
69
+ method: 'POST',
70
+ body: JSON.stringify(op),
71
+ }));
72
+ return 0;
73
+ }
@@ -0,0 +1,9 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export function openBrowser(url) {
4
+ const cmd = process.platform === 'darwin' ? 'open'
5
+ : process.platform === 'win32' ? 'start' : 'xdg-open';
6
+ try {
7
+ spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
8
+ } catch {}
9
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,231 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ // Short flags and the boolean flags that never consume the next argument.
6
+ const ALIASES = { d: 'dev', l: 'live', p: 'port', o: 'open', h: 'help', v: 'version' };
7
+ const BOOLEANS = new Set(['open', 'http', 'https', 'setup-https', 'help', 'version']);
8
+ const VALUE_FLAGS = new Set([
9
+ 'dev', 'live', 'port', 'host', 'cert', 'key', 'notes', 'brand', 'author',
10
+ 'vault', 'config', 'route', 'side', 'dir', 'production-branch',
11
+ ]);
12
+ const KNOWN_FLAGS = new Set([...BOOLEANS, ...VALUE_FLAGS]);
13
+ const CONFIG_NAMES = ['sitedrift.config.json', '.sitedriftrc.json'];
14
+
15
+ export function parseArgs(argv) {
16
+ const opts = {};
17
+ const positionals = [];
18
+ for (let i = 0; i < argv.length; i++) {
19
+ let arg = argv[i];
20
+ if (arg === '--') { positionals.push(...argv.slice(i + 1)); break; }
21
+ if (arg[0] !== '-' || arg === '-') { positionals.push(arg); continue; }
22
+ arg = arg.replace(/^--?/, '');
23
+ let value;
24
+ const eq = arg.indexOf('=');
25
+ if (eq !== -1) { value = arg.slice(eq + 1); arg = arg.slice(0, eq); }
26
+ const name = ALIASES[arg] || arg;
27
+ if (!KNOWN_FLAGS.has(name)) throw new Error(`Unknown option: --${arg}`);
28
+ if (BOOLEANS.has(name)) { opts[name] = true; continue; }
29
+ if (value === undefined) {
30
+ const next = argv[i + 1];
31
+ if (next !== undefined && next[0] !== '-') { value = next; i++; }
32
+ else throw new Error(`Option --${name} requires a value.`);
33
+ }
34
+ opts[name] = value;
35
+ }
36
+ return { opts, positionals };
37
+ }
38
+
39
+ function findConfigFile(start = process.cwd()) {
40
+ let dir = path.resolve(start);
41
+ while (true) {
42
+ for (const name of CONFIG_NAMES) {
43
+ const file = path.join(dir, name);
44
+ if (fs.existsSync(file)) return file;
45
+ }
46
+ const parent = path.dirname(dir);
47
+ if (parent === dir) return null;
48
+ dir = parent;
49
+ }
50
+ }
51
+
52
+ function readConfigFile(explicit) {
53
+ const file = explicit ? path.resolve(explicit) : findConfigFile();
54
+ if (!file) return {};
55
+ let value;
56
+ try {
57
+ value = JSON.parse(fs.readFileSync(file, 'utf8'));
58
+ } catch (error) {
59
+ throw new Error(`Could not read config ${file}: ${error.message}`);
60
+ }
61
+ if (!value || Array.isArray(value) || typeof value !== 'object') {
62
+ throw new Error(`Config ${file} must contain a JSON object.`);
63
+ }
64
+ const allowed = new Set(['dev', 'live', 'port', 'host', 'cert', 'key', 'notes', 'brand', 'author', 'vault', 'https', 'open']);
65
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
66
+ if (unknown.length) throw new Error(`Unknown config key${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
67
+ return value;
68
+ }
69
+
70
+ // SITEDRIFT_<NAME> is the public env var; SITE_COMPARE_<NAME> is the legacy name
71
+ // kept so the `site compare` wrapper keeps working after extraction.
72
+ function envVal(name) {
73
+ const v = process.env[`SITEDRIFT_${name}`] ?? process.env[`SITE_COMPARE_${name}`];
74
+ return v === undefined || v === '' ? undefined : v;
75
+ }
76
+
77
+ // Precedence for every setting: CLI flag > env > built-in default.
78
+ function pick(opts, fileConfig, flag, name, fallback) {
79
+ return opts[flag] ?? envVal(name) ?? fileConfig[flag] ?? fallback;
80
+ }
81
+
82
+ function boolean(value, name) {
83
+ if (typeof value === 'boolean') return value;
84
+ if (value === undefined) return false;
85
+ if (value === '1' || value === 'true') return true;
86
+ if (value === '0' || value === 'false') return false;
87
+ throw new Error(`${name} must be true/false or 1/0.`);
88
+ }
89
+
90
+ export function cleanBase(value) {
91
+ const url = new URL(value);
92
+ url.pathname = url.pathname.replace(/\/+$/, '');
93
+ url.search = '';
94
+ url.hash = '';
95
+ return url;
96
+ }
97
+
98
+ export function readVersion() {
99
+ try {
100
+ return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
101
+ } catch { return '0.0.0'; }
102
+ }
103
+
104
+ export function printHelp() {
105
+ console.log(`sitedrift — frame local dev against production, side-by-side on the same route.
106
+
107
+ Usage:
108
+ sitedrift [path] [options]
109
+ npx sitedrift /pricing --dev http://localhost:4321 --live https://example.com --open
110
+ sitedrift status
111
+ sitedrift context
112
+ sitedrift mcp
113
+ sitedrift cloudflare --dir dist --live https://example.com
114
+ sitedrift notes list
115
+ sitedrift notes add <text> [--route /path] [--side dev|live] [--author name]
116
+ sitedrift notes resolve|reopen|remove <id>
117
+ sitedrift notes clear
118
+
119
+ Options:
120
+ -d, --dev <url> Left-pane (dev) origin [default http://127.0.0.1:4321]
121
+ -l, --live <url> Right-pane (live) origin [default https://example.com]
122
+ -p, --port <n> Listen port [default 4178]
123
+ --host <addr> Bind address [default 127.0.0.1]
124
+ -o, --open Open the viewer in your browser
125
+ --https Serve HTTPS with an auto cert (mkcert if present, else openssl)
126
+ --setup-https One-time: generate + trust a local cert, then exit
127
+ --http Force plain HTTP (the default; overrides --https)
128
+ --cert <file> TLS cert (serve HTTPS; needs --key)
129
+ --key <file> TLS key
130
+ --notes <file> Shared review-notes JSON [default \$TMPDIR/sitedrift-notes.json]
131
+ --brand <text> Strip "| <text>" from pane-header titles
132
+ --author <name> Byline for notes added in the viewer
133
+ --vault <dir> Enable "Send to vault" (writes review markdown here)
134
+ --config <file> Read project configuration from a JSON file
135
+ -h, --help Show this help
136
+ -v, --version Print version
137
+
138
+ Every option also reads SITEDRIFT_<NAME> (e.g. SITEDRIFT_DEV). Binds to
139
+ 127.0.0.1 by default — it strips framing/isolation headers, so never expose it
140
+ publicly. See https://github.com/joeseverino/sitedrift`);
141
+ }
142
+
143
+ export function resolveConfig(argv = process.argv.slice(2)) {
144
+ const { opts, positionals } = parseArgs(argv);
145
+ if (positionals.length > 1) throw new Error(`Unexpected argument: ${positionals[1]}`);
146
+ const fileConfig = readConfigFile(opts.config);
147
+ const port = Number(pick(opts, fileConfig, 'port', 'PORT', 4178));
148
+ if (!Number.isInteger(port) || port < 1 || port > 65533) {
149
+ throw new Error('Port must be an integer from 1 to 65533 (the next two ports isolate DEV and LIVE).');
150
+ }
151
+ const certFile = opts.http ? undefined : pick(opts, fileConfig, 'cert', 'CERT', undefined);
152
+ const keyFile = opts.http ? undefined : pick(opts, fileConfig, 'key', 'KEY', undefined);
153
+ if (!!certFile !== !!keyFile) throw new Error('--cert and --key must be provided together.');
154
+ const host = pick(opts, fileConfig, 'host', 'HOST', '127.0.0.1');
155
+ if (!['127.0.0.1', 'localhost', '::1'].includes(host)) {
156
+ throw new Error('Host must be loopback (127.0.0.1, localhost, or ::1).');
157
+ }
158
+ return {
159
+ opts,
160
+ help: !!opts.help,
161
+ version: !!opts.version,
162
+ setupHttps: !!opts['setup-https'],
163
+ https: !opts.http && boolean(pick(opts, fileConfig, 'https', 'HTTPS', false), 'https'),
164
+ host,
165
+ port,
166
+ devBase: cleanBase(pick(opts, fileConfig, 'dev', 'DEV', 'http://127.0.0.1:4321')),
167
+ liveBase: cleanBase(pick(opts, fileConfig, 'live', 'LIVE', 'https://example.com')),
168
+ certFile,
169
+ keyFile,
170
+ notesFile: pick(opts, fileConfig, 'notes', 'NOTES', `${os.tmpdir()}/sitedrift-notes.json`),
171
+ brand: pick(opts, fileConfig, 'brand', 'BRAND', ''),
172
+ author: pick(opts, fileConfig, 'author', 'AUTHOR', 'you'),
173
+ vaultDir: pick(opts, fileConfig, 'vault', 'VAULT', ''),
174
+ open: boolean(pick(opts, fileConfig, 'open', 'OPEN', false), 'open'),
175
+ initialPath: positionals[0]
176
+ ? '/' + String(positionals[0]).replace(/^\/+/, '')
177
+ : '',
178
+ };
179
+ }
180
+
181
+ export function parseCommand(argv = process.argv.slice(2)) {
182
+ const name = argv[0];
183
+ if (!['status', 'context', 'notes', 'mcp', 'cloudflare'].includes(name)) return null;
184
+ if (name === 'cloudflare') {
185
+ const { opts, positionals } = parseArgs(argv.slice(1));
186
+ if (positionals.length) throw new Error(`Unexpected argument: ${positionals[0]}`);
187
+ if (!opts.live) throw new Error('sitedrift cloudflare requires --live.');
188
+ return {
189
+ command: {
190
+ name,
191
+ dir: opts.dir || 'dist',
192
+ live: opts.live,
193
+ brand: opts.brand || '',
194
+ productionBranch: opts['production-branch'] || 'main',
195
+ },
196
+ argv: [],
197
+ };
198
+ }
199
+ if (name === 'mcp') {
200
+ if (argv.length > 1) throw new Error('Usage: sitedrift mcp');
201
+ return { command: { name }, argv: [] };
202
+ }
203
+ if (name === 'status' || name === 'context') {
204
+ return { command: { name }, argv: argv.slice(1) };
205
+ }
206
+ const action = argv[1];
207
+ if (!['list', 'add', 'resolve', 'reopen', 'remove', 'clear'].includes(action)) {
208
+ throw new Error('Usage: sitedrift notes list|add|resolve|reopen|remove|clear');
209
+ }
210
+ const tail = argv.slice(2);
211
+ let subject;
212
+ if (action === 'add' || ['resolve', 'reopen', 'remove'].includes(action)) {
213
+ subject = tail.shift();
214
+ if (!subject) throw new Error(`sitedrift notes ${action} requires ${action === 'add' ? 'text' : 'an id'}.`);
215
+ }
216
+ const { opts, positionals } = parseArgs(tail);
217
+ if (positionals.length) throw new Error(`Unexpected argument: ${positionals[0]}`);
218
+ if (opts.side && !['dev', 'live'].includes(opts.side)) throw new Error('--side must be dev or live.');
219
+ return {
220
+ command: {
221
+ name,
222
+ action,
223
+ text: action === 'add' ? subject : undefined,
224
+ id: action === 'add' ? undefined : subject,
225
+ route: opts.route,
226
+ side: opts.side,
227
+ author: opts.author,
228
+ },
229
+ argv: tail,
230
+ };
231
+ }
@@ -0,0 +1,114 @@
1
+ import { frameBridge, rewriteRootPaths } from './frame-content.mjs';
2
+
3
+ const STRIP_HEADERS = [
4
+ 'content-encoding',
5
+ 'content-length',
6
+ 'content-security-policy',
7
+ 'content-security-policy-report-only',
8
+ 'cross-origin-embedder-policy',
9
+ 'cross-origin-opener-policy',
10
+ 'cross-origin-resource-policy',
11
+ 'transfer-encoding',
12
+ 'x-frame-options',
13
+ ];
14
+
15
+ function cleanHeaders(source) {
16
+ const headers = new Headers(source);
17
+ for (const name of STRIP_HEADERS) headers.delete(name);
18
+ headers.set('cache-control', 'no-store');
19
+ headers.set('x-robots-tag', 'noindex, nofollow');
20
+ return headers;
21
+ }
22
+
23
+ async function configFor(context) {
24
+ const url = new URL('/__sitedrift/config.json', context.request.url);
25
+ const response = await context.env.ASSETS.fetch(url);
26
+ if (!response.ok) throw new Error('sitedrift preview config is unavailable');
27
+ return response.json();
28
+ }
29
+
30
+ async function devResponse(context, route) {
31
+ const requestUrl = new URL(context.request.url);
32
+ const routeUrl = new URL(route, requestUrl);
33
+ const pathname = routeUrl.pathname;
34
+ const accept = context.request.headers.get('accept') || '';
35
+ if (context.request.method === 'GET' && accept.includes('text/html')) {
36
+ const clean = pathname.replace(/^\/+/, '');
37
+ const candidates = pathname.endsWith('/')
38
+ ? [`/__sitedrift/source/${clean}index.html`]
39
+ : [`/__sitedrift/source/${clean}.html`, `/__sitedrift/source/${clean}/index.html`];
40
+ if (pathname === '/') candidates.unshift('/__sitedrift/source/index.html');
41
+ for (const pathname of candidates) {
42
+ const response = await context.env.ASSETS.fetch(new URL(pathname, requestUrl));
43
+ if (response.ok) return response;
44
+ }
45
+ }
46
+ return context.env.ASSETS.fetch(routeUrl);
47
+ }
48
+
49
+ async function liveResponse(context, route, live) {
50
+ const base = new URL(live);
51
+ const target = new URL(route, `${base.href.replace(/\/$/, '')}/`);
52
+ if (target.origin !== base.origin) return new Response('Invalid live target.', { status: 400 });
53
+ const headers = new Headers(context.request.headers);
54
+ headers.delete('host');
55
+ headers.delete('accept-encoding');
56
+ const init = {
57
+ method: context.request.method,
58
+ headers,
59
+ redirect: 'manual',
60
+ };
61
+ if (!['GET', 'HEAD'].includes(context.request.method)) init.body = context.request.body;
62
+ return fetch(target, init);
63
+ }
64
+
65
+ export async function onRequest(context) {
66
+ const requestUrl = new URL(context.request.url);
67
+ const match = requestUrl.pathname.match(/^\/__sitedrift\/(dev|live)(\/.*)?$/);
68
+ if (!match) return context.env.ASSETS.fetch(context.request);
69
+ if (!['GET', 'HEAD'].includes(context.request.method)) {
70
+ return new Response('sitedrift preview proxies are read-only.', {
71
+ status: 405,
72
+ headers: { allow: 'GET, HEAD' },
73
+ });
74
+ }
75
+
76
+ const side = match[1];
77
+ const route = `${match[2] || '/'}${requestUrl.search}`;
78
+ let upstream;
79
+ try {
80
+ const config = await configFor(context);
81
+ upstream = side === 'dev'
82
+ ? await devResponse(context, route)
83
+ : await liveResponse(context, route, config.live);
84
+ } catch (error) {
85
+ return new Response(`sitedrift: ${error.message}`, { status: 502 });
86
+ }
87
+
88
+ const headers = cleanHeaders(upstream.headers);
89
+ const location = upstream.headers.get('location');
90
+ if (location) {
91
+ const config = await configFor(context);
92
+ const base = side === 'live' ? new URL(config.live) : requestUrl;
93
+ const redirected = new URL(location, base);
94
+ if (side === 'dev' || redirected.origin === base.origin) {
95
+ headers.set('location', `/__sitedrift/${side}${redirected.pathname}${redirected.search}${redirected.hash}`);
96
+ }
97
+ }
98
+
99
+ const type = upstream.headers.get('content-type') || '';
100
+ const rewritable = /text\/html|text\/css|javascript/.test(type);
101
+ if (rewritable && context.request.method !== 'HEAD') {
102
+ let body = rewriteRootPaths(await upstream.text(), `/__sitedrift/${side}`);
103
+ if (/text\/html/.test(type)) {
104
+ const bridge = frameBridge(side, `/__sitedrift/${side}`);
105
+ body = body.includes('</head>') ? body.replace('</head>', `${bridge}</head>`) : `${bridge}${body}`;
106
+ }
107
+ return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers });
108
+ }
109
+ return new Response(context.request.method === 'HEAD' ? null : upstream.body, {
110
+ status: upstream.status,
111
+ statusText: upstream.statusText,
112
+ headers,
113
+ });
114
+ }
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { assets, renderHostedViewer } from './viewer.mjs';
5
+
6
+ function htmlFiles(root) {
7
+ const found = [];
8
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
9
+ const file = path.join(root, entry.name);
10
+ if (entry.isDirectory()) {
11
+ if (entry.name !== '__sitedrift') found.push(...htmlFiles(file));
12
+ } else if (entry.isFile() && entry.name.endsWith('.html')) {
13
+ found.push(file);
14
+ }
15
+ }
16
+ return found;
17
+ }
18
+
19
+ function routeFor(relative) {
20
+ if (relative === 'index.html') return '/';
21
+ if (relative.endsWith('/index.html')) return `/${relative.slice(0, -'index.html'.length)}`;
22
+ return `/${relative.slice(0, -'.html'.length)}`;
23
+ }
24
+
25
+ function secureLive(value) {
26
+ const url = new URL(value);
27
+ if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '::1'].includes(url.hostname)) {
28
+ throw new Error('--live must use HTTPS.');
29
+ }
30
+ url.pathname = url.pathname.replace(/\/+$/, '');
31
+ url.search = '';
32
+ url.hash = '';
33
+ return url.href.replace(/\/$/, '');
34
+ }
35
+
36
+ export function installCloudflarePreview({
37
+ dir,
38
+ live,
39
+ brand = '',
40
+ productionBranch = 'main',
41
+ env = process.env,
42
+ force = false,
43
+ }) {
44
+ const branch = env.CF_PAGES_BRANCH || '';
45
+ if (!force && (env.CF_PAGES !== '1' || !branch || branch === productionBranch)) {
46
+ return { installed: false, reason: branch === productionBranch ? 'production branch' : 'not a Pages preview' };
47
+ }
48
+
49
+ const output = path.resolve(dir);
50
+ if (!fs.existsSync(output)) throw new Error(`Build output does not exist: ${output}`);
51
+ const files = htmlFiles(output);
52
+ if (!files.length) throw new Error(`No HTML files found in ${output}`);
53
+
54
+ const liveUrl = secureLive(live);
55
+ const internal = path.join(output, '__sitedrift');
56
+ const source = path.join(internal, 'source');
57
+ const assetDir = path.join(internal, 'assets');
58
+ fs.mkdirSync(source, { recursive: true });
59
+ fs.mkdirSync(assetDir, { recursive: true });
60
+
61
+ for (const file of files) {
62
+ const relative = path.relative(output, file);
63
+ const preserved = path.join(source, relative);
64
+ fs.mkdirSync(path.dirname(preserved), { recursive: true });
65
+ fs.copyFileSync(file, preserved);
66
+ fs.writeFileSync(file, renderHostedViewer({
67
+ live: liveUrl,
68
+ brand,
69
+ initialPath: routeFor(relative),
70
+ }));
71
+ }
72
+
73
+ fs.writeFileSync(path.join(assetDir, 'viewer.css'), assets.css);
74
+ fs.writeFileSync(path.join(assetDir, 'viewer.js'), assets.js);
75
+ fs.writeFileSync(path.join(assetDir, 'icon.svg'), assets.icon);
76
+ fs.writeFileSync(path.join(internal, 'config.json'), JSON.stringify({ live: liveUrl }));
77
+ return { installed: true, branch: branch || 'forced', files: files.length };
78
+ }
@@ -0,0 +1,65 @@
1
+ export function frameBridge(side, prefix = `/__${side}`) {
2
+ const script = `(() => {
3
+ const side=${JSON.stringify(side)},prefix=${JSON.stringify(prefix)};
4
+ let linked=false,mirror=false;
5
+ const send=(type,data={})=>parent.postMessage({source:'sitedrift-frame',side,type,...data},'*');
6
+ const root=()=>document.scrollingElement||document.documentElement;
7
+ const route=()=>location.pathname.replace(prefix,'')+location.search+location.hash||'/';
8
+ const snapshot=()=>{
9
+ const q=(s)=>document.querySelector(s);
10
+ const imgs=[...document.querySelectorAll('img')];
11
+ const title=(document.title||'').trim();
12
+ const description=q('meta[name="description"]')?.content?.trim()||'';
13
+ const canonical=q('link[rel="canonical"]')?.href||'';
14
+ const checks=[
15
+ ['Title present',!!title],['Title 30–60 chars',title.length>=30&&title.length<=60,title.length+''],
16
+ ['Meta description',!!description],['Description 70–160',description.length>=70&&description.length<=160,description.length+''],
17
+ ['Exactly one H1',document.querySelectorAll('h1').length===1,document.querySelectorAll('h1').length+' found'],
18
+ ['Canonical link',!!q('link[rel="canonical"]')],['Viewport meta',!!q('meta[name="viewport"]')],
19
+ ['html lang',!!document.documentElement.lang],['Open Graph title',!!q('meta[property="og:title"]')],
20
+ ['Open Graph image',!!q('meta[property="og:image"]')],
21
+ ['Not noindex',!(q('meta[name="robots"]')?.content||'').toLowerCase().includes('noindex')],
22
+ ['Favicon',!!q('link[rel~="icon"]')],
23
+ ['Images have alt',imgs.every((img)=>img.hasAttribute('alt')),imgs.filter((img)=>!img.hasAttribute('alt')).length+' missing']
24
+ ].map(([label,ok,note])=>({label,ok,note}));
25
+ send('ready',{route:route(),meta:{title,description,canonical,heading:q('h1')?.textContent?.trim()||'',
26
+ siteName:q('meta[property="og:site_name"]')?.content?.trim()||'',icon:q('link[rel~="icon"]')?.href||'',checks}});
27
+ send('scroll',{y:scrollY,max:Math.max(0,root().scrollHeight-innerHeight)});
28
+ };
29
+ addEventListener('message',(event)=>{
30
+ const msg=event.data||{};
31
+ if(msg.source!=='sitedrift-parent'||msg.side!==side)return;
32
+ if(msg.type==='settings'){linked=!!msg.linked;mirror=!!msg.mirror;document.documentElement.style.scrollBehavior='auto';}
33
+ if(msg.type==='scroll'){root().scrollTop=msg.y;}
34
+ if(msg.type==='reload')location.reload();
35
+ });
36
+ addEventListener('scroll',()=>send('scroll',{y:scrollY,max:Math.max(0,root().scrollHeight-innerHeight)}),{passive:true});
37
+ addEventListener('wheel',(event)=>{if(!linked||!event.deltaY)return;event.preventDefault();send('wheel',{delta:event.deltaY,mode:event.deltaMode,height:innerHeight,y:scrollY});},{passive:false,capture:true});
38
+ addEventListener('keydown',(event)=>{
39
+ const typing=/^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName)||event.target.isContentEditable;
40
+ if(!typing&&!event.metaKey&&!event.ctrlKey&&!event.altKey&&['r','s','0','/','o','d'].includes(event.key.toLowerCase())){
41
+ event.preventDefault();send('key',{key:event.key.toLowerCase()});return;
42
+ }
43
+ if(linked&&!typing&&!event.metaKey&&!event.ctrlKey&&!event.altKey)send('key',{key:event.key,shift:event.shiftKey,y:scrollY,height:innerHeight,max:Math.max(0,root().scrollHeight-innerHeight)});
44
+ },true);
45
+ addEventListener('click',(event)=>{
46
+ if(!mirror||event.defaultPrevented||event.button!==0||event.metaKey||event.ctrlKey||event.shiftKey||event.altKey)return;
47
+ const link=event.target.closest('a[href]');if(!link||link.target==='_blank'||link.hasAttribute('download'))return;
48
+ const url=new URL(link.href,location.href);if(url.origin!==location.origin||!url.pathname.startsWith(prefix))return;
49
+ event.preventDefault();send('navigate',{route:url.pathname.slice(prefix.length)||'/'});
50
+ },true);
51
+ addEventListener('DOMContentLoaded',snapshot,{once:true});if(document.readyState!=='loading')snapshot();
52
+ })();`;
53
+ return `<script>${script.replace(/<\//g, '<\\/')}</script>`;
54
+ }
55
+
56
+ export function rewriteRootPaths(body, prefix) {
57
+ return body
58
+ .replace(/(\b(?:href|src|action|poster)=["'])\/(?!\/)/gi, `$1${prefix}/`)
59
+ .replace(/\bsrcset=(["'])(.*?)\1/gi, (attribute, quote, value) => {
60
+ const rewritten = value.replace(/(^|,\s*)\/(?!\/)/g, `$1${prefix}/`);
61
+ return `srcset=${quote}${rewritten}${quote}`;
62
+ })
63
+ .replace(/url\((["']?)\/(?!\/)/gi, `url($1${prefix}/`)
64
+ .replace(/(["'`])\/(@(?:id|vite|fs)\/|_astro\/)/g, `$1${prefix}/$2`);
65
+ }
package/src/http.mjs ADDED
@@ -0,0 +1,21 @@
1
+ // Small shared HTTP helpers used by the request handler and proxy.
2
+
3
+ export function send(res, status, body, type = 'text/plain; charset=utf-8') {
4
+ res.writeHead(status, {
5
+ 'Content-Type': type,
6
+ 'Cache-Control': 'no-store',
7
+ });
8
+ res.end(body);
9
+ }
10
+
11
+ export function readBody(req) {
12
+ return new Promise((resolve) => {
13
+ let data = '';
14
+ req.on('data', (chunk) => {
15
+ data += chunk;
16
+ if (data.length > 1e6) req.destroy();
17
+ });
18
+ req.on('end', () => resolve(data));
19
+ req.on('error', () => resolve(data));
20
+ });
21
+ }