@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 +88 -9
- package/bin/blocofy.mjs +22 -11
- package/lib/browse-html.mjs +323 -0
- package/lib/dev-server.mjs +263 -44
- package/package.json +1 -1
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
|
|
129
|
-
Press `l` to open it, `q` to quit. Edit a file and save → the
|
|
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` —
|
|
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
|
|
139
|
-
CLI draft (see "Which draft a draft command writes to" below); the live theme's handle
|
|
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
|
|
912
|
-
renders them with
|
|
913
|
-
|
|
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 +
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 —
|
|
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, "&").replace(/"/g, """);
|
|
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, "&"), 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
|
+
}
|
package/lib/dev-server.mjs
CHANGED
|
@@ -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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
+
instance: sent,
|
|
211
|
+
...(search ? { search } : {}),
|
|
212
|
+
localOrigin,
|
|
88
213
|
}),
|
|
89
214
|
});
|
|
90
215
|
const text = await res.text();
|
|
91
|
-
|
|
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
|
-
|
|
218
|
+
body = JSON.parse(text);
|
|
102
219
|
} catch {
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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,
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
441
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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