sitedrift 0.2.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/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;
@@ -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', false);
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' : order[0];
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 proxied(side, route) { return config.frameOrigins[side] + '/__' + side + normalizeRoute(route); }
71
- function statusUrl(side, route) { return '/__' + side + normalizeRoute(route); }
72
- function direct(side, route) { return config[side] + normalizeRoute(route); }
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({ source: 'sitedrift-parent', side, type, ...data }, config.frameOrigins[side]);
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 escapeHtml(value) {
162
- return String(value || '').replace(/[&<>"']/g, (char) => ({
163
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
164
- })[char]);
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] + '/__' + side + '/favicon.ico');
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').innerHTML =
209
- '<div class="seo-eyebrow">' + side.toUpperCase() + ' metadata preview</div>' +
210
- '<div class="seo-source">' +
211
- '<img class="seo-favicon" alt="" src="' + escapeHtml(faviconSrc) + '">' +
212
- '<div><div class="seo-site">' + escapeHtml(siteName) + '</div>' +
213
- '<div class="seo-url" data-seo="url">' + escapeHtml(crumb(canonical)) + '</div></div>' +
214
- '<div class="seo-menu" aria-hidden="true">⋮</div>' +
215
- '</div>' +
216
- '<div class="seo-title' + (title ? '' : ' seo-empty') + '" data-seo="title">' +
217
- escapeHtml(truncate(title || 'Missing page title', 62)) + '</div>' +
218
- '<div class="seo-description' + (description ? '' : ' seo-empty') + '" data-seo="desc">' +
219
- escapeHtml(truncate(description || 'Missing meta description', 158)) + '</div>' +
220
- seoChecksHtml(source.checks || []);
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 seoChecksHtml(checks) {
290
+ function seoChecks(checks) {
234
291
  const fails = checks.filter((check) => !check.ok).length;
235
- const head = '<div class="seo-checks-head"><span>SEO checks</span>'
236
- + (fails
237
- ? '<span class="bad">' + fails + ' to fix</span>'
238
- : '<span class="good">all good</span>')
239
- + '</div>';
240
- const rows = checks.map((check) =>
241
- '<div class="seo-check ' + (check.ok ? 'ok' : 'bad') + '">'
242
- + '<span class="seo-check-mark">' + (check.ok ? '✓' : '✗') + '</span>'
243
- + '<span class="seo-check-label">' + escapeHtml(check.label) + '</span>'
244
- + (check.note ? '<span class="seo-check-note">' + escapeHtml(check.note) + '</span>' : '')
245
- + '</div>').join('');
246
- return '<div class="seo-checks">' + head + rows + '</div>';
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.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path 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" fill="none" stroke="currentColor" stroke-width="1.5"/><rect x="4" y="8" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>';
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
- link.href = '/notes.md';
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.2.0",
3
+ "version": "0.3.0",
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 (name !== 'status' && name !== 'context' && name !== 'notes' && name !== 'mcp') return null;
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,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/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 = bridge(side);
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 = 28;
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
+ }