@blocofy/cli 0.1.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/LICENSE +21 -0
- package/README.md +48 -0
- package/bin/blocofy.mjs +252 -0
- package/lib/credentials.mjs +50 -0
- package/lib/dev-server.mjs +155 -0
- package/lib/local-theme.mjs +82 -0
- package/lib/theme-sync.mjs +48 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Blocofy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @blocofy/cli
|
|
2
|
+
|
|
3
|
+
Blocofy tema geliştirme CLI — Shopify CLI modeli: local tema + **canlı veri** ile anında
|
|
4
|
+
önizleme, sonra yayına alma. CLI **build almaz**; asset'leri kendi araçlarınla
|
|
5
|
+
(npm/Vite/Tailwind) üretirsin, platform Liquid + statik asset olarak sunar.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @blocofy/cli login # site URL + dev token (admin → Ayarlar → Tema CLI token'ları)
|
|
9
|
+
cd path/to/tema
|
|
10
|
+
npx @blocofy/cli theme dev # http://localhost:3030 — local tema + canlı veri, livereload
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Komutlar
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
blocofy login [--url <url>] [--token <bcf_…>]
|
|
17
|
+
# Platform URL + dev token kaydet (~/.blocofy/credentials.json, 0600).
|
|
18
|
+
blocofy theme dev [dir] # Local dev sunucusu: düzenle → tarayıcı otomatik yenilenir.
|
|
19
|
+
# --port <n> (varsayılan 3030)
|
|
20
|
+
blocofy theme pull [dir] # Canlı temayı diske indir.
|
|
21
|
+
blocofy theme push [dir] # Local temayı canlı siteye yaz (create/update; silme yok).
|
|
22
|
+
blocofy --version
|
|
23
|
+
blocofy --help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Nasıl çalışır
|
|
27
|
+
|
|
28
|
+
`theme dev` kendi http sunucusunu açar; her sayfa isteğinde local tema dosyalarını okuyup
|
|
29
|
+
platformun dev-render endpoint'ine (`/api/dev/render`) gönderir. Platform site'ın **canlı
|
|
30
|
+
verisiyle** render edip HTML döner — yani CLI render motorunu içermez, üretimle birebir aynı
|
|
31
|
+
çıktı görürsün. `fs.watch` ile dosya değişince tarayıcı SSE üzerinden yenilenir.
|
|
32
|
+
|
|
33
|
+
Kimlik bilgisi: `~/.blocofy/credentials.json` ya da `BLOCOFY_URL` + `BLOCOFY_TOKEN` ortam
|
|
34
|
+
değişkenleri (CI/agent için).
|
|
35
|
+
|
|
36
|
+
## Geliştirme
|
|
37
|
+
|
|
38
|
+
Zero-dependency (yalnız Node yerleşikleri). Test:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
node --test
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Node ≥ 18 gerekir.
|
|
45
|
+
|
|
46
|
+
## Lisans
|
|
47
|
+
|
|
48
|
+
[MIT](./LICENSE)
|
package/bin/blocofy.mjs
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Blocofy tema CLI (#119). Shopify CLI modeli: local tema + canlı veri instant
|
|
4
|
+
* preview, sonra yayına alma.
|
|
5
|
+
*
|
|
6
|
+
* İki mod:
|
|
7
|
+
* - Standalone (Faz 3d): `blocofy login` ile platform URL + dev token kaydet →
|
|
8
|
+
* `blocofy theme dev` kendi http sunucusunu açar, her isteği platformun
|
|
9
|
+
* `/api/dev/render`'ına proxy'ler (local tema + canlı veri), fs.watch ile
|
|
10
|
+
* livereload. Monorepo GEREKMEZ → external müşteri `npx @blocofy/cli` ile.
|
|
11
|
+
* - Monorepo (Faz 2 v1): login yoksa, monorepo içindeyse renderer'ı THEME_DEV_DIR
|
|
12
|
+
* ile spawn eder (fallback).
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { dirname, resolve } from "node:path";
|
|
17
|
+
import { createInterface } from "node:readline/promises";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
import { credentialsPath, loadCredentials, saveCredentials } from "../lib/credentials.mjs";
|
|
21
|
+
import { startDevServer } from "../lib/dev-server.mjs";
|
|
22
|
+
import { pullTheme, pushTheme } from "../lib/theme-sync.mjs";
|
|
23
|
+
|
|
24
|
+
const VERSION = "0.1.0";
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
|
|
27
|
+
/** `--key value` ve `--flag` (boolean) ayrıştırıcı. */
|
|
28
|
+
function parseFlags(rest) {
|
|
29
|
+
const flags = {};
|
|
30
|
+
for (let i = 0; i < rest.length; i++) {
|
|
31
|
+
const arg = rest[i];
|
|
32
|
+
if (!arg.startsWith("--")) continue;
|
|
33
|
+
const key = arg.slice(2);
|
|
34
|
+
const next = rest[i + 1];
|
|
35
|
+
if (next && !next.startsWith("--")) {
|
|
36
|
+
flags[key] = next;
|
|
37
|
+
i++;
|
|
38
|
+
} else {
|
|
39
|
+
flags[key] = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return flags;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function findMonorepoRoot(start) {
|
|
46
|
+
let dir = start;
|
|
47
|
+
for (let i = 0; i < 8; i++) {
|
|
48
|
+
if (existsSync(resolve(dir, "pnpm-workspace.yaml"))) return dir;
|
|
49
|
+
const parent = resolve(dir, "..");
|
|
50
|
+
if (parent === dir) break;
|
|
51
|
+
dir = parent;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function printHelp() {
|
|
57
|
+
console.log(`blocofy — Blocofy tema geliştirme CLI (v${VERSION})
|
|
58
|
+
|
|
59
|
+
Kullanım:
|
|
60
|
+
blocofy login [--url <url>] [--token <bcf_…>]
|
|
61
|
+
Platform URL + dev token kaydet (~/.blocofy/credentials.json).
|
|
62
|
+
Token: admin → Ayarlar → Tema CLI token'ları.
|
|
63
|
+
blocofy theme dev [dir] Local tema dev sunucusu — local tema + canlı veri instant
|
|
64
|
+
preview. Giriş yapıldıysa standalone (monorepo gerekmez);
|
|
65
|
+
yoksa monorepo içinde renderer'ı sarar. dir varsayılan: cwd.
|
|
66
|
+
--port <n> (varsayılan 3030), --standalone, --monorepo
|
|
67
|
+
blocofy theme pull [dir] Canlı temayı diske indir (giriş gerekli). dir varsayılan: cwd.
|
|
68
|
+
blocofy theme push [dir] Local temayı canlı siteye yaz (create/update; v1 silme yok).
|
|
69
|
+
blocofy --version
|
|
70
|
+
blocofy --help
|
|
71
|
+
|
|
72
|
+
Yakında (#119): blocofy init, blocofy check.
|
|
73
|
+
Not: CLI build ALMAZ — asset'leri kendi araçlarınla (npm/Vite/Tailwind) üretirsin;
|
|
74
|
+
platform Liquid + statik asset olarak kalır.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function login(rest) {
|
|
78
|
+
const flags = parseFlags(rest);
|
|
79
|
+
let url = typeof flags.url === "string" ? flags.url : "";
|
|
80
|
+
let token = typeof flags.token === "string" ? flags.token : "";
|
|
81
|
+
|
|
82
|
+
if (!url || !token) {
|
|
83
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
84
|
+
try {
|
|
85
|
+
if (!url) url = (await rl.question("Platform/site URL (örn. https://magaza.myblocofy.com): ")).trim();
|
|
86
|
+
if (!token) token = (await rl.question("Dev token (bcf_…): ")).trim();
|
|
87
|
+
} finally {
|
|
88
|
+
rl.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
url = url.replace(/\/+$/, "");
|
|
93
|
+
if (!/^https?:\/\//.test(url)) {
|
|
94
|
+
console.error("Geçersiz URL — http(s):// ile başlamalı.");
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
if (!token.startsWith("bcf_")) {
|
|
98
|
+
console.error("Geçersiz token — bcf_ ile başlamalı (admin → Ayarlar → Tema CLI token'ları).");
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
saveCredentials({ url, token });
|
|
103
|
+
console.log(`Giriş kaydedildi: ${credentialsPath()}`);
|
|
104
|
+
console.log(`Artık tema dizininde: blocofy theme dev`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Giriş bilgisi (URL+token) çöz ya da hata ver. pull/push standalone gerektirir. */
|
|
108
|
+
function requireCreds() {
|
|
109
|
+
const creds = loadCredentials();
|
|
110
|
+
if (!creds || !creds.url || !creds.token) {
|
|
111
|
+
console.error("Giriş gerekli: `blocofy login` (ya da BLOCOFY_URL + BLOCOFY_TOKEN).");
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
return creds;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function themePull(rest) {
|
|
118
|
+
const positional = rest.filter((a) => !a.startsWith("--"));
|
|
119
|
+
const dir = resolve(positional[0] ?? process.cwd());
|
|
120
|
+
const creds = requireCreds();
|
|
121
|
+
const { count } = await pullTheme({ dir, url: creds.url, token: creds.token });
|
|
122
|
+
console.log(`${count} tema dosyası indirildi → ${dir}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function themePush(rest) {
|
|
126
|
+
const positional = rest.filter((a) => !a.startsWith("--"));
|
|
127
|
+
const dir = resolve(positional[0] ?? process.cwd());
|
|
128
|
+
if (!existsSync(dir)) {
|
|
129
|
+
console.error(`Tema dizini yok: ${dir}`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
const creds = requireCreds();
|
|
133
|
+
const result = await pushTheme({ dir, url: creds.url, token: creds.token });
|
|
134
|
+
const extra = result.skippedDeletes
|
|
135
|
+
? `, ${result.skippedDeletes} uzak dosya yerelde yok (v1: silinmedi)`
|
|
136
|
+
: "";
|
|
137
|
+
console.log(`Push: ${result.created} yeni, ${result.updated} güncellendi${extra}.`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function themeDev(rest) {
|
|
141
|
+
const flags = parseFlags(rest);
|
|
142
|
+
const positional = rest.filter((a) => !a.startsWith("--"));
|
|
143
|
+
const themeDir = resolve(positional[0] ?? process.cwd());
|
|
144
|
+
|
|
145
|
+
if (!existsSync(themeDir)) {
|
|
146
|
+
console.error(`Tema dizini yok: ${themeDir}`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const creds = flags.monorepo ? null : loadCredentials();
|
|
151
|
+
const canStandalone = Boolean(creds && creds.url && creds.token);
|
|
152
|
+
|
|
153
|
+
// Standalone: giriş yapıldıysa (ya da --standalone zorlanırsa).
|
|
154
|
+
if (canStandalone || flags.standalone) {
|
|
155
|
+
if (!canStandalone) {
|
|
156
|
+
console.error("Standalone için giriş gerekli: `blocofy login` (ya da BLOCOFY_URL + BLOCOFY_TOKEN).");
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
const port = Number(flags.port) || 3030;
|
|
160
|
+
console.log(`blocofy theme dev (standalone)`);
|
|
161
|
+
console.log(` tema dizini : ${themeDir}`);
|
|
162
|
+
console.log(` platform : ${creds.url}`);
|
|
163
|
+
console.log(` token : ${creds.token.slice(0, 8)}… (${creds.source})`);
|
|
164
|
+
console.log(` önizleme : http://localhost:${port}`);
|
|
165
|
+
console.log(` → tema dosyasını düzenle, kaydet = tarayıcı otomatik yenilenir (livereload)`);
|
|
166
|
+
|
|
167
|
+
if (flags.dry) {
|
|
168
|
+
console.log("(--dry: sunucu başlatılmadı)");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const handle = startDevServer({
|
|
173
|
+
dir: themeDir,
|
|
174
|
+
url: creds.url,
|
|
175
|
+
token: creds.token,
|
|
176
|
+
port,
|
|
177
|
+
onError: (err) => {
|
|
178
|
+
if (err && err.code === "EADDRINUSE") {
|
|
179
|
+
console.error(`Port ${port} kullanımda. Farklı port: blocofy theme dev --port <n>`);
|
|
180
|
+
} else {
|
|
181
|
+
console.error(`Sunucu hatası: ${err?.message ?? err}`);
|
|
182
|
+
}
|
|
183
|
+
process.exit(1);
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
const shutdown = () => {
|
|
187
|
+
handle.close();
|
|
188
|
+
process.exit(0);
|
|
189
|
+
};
|
|
190
|
+
process.on("SIGINT", shutdown);
|
|
191
|
+
process.on("SIGTERM", shutdown);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Monorepo fallback (Faz 2 v1): renderer'ı THEME_DEV_DIR ile spawn et.
|
|
196
|
+
const root = findMonorepoRoot(dirname(fileURLToPath(import.meta.url)));
|
|
197
|
+
if (!root) {
|
|
198
|
+
console.error(
|
|
199
|
+
"Ne giriş yapıldı ne de monorepo bulundu.\n" +
|
|
200
|
+
"External kullanım için: `blocofy login` (admin'den dev token al).\n" +
|
|
201
|
+
"Monorepo içinde çalışıyorsan pnpm-workspace.yaml görünür bir dizinden çalıştır.",
|
|
202
|
+
);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
console.log(`blocofy theme dev (monorepo)`);
|
|
207
|
+
console.log(` tema dizini : ${themeDir}`);
|
|
208
|
+
console.log(` renderer : ${root} (THEME_DEV_DIR ile)`);
|
|
209
|
+
console.log(` önizleme : http://<slug>.localhost:3003 (örn. test-cafe.localhost)`);
|
|
210
|
+
console.log(` → tema dosyasını düzenle, tarayıcıyı yenile = instant preview (gerçek içerikle)`);
|
|
211
|
+
|
|
212
|
+
if (flags.dry) {
|
|
213
|
+
console.log("(--dry: renderer başlatılmadı)");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const child = spawn("pnpm", ["--filter", "renderer", "dev"], {
|
|
218
|
+
cwd: root,
|
|
219
|
+
env: { ...process.env, THEME_DEV_DIR: themeDir },
|
|
220
|
+
stdio: "inherit",
|
|
221
|
+
});
|
|
222
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const [first, ...rest] = args;
|
|
226
|
+
|
|
227
|
+
if (first === "--version" || first === "-v") {
|
|
228
|
+
console.log(VERSION);
|
|
229
|
+
} else if (!first || first === "--help" || first === "-h" || first === "help") {
|
|
230
|
+
printHelp();
|
|
231
|
+
} else if (first === "login") {
|
|
232
|
+
login(rest).catch((error) => {
|
|
233
|
+
console.error(error?.message ?? error);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
});
|
|
236
|
+
} else if (first === "theme" && rest[0] === "dev") {
|
|
237
|
+
themeDev(rest.slice(1));
|
|
238
|
+
} else if (first === "theme" && rest[0] === "pull") {
|
|
239
|
+
themePull(rest.slice(1)).catch((error) => {
|
|
240
|
+
console.error(error?.message ?? error);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
});
|
|
243
|
+
} else if (first === "theme" && rest[0] === "push") {
|
|
244
|
+
themePush(rest.slice(1)).catch((error) => {
|
|
245
|
+
console.error(error?.message ?? error);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
});
|
|
248
|
+
} else {
|
|
249
|
+
console.error(`Bilinmeyen komut: ${args.join(" ")}\n`);
|
|
250
|
+
printHelp();
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Blocofy CLI kimlik bilgileri (#119 Faz 3d). `blocofy login` platform URL'i +
|
|
7
|
+
* dev token'ı `~/.blocofy/credentials.json`'a (0600) yazar; `blocofy theme dev`
|
|
8
|
+
* standalone modu bunu okur. Env (BLOCOFY_URL/BLOCOFY_TOKEN) dosyayı geçersiz
|
|
9
|
+
* kılar (CI/agent). Token plaintext diskte tutulur (Shopify/gh CLI deseni) →
|
|
10
|
+
* dosya izni 0600.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DIR = join(homedir(), ".blocofy");
|
|
14
|
+
const FILE = join(DIR, "credentials.json");
|
|
15
|
+
|
|
16
|
+
export function credentialsPath() {
|
|
17
|
+
return FILE;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** URL + token'ı 0600 izniyle yaz. */
|
|
21
|
+
export function saveCredentials({ url, token }) {
|
|
22
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
23
|
+
writeFileSync(FILE, JSON.stringify({ url, token }, null, 2) + "\n", { mode: 0o600 });
|
|
24
|
+
chmodSync(FILE, 0o600); // dosya zaten varsa create-mode uygulanmaz → zorla
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Kimlik bilgisi çöz: önce env (tam çift), sonra dosya. `{ url, token, source }`
|
|
29
|
+
* ya da null. URL sondaki `/`'lardan arındırılır.
|
|
30
|
+
*/
|
|
31
|
+
export function loadCredentials() {
|
|
32
|
+
const envUrl = process.env.BLOCOFY_URL;
|
|
33
|
+
const envToken = process.env.BLOCOFY_TOKEN;
|
|
34
|
+
if (envUrl && envToken) {
|
|
35
|
+
return { url: stripSlash(envUrl), token: envToken, source: "env" };
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const data = JSON.parse(readFileSync(FILE, "utf8"));
|
|
39
|
+
if (data && typeof data.url === "string" && typeof data.token === "string") {
|
|
40
|
+
return { url: stripSlash(data.url), token: data.token, source: "file" };
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// dosya yok/bozuk → null
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stripSlash(url) {
|
|
49
|
+
return url.replace(/\/+$/, "");
|
|
50
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { existsSync, readFileSync, statSync, watch } from "node:fs";
|
|
3
|
+
import { join, normalize, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
LIVERELOAD_PATH,
|
|
7
|
+
contentTypeFor,
|
|
8
|
+
injectLivereload,
|
|
9
|
+
readLocalTemplates,
|
|
10
|
+
} from "./local-theme.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Standalone dev sunucusu (#119 Faz 3d). Her sayfa isteğinde local tema dosyalarını
|
|
14
|
+
* okuyup platformun `/api/dev/render`'ına yollar, dönen HTML'e livereload script'i
|
|
15
|
+
* enjekte eder. `fs.watch` ile dosya değişince SSE üzerinden tarayıcıyı yeniler.
|
|
16
|
+
*
|
|
17
|
+
* Asset'ler genelde `{% render 'asset/x.css' %}` ile HTML'e GÖMÜLÜ gelir (ayrı
|
|
18
|
+
* istek yok); doğrudan `/asset/*` referansları için diskten statik servis edilir.
|
|
19
|
+
* Görsel/JS uzak (Directus/CDN) → local servise gerek yok.
|
|
20
|
+
*/
|
|
21
|
+
export function startDevServer({ dir, url, token, port, onError }) {
|
|
22
|
+
const base = url.replace(/\/+$/, "");
|
|
23
|
+
const clients = new Set();
|
|
24
|
+
|
|
25
|
+
async function renderPath(pathname) {
|
|
26
|
+
const templates = readLocalTemplates(dir);
|
|
27
|
+
const res = await fetch(`${base}/api/dev/render`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
30
|
+
body: JSON.stringify({ path: pathname, templates }),
|
|
31
|
+
});
|
|
32
|
+
const text = await res.text();
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
let message = text;
|
|
35
|
+
try {
|
|
36
|
+
message = JSON.parse(text).error ?? text;
|
|
37
|
+
} catch {
|
|
38
|
+
/* ham metin */
|
|
39
|
+
}
|
|
40
|
+
return { status: res.status, html: errorHtml(res.status, message) };
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return { status: 200, html: JSON.parse(text).html ?? "" };
|
|
44
|
+
} catch {
|
|
45
|
+
return { status: 200, html: text };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const server = createServer(async (req, res) => {
|
|
50
|
+
try {
|
|
51
|
+
const pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
|
|
52
|
+
|
|
53
|
+
// Livereload SSE kanalı.
|
|
54
|
+
if (pathname === LIVERELOAD_PATH) {
|
|
55
|
+
res.writeHead(200, {
|
|
56
|
+
"content-type": "text/event-stream",
|
|
57
|
+
"cache-control": "no-cache, no-transform",
|
|
58
|
+
connection: "keep-alive",
|
|
59
|
+
});
|
|
60
|
+
res.write("retry: 1000\n\n");
|
|
61
|
+
clients.add(res);
|
|
62
|
+
req.on("close", () => clients.delete(res));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Doğrudan /asset/* referansı → diskten servis et (varsa).
|
|
67
|
+
const assetFile = safeAssetFile(dir, pathname);
|
|
68
|
+
if (assetFile) {
|
|
69
|
+
res.writeHead(200, { "content-type": contentTypeFor(assetFile), "cache-control": "no-store" });
|
|
70
|
+
res.end(readFileSync(assetFile));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Sayfa → platforma render ettir.
|
|
75
|
+
const { status, html } = await renderPath(pathname);
|
|
76
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
77
|
+
res.end(injectLivereload(html));
|
|
78
|
+
} catch (err) {
|
|
79
|
+
res.writeHead(502, { "content-type": "text/html; charset=utf-8" });
|
|
80
|
+
res.end(injectLivereload(errorHtml(502, err?.message ?? String(err))));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (onError) server.on("error", onError);
|
|
85
|
+
|
|
86
|
+
const watcher = startWatch(dir, () => {
|
|
87
|
+
for (const client of clients) client.write("data: reload\n\n");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
server.listen(port);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
server,
|
|
94
|
+
close() {
|
|
95
|
+
try {
|
|
96
|
+
watcher?.close();
|
|
97
|
+
} catch {
|
|
98
|
+
/* yoksay */
|
|
99
|
+
}
|
|
100
|
+
for (const client of clients) client.end();
|
|
101
|
+
server.close();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `dir`'i izle; recursive desteklenmiyorsa (eski Node/Linux) recursive'siz dener.
|
|
108
|
+
* Debounce'lu `onChange` çağırır. İzlenemezse null (livereload pasif).
|
|
109
|
+
*/
|
|
110
|
+
function startWatch(dir, onChange) {
|
|
111
|
+
let timer = null;
|
|
112
|
+
const fire = () => {
|
|
113
|
+
if (timer) clearTimeout(timer);
|
|
114
|
+
timer = setTimeout(onChange, 120);
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
return watch(dir, { recursive: true }, fire);
|
|
118
|
+
} catch {
|
|
119
|
+
try {
|
|
120
|
+
return watch(dir, fire);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `/asset/*` yolunu disk dosyasına eşle (path-traversal korumalı); yoksa null. */
|
|
128
|
+
function safeAssetFile(dir, pathname) {
|
|
129
|
+
if (!pathname.startsWith("/asset/")) return null;
|
|
130
|
+
const root = normalize(dir);
|
|
131
|
+
const full = normalize(join(dir, pathname.replace(/^\/+/, "")));
|
|
132
|
+
if (full !== root && !full.startsWith(root + sep)) return null;
|
|
133
|
+
try {
|
|
134
|
+
if (existsSync(full) && statSync(full).isFile()) return full;
|
|
135
|
+
} catch {
|
|
136
|
+
/* yoksay */
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function escapeHtml(value) {
|
|
142
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function errorHtml(status, message) {
|
|
146
|
+
return (
|
|
147
|
+
`<!doctype html><html lang="tr"><head><meta charset="utf-8">` +
|
|
148
|
+
`<title>blocofy dev — ${status}</title></head>` +
|
|
149
|
+
`<body style="font-family:system-ui,sans-serif;padding:2rem;max-width:48rem;margin:0 auto">` +
|
|
150
|
+
`<h1 style="color:#b91c1c">Render hatası (${status})</h1>` +
|
|
151
|
+
`<pre style="white-space:pre-wrap;background:#f4f4f5;padding:1rem;border-radius:.5rem;color:#333">${escapeHtml(message)}</pre>` +
|
|
152
|
+
`<p style="color:#666">Tema dosyasını düzelt — kaydedince bu sayfa otomatik yenilenir.</p>` +
|
|
153
|
+
`</body></html>`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { extname, join, relative, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Local tema dosyalarını okuma + livereload — `@blocofy/cms`'in zero-dep aynası
|
|
6
|
+
* (standalone CLI cms'i bundle edemez). `readLocalTemplates`, cms'teki
|
|
7
|
+
* `loadLocalTemplates` (templates.ts) ile BİREBİR aynı eşlemeyi yapar:
|
|
8
|
+
* üst-dizini THEME_DIRS olan dosyalar, key = göreli yol, `.liquid` strip'li.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const THEME_DIRS = new Set(["layout", "section", "block", "partial", "asset", "template"]);
|
|
12
|
+
const LIQUID_KINDS = new Set(["layout", "section", "block", "partial", "template"]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stripped key → disk yolu (cms `repoPathFor`'un aynası): liquid kind'lara `.liquid`
|
|
16
|
+
* ekler, asset'i ham bırakır. `pull` diske yazarken kullanır (readLocalTemplates tersi).
|
|
17
|
+
*/
|
|
18
|
+
export function localPathFor(key) {
|
|
19
|
+
const top = key.split("/")[0] ?? "";
|
|
20
|
+
return LIQUID_KINDS.has(top) && !key.endsWith(".liquid") ? `${key}.liquid` : key;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Tema dizinini `{path: content}` haritasına çevir (dev-render'a gönderilir). */
|
|
24
|
+
export function readLocalTemplates(dir) {
|
|
25
|
+
const out = {};
|
|
26
|
+
const walk = (current) => {
|
|
27
|
+
for (const name of readdirSync(current)) {
|
|
28
|
+
const full = join(current, name);
|
|
29
|
+
if (statSync(full).isDirectory()) {
|
|
30
|
+
walk(full);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const rel = relative(dir, full).split(sep).join("/");
|
|
34
|
+
const top = rel.split("/")[0] ?? "";
|
|
35
|
+
if (!THEME_DIRS.has(top)) continue;
|
|
36
|
+
out[rel.endsWith(".liquid") ? rel.slice(0, -7) : rel] = readFileSync(full, "utf8");
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(dir);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const LIVERELOAD_PATH = "/__blocofy_livereload";
|
|
44
|
+
|
|
45
|
+
const LIVERELOAD_SNIPPET = `<script>
|
|
46
|
+
(function(){try{var es=new EventSource(${JSON.stringify(LIVERELOAD_PATH)});es.onmessage=function(e){if(e.data==="reload")location.reload();};}catch(_){}})();
|
|
47
|
+
</script>`;
|
|
48
|
+
|
|
49
|
+
export { LIVERELOAD_PATH };
|
|
50
|
+
|
|
51
|
+
/** Render edilen HTML'e livereload script'ini ekle (</body>'den önce). */
|
|
52
|
+
export function injectLivereload(html) {
|
|
53
|
+
if (typeof html !== "string") return html;
|
|
54
|
+
return html.includes("</body>")
|
|
55
|
+
? html.replace("</body>", LIVERELOAD_SNIPPET + "</body>")
|
|
56
|
+
: html + LIVERELOAD_SNIPPET;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CONTENT_TYPES = {
|
|
60
|
+
".css": "text/css",
|
|
61
|
+
".js": "text/javascript",
|
|
62
|
+
".mjs": "text/javascript",
|
|
63
|
+
".json": "application/json",
|
|
64
|
+
".svg": "image/svg+xml",
|
|
65
|
+
".png": "image/png",
|
|
66
|
+
".jpg": "image/jpeg",
|
|
67
|
+
".jpeg": "image/jpeg",
|
|
68
|
+
".gif": "image/gif",
|
|
69
|
+
".webp": "image/webp",
|
|
70
|
+
".avif": "image/avif",
|
|
71
|
+
".ico": "image/x-icon",
|
|
72
|
+
".woff": "font/woff",
|
|
73
|
+
".woff2": "font/woff2",
|
|
74
|
+
".ttf": "font/ttf",
|
|
75
|
+
".txt": "text/plain",
|
|
76
|
+
".map": "application/json",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Uzantıdan content-type (statik asset servisi için). */
|
|
80
|
+
export function contentTypeFor(p) {
|
|
81
|
+
return CONTENT_TYPES[extname(p).toLowerCase()] ?? "application/octet-stream";
|
|
82
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { localPathFor, readLocalTemplates } from "./local-theme.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `blocofy theme pull/push` (#119 Faz 3e). Platformun `/api/dev/theme` endpoint'iyle
|
|
8
|
+
* konuşur (token Bearer). pull = GET → diske yaz (.liquid re-map); push = readLocalTemplates
|
|
9
|
+
* → POST (create/update; v1 silme yok).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
async function errorText(res) {
|
|
13
|
+
const text = await res.text();
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(text).error ?? text;
|
|
16
|
+
} catch {
|
|
17
|
+
return text;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Canlı temayı diske indir. `{path: content}` (stripped) → `.liquid`'li disk dosyaları. */
|
|
22
|
+
export async function pullTheme({ dir, url, token }) {
|
|
23
|
+
const base = url.replace(/\/+$/, "");
|
|
24
|
+
const res = await fetch(`${base}/api/dev/theme`, { headers: { authorization: `Bearer ${token}` } });
|
|
25
|
+
if (!res.ok) throw new Error(await errorText(res));
|
|
26
|
+
const { files } = await res.json();
|
|
27
|
+
let count = 0;
|
|
28
|
+
for (const [key, content] of Object.entries(files ?? {})) {
|
|
29
|
+
const full = join(dir, localPathFor(key));
|
|
30
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
31
|
+
writeFileSync(full, typeof content === "string" ? content : "");
|
|
32
|
+
count += 1;
|
|
33
|
+
}
|
|
34
|
+
return { count };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Local temayı canlı siteye yaz (create/update; v1 silme yok). */
|
|
38
|
+
export async function pushTheme({ dir, url, token }) {
|
|
39
|
+
const base = url.replace(/\/+$/, "");
|
|
40
|
+
const files = readLocalTemplates(dir);
|
|
41
|
+
const res = await fetch(`${base}/api/dev/theme`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
44
|
+
body: JSON.stringify({ files }),
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) throw new Error(await errorText(res));
|
|
47
|
+
return res.json();
|
|
48
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blocofy/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Blocofy tema geliştirme CLI — local dev, pull, push (Shopify CLI modeli).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Blocofy",
|
|
8
|
+
"homepage": "https://github.com/Blocofy/cli#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Blocofy/cli.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Blocofy/cli/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"blocofy",
|
|
18
|
+
"theme",
|
|
19
|
+
"cli",
|
|
20
|
+
"liquid",
|
|
21
|
+
"shopify-cli"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"blocofy": "./bin/blocofy.mjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"lib"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
}
|
|
39
|
+
}
|