sitedrift 0.2.0 → 0.3.1
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/AGENTS.md +11 -0
- package/README.md +39 -0
- package/assets/viewer.css +13 -0
- package/assets/viewer.html +1 -0
- package/assets/viewer.js +142 -43
- package/package.json +4 -1
- package/sitedrift.mjs +11 -0
- package/src/cli.mjs +18 -2
- package/src/cloudflare-runtime.mjs +117 -0
- package/src/cloudflare.mjs +78 -0
- package/src/frame-content.mjs +65 -0
- package/src/proxy.mjs +3 -69
- package/src/viewer.mjs +25 -1
package/AGENTS.md
CHANGED
|
@@ -59,6 +59,11 @@ only when the host cannot run MCP.
|
|
|
59
59
|
5. Re-list notes after code changes. Resolve only findings you verified.
|
|
60
60
|
6. Remove or clear notes only when the user explicitly requests it.
|
|
61
61
|
|
|
62
|
+
Hosted Cloudflare preview deployments are a different mode: their notes are
|
|
63
|
+
browser-local and intentionally unavailable to MCP. Use browser inspection for
|
|
64
|
+
those URLs. Do not claim that a hosted note was shared with an agent or written
|
|
65
|
+
to the project.
|
|
66
|
+
|
|
62
67
|
## MCP tools
|
|
63
68
|
|
|
64
69
|
- `sitedrift_context`: active targets, viewer URL, and capabilities.
|
|
@@ -99,3 +104,9 @@ sitedrift --https
|
|
|
99
104
|
sitedrift accepts loopback hosts only. The control API uses a random bearer
|
|
100
105
|
token stored in `~/.sitedrift/sessions/<port>.json` with mode `0600`. DEV and
|
|
101
106
|
LIVE render on separate origins. Never expose sitedrift through a public proxy.
|
|
107
|
+
|
|
108
|
+
The optional Cloudflare Pages addon is intentionally public-preview safe: it is
|
|
109
|
+
installed only on non-production builds, exposes only `/__sitedrift/*`, permits
|
|
110
|
+
only `GET` and `HEAD`, allowlists one configured live origin, and sandboxes both
|
|
111
|
+
frames without same-origin authority. Production output and existing API
|
|
112
|
+
Functions are unchanged.
|
package/README.md
CHANGED
|
@@ -66,6 +66,42 @@ mkcert isn't found it falls back to an `openssl` self-signed cert and prints the
|
|
|
66
66
|
one command to trust it on your OS. Already have a cert? Skip all of this and
|
|
67
67
|
pass `--cert <file> --key <file>`.
|
|
68
68
|
|
|
69
|
+
### Cloudflare preview deployments
|
|
70
|
+
|
|
71
|
+
Turn every non-production Cloudflare Pages deployment into a compact sitedrift
|
|
72
|
+
review URL. The deployment opens its own preview in DEV Solo mode and can switch
|
|
73
|
+
to Split, Overlay, or Diff against the configured production site.
|
|
74
|
+
|
|
75
|
+
Install sitedrift and run the wrapper after your static build:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"scripts": {
|
|
80
|
+
"build": "astro build && sitedrift cloudflare --dir dist --live https://example.com"
|
|
81
|
+
},
|
|
82
|
+
"devDependencies": {
|
|
83
|
+
"sitedrift": "^0.3.0"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Add one scoped Pages Function:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// functions/__sitedrift/[[path]].ts
|
|
92
|
+
export { onRequest } from 'sitedrift/cloudflare';
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
That is the entire integration. On Cloudflare Pages, the wrapper activates only
|
|
96
|
+
when `CF_PAGES=1` and `CF_PAGES_BRANCH` is not `main`. Production builds are
|
|
97
|
+
left unchanged. Use `--production-branch <name>` when production is another
|
|
98
|
+
branch.
|
|
99
|
+
|
|
100
|
+
Hosted proxies are read-only (`GET`/`HEAD`), sandboxed without same-origin
|
|
101
|
+
authority, and fixed to the configured live origin. Review notes stay in that
|
|
102
|
+
browser's `localStorage`; they are not sent to an API, shared with agents, or
|
|
103
|
+
written to disk. Existing application Functions keep their original routes.
|
|
104
|
+
|
|
69
105
|
---
|
|
70
106
|
|
|
71
107
|
## See the whole review loop
|
|
@@ -122,6 +158,9 @@ npm run docs:screenshots
|
|
|
122
158
|
notes that appear live. Click a note to jump to its route, copy a per-note
|
|
123
159
|
deep link, dock or float the drawer, and **Send to vault** or export Markdown.
|
|
124
160
|
- **No runtime dependencies.** Node standard library only.
|
|
161
|
+
- **Deploy-preview mode for Cloudflare Pages.** Preview branches can carry the
|
|
162
|
+
compact comparison toolbar without changing production output or application
|
|
163
|
+
API routes.
|
|
125
164
|
|
|
126
165
|
### Keyboard
|
|
127
166
|
|
package/assets/viewer.css
CHANGED
|
@@ -627,6 +627,9 @@ iframe { display: block; inline-size: 100%; block-size: 100%; border: 0; backgro
|
|
|
627
627
|
|
|
628
628
|
&.open { transform: translateX(0); }
|
|
629
629
|
}
|
|
630
|
+
.review-drawer:has(.local-notes-notice:not([hidden])) {
|
|
631
|
+
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
|
632
|
+
}
|
|
630
633
|
.drawer-head {
|
|
631
634
|
display: flex;
|
|
632
635
|
align-items: center;
|
|
@@ -638,6 +641,16 @@ iframe { display: block; inline-size: 100%; block-size: 100%; border: 0; backgro
|
|
|
638
641
|
& .icon { inline-size: 30px; block-size: 30px; }
|
|
639
642
|
& .icon.active { color: #fff; background: var(--active); border-color: var(--active-line); }
|
|
640
643
|
}
|
|
644
|
+
.local-notes-notice {
|
|
645
|
+
margin: 12px 16px 0;
|
|
646
|
+
padding: 9px 10px;
|
|
647
|
+
color: var(--muted);
|
|
648
|
+
background: color-mix(in srgb, var(--field) 82%, transparent);
|
|
649
|
+
border: 1px solid var(--line);
|
|
650
|
+
border-radius: var(--r);
|
|
651
|
+
font-size: 12px;
|
|
652
|
+
line-height: 1.45;
|
|
653
|
+
}
|
|
641
654
|
|
|
642
655
|
.note-list {
|
|
643
656
|
margin: 0;
|
package/assets/viewer.html
CHANGED
|
@@ -140,6 +140,7 @@
|
|
|
140
140
|
</button>
|
|
141
141
|
<button class="icon" data-action="notes-close" aria-label="Close notes">×</button>
|
|
142
142
|
</div>
|
|
143
|
+
<p class="local-notes-notice" hidden>Stored only in this browser. These notes are not shared with agents or written to disk.</p>
|
|
143
144
|
<ol class="note-list"></ol>
|
|
144
145
|
<div class="note-compose">
|
|
145
146
|
<div class="note-grip" title="Drag to resize" aria-hidden="true"></div>
|
package/assets/viewer.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
const config = window.__SITEDRIFT_CONFIG__;
|
|
1
|
+
const config = window.__SITEDRIFT_CONFIG__;
|
|
2
2
|
delete window.__SITEDRIFT_CONFIG__;
|
|
3
|
+
if (config.hosted) {
|
|
4
|
+
config.dev = location.origin;
|
|
5
|
+
config.frameOrigins = { dev: location.origin, live: location.origin };
|
|
6
|
+
for (const iframe of document.querySelectorAll('iframe[data-side]')) {
|
|
7
|
+
iframe.setAttribute('sandbox', 'allow-downloads allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-scripts');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
3
10
|
const root = document.documentElement;
|
|
4
11
|
const app = document.querySelector('.app');
|
|
5
12
|
const routeInput = document.querySelector('.route');
|
|
@@ -26,18 +33,19 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
26
33
|
if (!['exact', 'ratio'].includes(scrollMode)) scrollMode = 'exact';
|
|
27
34
|
let mirrorLinks = queryOrStoredBool('mirror', 'site-compare-mirror', false);
|
|
28
35
|
let mobileMode = (params.get('mode') || localStorage.getItem('site-compare-mode')) === 'mobile';
|
|
29
|
-
let compactMode = queryOrStoredBool('compact', 'site-compare-compact',
|
|
36
|
+
let compactMode = queryOrStoredBool('compact', 'site-compare-compact', !!config.hosted);
|
|
30
37
|
const storedView = localStorage.getItem('site-compare-view');
|
|
31
38
|
let viewMode = params.get('view')
|
|
32
39
|
|| (params.get('overlay') === '1' ? 'overlay' : params.get('solo') === '1' ? 'solo' : null)
|
|
33
40
|
|| storedView
|
|
41
|
+
|| (config.hosted ? 'solo' : null)
|
|
34
42
|
|| (innerWidth <= 600 ? 'solo' : 'split');
|
|
35
43
|
let overlayBlend = (params.get('overlayBlend') || localStorage.getItem('site-compare-overlay-blend')) === 'difference' ? 'difference' : 'opacity';
|
|
36
44
|
if (viewMode === 'diff') { viewMode = 'overlay'; overlayBlend = 'difference'; } // back-compat
|
|
37
45
|
if (!['split', 'solo', 'overlay'].includes(viewMode)) viewMode = 'split';
|
|
38
46
|
let overlayAmount = Number(params.get('overlayAmount') ?? localStorage.getItem('site-compare-overlay-amount'));
|
|
39
47
|
if (!Number.isFinite(overlayAmount)) overlayAmount = 50;
|
|
40
|
-
let focusSide = params.get('focus') === 'live' ? 'live' : params.get('focus') === 'dev' ? 'dev' :
|
|
48
|
+
let focusSide = params.get('focus') === 'live' ? 'live' : params.get('focus') === 'dev' ? 'dev' : 'dev';
|
|
41
49
|
let reviewNotes = [];
|
|
42
50
|
let notesSignature = '';
|
|
43
51
|
let notesOpen = params.get('notes') === '1';
|
|
@@ -48,6 +56,7 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
48
56
|
authorization: 'Bearer ' + config.token,
|
|
49
57
|
'content-type': 'application/json',
|
|
50
58
|
};
|
|
59
|
+
const localNotesKey = 'sitedrift-preview-notes:' + location.host + ':' + config.live;
|
|
51
60
|
|
|
52
61
|
function queryOrStoredBool(queryName, storageName, fallback) {
|
|
53
62
|
if (params.has(queryName)) return params.get(queryName) === '1';
|
|
@@ -67,11 +76,19 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
function frame(side) { return document.querySelector('iframe[data-side="' + side + '"]'); }
|
|
70
|
-
function
|
|
71
|
-
function
|
|
72
|
-
function
|
|
79
|
+
function proxyPath(side) { return config.hosted ? '/__sitedrift/' + side : '/__' + side; }
|
|
80
|
+
function proxied(side, route) { return config.frameOrigins[side] + proxyPath(side) + normalizeRoute(route); }
|
|
81
|
+
function statusUrl(side, route) { return proxyPath(side) + normalizeRoute(route); }
|
|
82
|
+
function direct(side, route) {
|
|
83
|
+
return config.hosted && side === 'dev'
|
|
84
|
+
? location.origin + proxyPath(side) + normalizeRoute(route)
|
|
85
|
+
: config[side] + normalizeRoute(route);
|
|
86
|
+
}
|
|
73
87
|
function framePost(side, type, data = {}) {
|
|
74
|
-
frame(side).contentWindow?.postMessage(
|
|
88
|
+
frame(side).contentWindow?.postMessage(
|
|
89
|
+
{ source: 'sitedrift-parent', side, type, ...data },
|
|
90
|
+
config.hosted ? '*' : config.frameOrigins[side],
|
|
91
|
+
);
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
function statusBadges(side) {
|
|
@@ -158,10 +175,34 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
158
175
|
showToast.timer = setTimeout(() => toast.classList.remove('show'), 1600);
|
|
159
176
|
}
|
|
160
177
|
|
|
161
|
-
function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
178
|
+
function element(tag, className, text) {
|
|
179
|
+
const node = document.createElement(tag);
|
|
180
|
+
if (className) node.className = className;
|
|
181
|
+
if (text !== undefined) node.textContent = text;
|
|
182
|
+
return node;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function copyIcon() {
|
|
186
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
187
|
+
const svg = document.createElementNS(ns, 'svg');
|
|
188
|
+
svg.setAttribute('viewBox', '0 0 20 20');
|
|
189
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
190
|
+
const path = document.createElementNS(ns, 'path');
|
|
191
|
+
path.setAttribute('d', 'M8 8V5.5A1.5 1.5 0 0 1 9.5 4h5A1.5 1.5 0 0 1 16 5.5v5A1.5 1.5 0 0 1 14.5 12H12');
|
|
192
|
+
path.setAttribute('fill', 'none');
|
|
193
|
+
path.setAttribute('stroke', 'currentColor');
|
|
194
|
+
path.setAttribute('stroke-width', '1.5');
|
|
195
|
+
const rect = document.createElementNS(ns, 'rect');
|
|
196
|
+
rect.setAttribute('x', '4');
|
|
197
|
+
rect.setAttribute('y', '8');
|
|
198
|
+
rect.setAttribute('width', '8');
|
|
199
|
+
rect.setAttribute('height', '8');
|
|
200
|
+
rect.setAttribute('rx', '1.5');
|
|
201
|
+
rect.setAttribute('fill', 'none');
|
|
202
|
+
rect.setAttribute('stroke', 'currentColor');
|
|
203
|
+
rect.setAttribute('stroke-width', '1.5');
|
|
204
|
+
svg.append(path, rect);
|
|
205
|
+
return svg;
|
|
165
206
|
}
|
|
166
207
|
|
|
167
208
|
function truncate(value, max) {
|
|
@@ -192,7 +233,7 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
192
233
|
const siteName = source.siteName
|
|
193
234
|
|| config.brand
|
|
194
235
|
|| new URL(direct(side, route)).hostname;
|
|
195
|
-
const faviconSrc = source.icon || (config.frameOrigins[side] +
|
|
236
|
+
const faviconSrc = source.icon || (config.frameOrigins[side] + proxyPath(side) + '/favicon.ico');
|
|
196
237
|
let canonicalPath = canonical;
|
|
197
238
|
try { canonicalPath = new URL(canonical).pathname; } catch {}
|
|
198
239
|
meta[side] = { title, description, canonicalPath, heading };
|
|
@@ -205,19 +246,35 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
205
246
|
fav.onerror = () => { fav.onerror = null; fav.src = '/icon.svg'; };
|
|
206
247
|
fav.src = faviconSrc;
|
|
207
248
|
label.querySelector('.open-side').href = direct(side, route);
|
|
208
|
-
label.querySelector('.seo-card')
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
'
|
|
216
|
-
'
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
249
|
+
const card = label.querySelector('.seo-card');
|
|
250
|
+
const sourceRow = element('div', 'seo-source');
|
|
251
|
+
const seoFavicon = element('img', 'seo-favicon');
|
|
252
|
+
seoFavicon.alt = '';
|
|
253
|
+
seoFavicon.src = faviconSrc;
|
|
254
|
+
const sourceText = element('div');
|
|
255
|
+
sourceText.append(
|
|
256
|
+
element('div', 'seo-site', siteName),
|
|
257
|
+
element('div', 'seo-url', crumb(canonical)),
|
|
258
|
+
);
|
|
259
|
+
sourceText.lastChild.dataset.seo = 'url';
|
|
260
|
+
const menu = element('div', 'seo-menu', '⋮');
|
|
261
|
+
menu.setAttribute('aria-hidden', 'true');
|
|
262
|
+
sourceRow.append(seoFavicon, sourceText, menu);
|
|
263
|
+
const seoTitle = element('div', `seo-title${title ? '' : ' seo-empty'}`, truncate(title || 'Missing page title', 62));
|
|
264
|
+
seoTitle.dataset.seo = 'title';
|
|
265
|
+
const seoDescription = element(
|
|
266
|
+
'div',
|
|
267
|
+
`seo-description${description ? '' : ' seo-empty'}`,
|
|
268
|
+
truncate(description || 'Missing meta description', 158),
|
|
269
|
+
);
|
|
270
|
+
seoDescription.dataset.seo = 'desc';
|
|
271
|
+
card.replaceChildren(
|
|
272
|
+
element('div', 'seo-eyebrow', `${side.toUpperCase()} metadata preview`),
|
|
273
|
+
sourceRow,
|
|
274
|
+
seoTitle,
|
|
275
|
+
seoDescription,
|
|
276
|
+
seoChecks(source.checks || []),
|
|
277
|
+
);
|
|
221
278
|
const seoFav = label.querySelector('.seo-favicon');
|
|
222
279
|
if (seoFav) seoFav.onerror = () => { seoFav.onerror = null; seoFav.src = '/icon.svg'; };
|
|
223
280
|
const fails = (source.checks || []).filter((check) => !check.ok).length;
|
|
@@ -230,20 +287,25 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
230
287
|
renderMetaDiff();
|
|
231
288
|
}
|
|
232
289
|
|
|
233
|
-
function
|
|
290
|
+
function seoChecks(checks) {
|
|
234
291
|
const fails = checks.filter((check) => !check.ok).length;
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
292
|
+
const container = element('div', 'seo-checks');
|
|
293
|
+
const head = element('div', 'seo-checks-head');
|
|
294
|
+
head.append(
|
|
295
|
+
element('span', '', 'SEO checks'),
|
|
296
|
+
element('span', fails ? 'bad' : 'good', fails ? `${fails} to fix` : 'all good'),
|
|
297
|
+
);
|
|
298
|
+
container.append(head);
|
|
299
|
+
for (const check of checks) {
|
|
300
|
+
const row = element('div', `seo-check ${check.ok ? 'ok' : 'bad'}`);
|
|
301
|
+
row.append(
|
|
302
|
+
element('span', 'seo-check-mark', check.ok ? '✓' : '✗'),
|
|
303
|
+
element('span', 'seo-check-label', check.label),
|
|
304
|
+
);
|
|
305
|
+
if (check.note) row.append(element('span', 'seo-check-note', check.note));
|
|
306
|
+
container.append(row);
|
|
307
|
+
}
|
|
308
|
+
return container;
|
|
247
309
|
}
|
|
248
310
|
|
|
249
311
|
function positionSeoCard(details) {
|
|
@@ -420,7 +482,7 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
420
482
|
const message = event.data || {};
|
|
421
483
|
const side = message.side;
|
|
422
484
|
if (!['dev', 'live'].includes(side)
|
|
423
|
-
|| event.origin !== config.frameOrigins[side]
|
|
485
|
+
|| (!config.hosted && event.origin !== config.frameOrigins[side])
|
|
424
486
|
|| message.source !== 'sitedrift-frame'
|
|
425
487
|
|| event.source !== frame(side).contentWindow) return;
|
|
426
488
|
if (message.type === 'ready') {
|
|
@@ -561,6 +623,10 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
561
623
|
}
|
|
562
624
|
|
|
563
625
|
async function notesPull() {
|
|
626
|
+
if (config.localNotes) {
|
|
627
|
+
try { applyNotes(JSON.parse(localStorage.getItem(localNotesKey) || '[]')); } catch { applyNotes([]); }
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
564
630
|
try {
|
|
565
631
|
const res = await fetch(config.api + '/notes', { cache: 'no-store', headers: apiHeaders });
|
|
566
632
|
const data = await res.json();
|
|
@@ -569,6 +635,29 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
569
635
|
}
|
|
570
636
|
|
|
571
637
|
async function notesPost(op) {
|
|
638
|
+
if (config.localNotes) {
|
|
639
|
+
let notes = [...reviewNotes];
|
|
640
|
+
if (op.op === 'add') {
|
|
641
|
+
notes.push({
|
|
642
|
+
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now()),
|
|
643
|
+
text: op.text,
|
|
644
|
+
author: op.author,
|
|
645
|
+
route: op.route,
|
|
646
|
+
side: op.side || null,
|
|
647
|
+
done: false,
|
|
648
|
+
createdAt: new Date().toISOString(),
|
|
649
|
+
});
|
|
650
|
+
} else if (op.op === 'toggle') {
|
|
651
|
+
notes = notes.map((note) => note.id === op.id ? { ...note, done: !note.done } : note);
|
|
652
|
+
} else if (op.op === 'remove') {
|
|
653
|
+
notes = notes.filter((note) => note.id !== op.id);
|
|
654
|
+
} else if (op.op === 'clear') {
|
|
655
|
+
notes = [];
|
|
656
|
+
}
|
|
657
|
+
localStorage.setItem(localNotesKey, JSON.stringify(notes));
|
|
658
|
+
applyNotes(notes);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
572
661
|
try {
|
|
573
662
|
const res = await fetch(config.api + '/notes', {
|
|
574
663
|
method: 'POST',
|
|
@@ -632,7 +721,7 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
632
721
|
copy.className = 'note-copy';
|
|
633
722
|
copy.title = 'Copy a link to this note';
|
|
634
723
|
copy.setAttribute('aria-label', 'Copy link to this note');
|
|
635
|
-
copy.
|
|
724
|
+
copy.append(copyIcon());
|
|
636
725
|
copy.addEventListener('click', async () => {
|
|
637
726
|
const url = new URL(location.href);
|
|
638
727
|
url.searchParams.set('path', note.route || '/');
|
|
@@ -738,7 +827,15 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
738
827
|
});
|
|
739
828
|
document.querySelector('[data-action="note-export"]').addEventListener('click', () => {
|
|
740
829
|
const link = document.createElement('a');
|
|
741
|
-
|
|
830
|
+
if (config.localNotes) {
|
|
831
|
+
const lines = ['# sitedrift review notes', ''];
|
|
832
|
+
for (const note of reviewNotes) {
|
|
833
|
+
lines.push(`- [${note.done ? 'x' : ' '}] ${note.text} (${note.side || 'both'} ${note.route || '/'})`);
|
|
834
|
+
}
|
|
835
|
+
link.href = URL.createObjectURL(new Blob([lines.join('\n') + '\n'], { type: 'text/markdown' }));
|
|
836
|
+
} else {
|
|
837
|
+
link.href = '/notes.md';
|
|
838
|
+
}
|
|
742
839
|
link.download = 'site-compare-notes.md';
|
|
743
840
|
link.click();
|
|
744
841
|
showToast('Exported notes .md');
|
|
@@ -754,6 +851,8 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
754
851
|
showToast('Vault save failed');
|
|
755
852
|
}
|
|
756
853
|
});
|
|
854
|
+
const localNotesNotice = document.querySelector('.local-notes-notice');
|
|
855
|
+
if (config.localNotes) localNotesNotice.hidden = false;
|
|
757
856
|
|
|
758
857
|
divider.addEventListener('pointerdown', (event) => {
|
|
759
858
|
divider.setPointerCapture(event.pointerId);
|
|
@@ -854,6 +953,6 @@ const config = window.__SITEDRIFT_CONFIG__;
|
|
|
854
953
|
autosizeNote();
|
|
855
954
|
setSplit(initialSplit);
|
|
856
955
|
setMode(viewMode);
|
|
857
|
-
go(params.get('path') || '/');
|
|
956
|
+
go(params.get('path') || config.initialPath || '/');
|
|
858
957
|
notesPull();
|
|
859
|
-
setInterval(notesPull, 4000);
|
|
958
|
+
if (!config.localNotes) setInterval(notesPull, 4000);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sitedrift",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Catch the drift between dev and live — frame your local site and production side-by-side on the same route, locked scroll, with a difference-blend overlay. Zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -55,6 +55,9 @@
|
|
|
55
55
|
"bugs": {
|
|
56
56
|
"url": "https://github.com/joeseverino/sitedrift/issues"
|
|
57
57
|
},
|
|
58
|
+
"exports": {
|
|
59
|
+
"./cloudflare": "./src/cloudflare-runtime.mjs"
|
|
60
|
+
},
|
|
58
61
|
"devDependencies": {
|
|
59
62
|
"@playwright/test": "^1.60.0",
|
|
60
63
|
"playwright": "^1.60.0"
|
package/sitedrift.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { openBrowser } from './src/browser.mjs';
|
|
|
6
6
|
import { runAgentCommand } from './src/agent.mjs';
|
|
7
7
|
import { createSession, removeSession, writeSession } from './src/session.mjs';
|
|
8
8
|
import { runMcpServer } from './src/mcp.mjs';
|
|
9
|
+
import { installCloudflarePreview } from './src/cloudflare.mjs';
|
|
9
10
|
|
|
10
11
|
let command;
|
|
11
12
|
let config;
|
|
@@ -19,6 +20,16 @@ try {
|
|
|
19
20
|
|
|
20
21
|
if (command?.name === 'mcp') {
|
|
21
22
|
runMcpServer();
|
|
23
|
+
} else if (command?.name === 'cloudflare') {
|
|
24
|
+
try {
|
|
25
|
+
const result = installCloudflarePreview(command);
|
|
26
|
+
console.log(result.installed
|
|
27
|
+
? `sitedrift: wrapped ${result.files} HTML files for Cloudflare preview ${result.branch}`
|
|
28
|
+
: `sitedrift: unchanged (${result.reason})`);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error(`sitedrift: ${error.message}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
22
33
|
} else {
|
|
23
34
|
try {
|
|
24
35
|
const parsed = parseCommand();
|
package/src/cli.mjs
CHANGED
|
@@ -7,7 +7,7 @@ const ALIASES = { d: 'dev', l: 'live', p: 'port', o: 'open', h: 'help', v: 'vers
|
|
|
7
7
|
const BOOLEANS = new Set(['open', 'http', 'https', 'setup-https', 'help', 'version']);
|
|
8
8
|
const VALUE_FLAGS = new Set([
|
|
9
9
|
'dev', 'live', 'port', 'host', 'cert', 'key', 'notes', 'brand', 'author',
|
|
10
|
-
'vault', 'config', 'route', 'side',
|
|
10
|
+
'vault', 'config', 'route', 'side', 'dir', 'production-branch',
|
|
11
11
|
]);
|
|
12
12
|
const KNOWN_FLAGS = new Set([...BOOLEANS, ...VALUE_FLAGS]);
|
|
13
13
|
const CONFIG_NAMES = ['sitedrift.config.json', '.sitedriftrc.json'];
|
|
@@ -110,6 +110,7 @@ Usage:
|
|
|
110
110
|
sitedrift status
|
|
111
111
|
sitedrift context
|
|
112
112
|
sitedrift mcp
|
|
113
|
+
sitedrift cloudflare --dir dist --live https://example.com
|
|
113
114
|
sitedrift notes list
|
|
114
115
|
sitedrift notes add <text> [--route /path] [--side dev|live] [--author name]
|
|
115
116
|
sitedrift notes resolve|reopen|remove <id>
|
|
@@ -179,7 +180,22 @@ export function resolveConfig(argv = process.argv.slice(2)) {
|
|
|
179
180
|
|
|
180
181
|
export function parseCommand(argv = process.argv.slice(2)) {
|
|
181
182
|
const name = argv[0];
|
|
182
|
-
if (
|
|
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
|
+
}
|
|
183
199
|
if (name === 'mcp') {
|
|
184
200
|
if (argv.length > 1) throw new Error('Usage: sitedrift mcp');
|
|
185
201
|
return { command: { name }, argv: [] };
|
|
@@ -0,0 +1,117 @@
|
|
|
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}`]
|
|
39
|
+
: [`/__sitedrift/source/${clean}`, `/__sitedrift/source/${clean}/`];
|
|
40
|
+
if (pathname === '/') candidates.unshift('/__sitedrift/source/');
|
|
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
|
+
if (requestUrl.pathname.startsWith('/__sitedrift/source')) {
|
|
68
|
+
return new Response('Not found.', { status: 404 });
|
|
69
|
+
}
|
|
70
|
+
const match = requestUrl.pathname.match(/^\/__sitedrift\/(dev|live)(\/.*)?$/);
|
|
71
|
+
if (!match) return context.env.ASSETS.fetch(context.request);
|
|
72
|
+
if (!['GET', 'HEAD'].includes(context.request.method)) {
|
|
73
|
+
return new Response('sitedrift preview proxies are read-only.', {
|
|
74
|
+
status: 405,
|
|
75
|
+
headers: { allow: 'GET, HEAD' },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const side = match[1];
|
|
80
|
+
const route = `${match[2] || '/'}${requestUrl.search}`;
|
|
81
|
+
let upstream;
|
|
82
|
+
try {
|
|
83
|
+
const config = await configFor(context);
|
|
84
|
+
upstream = side === 'dev'
|
|
85
|
+
? await devResponse(context, route)
|
|
86
|
+
: await liveResponse(context, route, config.live);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return new Response(`sitedrift: ${error.message}`, { status: 502 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const headers = cleanHeaders(upstream.headers);
|
|
92
|
+
const location = upstream.headers.get('location');
|
|
93
|
+
if (location) {
|
|
94
|
+
const config = await configFor(context);
|
|
95
|
+
const base = side === 'live' ? new URL(config.live) : requestUrl;
|
|
96
|
+
const redirected = new URL(location, base);
|
|
97
|
+
if (side === 'dev' || redirected.origin === base.origin) {
|
|
98
|
+
headers.set('location', `/__sitedrift/${side}${redirected.pathname}${redirected.search}${redirected.hash}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const type = upstream.headers.get('content-type') || '';
|
|
103
|
+
const rewritable = /text\/html|text\/css|javascript/.test(type);
|
|
104
|
+
if (rewritable && context.request.method !== 'HEAD') {
|
|
105
|
+
let body = rewriteRootPaths(await upstream.text(), `/__sitedrift/${side}`);
|
|
106
|
+
if (/text\/html/.test(type)) {
|
|
107
|
+
const bridge = frameBridge(side, `/__sitedrift/${side}`);
|
|
108
|
+
body = body.includes('</head>') ? body.replace('</head>', `${bridge}</head>`) : `${bridge}${body}`;
|
|
109
|
+
}
|
|
110
|
+
return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers });
|
|
111
|
+
}
|
|
112
|
+
return new Response(context.request.method === 'HEAD' ? null : upstream.body, {
|
|
113
|
+
status: upstream.status,
|
|
114
|
+
statusText: upstream.statusText,
|
|
115
|
+
headers,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -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/proxy.mjs
CHANGED
|
@@ -1,82 +1,16 @@
|
|
|
1
1
|
import { send } from './http.mjs';
|
|
2
|
+
import { frameBridge, rewriteRootPaths } from './frame-content.mjs';
|
|
2
3
|
|
|
3
4
|
// Reverse-proxies the two origins under /__dev/* and /__live/*, rewriting
|
|
4
5
|
// root-relative URLs so both sites render framed side-by-side. Deliberately
|
|
5
6
|
// strips framing/isolation headers — safe for loopback development only.
|
|
6
7
|
export function createProxy({ devBase, liveBase }) {
|
|
7
|
-
function bridge(side) {
|
|
8
|
-
const script = `(() => {
|
|
9
|
-
const side=${JSON.stringify(side)};
|
|
10
|
-
let linked=false, mirror=false;
|
|
11
|
-
const send=(type,data={})=>parent.postMessage({source:'sitedrift-frame',side,type,...data},'*');
|
|
12
|
-
const root=()=>document.scrollingElement||document.documentElement;
|
|
13
|
-
const route=()=>location.pathname.replace(/^\\/__${side}/,'')+location.search+location.hash||'/';
|
|
14
|
-
const snapshot=()=>{
|
|
15
|
-
const q=(s)=>document.querySelector(s);
|
|
16
|
-
const imgs=[...document.querySelectorAll('img')];
|
|
17
|
-
const title=(document.title||'').trim();
|
|
18
|
-
const description=q('meta[name="description"]')?.content?.trim()||'';
|
|
19
|
-
const canonical=q('link[rel="canonical"]')?.href||'';
|
|
20
|
-
const checks=[
|
|
21
|
-
['Title present',!!title],['Title 30–60 chars',title.length>=30&&title.length<=60,title.length+''],
|
|
22
|
-
['Meta description',!!description],['Description 70–160',description.length>=70&&description.length<=160,description.length+''],
|
|
23
|
-
['Exactly one H1',document.querySelectorAll('h1').length===1,document.querySelectorAll('h1').length+' found'],
|
|
24
|
-
['Canonical link',!!q('link[rel="canonical"]')],['Viewport meta',!!q('meta[name="viewport"]')],
|
|
25
|
-
['html lang',!!document.documentElement.lang],['Open Graph title',!!q('meta[property="og:title"]')],
|
|
26
|
-
['Open Graph image',!!q('meta[property="og:image"]')],
|
|
27
|
-
['Not noindex',!(q('meta[name="robots"]')?.content||'').toLowerCase().includes('noindex')],
|
|
28
|
-
['Favicon',!!q('link[rel~="icon"]')],
|
|
29
|
-
['Images have alt',imgs.every((img)=>img.hasAttribute('alt')),imgs.filter((img)=>!img.hasAttribute('alt')).length+' missing']
|
|
30
|
-
].map(([label,ok,note])=>({label,ok,note}));
|
|
31
|
-
send('ready',{route:route(),meta:{title,description,canonical,heading:q('h1')?.textContent?.trim()||'',
|
|
32
|
-
siteName:q('meta[property="og:site_name"]')?.content?.trim()||'',icon:q('link[rel~="icon"]')?.href||'',checks}});
|
|
33
|
-
send('scroll',{y:scrollY,max:Math.max(0,root().scrollHeight-innerHeight)});
|
|
34
|
-
};
|
|
35
|
-
addEventListener('message',(event)=>{
|
|
36
|
-
const msg=event.data||{};
|
|
37
|
-
if(msg.source!=='sitedrift-parent'||msg.side!==side)return;
|
|
38
|
-
if(msg.type==='settings'){linked=!!msg.linked;mirror=!!msg.mirror;document.documentElement.style.scrollBehavior='auto';}
|
|
39
|
-
if(msg.type==='scroll'){root().scrollTop=msg.y;}
|
|
40
|
-
if(msg.type==='reload')location.reload();
|
|
41
|
-
});
|
|
42
|
-
addEventListener('scroll',()=>send('scroll',{y:scrollY,max:Math.max(0,root().scrollHeight-innerHeight)}),{passive:true});
|
|
43
|
-
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});
|
|
44
|
-
addEventListener('keydown',(event)=>{
|
|
45
|
-
const typing=/^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName)||event.target.isContentEditable;
|
|
46
|
-
if(!typing&&!event.metaKey&&!event.ctrlKey&&!event.altKey&&['r','s','0','/','o','d'].includes(event.key.toLowerCase())){
|
|
47
|
-
event.preventDefault();send('key',{key:event.key.toLowerCase()});return;
|
|
48
|
-
}
|
|
49
|
-
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)});
|
|
50
|
-
},true);
|
|
51
|
-
addEventListener('click',(event)=>{
|
|
52
|
-
if(!mirror||event.defaultPrevented||event.button!==0||event.metaKey||event.ctrlKey||event.shiftKey||event.altKey)return;
|
|
53
|
-
const link=event.target.closest('a[href]');if(!link||link.target==='_blank'||link.hasAttribute('download'))return;
|
|
54
|
-
const url=new URL(link.href,location.href);if(url.origin!==location.origin||!url.pathname.startsWith('/__'+side))return;
|
|
55
|
-
event.preventDefault();send('navigate',{route:url.pathname.slice(('/__'+side).length)||'/'});
|
|
56
|
-
},true);
|
|
57
|
-
addEventListener('DOMContentLoaded',snapshot,{once:true});if(document.readyState!=='loading')snapshot();
|
|
58
|
-
})();`;
|
|
59
|
-
return `<script>${script.replace(/<\//g, '<\\/')}</script>`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
8
|
function targetFor(side, pathname, search) {
|
|
63
9
|
const base = side === 'dev' ? devBase : liveBase;
|
|
64
10
|
const relative = pathname.replace(new RegExp(`^/__${side}`), '') || '/';
|
|
65
11
|
return new URL(`${relative}${search}`, `${base.href}/`);
|
|
66
12
|
}
|
|
67
13
|
|
|
68
|
-
function rewriteRootPaths(body, side) {
|
|
69
|
-
const prefix = `/__${side}`;
|
|
70
|
-
return body
|
|
71
|
-
.replace(/(\b(?:href|src|action|poster)=["'])\/(?!\/)/gi, `$1${prefix}/`)
|
|
72
|
-
.replace(/\bsrcset=(["'])(.*?)\1/gi, (attribute, quote, value) => {
|
|
73
|
-
const rewritten = value.replace(/(^|,\s*)\/(?!\/)/g, `$1${prefix}/`);
|
|
74
|
-
return `srcset=${quote}${rewritten}${quote}`;
|
|
75
|
-
})
|
|
76
|
-
.replace(/url\((["']?)\/(?!\/)/gi, `url($1${prefix}/`)
|
|
77
|
-
.replace(/(["'`])\/(@(?:id|vite|fs)\/|_astro\/)/g, `$1${prefix}/$2`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
14
|
async function proxy(req, res, side, requestUrl) {
|
|
81
15
|
const target = targetFor(side, requestUrl.pathname, requestUrl.search);
|
|
82
16
|
const headers = { ...req.headers, host: target.host };
|
|
@@ -121,9 +55,9 @@ export function createProxy({ devBase, liveBase }) {
|
|
|
121
55
|
const rewritable = /text\/html|text\/css|javascript/.test(type)
|
|
122
56
|
|| (side === 'dev' && /application\/json/.test(type));
|
|
123
57
|
if (rewritable) {
|
|
124
|
-
let body = rewriteRootPaths(await upstream.text(), side);
|
|
58
|
+
let body = rewriteRootPaths(await upstream.text(), `/__${side}`);
|
|
125
59
|
if (/text\/html/.test(type)) {
|
|
126
|
-
const injected =
|
|
60
|
+
const injected = frameBridge(side);
|
|
127
61
|
body = body.includes('</head>') ? body.replace('</head>', `${injected}</head>`) : `${injected}${body}`;
|
|
128
62
|
}
|
|
129
63
|
res.writeHead(upstream.status, responseHeaders);
|
package/src/viewer.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
|
|
3
3
|
// Bumped when the viewer assets change; busts the ?v= cache and reported in
|
|
4
4
|
// /health so the `site compare` wrapper knows when to restart the server.
|
|
5
|
-
export const VIEWER_VERSION =
|
|
5
|
+
export const VIEWER_VERSION = 29;
|
|
6
6
|
|
|
7
7
|
function readAsset(path) {
|
|
8
8
|
try {
|
|
@@ -30,9 +30,33 @@ export function renderViewer({ devBase, liveBase, brand, author, vaultDir }, ses
|
|
|
30
30
|
token: session.token,
|
|
31
31
|
api: '/api/v1',
|
|
32
32
|
frameOrigins: session.frameUrls,
|
|
33
|
+
hosted: false,
|
|
33
34
|
}).replace(/</g, '\\u003c');
|
|
34
35
|
|
|
35
36
|
return assets.html
|
|
36
37
|
.replaceAll('__VERSION__', String(VIEWER_VERSION))
|
|
37
38
|
.replace('__CONFIG__', config);
|
|
38
39
|
}
|
|
40
|
+
|
|
41
|
+
export function renderHostedViewer({ live, brand = '', initialPath = '/' }) {
|
|
42
|
+
const config = JSON.stringify({
|
|
43
|
+
dev: '',
|
|
44
|
+
live,
|
|
45
|
+
brand,
|
|
46
|
+
author: 'you',
|
|
47
|
+
vault: false,
|
|
48
|
+
token: '',
|
|
49
|
+
api: '',
|
|
50
|
+
frameOrigins: { dev: '', live: '' },
|
|
51
|
+
hosted: true,
|
|
52
|
+
localNotes: true,
|
|
53
|
+
initialPath,
|
|
54
|
+
}).replace(/</g, '\\u003c');
|
|
55
|
+
|
|
56
|
+
return assets.html
|
|
57
|
+
.replaceAll('/icon.svg', '/__sitedrift/assets/icon.svg')
|
|
58
|
+
.replaceAll('/viewer.css', '/__sitedrift/assets/viewer.css')
|
|
59
|
+
.replaceAll('/viewer.js', '/__sitedrift/assets/viewer.js')
|
|
60
|
+
.replaceAll('__VERSION__', String(VIEWER_VERSION))
|
|
61
|
+
.replace('__CONFIG__', config);
|
|
62
|
+
}
|