@blocofy/cli 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,18 +125,72 @@ Show which site a command in `[dir]` would hit (verified), without writing anyth
125
125
  ```
126
126
  blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>] [--instance <handle>]
127
127
  ```
128
- Start a local dev server that renders your local theme files with the site's live content.
129
- Press `l` to open it, `q` to quit. Edit a file and save → the view reloads. Saves sync to a
130
- **draft** theme only (never the live site). `dir` defaults to cwd. The platform's remote preview
128
+ Start a local dev server that shows a **draft** theme: your local theme files with that draft's
129
+ saved content (local draft browsing). Press `l` to open it, `q` to quit. Edit a file and save → the
130
+ view reloads. Saves sync to a **draft** theme only (never the live site). `dir` defaults to cwd. The platform's remote preview
131
131
  and editor views for this command are retired; to share a draft page, create a preview link for
132
132
  it (v1 API `POST /themes/{id}/preview-links` or MCP `create_preview_link`; one page per link).
133
133
  The target site is verified once at start; a long session keeps that target (restart it after
134
134
  changing credentials, context or the project binding).
135
+ The dev server answers this machine only: it listens on `http://127.0.0.1:<port>`, refuses a request
136
+ whose `Host` (or `Origin`, when sent) is not that address or `localhost:<port>`, and answers only
137
+ `GET`/`HEAD` (anything else gets `405`). The reload channel is same-origin only.
135
138
  - `--port <n>` — local port (default 3030).
136
- - `--no-sync` — local preview only (skip draft sync).
139
+ - `--no-sync` — do not sync; needs `--instance <draft-handle>` to have something to show. Without it
140
+ the local view only shows an error page (there is no implicit live preview).
137
141
  - `--name <name>` — name the draft when it is first created (ignored if it already exists).
138
- - `--instance <handle>` — sync into this draft. Needed when the platform cannot tell which draft is the
139
- CLI draft (see "Which draft a draft command writes to" below); the live theme's handle is refused.
142
+ - `--instance <handle>` — sync into and show this draft. Needed when the platform cannot tell which
143
+ draft is the CLI draft (see "Which draft a draft command writes to" below); the live theme's handle
144
+ is refused.
145
+
146
+ **Local draft browsing.** The local view renders only a draft theme, never the live one:
147
+ - Which draft: `--instance` (or the theme work saved by `init` / `theme work start`), otherwise the
148
+ draft the startup sync wrote to. With no draft the CLI does not ask the platform to render at all and
149
+ the page says "Tema hedefi hazırlanamadı" with the reason (sync failure, no handle returned,
150
+ `--no-sync` without `--instance`). Requests are never sent with a live theme.
151
+ - What you see: your **local theme files** plus the draft's **saved** content (pages, entries,
152
+ settings); drafts and scheduled entries are visible, trashed pages and entries are not. The platform
153
+ adds the local preview band; the CLI adds only the reload script.
154
+ - Navigation stays local: links, `area`s, form actions, `formaction`, `<meta http-equiv=refresh>` and
155
+ `<link rel=alternate>` that point at one of the site's own addresses (as the platform reports them,
156
+ also protocol-relative `//host/…`) are rewritten to `http://127.0.0.1:<port>`, keeping path, query and
157
+ hash. Relative links, other sites and other tenants are left alone. A `javascript:`, `data:` or
158
+ `vbscript:` URL, a URL with a user name/password, a malformed URL or one with a character reference
159
+ the CLI cannot decode is removed from those attributes (marked `data-blocofy-blocked="<attribute>"`).
160
+ Images, scripts, stylesheets, iframes and `<link rel=canonical>` are not touched (assets load from the
161
+ site's system host; canonical keeps the site's public address).
162
+ - The query string reaches the theme (pagination, filters); the `#hash` stays in the browser.
163
+ - A missing page is answered with HTTP 404 and the draft's own 404 page, plus a small note with the
164
+ reason. Redirects (for example to a language's canonical address) are not followed locally: the note
165
+ shows where the live site would redirect.
166
+ - Platform refusals (`instance_is_live`, `instance_not_found`, `bad_search`, …) are shown as a page with
167
+ their code and HTTP status. A platform that does not speak this contract (version 2) gets the page
168
+ "Platform sürümü yerel taslak gezinmeyi desteklemiyor"; its HTML is never shown as your draft.
169
+ - **Forms are not sent from the local view.** Every HTML answer carries the header
170
+ `Content-Security-Policy: form-action 'self'; connect-src 'self'; base-uri 'none'` (plus
171
+ `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, `Cache-Control: no-store`). Any form
172
+ submission can only reach the local server, which answers `405` with "Yerel önizlemede formlar
173
+ gönderilmez"; a `GET` search form becomes a local page request. `fetch`, XHR, `sendBeacon`, WebSocket
174
+ and `<a ping>` to the live site or third parties are blocked. A theme's own `<meta>` CSP applies on top
175
+ (the stricter rule wins).
176
+
177
+ Known limits of local draft browsing:
178
+ - Navigation done by JavaScript (`location.href = …`, `window.open`, the History API, inline `onclick`
179
+ handlers, a JS redirect) is not rewritten and can open the live site or another address.
180
+ - Scripts, images, iframes and fonts are not restricted (so menus, sliders and embeds work): a theme or
181
+ third-party script can still load images or frames from other sites (a `GET` request, e.g. a
182
+ tracking pixel), and JavaScript can open another site in the top window or a new one. The CLI does
183
+ not claim the local view has no outside effects; it blocks form submissions and script network calls.
184
+ - Features that call the live site from the browser (load-more by `fetch`, search-as-you-type, etc.)
185
+ do not work locally (`connect-src 'self'`).
186
+ - `http://<site host>` links are matched only when they equal one of the site's addresses exactly
187
+ (normally `https://`), so an `http://` link to the site is left as is.
188
+ - Absolute form actions are rewritten to `127.0.0.1`; open the view on `http://127.0.0.1:<port>` (the
189
+ printed address), on `localhost:<port>` such a form is refused by the browser.
190
+ - The link rewrite reads HTML tags, comments and raw-text elements (`script`, `style`, `textarea`,
191
+ `title`, `noscript`, …) like a browser, but not the rarer escape rules inside `<script>` or SVG/MathML
192
+ `CDATA`; such text may be left unmapped. A `javascript:` link that a theme uses as a menu toggle loses
193
+ its `href` (its click handler still runs, but it is no longer keyboard-focusable).
140
194
 
141
195
  ```
142
196
  blocofy theme pull [dir] [--draft] [--instance <handle>]
@@ -623,6 +677,30 @@ Each retry prints a notice on stderr.
623
677
 
624
678
  ## Changelog
625
679
 
680
+ - **0.18.0** (2026-11-01) — `theme dev` local draft browsing (needs a platform that speaks browse contract 2).
681
+ - The local view renders only a draft: `--instance`, the saved theme work or the draft the sync
682
+ wrote to. Without one it no longer falls back to the live theme: no render request is sent and the
683
+ page says why ("Tema hedefi hazırlanamadı"). `--no-sync` now needs `--instance`.
684
+ - Pages are requested with `mode: "browse"`, the raw query string and the local address; the answer
685
+ is accepted only when it is contract 2 for the same draft. An older platform's answer is shown as
686
+ "Platform sürümü yerel taslak gezinmeyi desteklemiyor", never as the draft. Platform refusals show
687
+ their code and keep their HTTP status; a 404 stays a 404.
688
+ - Links, form actions, `formaction`, meta refresh and `rel=alternate` links to the site's own
689
+ addresses (also `//host/…`) are rewritten to the local address; `javascript:`/`data:`/userinfo and
690
+ malformed URLs on those attributes are removed. Assets and canonical are left alone.
691
+ - Every HTML answer carries `Content-Security-Policy: form-action 'self'; connect-src 'self';
692
+ base-uri 'none'`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff` and
693
+ `Cache-Control: no-store`; a form submission gets a `405` page saying forms are not sent locally.
694
+ JavaScript-driven navigation and image/frame requests are not contained (see "Known limits").
695
+ - **0.17.1** (2026-11-01) — Security fix: `theme dev` answers this machine only.
696
+ - The local server listens on IPv4 loopback (`127.0.0.1`) instead of every network interface, and
697
+ prints that address. There is no option to open it to the network.
698
+ - A request whose `Host` is not `127.0.0.1:<port>` / `localhost:<port>`, or whose `Origin` is another
699
+ site, is refused before anything is read or rendered (DNS-rebinding and cross-site pages).
700
+ - Only `GET` and `HEAD` are answered; other methods get `405` with `Allow: GET, HEAD` and never reach
701
+ the platform. `HEAD` returns headers only and never spends a render.
702
+ - The reload channel no longer sends `Access-Control-Allow-Origin: *`; the local page still reloads on
703
+ save. Views on other origins no longer subscribe to it.
626
704
  - **0.17.0** (2026-10-31) — `theme publish` tells you when the live site is not serving the files
627
705
  you published.
628
706
  - **`theme publish` reports an unrefreshed live site.** When the platform answers
@@ -908,9 +986,10 @@ Each retry prints a notice on stderr.
908
986
  ## How it works
909
987
 
910
988
  `theme dev` starts a local HTTP server. For each page request it reads your local theme
911
- files and sends them to the platform's dev-render endpoint (`/api/dev/render`). The platform
912
- renders them with the site's **live data** and returns HTML — so the CLI ships no rendering
913
- engine and you see exactly the production output.
989
+ files and sends them, with the draft theme's handle, to the platform's dev-render endpoint
990
+ (`/api/dev/render`, `mode: "browse"`). The platform renders them with that draft's **saved
991
+ content** and returns HTML — so the CLI ships no rendering engine. The CLI maps the site's own
992
+ links to the local address and adds the reload script.
914
993
 
915
994
  It also continuously syncs your local files to a **draft theme**, so the same work can be opened
916
995
  in the admin theme editor without affecting your published theme. Save a file and the local view
package/bin/blocofy.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * `blocofy login` stores your platform URL + dev token, then `blocofy theme dev`
7
7
  * starts a local server that proxies each request to the platform's
8
- * `/api/dev/render` endpoint (local theme files + live data) with file-watch
9
- * livereload. No monorepo required.
8
+ * `/api/dev/render` endpoint (local theme files + the draft's saved content,
9
+ * local draft browsing) with file-watch livereload. No monorepo required.
10
10
  */
11
11
  import { randomUUID } from "node:crypto";
12
12
  import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -219,18 +219,20 @@ Usage
219
219
  Show which site a command in [dir] would hit (verified), without writing anything.
220
220
 
221
221
  blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>] [--instance <handle>]
222
- Start a local dev server that renders your local theme files with the site's
223
- live content. Press l to open it, q to quit. Edit a file and save → the view
224
- reloads. Saves sync to a DRAFT theme only (never the live site). (dir defaults to cwd)
222
+ Start a local dev server that shows a DRAFT theme with your local theme files and
223
+ the draft's saved content; menus, pages, entries and languages stay on the local
224
+ address (never the live theme). Press l to open it, q to quit. Edit a file and save →
225
+ the view reloads. Saves sync to a DRAFT theme only (never the live site). Forms are not
226
+ sent from the local view. (dir defaults to cwd)
225
227
  The platform's remote preview and editor views for this command are retired; to
226
228
  share a draft page, create a preview link for it (v1 API / MCP create_preview_link).
227
229
  The target site is verified ONCE at start; a long session keeps that target (restart
228
230
  it after changing credentials, context or the project binding).
229
231
  --port <n> local port (default 3030)
230
- --no-sync local preview only (skip draft sync)
232
+ --no-sync skip draft sync; needs --instance <draft-handle> (no draft = no preview)
231
233
  --name <name> name the draft when it is first created (ignored if it already exists)
232
- --instance <handle> sync into this draft (when the platform cannot tell which draft is
233
- the CLI draft it refuses and asks for this; never the live theme)
234
+ --instance <handle> sync into and show this draft (when the platform cannot tell which
235
+ draft is the CLI draft it refuses and asks for this; never the live theme)
234
236
 
235
237
  blocofy theme pull [dir] [--draft] [--instance <handle>]
236
238
  Download the live theme to disk. (dir defaults to cwd)
@@ -2577,7 +2579,7 @@ async function themeDev(rest) {
2577
2579
  // sync never writes the live theme, so the live theme's handle is refused here.
2578
2580
  const devInstance = typeof flags.instance === "string" ? flags.instance : savedInstance;
2579
2581
  if (devInstance && instanceMaybeLive(devInstance, target.identity?.liveThemeId ?? null)) {
2580
- console.error(`✗ ${devInstance} is (or could not be told apart from) the LIVE theme. 'theme dev' syncs to a draft only; pick a draft handle (\`blocofy status\`) or use --no-sync.`);
2582
+ console.error(`✗ ${devInstance} is (or could not be told apart from) the LIVE theme. 'theme dev' shows and syncs a draft only; pick a draft handle (\`blocofy status\`).`);
2581
2583
  process.exit(2);
2582
2584
  }
2583
2585
 
@@ -2596,7 +2598,8 @@ async function themeDev(rest) {
2596
2598
  }
2597
2599
  }
2598
2600
 
2599
- const localUrl = `http://localhost:${port}`;
2601
+ // The URL the dev server actually binds (IPv4 loopback): `localhost` may resolve to ::1 first.
2602
+ const localUrl = `http://127.0.0.1:${port}`;
2600
2603
  // A session without a URL (an older platform answer) must not become "undefined&hr=…".
2601
2604
  const previewUrl = session?.previewUrl ? `${session.previewUrl}&hr=${port}` : null;
2602
2605
  const editorUrl = session?.editorUrl ? `${session.editorUrl}&hr=${port}` : null;
@@ -2616,7 +2619,7 @@ async function themeDev(rest) {
2616
2619
  .filter(Boolean)
2617
2620
  .join(" ");
2618
2621
  console.log(`\n Press: ${keys}`);
2619
- console.log(` Edit a theme file and save — every open view reloads automatically.\n`);
2622
+ console.log(` Edit a theme file and save — the local view reloads automatically.\n`);
2620
2623
 
2621
2624
  // Senkron kapsamı (#119 CLI bulgu #2): hangi dizinler taşınıyor / taşınmıyor —
2622
2625
  // "config/pages senkronlanıyor sandım" karışıklığını açıkça önler.
@@ -2644,6 +2647,14 @@ async function themeDev(rest) {
2644
2647
  console.log(` Watching ${themeDir} — ${fileCount} theme files\n`);
2645
2648
  }
2646
2649
 
2650
+ // Local draft browsing: no draft handle → the local view renders nothing (never the live theme).
2651
+ if (noSync && !devInstance) {
2652
+ console.warn(
2653
+ " ⚠ --no-sync without --instance: there is no draft to show, so the local view only explains that.\n" +
2654
+ " Pass the draft to preview: blocofy theme dev --no-sync --instance <draft-handle> (handles: blocofy status)\n",
2655
+ );
2656
+ }
2657
+
2647
2658
  if (flags.dry) {
2648
2659
  console.log("(--dry: server not started)");
2649
2660
  return;
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Local draft browsing (theme dev, contract 2): navigation-link mapping on the HTML the platform returns.
3
+ *
4
+ * A narrow HTML start-tag tokenizer (no dependency) finds the navigation surfaces — `a[href]`, `area[href]`,
5
+ * `form[action]`, `button[formaction]`, `input[formaction]`, `meta[http-equiv=refresh]` content URL and
6
+ * `link[rel~=alternate][href]` — and classifies each URL:
7
+ * - its origin is one of the site's `internalOrigins` (also a protocol-relative `//host/…`) → rewritten to the
8
+ * local origin, path + query + hash kept;
9
+ * - `javascript:` / `data:` / `vbscript:`, userinfo (`https://u@host`), a malformed URL or a character reference
10
+ * this tokenizer does not know → the attribute is removed and `data-blocofy-blocked="<attribute>"` is left;
11
+ * - anything else (relative, external, `mailto:`…) → unchanged.
12
+ * Assets (img/src, srcset, script src, stylesheets, iframes) and `link[rel=canonical]` (public identity, PM decision 5)
13
+ * are never touched. This mapping is navigation convenience only: side-effect containment is the CSP response header
14
+ * set by the dev server, not this rewrite.
15
+ */
16
+
17
+ const RAW_TEXT = new Set(["script", "style", "xmp", "iframe", "noembed", "noframes", "noscript", "textarea", "title"]);
18
+ const BLOCKED_SCHEMES = new Set(["javascript:", "data:", "vbscript:"]);
19
+ const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\f" || c === "\r";
20
+ const isAlpha = (c) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
21
+
22
+ /**
23
+ * Parse the start (or end) tag whose name begins at `p`. Returns { name, attrs, end } or null at EOF (an unfinished tag
24
+ * is not emitted by a browser either). Each attribute: { name, raw, start, end } — `raw` is the undecoded value,
25
+ * `start..end` the whole attribute (name through value) in `html`.
26
+ */
27
+ function parseTag(html, p) {
28
+ const n = html.length;
29
+ let j = p;
30
+ while (j < n && !isWs(html[j]) && html[j] !== "/" && html[j] !== ">") j++;
31
+ const name = html.slice(p, j).toLowerCase();
32
+ const attrs = [];
33
+ for (;;) {
34
+ while (j < n && (isWs(html[j]) || html[j] === "/")) j++;
35
+ if (j >= n) return null;
36
+ if (html[j] === ">") return { name, attrs, end: j + 1 };
37
+ const start = j;
38
+ j++; // the first character of a name may be "=" (spec: part of the name)
39
+ while (j < n && !isWs(html[j]) && html[j] !== "/" && html[j] !== ">" && html[j] !== "=") j++;
40
+ const attrName = html.slice(start, j).toLowerCase();
41
+ let k = j;
42
+ while (k < n && isWs(html[k])) k++;
43
+ if (html[k] !== "=") {
44
+ attrs.push({ name: attrName, raw: "", start, end: j });
45
+ continue;
46
+ }
47
+ k++;
48
+ while (k < n && isWs(html[k])) k++;
49
+ if (k >= n) return null;
50
+ const q = html[k];
51
+ if (q === '"' || q === "'") {
52
+ const close = html.indexOf(q, k + 1);
53
+ if (close < 0) return null;
54
+ attrs.push({ name: attrName, raw: html.slice(k + 1, close), start, end: close + 1 });
55
+ j = close + 1;
56
+ } else if (q === ">") {
57
+ attrs.push({ name: attrName, raw: "", start, end: k });
58
+ j = k;
59
+ } else {
60
+ let e = k;
61
+ while (e < n && !isWs(html[e]) && html[e] !== ">") e++;
62
+ attrs.push({ name: attrName, raw: html.slice(k, e), start, end: e });
63
+ j = e;
64
+ }
65
+ }
66
+ }
67
+
68
+ /** Index of the `</name` end tag (followed by whitespace, "/" or ">") from `from`, case-insensitive; else EOF. */
69
+ function rawTextEnd(html, from, name) {
70
+ let i = from;
71
+ for (;;) {
72
+ const lt = html.indexOf("</", i);
73
+ if (lt < 0) return html.length;
74
+ const after = html[lt + 2 + name.length];
75
+ if (
76
+ html.slice(lt + 2, lt + 2 + name.length).toLowerCase() === name &&
77
+ (after === undefined || isWs(after) || after === "/" || after === ">")
78
+ ) {
79
+ return lt;
80
+ }
81
+ i = lt + 2;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Every start tag in `html`, in document order: { name, attrs, start, end }. Comments, doctype/bogus comments, end
87
+ * tags and the content of raw-text/RCDATA elements (script, style, textarea, title, noscript, iframe, xmp, noembed,
88
+ * noframes) are skipped; `<template>` content is markup and is scanned.
89
+ */
90
+ export function startTags(html) {
91
+ const tags = [];
92
+ const n = html.length;
93
+ let i = 0;
94
+ while (i < n) {
95
+ const lt = html.indexOf("<", i);
96
+ if (lt < 0) break;
97
+ const next = html[lt + 1];
98
+ if (html.startsWith("<!--", lt)) {
99
+ const body = lt + 4;
100
+ if (html[body] === ">") {
101
+ i = body + 1;
102
+ continue;
103
+ }
104
+ if (html.startsWith("->", body)) {
105
+ i = body + 2;
106
+ continue;
107
+ }
108
+ const a = html.indexOf("-->", body);
109
+ const b = html.indexOf("--!>", body);
110
+ const ends = [a < 0 ? Infinity : a + 3, b < 0 ? Infinity : b + 4];
111
+ const end = Math.min(...ends);
112
+ if (end === Infinity) break;
113
+ i = end;
114
+ continue;
115
+ }
116
+ if (next === "!" || next === "?") {
117
+ const gt = html.indexOf(">", lt + 2);
118
+ if (gt < 0) break;
119
+ i = gt + 1;
120
+ continue;
121
+ }
122
+ if (next === "/") {
123
+ if (isAlpha(html[lt + 2] ?? "")) {
124
+ const endTag = parseTag(html, lt + 2);
125
+ if (!endTag) break;
126
+ i = endTag.end;
127
+ continue;
128
+ }
129
+ const gt = html.indexOf(">", lt + 2);
130
+ if (gt < 0) break;
131
+ i = gt + 1;
132
+ continue;
133
+ }
134
+ if (isAlpha(next ?? "")) {
135
+ const tag = parseTag(html, lt + 1);
136
+ if (!tag) break;
137
+ tags.push({ ...tag, start: lt });
138
+ i = tag.end;
139
+ if (tag.name === "plaintext") break;
140
+ if (RAW_TEXT.has(tag.name)) i = rawTextEnd(html, i, tag.name);
141
+ continue;
142
+ }
143
+ i = lt + 1;
144
+ }
145
+ return tags;
146
+ }
147
+
148
+ // Character references that can change how a URL is classified (ASCII punctuation, tab/newline) + the common ones.
149
+ // A named reference not listed here makes the value "malformed" (fail-closed), never silently literal.
150
+ const NAMED = {
151
+ amp: "&", AMP: "&", lt: "<", LT: "<", gt: ">", GT: ">", quot: '"', QUOT: '"', apos: "'",
152
+ colon: ":", sol: "/", bsol: "\\", period: ".", quest: "?", num: "#", commat: "@", percnt: "%",
153
+ equals: "=", excl: "!", dollar: "$", lpar: "(", rpar: ")", ast: "*", midast: "*", plus: "+",
154
+ comma: ",", semi: ";", lsqb: "[", lbrack: "[", rsqb: "]", rbrack: "]", lowbar: "_", UnderBar: "_",
155
+ grave: "`", DiacriticalGrave: "`", lcub: "{", lbrace: "{", rcub: "}", rbrace: "}", verbar: "|",
156
+ vert: "|", VerticalLine: "|", Hat: "^", Tab: "\t", NewLine: "\n", nbsp: " ",
157
+ };
158
+ // Legacy names a browser also decodes without ";" (in an attribute only when not followed by [A-Za-z0-9=]).
159
+ const LEGACY = new Set(["amp", "AMP", "lt", "LT", "gt", "GT", "quot", "QUOT", "nbsp"]);
160
+
161
+ /** Decode an attribute value's character references. { text, unknown } — `unknown` = a reference we cannot decode. */
162
+ export function decodeAttr(raw) {
163
+ let unknown = false;
164
+ const text = raw.replace(/&(#[xX][0-9a-fA-F]+;?|#[0-9]+;?|[A-Za-z][A-Za-z0-9]*;?)/g, (whole, ref, offset) => {
165
+ if (ref[0] === "#") {
166
+ const hex = ref[1] === "x" || ref[1] === "X";
167
+ const digits = ref.slice(hex ? 2 : 1).replace(/;$/, "");
168
+ const cp = parseInt(digits, hex ? 16 : 10);
169
+ if (!cp || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) return "�";
170
+ return String.fromCodePoint(cp);
171
+ }
172
+ if (ref.endsWith(";")) {
173
+ const name = ref.slice(0, -1);
174
+ if (Object.hasOwn(NAMED, name)) return NAMED[name];
175
+ unknown = true;
176
+ return whole;
177
+ }
178
+ const nextChar = raw[offset + whole.length] ?? "";
179
+ if (LEGACY.has(ref) && !/[A-Za-z0-9=]/.test(nextChar)) return NAMED[ref];
180
+ return whole; // `?a=1&b=2`: not a reference
181
+ });
182
+ return { text, unknown };
183
+ }
184
+
185
+ const escapeAttr = (value) => value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
186
+
187
+ /**
188
+ * Classify one navigation URL. → { action: "keep" } | { action: "rewrite", value } | { action: "block", reason }.
189
+ * `pageUrl` is the local page URL (http); a protocol-relative value is also resolved over https so `//host/x` of an
190
+ * internal https origin is recognised.
191
+ */
192
+ export function classifyNavigationUrl(raw, { pageUrl, internal, localOrigin }) {
193
+ const { text, unknown } = decodeAttr(raw);
194
+ if (unknown) return { action: "block", reason: "malformed" };
195
+ let viaHttp;
196
+ let viaHttps;
197
+ try {
198
+ viaHttp = new URL(text, pageUrl);
199
+ const httpsBase = new URL(pageUrl);
200
+ httpsBase.protocol = "https:";
201
+ viaHttps = new URL(text, httpsBase);
202
+ } catch {
203
+ return { action: "block", reason: "malformed" };
204
+ }
205
+ if (BLOCKED_SCHEMES.has(viaHttp.protocol)) return { action: "block", reason: viaHttp.protocol.slice(0, -1) };
206
+ if (viaHttp.username || viaHttp.password) return { action: "block", reason: "userinfo" };
207
+ const hit = internal.has(viaHttp.origin) ? viaHttp : internal.has(viaHttps.origin) ? viaHttps : null;
208
+ if (hit) return { action: "rewrite", value: `${localOrigin}${hit.pathname}${hit.search}${hit.hash}` };
209
+ return { action: "keep" };
210
+ }
211
+
212
+ /** `<delay>[;,] url=<url>` → { delay, url } (HTML "shared declarative refresh steps"); url null when absent/invalid. */
213
+ export function parseRefresh(content) {
214
+ let i = 0;
215
+ const s = content;
216
+ while (i < s.length && isWs(s[i])) i++;
217
+ const delayStart = i;
218
+ while (i < s.length && /[0-9.]/.test(s[i])) i++;
219
+ if (i === delayStart) return null;
220
+ const delay = s.slice(delayStart, i);
221
+ if (i >= s.length) return { delay, url: null };
222
+ if (!isWs(s[i]) && s[i] !== ";" && s[i] !== ",") return null;
223
+ while (i < s.length && isWs(s[i])) i++;
224
+ if (s[i] === ";" || s[i] === ",") i++;
225
+ while (i < s.length && isWs(s[i])) i++;
226
+ if (i >= s.length) return { delay, url: null };
227
+ if (s.slice(i, i + 3).toLowerCase() === "url") {
228
+ let j = i + 3;
229
+ while (j < s.length && isWs(s[j])) j++;
230
+ if (s[j] === "=") {
231
+ j++;
232
+ while (j < s.length && isWs(s[j])) j++;
233
+ i = j;
234
+ }
235
+ }
236
+ let url = s.slice(i);
237
+ if (url[0] === '"' || url[0] === "'") {
238
+ const close = url.indexOf(url[0], 1);
239
+ url = close < 0 ? url.slice(1) : url.slice(1, close);
240
+ }
241
+ return { delay, url };
242
+ }
243
+
244
+ const relTokens = (attr) => (attr ? decodeAttr(attr.raw).text.toLowerCase().split(/[\t\n\f\r ]+/).filter(Boolean) : []);
245
+
246
+ /** Which attribute of this tag is a navigation URL (null when the tag is not a navigation surface). */
247
+ function navigationAttribute(name, first) {
248
+ switch (name) {
249
+ case "a":
250
+ return first.has("href") ? "href" : first.has("xlink:href") ? "xlink:href" : null;
251
+ case "area":
252
+ return "href";
253
+ case "form":
254
+ return "action";
255
+ case "button":
256
+ case "input":
257
+ return "formaction";
258
+ case "link": {
259
+ const rel = relTokens(first.get("rel"));
260
+ // canonical = public identity (PM decision 5): kept at the site's authority. `alternate stylesheet` is an asset.
261
+ if (!rel.includes("alternate") || rel.includes("canonical") || rel.includes("stylesheet")) return null;
262
+ return "href";
263
+ }
264
+ case "meta":
265
+ return decodeAttr(first.get("http-equiv")?.raw ?? "").text.trim().toLowerCase() === "refresh" ? "content" : null;
266
+ default:
267
+ return null;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Rewrite the navigation surfaces of `html` (see the module comment). Returns { html, rewritten, blocked }.
273
+ * `internalOrigins` must already be validated origins; `localOrigin` is `http://127.0.0.1:<port>`.
274
+ */
275
+ export function rewriteNavigation(html, { pageUrl, internalOrigins, localOrigin }) {
276
+ const internal = new Set(internalOrigins);
277
+ const ctx = { pageUrl, internal, localOrigin };
278
+ const edits = [];
279
+ let rewritten = 0;
280
+ let blocked = 0;
281
+ for (const tag of startTags(html)) {
282
+ // A browser keeps the FIRST of duplicate attributes; later ones are inert — and removed when the first is edited,
283
+ // so an edit never promotes an ignored duplicate.
284
+ const first = new Map();
285
+ for (const attr of tag.attrs) if (!first.has(attr.name)) first.set(attr.name, attr);
286
+ const attrName = navigationAttribute(tag.name, first);
287
+ const attr = attrName && first.get(attrName);
288
+ if (!attr || attr.raw === "") continue;
289
+
290
+ let result;
291
+ let refresh = null;
292
+ if (tag.name === "meta") {
293
+ const content = decodeAttr(attr.raw);
294
+ refresh = parseRefresh(content.text);
295
+ if (!refresh || refresh.url === null) continue;
296
+ // The URL was already decoded with the content attribute; re-escape `&` so it is not decoded twice.
297
+ result = content.unknown
298
+ ? { action: "block", reason: "malformed" }
299
+ : classifyNavigationUrl(refresh.url.replace(/&/g, "&amp;"), ctx);
300
+ } else {
301
+ result = classifyNavigationUrl(attr.raw, ctx);
302
+ }
303
+ if (result.action === "keep") continue;
304
+
305
+ const dropDuplicates = () => {
306
+ for (const other of tag.attrs) if (other !== attr && other.name === attr.name) edits.push({ start: other.start, end: other.end, text: "" });
307
+ };
308
+ if (result.action === "block") {
309
+ blocked++;
310
+ edits.push({ start: attr.start, end: attr.end, text: `data-blocofy-blocked="${escapeAttr(attrName)}"` });
311
+ } else {
312
+ rewritten++;
313
+ const value = refresh ? `${refresh.delay};url=${result.value}` : result.value;
314
+ edits.push({ start: attr.start, end: attr.end, text: `${attrName}="${escapeAttr(value)}"` });
315
+ }
316
+ dropDuplicates();
317
+ }
318
+ if (edits.length === 0) return { html, rewritten, blocked };
319
+ edits.sort((x, y) => y.start - x.start);
320
+ let out = html;
321
+ for (const edit of edits) out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
322
+ return { html: out, rewritten, blocked };
323
+ }
@@ -9,16 +9,118 @@ import {
9
9
  injectLivereload,
10
10
  readLocalTemplates,
11
11
  } from "./local-theme.mjs";
12
+ import { rewriteNavigation } from "./browse-html.mjs";
12
13
  import { DRAFT_TARGET_AMBIGUOUS, draftSyncErrorLine, pushTheme } from "./theme-sync.mjs";
13
14
 
14
15
  /**
15
- * Standalone dev server. For each page request it reads the local theme files and
16
- * posts them to the platform's `/api/dev/render` endpoint, then injects a livereload
17
- * script into the returned HTML. `fs.watch` reloads the browser over SSE on change.
16
+ * The dev server proxies page renders with the developer's bearer, so it answers only this machine:
17
+ * it binds IPv4 loopback, and every request must name it (Host + optional Origin) before any branch runs.
18
+ * There is no LAN option on purpose — exposing it needs its own auth/threat model.
19
+ */
20
+ const DEV_SERVER_HOST = "127.0.0.1";
21
+ const ALLOWED_METHODS = "GET, HEAD";
22
+
23
+ /** Why `req` is refused (status + short reason; never echoes request data or the credential), or null. */
24
+ function refusal(req, port) {
25
+ if (req.method !== "GET" && req.method !== "HEAD") return { status: 405, reason: "Method not allowed." };
26
+ // Origin-form only: an absolute request target (proxy style) could name another authority.
27
+ if (typeof req.url !== "string" || !req.url.startsWith("/")) return { status: 400, reason: "Bad request target." };
28
+ const ownHosts = new Set([`${DEV_SERVER_HOST}:${port}`, `localhost:${port}`]);
29
+ const ownOrigins = new Set([...ownHosts].map((host) => `http://${host}`));
30
+ // rawHeaders keeps duplicates that req.headers would silently collapse.
31
+ const headerValues = (name) => {
32
+ const values = [];
33
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
34
+ if (req.rawHeaders[i].toLowerCase() === name) values.push(req.rawHeaders[i + 1].trim().toLowerCase());
35
+ }
36
+ return values;
37
+ };
38
+ // Exactly one Host, and it is this server: a DNS-rebinding page arrives with its own host name.
39
+ // X-Forwarded-Host / Forwarded / Referer are never read.
40
+ const hosts = headerValues("host");
41
+ if (hosts.length !== 1 || !ownHosts.has(hosts[0])) {
42
+ return { status: 403, reason: `This dev server only answers http://${DEV_SERVER_HOST}:${port}.` };
43
+ }
44
+ // A browser that sends Origin must be on this server's own origin (cross-origin pages and `null` refused).
45
+ const origins = headerValues("origin");
46
+ if (origins.length > 1 || (origins.length === 1 && !ownOrigins.has(origins[0]))) {
47
+ return { status: 403, reason: `Cross-origin requests are not served; open http://${DEV_SERVER_HOST}:${port}.` };
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Local draft browsing (contract 2) response headers on every HTML answer of this server. The CSP is an HTTP header,
54
+ * not a `<meta>`, so a theme cannot remove it (a theme's own meta CSP still applies too — the stricter wins):
55
+ * - `form-action 'self'`: every form submission (action, formaction, formtarget/new window) can only go to this
56
+ * server, which answers 405 for anything but GET/HEAD; a GET form becomes a local read-only page request;
57
+ * - `connect-src 'self'`: fetch / XHR / sendBeacon / WebSocket / EventSource / `<a ping>` to the live site or a third
58
+ * party are blocked (the livereload EventSource is same-origin and keeps working);
59
+ * - `base-uri 'none'`: a theme `<base href>` cannot re-target relative links to the live site.
60
+ * script/style/img/frame/font sources are deliberately NOT restricted: theme menus, sliders and embeds keep working.
61
+ */
62
+ export const BROWSE_CSP = "form-action 'self'; connect-src 'self'; base-uri 'none'";
63
+ const HTML_HEADERS = {
64
+ "content-type": "text/html; charset=utf-8",
65
+ "cache-control": "no-store",
66
+ "content-security-policy": BROWSE_CSP,
67
+ "referrer-policy": "no-referrer",
68
+ "x-content-type-options": "nosniff",
69
+ };
70
+
71
+ /** The browse contract version this CLI speaks (platform `POST /api/dev/render` with `mode: "browse"`). */
72
+ export const BROWSE_CONTRACT = 2;
73
+
74
+ const isLocalDevHost = (hostname) =>
75
+ hostname === "localhost" || hostname.endsWith(".localhost") || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
76
+
77
+ /** An internal origin: canonical `https://host[:port]`, or `http://` only for a local development host (fixture stack). */
78
+ function validOrigin(value) {
79
+ if (typeof value !== "string") return false;
80
+ try {
81
+ const u = new URL(value);
82
+ if (u.origin !== value) return false;
83
+ return u.protocol === "https:" || (u.protocol === "http:" && isLocalDevHost(u.hostname));
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Strict contract-2 check of a `/api/dev/render` browse answer. → { ok: true, status, html, internalOrigins, diagnosis }
91
+ * or { ok: false, why }. An old platform's `{ html }` (or any other shape) is never served as a browsable draft.
92
+ */
93
+ export function validateBrowseResponse(httpStatus, body, sentInstance) {
94
+ if (httpStatus !== 200 && httpStatus !== 404) return { ok: false, why: `HTTP ${httpStatus}` };
95
+ if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, why: "JSON nesnesi değil" };
96
+ if (body.contract !== BROWSE_CONTRACT) return { ok: false, why: `contract ${JSON.stringify(body.contract ?? null)}` };
97
+ if (body.status !== httpStatus) return { ok: false, why: "status HTTP durumuyla eşleşmiyor" };
98
+ if (!body.target || typeof body.target !== "object" || body.target.instance !== sentInstance) {
99
+ return { ok: false, why: "target.instance gönderilen taslakla eşleşmiyor" };
100
+ }
101
+ if (typeof body.html !== "string") return { ok: false, why: "html dizgi değil" };
102
+ if (!Array.isArray(body.internalOrigins) || !body.internalOrigins.every(validOrigin)) {
103
+ return { ok: false, why: "internalOrigins geçersiz" };
104
+ }
105
+ const d = body.diagnosis;
106
+ const diagnosis =
107
+ d && typeof d === "object" && typeof d.code === "string"
108
+ ? { code: d.code.slice(0, 64), location: typeof d.location === "string" ? d.location.slice(0, 2048) : null }
109
+ : null;
110
+ return { ok: true, status: httpStatus, html: body.html, internalOrigins: body.internalOrigins, diagnosis };
111
+ }
112
+
113
+ /**
114
+ * Standalone dev server — local draft browsing. For each page request it reads the local theme files and posts them
115
+ * with the draft theme handle to the platform's `/api/dev/render` (`mode: "browse"`), validates the contract-2 answer,
116
+ * maps the site's own links to this local origin, and injects a livereload script. `fs.watch` reloads the browser over
117
+ * SSE on change.
18
118
  *
19
- * Assets are usually inlined into the HTML via `{% render 'asset/x.css' %}` (no
20
- * separate request); direct `/asset/*` references are served from disk. Images/JS
21
- * are remote (Directus/CDN), so no local serving is needed for them.
119
+ * No draft handle → no render request at all (a local terminal error page; never the live theme). The handle is
120
+ * `instance` (theme dev --instance, or the saved theme work) or the one the startup draft sync returns.
121
+ *
122
+ * Assets are usually inlined into the HTML via `{% render 'asset/x.css' %}` (no separate request); direct `/asset/*`
123
+ * references are served from disk. Images come from the site's system host (`/cdn`), not through this server.
22
124
  */
23
125
  export function startDevServer({ dir, url, token, port, syncDraft = false, instance = null, onError, onReload, onRetry, onWarn, onSyncRefused }) {
24
126
  const base = url.replace(/\/+$/, "");
@@ -26,28 +128,31 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
26
128
  const bearer = async () => (typeof token === "function" ? await token() : token);
27
129
  const clients = new Set();
28
130
 
29
- // Taslak instance handle'ı: `pushTheme({draft:true})` yanıtından gelir (ek istek YOK). Render
30
- // istekleri bunu taşıyınca platform CANLI sayfa belgeleri yerine TASLAĞIN sayfa belgelerini
31
- // çözer — `theme dev`'in yerel görünümü nihayet üzerinde çalışılan şeyi gösterir.
32
- let draftInstance = null;
33
- // Kanonik yazma yolu ham control-plane JSON'u döndürür ve `instanceId` içermeyebilir. O durumda
34
- // sessizce canlıya dönmek, bu onarımın kapattığı arıza sınıfının aynısı olurdu — bir kez uyar.
131
+ // The draft the local view shows. `--instance` (or the saved theme work) is fixed for the session; otherwise the
132
+ // handle comes from the draft sync answer (`pushTheme({draft:true})`, no extra request).
133
+ let draftInstance = instance ? String(instance) : null;
134
+ // Why there is no handle (shown on the local error page; no render is requested without one).
135
+ let noHandleReason = syncDraft
136
+ ? "Taslak senkronu henüz bitmedi."
137
+ : "--no-sync ile --instance <taslak-handle> verilmedi; yerel önizleme canlı temayı göstermez.";
35
138
  let warnedNoHandle = false;
36
139
  const noteHandle = (pushed) => {
140
+ if (instance) return;
37
141
  if (pushed?.instanceId) {
38
- draftInstance = pushed.instanceId;
142
+ draftInstance = String(pushed.instanceId);
39
143
  return;
40
144
  }
145
+ noHandleReason = "Platform taslak senkronundan sonra bir taslak tema handle'ı döndürmedi.";
41
146
  if (!warnedNoHandle) {
42
147
  warnedNoHandle = true;
43
148
  onWarn?.(
44
- "the server did not return a draft theme handle — local preview will render LIVE page content, not your draft.",
149
+ "the server did not return a draft theme handle — the local view cannot show your draft (it never falls back to live); pass --instance <draft-handle>.",
45
150
  );
46
151
  }
47
152
  };
48
153
 
49
154
  // Açılıştaki push'u BİR promise olarak tut: ilk render isteği bunu bekler, yoksa ilk sayfa
50
- // yüklemesi canlı içerikle gelir ve geliştirici "taslağım görünmüyor" sanır.
155
+ // yüklemesi senkron bitmeden gelir ve geliştirici "taslağım görünmüyor" sanır.
51
156
  // #989: `instance` (theme dev --instance) syncs into that draft; otherwise the platform picks the CLI draft and
52
157
  // refuses (409 draft_target_ambiguous) when it cannot prove which one that is. A refusal is final for the session:
53
158
  // `onSyncRefused` is told once (the caller exits) — the sync is never retried in a loop.
@@ -68,55 +173,103 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
68
173
  .then(noteHandle)
69
174
  .catch((err) => {
70
175
  // #989 re-review P3: a startup sync failure is said, in one line — never swallowed.
71
- if (!refusedBy(err)) onWarn?.(draftSyncErrorLine(err));
176
+ if (!refusedBy(err)) {
177
+ const line = draftSyncErrorLine(err);
178
+ noHandleReason = line;
179
+ onWarn?.(line);
180
+ }
72
181
  })
73
182
  : Promise.resolve();
74
183
 
75
- async function renderPath(pathname) {
184
+ /** One page through the browse contract. → { status, html } (html is complete; livereload not yet injected). */
185
+ async function renderPage(pathname, search, localOrigin) {
76
186
  // Açılış push'u bitene kadar bekle — yalnız ilk istekte gerçekten bekler, sonra çözülmüş promise.
77
- if (!draftInstance) await draftReady;
187
+ await draftReady;
188
+ if (!draftInstance) {
189
+ return {
190
+ status: 503,
191
+ html: localPage({
192
+ title: "Tema hedefi hazırlanamadı",
193
+ lines: [
194
+ "Yerel önizleme yalnız bir taslak temayı gösterir. Hangi taslağın gösterileceği belirlenemediği için platformdan sayfa istenmedi; canlı siteye de geçilmedi.",
195
+ ],
196
+ detail: noHandleReason,
197
+ hint: "Terminaldeki mesaja bakın. Bir taslak seçmek için: blocofy theme dev --instance <taslak-handle> (handle'lar: blocofy status).",
198
+ }),
199
+ };
200
+ }
201
+ const sent = draftInstance;
78
202
  const templates = readLocalTemplates(dir);
79
203
  const res = await fetch(`${base}/api/dev/render`, {
80
204
  method: "POST",
81
205
  headers: { "content-type": "application/json", authorization: `Bearer ${await bearer()}` },
82
- // `preview` BİLİNÇLE gönderilmez: sunucuda preview yalnız tazelik değil — önizleme bandı,
83
- // editör bridge'i ve blok işaretleyicileri de ona bağlı. `theme dev`'in vaadi prod paritesi.
84
206
  body: JSON.stringify({
207
+ mode: "browse",
85
208
  path: pathname,
86
209
  templates,
87
- ...(draftInstance ? { instance: draftInstance } : {}),
210
+ instance: sent,
211
+ ...(search ? { search } : {}),
212
+ localOrigin,
88
213
  }),
89
214
  });
90
215
  const text = await res.text();
91
- if (!res.ok) {
92
- let message = text;
93
- try {
94
- message = JSON.parse(text).error ?? text;
95
- } catch {
96
- /* raw text */
97
- }
98
- return { status: res.status, html: errorHtml(res.status, message) };
99
- }
216
+ let body = null;
100
217
  try {
101
- return { status: 200, html: JSON.parse(text).html ?? "" };
218
+ body = JSON.parse(text);
102
219
  } catch {
103
- return { status: 200, html: text };
220
+ /* not JSON */
221
+ }
222
+ if (res.status !== 200 && res.status !== 404) return { status: res.status, html: refusalPage(res.status, body, text) };
223
+ const checked = validateBrowseResponse(res.status, body, sent);
224
+ if (!checked.ok) {
225
+ // A 404 with a typed error body (e.g. instance_not_found) is a refusal, not a contract-2 page.
226
+ if (res.status === 404 && body?.error) return { status: 404, html: refusalPage(404, body, text) };
227
+ return {
228
+ status: 502,
229
+ html: localPage({
230
+ title: "Platform sürümü yerel taslak gezinmeyi desteklemiyor",
231
+ lines: [
232
+ `Platformun yanıtı yerel taslak gezinme sözleşmesine (contract ${BROWSE_CONTRACT}) uymuyor; bu yanıt taslağınız diye gösterilmedi.`,
233
+ "Platform güncellenene kadar bu CLI sürümüyle yerel önizleme kullanılamaz.",
234
+ ],
235
+ detail: `Uyumsuzluk: ${checked.why}`,
236
+ }),
237
+ };
104
238
  }
239
+ const pageUrl = `${localOrigin}${pathname}${search}`;
240
+ let html = rewriteNavigation(checked.html, { pageUrl, internalOrigins: checked.internalOrigins, localOrigin }).html;
241
+ if (checked.status === 404) html = injectBeforeBodyEnd(html, notFoundNote(checked.diagnosis));
242
+ return { status: checked.status, html };
105
243
  }
106
244
 
107
245
  const server = createServer(async (req, res) => {
246
+ // Before every branch: no SSE subscription, disk read or authenticated render for a refused request.
247
+ const refused = refusal(req, server.address()?.port);
248
+ if (refused) {
249
+ if (refused.status === 405) {
250
+ // Forms (POST/PUT/…) end here: CSP `form-action 'self'` keeps every submission on this server.
251
+ res.writeHead(405, { ...HTML_HEADERS, allow: ALLOWED_METHODS });
252
+ res.end(formsNotSentPage());
253
+ return;
254
+ }
255
+ res.writeHead(refused.status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
256
+ res.end(`${refused.reason}\n`);
257
+ return;
258
+ }
259
+ const head = req.method === "HEAD";
108
260
  try {
109
- const pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
261
+ // Origin-form request target (checked above): parse it as a path of this origin, never as an authority.
262
+ const target = new URL(`http://localhost${req.url}`);
263
+ const pathname = decodeURIComponent(target.pathname);
110
264
 
111
- // Livereload SSE channel.
265
+ // Livereload SSE channel — same-origin only (no CORS header): cross-origin views no longer subscribe.
112
266
  if (pathname === LIVERELOAD_PATH) {
113
267
  res.writeHead(200, {
114
268
  "content-type": "text/event-stream",
115
269
  "cache-control": "no-cache, no-transform",
116
- connection: "keep-alive",
117
- // Cross-origin iframe'ler (canlı-domain önizleme + editör) subscribe edebilsin.
118
- "access-control-allow-origin": "*",
270
+ ...(head ? {} : { connection: "keep-alive" }),
119
271
  });
272
+ if (head) return void res.end();
120
273
  res.write("retry: 1000\n\n");
121
274
  clients.add(res);
122
275
  req.on("close", () => clients.delete(res));
@@ -127,16 +280,25 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
127
280
  const assetFile = safeAssetFile(dir, pathname);
128
281
  if (assetFile) {
129
282
  res.writeHead(200, { "content-type": contentTypeFor(assetFile), "cache-control": "no-store" });
130
- res.end(readFileSync(assetFile));
283
+ res.end(head ? undefined : readFileSync(assetFile));
284
+ return;
285
+ }
286
+
287
+ // HEAD never spends an authenticated render: headers only.
288
+ if (head) {
289
+ res.writeHead(200, HTML_HEADERS);
290
+ res.end();
131
291
  return;
132
292
  }
133
293
 
134
- // Page → render on the platform.
135
- const { status, html } = await renderPath(pathname);
136
- res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
294
+ // Page → render the draft on the platform. The raw (still percent-encoded) path and query go as they are: the
295
+ // platform decodes once. The hash never reaches a server.
296
+ const localOrigin = `http://${DEV_SERVER_HOST}:${server.address().port}`;
297
+ const { status, html } = await renderPage(target.pathname, target.search, localOrigin);
298
+ res.writeHead(status, HTML_HEADERS);
137
299
  res.end(injectLivereload(html));
138
300
  } catch (err) {
139
- res.writeHead(502, { "content-type": "text/html; charset=utf-8" });
301
+ res.writeHead(502, HTML_HEADERS);
140
302
  res.end(injectLivereload(errorHtml(502, err?.message ?? String(err))));
141
303
  }
142
304
  });
@@ -166,7 +328,7 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
166
328
  onReload?.({ file, synced: syncDraft && !error, clients: clients.size, error });
167
329
  });
168
330
 
169
- server.listen(port);
331
+ server.listen(port, DEV_SERVER_HOST);
170
332
 
171
333
  return {
172
334
  server,
@@ -276,7 +438,64 @@ function safeAssetFile(dir, pathname) {
276
438
  }
277
439
 
278
440
  function escapeHtml(value) {
279
- return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
441
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
442
+ }
443
+
444
+ function injectBeforeBodyEnd(html, snippet) {
445
+ const at = html.lastIndexOf("</body>");
446
+ return at < 0 ? html + snippet : html.slice(0, at) + snippet + html.slice(at);
447
+ }
448
+
449
+ /** A local page of this CLI (not the theme): title, paragraphs, an optional detail block and hint. */
450
+ function localPage({ title, lines = [], detail = null, hint = null }) {
451
+ return (
452
+ `<!doctype html><html lang="tr"><head><meta charset="utf-8"><meta name="robots" content="noindex">` +
453
+ `<title>blocofy theme dev — ${escapeHtml(title)}</title></head>` +
454
+ `<body style="font-family:system-ui,sans-serif;padding:2rem;max-width:48rem;margin:0 auto;color:#18181b">` +
455
+ `<h1 style="color:#b91c1c;font-size:1.5rem">${escapeHtml(title)}</h1>` +
456
+ lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("") +
457
+ (detail ? `<pre style="white-space:pre-wrap;background:#f4f4f5;padding:1rem;border-radius:.5rem;color:#333">${escapeHtml(detail)}</pre>` : "") +
458
+ (hint ? `<p style="color:#52525b">${escapeHtml(hint)}</p>` : "") +
459
+ `</body></html>`
460
+ );
461
+ }
462
+
463
+ /** The platform refused the browse request: its error code (new `{error:{code,message}}` or legacy shape), status kept. */
464
+ function refusalPage(status, body, text) {
465
+ const error = body?.error;
466
+ const code = typeof error?.code === "string" ? error.code : typeof body?.code === "string" ? body.code : null;
467
+ const message =
468
+ typeof error?.message === "string" ? error.message : typeof error === "string" ? error : body ? "" : text.slice(0, 500);
469
+ return localPage({
470
+ title: "Platform bu sayfayı göstermeyi reddetti",
471
+ lines: [`HTTP ${status}${code ? ` · ${code}` : ""}`, ...(message ? [message] : [])],
472
+ hint:
473
+ code === "instance_is_live" || code === "instance_not_found" || code === "instance_required"
474
+ ? "Yerel önizleme yalnız bu sitenin bir taslak temasını gösterir; canlı temaya geçilmez. Bir taslak seçin: blocofy theme dev --instance <taslak-handle> (handle'lar: blocofy status)."
475
+ : "Bu sayfa kaydettiğinizde kendiliğinden yenilenir.",
476
+ });
477
+ }
478
+
479
+ /** A small CLI note on a 404 (the theme's own 404 HTML is otherwise left as is). */
480
+ function notFoundNote(diagnosis) {
481
+ const parts = ["blocofy theme dev: 404"];
482
+ if (diagnosis?.code) parts.push(diagnosis.code);
483
+ if (diagnosis?.location) parts.push(`canlı sitede yönlendirilirdi: ${diagnosis.location} (yerel önizlemede yönlendirme uygulanmaz)`);
484
+ return (
485
+ `<blocofy-dev-note role="note" style="all:initial;position:fixed;bottom:8px;left:8px;z-index:2147483647;` +
486
+ `font:12px/1.4 system-ui,sans-serif;background:#18181b;color:#fafafa;padding:6px 10px;border-radius:6px;max-width:90vw">` +
487
+ `${escapeHtml(parts.join(" · "))}</blocofy-dev-note>`
488
+ );
489
+ }
490
+
491
+ /** The 405 answer for a form submission (any non-GET/HEAD request). */
492
+ function formsNotSentPage() {
493
+ return localPage({
494
+ title: "Yerel önizlemede formlar gönderilmez",
495
+ lines: [
496
+ "Bu yerel sunucu yalnız sayfa gösterir (GET/HEAD). Bu form gönderimi canlı siteye ya da başka bir adrese iletilmedi.",
497
+ ],
498
+ });
280
499
  }
281
500
 
282
501
  function errorHtml(status, message) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocofy/cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Develop your Blocofy theme locally against live data with instant preview — login, theme dev, pull, push.",
5
5
  "type": "module",
6
6
  "license": "MIT",