@blocofy/cli 0.17.1 → 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,9 +125,9 @@ 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
@@ -136,10 +136,61 @@ The dev server answers this machine only: it listens on `http://127.0.0.1:<port>
136
136
  whose `Host` (or `Origin`, when sent) is not that address or `localhost:<port>`, and answers only
137
137
  `GET`/`HEAD` (anything else gets `405`). The reload channel is same-origin only.
138
138
  - `--port <n>` — local port (default 3030).
139
- - `--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).
140
141
  - `--name <name>` — name the draft when it is first created (ignored if it already exists).
141
- - `--instance <handle>` — sync into this draft. Needed when the platform cannot tell which draft is the
142
- 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).
143
194
 
144
195
  ```
145
196
  blocofy theme pull [dir] [--draft] [--instance <handle>]
@@ -626,6 +677,21 @@ Each retry prints a notice on stderr.
626
677
 
627
678
  ## Changelog
628
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").
629
695
  - **0.17.1** (2026-11-01) — Security fix: `theme dev` answers this machine only.
630
696
  - The local server listens on IPv4 loopback (`127.0.0.1`) instead of every network interface, and
631
697
  prints that address. There is no option to open it to the network.
@@ -920,9 +986,10 @@ Each retry prints a notice on stderr.
920
986
  ## How it works
921
987
 
922
988
  `theme dev` starts a local HTTP server. For each page request it reads your local theme
923
- files and sends them to the platform's dev-render endpoint (`/api/dev/render`). The platform
924
- renders them with the site's **live data** and returns HTML — so the CLI ships no rendering
925
- 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.
926
993
 
927
994
  It also continuously syncs your local files to a **draft theme**, so the same work can be opened
928
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
 
@@ -2645,6 +2647,14 @@ async function themeDev(rest) {
2645
2647
  console.log(` Watching ${themeDir} — ${fileCount} theme files\n`);
2646
2648
  }
2647
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
+
2648
2658
  if (flags.dry) {
2649
2659
  console.log("(--dry: server not started)");
2650
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,6 +9,7 @@ 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
  /**
@@ -49,13 +50,77 @@ function refusal(req, port) {
49
50
  }
50
51
 
51
52
  /**
52
- * Standalone dev server. For each page request it reads the local theme files and
53
- * posts them to the platform's `/api/dev/render` endpoint, then injects a livereload
54
- * script into the returned HTML. `fs.watch` reloads the browser over SSE on change.
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.
55
118
  *
56
- * Assets are usually inlined into the HTML via `{% render 'asset/x.css' %}` (no
57
- * separate request); direct `/asset/*` references are served from disk. Images/JS
58
- * 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.
59
124
  */
60
125
  export function startDevServer({ dir, url, token, port, syncDraft = false, instance = null, onError, onReload, onRetry, onWarn, onSyncRefused }) {
61
126
  const base = url.replace(/\/+$/, "");
@@ -63,28 +128,31 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
63
128
  const bearer = async () => (typeof token === "function" ? await token() : token);
64
129
  const clients = new Set();
65
130
 
66
- // Taslak instance handle'ı: `pushTheme({draft:true})` yanıtından gelir (ek istek YOK). Render
67
- // istekleri bunu taşıyınca platform CANLI sayfa belgeleri yerine TASLAĞIN sayfa belgelerini
68
- // çözer — `theme dev`'in yerel görünümü nihayet üzerinde çalışılan şeyi gösterir.
69
- let draftInstance = null;
70
- // Kanonik yazma yolu ham control-plane JSON'u döndürür ve `instanceId` içermeyebilir. O durumda
71
- // 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.";
72
138
  let warnedNoHandle = false;
73
139
  const noteHandle = (pushed) => {
140
+ if (instance) return;
74
141
  if (pushed?.instanceId) {
75
- draftInstance = pushed.instanceId;
142
+ draftInstance = String(pushed.instanceId);
76
143
  return;
77
144
  }
145
+ noHandleReason = "Platform taslak senkronundan sonra bir taslak tema handle'ı döndürmedi.";
78
146
  if (!warnedNoHandle) {
79
147
  warnedNoHandle = true;
80
148
  onWarn?.(
81
- "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>.",
82
150
  );
83
151
  }
84
152
  };
85
153
 
86
154
  // Açılıştaki push'u BİR promise olarak tut: ilk render isteği bunu bekler, yoksa ilk sayfa
87
- // 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.
88
156
  // #989: `instance` (theme dev --instance) syncs into that draft; otherwise the platform picks the CLI draft and
89
157
  // refuses (409 draft_target_ambiguous) when it cannot prove which one that is. A refusal is final for the session:
90
158
  // `onSyncRefused` is told once (the caller exits) — the sync is never retried in a loop.
@@ -105,57 +173,94 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
105
173
  .then(noteHandle)
106
174
  .catch((err) => {
107
175
  // #989 re-review P3: a startup sync failure is said, in one line — never swallowed.
108
- if (!refusedBy(err)) onWarn?.(draftSyncErrorLine(err));
176
+ if (!refusedBy(err)) {
177
+ const line = draftSyncErrorLine(err);
178
+ noHandleReason = line;
179
+ onWarn?.(line);
180
+ }
109
181
  })
110
182
  : Promise.resolve();
111
183
 
112
- 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) {
113
186
  // Açılış push'u bitene kadar bekle — yalnız ilk istekte gerçekten bekler, sonra çözülmüş promise.
114
- 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;
115
202
  const templates = readLocalTemplates(dir);
116
203
  const res = await fetch(`${base}/api/dev/render`, {
117
204
  method: "POST",
118
205
  headers: { "content-type": "application/json", authorization: `Bearer ${await bearer()}` },
119
- // `preview` BİLİNÇLE gönderilmez: sunucuda preview yalnız tazelik değil — önizleme bandı,
120
- // editör bridge'i ve blok işaretleyicileri de ona bağlı. `theme dev`'in vaadi prod paritesi.
121
206
  body: JSON.stringify({
207
+ mode: "browse",
122
208
  path: pathname,
123
209
  templates,
124
- ...(draftInstance ? { instance: draftInstance } : {}),
210
+ instance: sent,
211
+ ...(search ? { search } : {}),
212
+ localOrigin,
125
213
  }),
126
214
  });
127
215
  const text = await res.text();
128
- if (!res.ok) {
129
- let message = text;
130
- try {
131
- message = JSON.parse(text).error ?? text;
132
- } catch {
133
- /* raw text */
134
- }
135
- return { status: res.status, html: errorHtml(res.status, message) };
136
- }
216
+ let body = null;
137
217
  try {
138
- return { status: 200, html: JSON.parse(text).html ?? "" };
218
+ body = JSON.parse(text);
139
219
  } catch {
140
- 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
+ };
141
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 };
142
243
  }
143
244
 
144
245
  const server = createServer(async (req, res) => {
145
246
  // Before every branch: no SSE subscription, disk read or authenticated render for a refused request.
146
247
  const refused = refusal(req, server.address()?.port);
147
248
  if (refused) {
148
- res.writeHead(refused.status, {
149
- "content-type": "text/plain; charset=utf-8",
150
- "cache-control": "no-store",
151
- ...(refused.status === 405 ? { allow: ALLOWED_METHODS } : {}),
152
- });
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" });
153
256
  res.end(`${refused.reason}\n`);
154
257
  return;
155
258
  }
156
259
  const head = req.method === "HEAD";
157
260
  try {
158
- 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);
159
264
 
160
265
  // Livereload SSE channel — same-origin only (no CORS header): cross-origin views no longer subscribe.
161
266
  if (pathname === LIVERELOAD_PATH) {
@@ -181,17 +286,19 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
181
286
 
182
287
  // HEAD never spends an authenticated render: headers only.
183
288
  if (head) {
184
- res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
289
+ res.writeHead(200, HTML_HEADERS);
185
290
  res.end();
186
291
  return;
187
292
  }
188
293
 
189
- // Page → render on the platform.
190
- const { status, html } = await renderPath(pathname);
191
- 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);
192
299
  res.end(injectLivereload(html));
193
300
  } catch (err) {
194
- res.writeHead(502, { "content-type": "text/html; charset=utf-8" });
301
+ res.writeHead(502, HTML_HEADERS);
195
302
  res.end(injectLivereload(errorHtml(502, err?.message ?? String(err))));
196
303
  }
197
304
  });
@@ -331,7 +438,64 @@ function safeAssetFile(dir, pathname) {
331
438
  }
332
439
 
333
440
  function escapeHtml(value) {
334
- 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
+ });
335
499
  }
336
500
 
337
501
  function errorHtml(status, message) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocofy/cli",
3
- "version": "0.17.1",
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",