@fluid-app/fluid-cli-theme-dev 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/.turbo/turbo-build.log +18 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1240 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +43 -0
- package/src/api.ts +25 -0
- package/src/commands/dev.ts +150 -0
- package/src/commands/init.ts +51 -0
- package/src/commands/navigate.ts +159 -0
- package/src/commands/pull.ts +90 -0
- package/src/commands/push.ts +121 -0
- package/src/commands/theme.ts +21 -0
- package/src/index.ts +12 -0
- package/src/plugin-state.ts +26 -0
- package/src/theme/dev-server/hot-reload.ts +65 -0
- package/src/theme/dev-server/index.ts +125 -0
- package/src/theme/dev-server/proxy.ts +125 -0
- package/src/theme/dev-server/sse.ts +43 -0
- package/src/theme/dev-server/watcher.ts +54 -0
- package/src/theme/file.ts +68 -0
- package/src/theme/fluid-ignore.ts +64 -0
- package/src/theme/mime-type.ts +45 -0
- package/src/theme/root.ts +51 -0
- package/src/theme/syncer.ts +310 -0
- package/tsconfig.json +10 -0
- package/tsdown.config.ts +19 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { getAuthToken, readConfig, updateConfig } from "@fluid-app/fluid-cli";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import http from "node:http";
|
|
7
|
+
import https from "node:https";
|
|
8
|
+
import chokidar from "chokidar";
|
|
9
|
+
import ora from "ora";
|
|
10
|
+
import prompts from "prompts";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
//#region ../../platform/api-client-core/src/fetch-client.ts
|
|
13
|
+
/**
|
|
14
|
+
* API Error class compatible with fluid-admin's ApiError
|
|
15
|
+
*/
|
|
16
|
+
var ApiError = class ApiError extends Error {
|
|
17
|
+
status;
|
|
18
|
+
data;
|
|
19
|
+
constructor(message, status, data) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "ApiError";
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.data = data;
|
|
24
|
+
if ("captureStackTrace" in Error) Error.captureStackTrace(this, ApiError);
|
|
25
|
+
}
|
|
26
|
+
toJSON() {
|
|
27
|
+
return {
|
|
28
|
+
name: this.name,
|
|
29
|
+
message: this.message,
|
|
30
|
+
status: this.status,
|
|
31
|
+
data: this.data
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Creates a configured fetch client instance
|
|
37
|
+
*/
|
|
38
|
+
function createFetchClient(config) {
|
|
39
|
+
const { baseUrl, getAuthToken, onAuthError, defaultHeaders = {} } = config;
|
|
40
|
+
/**
|
|
41
|
+
* Build headers for a request
|
|
42
|
+
*/
|
|
43
|
+
async function buildHeaders(customHeaders) {
|
|
44
|
+
const headers = {
|
|
45
|
+
Accept: "application/json",
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
...defaultHeaders,
|
|
48
|
+
...customHeaders
|
|
49
|
+
};
|
|
50
|
+
if (getAuthToken) {
|
|
51
|
+
const token = await getAuthToken();
|
|
52
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
53
|
+
}
|
|
54
|
+
return headers;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Join baseUrl + endpoint via string concatenation (matches fetchApi).
|
|
58
|
+
* Using `new URL(endpoint, baseUrl)` would strip any path prefix from
|
|
59
|
+
* baseUrl (e.g. "/api") when the endpoint starts with "/".
|
|
60
|
+
*/
|
|
61
|
+
function joinUrl(endpoint) {
|
|
62
|
+
return `${baseUrl}${endpoint}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build URL with query parameters for GET requests
|
|
66
|
+
* Compatible with fluid-admin's query param handling
|
|
67
|
+
*/
|
|
68
|
+
function buildUrl(endpoint, params) {
|
|
69
|
+
const fullUrl = joinUrl(endpoint);
|
|
70
|
+
if (!params || Object.keys(params).length === 0) return fullUrl;
|
|
71
|
+
const queryString = new URLSearchParams();
|
|
72
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
73
|
+
if (value === void 0 || value === null) return;
|
|
74
|
+
if (Array.isArray(value)) value.forEach((item) => queryString.append(`${key}[]`, String(item)));
|
|
75
|
+
else if (typeof value === "object") Object.entries(value).forEach(([subKey, subValue]) => {
|
|
76
|
+
if (subValue === void 0 || subValue === null) return;
|
|
77
|
+
if (Array.isArray(subValue)) subValue.forEach((item) => queryString.append(`${key}[${subKey}][]`, String(item)));
|
|
78
|
+
else queryString.append(`${key}[${subKey}]`, String(subValue));
|
|
79
|
+
});
|
|
80
|
+
else queryString.append(key, String(value));
|
|
81
|
+
});
|
|
82
|
+
const qs = queryString.toString();
|
|
83
|
+
return qs ? `${fullUrl}?${qs}` : fullUrl;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Shared response handler for both JSON and FormData requests.
|
|
87
|
+
* Handles auth errors, non-OK responses, 204 No Content, and JSON parsing.
|
|
88
|
+
*/
|
|
89
|
+
async function handleResponse(response, method, _url) {
|
|
90
|
+
if (response.status === 401 && onAuthError) onAuthError();
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const errorText = await response.text().catch(() => "");
|
|
93
|
+
if (response.headers.get("content-type")?.includes("application/json")) {
|
|
94
|
+
let data;
|
|
95
|
+
try {
|
|
96
|
+
data = JSON.parse(errorText);
|
|
97
|
+
} catch {
|
|
98
|
+
throw new ApiError(errorText.slice(0, 200) || `${method} request failed with status ${response.status}`, response.status, null);
|
|
99
|
+
}
|
|
100
|
+
throw new ApiError(data.message || data.error_message || `${method} request failed`, response.status, data.errors || data);
|
|
101
|
+
} else throw new ApiError(`${method} request failed with status ${response.status}`, response.status, null);
|
|
102
|
+
}
|
|
103
|
+
if (response.status === 204 || response.headers.get("content-length") === "0") return null;
|
|
104
|
+
if (response.headers.get("content-type")?.includes("application/json")) try {
|
|
105
|
+
return await response.json();
|
|
106
|
+
} catch {
|
|
107
|
+
try {
|
|
108
|
+
return await response.text();
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Main request function
|
|
117
|
+
*/
|
|
118
|
+
async function request(endpoint, options = {}) {
|
|
119
|
+
const { method = "GET", headers: customHeaders, params, body, signal } = options;
|
|
120
|
+
const url = params ? buildUrl(endpoint, params) : joinUrl(endpoint);
|
|
121
|
+
const headers = await buildHeaders(customHeaders);
|
|
122
|
+
let response;
|
|
123
|
+
try {
|
|
124
|
+
const fetchOptions = {
|
|
125
|
+
method,
|
|
126
|
+
headers
|
|
127
|
+
};
|
|
128
|
+
const serializedBody = body && method !== "GET" ? JSON.stringify(body) : null;
|
|
129
|
+
if (serializedBody) fetchOptions.body = serializedBody;
|
|
130
|
+
if (signal) fetchOptions.signal = signal;
|
|
131
|
+
response = await fetch(url, fetchOptions);
|
|
132
|
+
} catch (networkError) {
|
|
133
|
+
throw new ApiError(`Network error: ${networkError instanceof Error ? networkError.message : "Unknown network error"}`, 0, null);
|
|
134
|
+
}
|
|
135
|
+
return handleResponse(response, method, url);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Request with FormData (for file uploads)
|
|
139
|
+
*/
|
|
140
|
+
async function requestWithFormData(endpoint, formData, options = {}) {
|
|
141
|
+
const { method = "POST", headers: customHeaders, signal } = options;
|
|
142
|
+
const url = joinUrl(endpoint);
|
|
143
|
+
const headers = await buildHeaders(customHeaders);
|
|
144
|
+
delete headers["Content-Type"];
|
|
145
|
+
let response;
|
|
146
|
+
try {
|
|
147
|
+
const fetchOptions = {
|
|
148
|
+
method,
|
|
149
|
+
headers,
|
|
150
|
+
body: formData
|
|
151
|
+
};
|
|
152
|
+
if (signal) fetchOptions.signal = signal;
|
|
153
|
+
response = await fetch(url, fetchOptions);
|
|
154
|
+
} catch (networkError) {
|
|
155
|
+
throw new ApiError(`Network error: ${networkError instanceof Error ? networkError.message : "Unknown network error"}`, 0, null);
|
|
156
|
+
}
|
|
157
|
+
return handleResponse(response, method, url);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
request,
|
|
161
|
+
requestWithFormData,
|
|
162
|
+
get: (endpoint, params, options) => request(endpoint, {
|
|
163
|
+
...options,
|
|
164
|
+
method: "GET",
|
|
165
|
+
...params && { params }
|
|
166
|
+
}),
|
|
167
|
+
post: (endpoint, body, options) => request(endpoint, {
|
|
168
|
+
...options,
|
|
169
|
+
method: "POST",
|
|
170
|
+
body
|
|
171
|
+
}),
|
|
172
|
+
put: (endpoint, body, options) => request(endpoint, {
|
|
173
|
+
...options,
|
|
174
|
+
method: "PUT",
|
|
175
|
+
body
|
|
176
|
+
}),
|
|
177
|
+
patch: (endpoint, body, options) => request(endpoint, {
|
|
178
|
+
...options,
|
|
179
|
+
method: "PATCH",
|
|
180
|
+
body
|
|
181
|
+
}),
|
|
182
|
+
delete: (endpoint, options) => request(endpoint, {
|
|
183
|
+
...options,
|
|
184
|
+
method: "DELETE"
|
|
185
|
+
})
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/api.ts
|
|
190
|
+
function getApiBase() {
|
|
191
|
+
return process.env["FLUID_API_BASE"] ?? "https://api.fluid.app";
|
|
192
|
+
}
|
|
193
|
+
function createApiClient(tokenOverride) {
|
|
194
|
+
return createFetchClient({
|
|
195
|
+
baseUrl: getApiBase(),
|
|
196
|
+
getAuthToken: () => tokenOverride ?? getAuthToken() ?? null
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function requireToken() {
|
|
200
|
+
const token = getAuthToken();
|
|
201
|
+
if (!token) {
|
|
202
|
+
console.error("Not logged in. Run `fluid login` first.");
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
return token;
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/plugin-state.ts
|
|
209
|
+
const PLUGIN_KEY = "theme-dev";
|
|
210
|
+
function getPluginState() {
|
|
211
|
+
return readConfig().plugins[PLUGIN_KEY] ?? {};
|
|
212
|
+
}
|
|
213
|
+
function setPluginState(updates) {
|
|
214
|
+
updateConfig((config) => ({
|
|
215
|
+
...config,
|
|
216
|
+
plugins: {
|
|
217
|
+
...config.plugins,
|
|
218
|
+
[PLUGIN_KEY]: {
|
|
219
|
+
...config.plugins[PLUGIN_KEY] ?? {},
|
|
220
|
+
...updates
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/theme/mime-type.ts
|
|
227
|
+
const TEXT_TYPES = {
|
|
228
|
+
".liquid": "text/x-liquid",
|
|
229
|
+
".json": "application/json",
|
|
230
|
+
".css": "text/css",
|
|
231
|
+
".js": "application/javascript",
|
|
232
|
+
".html": "text/html",
|
|
233
|
+
".txt": "text/plain",
|
|
234
|
+
".md": "text/markdown",
|
|
235
|
+
".svg": "image/svg+xml"
|
|
236
|
+
};
|
|
237
|
+
const BINARY_TYPES = {
|
|
238
|
+
".png": "image/png",
|
|
239
|
+
".jpg": "image/jpeg",
|
|
240
|
+
".jpeg": "image/jpeg",
|
|
241
|
+
".gif": "image/gif",
|
|
242
|
+
".webp": "image/webp",
|
|
243
|
+
".ico": "image/x-icon",
|
|
244
|
+
".woff": "font/woff",
|
|
245
|
+
".woff2": "font/woff2",
|
|
246
|
+
".ttf": "font/ttf",
|
|
247
|
+
".eot": "application/vnd.ms-fontobject",
|
|
248
|
+
".otf": "font/otf",
|
|
249
|
+
".pdf": "application/pdf",
|
|
250
|
+
".zip": "application/zip",
|
|
251
|
+
".mp4": "video/mp4",
|
|
252
|
+
".webm": "video/webm",
|
|
253
|
+
".mp3": "audio/mpeg",
|
|
254
|
+
".wav": "audio/wav"
|
|
255
|
+
};
|
|
256
|
+
function mimeTypeFor(ext) {
|
|
257
|
+
const text = TEXT_TYPES[ext];
|
|
258
|
+
if (text) return {
|
|
259
|
+
name: text,
|
|
260
|
+
isText: true
|
|
261
|
+
};
|
|
262
|
+
const binary = BINARY_TYPES[ext];
|
|
263
|
+
if (binary) return {
|
|
264
|
+
name: binary,
|
|
265
|
+
isText: false
|
|
266
|
+
};
|
|
267
|
+
return {
|
|
268
|
+
name: "application/octet-stream",
|
|
269
|
+
isText: false
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/theme/file.ts
|
|
274
|
+
var ThemeFile = class {
|
|
275
|
+
absolutePath;
|
|
276
|
+
relativePath;
|
|
277
|
+
mime;
|
|
278
|
+
constructor(absolutePath, root) {
|
|
279
|
+
this.absolutePath = absolutePath;
|
|
280
|
+
this.relativePath = relative(root, absolutePath);
|
|
281
|
+
this.mime = mimeTypeFor(extname(absolutePath).toLowerCase());
|
|
282
|
+
}
|
|
283
|
+
get name() {
|
|
284
|
+
return basename(this.absolutePath);
|
|
285
|
+
}
|
|
286
|
+
get isText() {
|
|
287
|
+
return this.mime.isText;
|
|
288
|
+
}
|
|
289
|
+
get isLiquid() {
|
|
290
|
+
return this.absolutePath.endsWith(".liquid");
|
|
291
|
+
}
|
|
292
|
+
get isJson() {
|
|
293
|
+
return this.absolutePath.endsWith(".json");
|
|
294
|
+
}
|
|
295
|
+
get exists() {
|
|
296
|
+
return existsSync(this.absolutePath);
|
|
297
|
+
}
|
|
298
|
+
read() {
|
|
299
|
+
return readFileSync(this.absolutePath, "utf-8");
|
|
300
|
+
}
|
|
301
|
+
readBinary() {
|
|
302
|
+
return readFileSync(this.absolutePath);
|
|
303
|
+
}
|
|
304
|
+
write(content) {
|
|
305
|
+
mkdirSync(dirname(this.absolutePath), { recursive: true });
|
|
306
|
+
if (typeof content === "string") writeFileSync(this.absolutePath, content, "utf-8");
|
|
307
|
+
else writeFileSync(this.absolutePath, content);
|
|
308
|
+
}
|
|
309
|
+
checksum() {
|
|
310
|
+
const content = this.isText ? this.read() : this.readBinary();
|
|
311
|
+
return createHash("sha256").update(content).digest("hex");
|
|
312
|
+
}
|
|
313
|
+
size() {
|
|
314
|
+
return statSync(this.absolutePath).size;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
//#endregion
|
|
318
|
+
//#region src/theme/fluid-ignore.ts
|
|
319
|
+
const IGNORE_FILE = ".fluidignore";
|
|
320
|
+
var FluidIgnore = class {
|
|
321
|
+
patterns;
|
|
322
|
+
constructor(root) {
|
|
323
|
+
this.patterns = this.parse(join(root, IGNORE_FILE));
|
|
324
|
+
}
|
|
325
|
+
ignore(relativePath) {
|
|
326
|
+
let result = false;
|
|
327
|
+
for (const { negated, pattern } of this.patterns) if (this.match(pattern, relativePath)) result = !negated;
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
parse(filePath) {
|
|
331
|
+
if (!existsSync(filePath)) return [];
|
|
332
|
+
return readFileSync(filePath, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).map((l) => {
|
|
333
|
+
const negated = l.startsWith("!");
|
|
334
|
+
let pattern = negated ? l.slice(1) : l;
|
|
335
|
+
if (pattern.startsWith("/")) pattern = pattern.slice(1);
|
|
336
|
+
return {
|
|
337
|
+
negated,
|
|
338
|
+
pattern
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
match(pattern, path) {
|
|
343
|
+
if (pattern.endsWith("/")) return path.startsWith(pattern) || path === pattern.slice(0, -1);
|
|
344
|
+
if (pattern.includes("/")) return this.fnmatch(pattern, path);
|
|
345
|
+
return this.fnmatch(pattern, path) || this.fnmatch(pattern, basename(path));
|
|
346
|
+
}
|
|
347
|
+
fnmatch(pattern, str) {
|
|
348
|
+
const re = pattern.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
|
|
349
|
+
return new RegExp(`^${re}$`).test(str);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
//#endregion
|
|
353
|
+
//#region src/theme/root.ts
|
|
354
|
+
const THEME_MARKERS = [
|
|
355
|
+
"templates",
|
|
356
|
+
"assets",
|
|
357
|
+
"config"
|
|
358
|
+
];
|
|
359
|
+
var ThemeRoot = class {
|
|
360
|
+
root;
|
|
361
|
+
ignore;
|
|
362
|
+
constructor(root) {
|
|
363
|
+
this.root = resolve(root);
|
|
364
|
+
this.ignore = new FluidIgnore(this.root);
|
|
365
|
+
}
|
|
366
|
+
isValid() {
|
|
367
|
+
return THEME_MARKERS.some((m) => {
|
|
368
|
+
try {
|
|
369
|
+
return statSync(join(this.root, m)).isDirectory();
|
|
370
|
+
} catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
files() {
|
|
376
|
+
return this.glob(this.root).filter((f) => !this.ignore.ignore(f.relativePath));
|
|
377
|
+
}
|
|
378
|
+
file(pathOrFile) {
|
|
379
|
+
if (pathOrFile instanceof ThemeFile) return pathOrFile;
|
|
380
|
+
return new ThemeFile(join(this.root, pathOrFile), this.root);
|
|
381
|
+
}
|
|
382
|
+
glob(dir) {
|
|
383
|
+
const results = [];
|
|
384
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
385
|
+
if (entry.name.startsWith(".")) continue;
|
|
386
|
+
const full = join(dir, entry.name);
|
|
387
|
+
if (entry.isDirectory()) results.push(...this.glob(full));
|
|
388
|
+
else if (entry.isFile()) results.push(new ThemeFile(full, this.root));
|
|
389
|
+
}
|
|
390
|
+
return results;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region src/theme/dev-server/sse.ts
|
|
395
|
+
var SSEStream = class {
|
|
396
|
+
responses = /* @__PURE__ */ new Set();
|
|
397
|
+
add(res) {
|
|
398
|
+
res.writeHead(200, {
|
|
399
|
+
"Content-Type": "text/event-stream",
|
|
400
|
+
"Cache-Control": "no-cache",
|
|
401
|
+
Connection: "keep-alive",
|
|
402
|
+
"Access-Control-Allow-Origin": "*"
|
|
403
|
+
});
|
|
404
|
+
res.write(":\n\n");
|
|
405
|
+
this.responses.add(res);
|
|
406
|
+
res.on("close", () => this.responses.delete(res));
|
|
407
|
+
}
|
|
408
|
+
broadcast(data) {
|
|
409
|
+
const payload = `data: ${data}\n\n`;
|
|
410
|
+
for (const res of this.responses) try {
|
|
411
|
+
res.write(payload);
|
|
412
|
+
} catch {
|
|
413
|
+
this.responses.delete(res);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
close() {
|
|
417
|
+
for (const res of this.responses) try {
|
|
418
|
+
res.end();
|
|
419
|
+
} catch {}
|
|
420
|
+
this.responses.clear();
|
|
421
|
+
}
|
|
422
|
+
get size() {
|
|
423
|
+
return this.responses.size;
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region src/theme/dev-server/hot-reload.ts
|
|
428
|
+
function buildHotReloadScript(mode) {
|
|
429
|
+
return `
|
|
430
|
+
<script>
|
|
431
|
+
(() => {
|
|
432
|
+
window.__FLUID_CLI_ENV__ = ${JSON.stringify({ mode })};
|
|
433
|
+
|
|
434
|
+
class HotReload {
|
|
435
|
+
static reloadMode() { return window.__FLUID_CLI_ENV__.mode; }
|
|
436
|
+
static isActive() { return HotReload.reloadMode() !== "off"; }
|
|
437
|
+
static setHotReloadCookie(files) {
|
|
438
|
+
const expires = new Date(Date.now() + 3000).toUTCString();
|
|
439
|
+
document.cookie = \`hot_reload_files=\${files.join(",")};expires=\${expires};path=/\`;
|
|
440
|
+
}
|
|
441
|
+
static refresh(files) {
|
|
442
|
+
HotReload.setHotReloadCookie(files);
|
|
443
|
+
console.log("[HotReload] Refreshing page");
|
|
444
|
+
window.location.reload();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
class SSEClient {
|
|
449
|
+
constructor(url, handler) {
|
|
450
|
+
if (typeof EventSource === "undefined") {
|
|
451
|
+
console.error("[HotReload] EventSource not supported in this browser.");
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
console.log("[HotReload] Initializing…");
|
|
455
|
+
this.url = url;
|
|
456
|
+
this.handler = handler;
|
|
457
|
+
}
|
|
458
|
+
connect() {
|
|
459
|
+
const es = new EventSource(this.url);
|
|
460
|
+
es.onopen = () => console.log("[HotReload] SSE connected.");
|
|
461
|
+
es.onerror = () => {
|
|
462
|
+
console.log("[HotReload] SSE closed. Reconnecting in 5s…");
|
|
463
|
+
es.close();
|
|
464
|
+
setTimeout(() => this.connect(), 5000);
|
|
465
|
+
};
|
|
466
|
+
es.onmessage = (msg) => {
|
|
467
|
+
const data = JSON.parse(msg.data);
|
|
468
|
+
if (data.reload_page) { HotReload.refresh([]); return; }
|
|
469
|
+
this.handler(data);
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (HotReload.isActive()) {
|
|
475
|
+
new SSEClient("/hot-reload", (data) => {
|
|
476
|
+
if (data.modified) HotReload.refresh(data.modified);
|
|
477
|
+
}).connect();
|
|
478
|
+
}
|
|
479
|
+
})();
|
|
480
|
+
<\/script>`;
|
|
481
|
+
}
|
|
482
|
+
function injectHotReload(html, mode) {
|
|
483
|
+
const script = buildHotReloadScript(mode);
|
|
484
|
+
if (html.includes("</body>")) return html.replace("</body>", `${script}\n</body>`);
|
|
485
|
+
return html + script;
|
|
486
|
+
}
|
|
487
|
+
//#endregion
|
|
488
|
+
//#region src/theme/dev-server/proxy.ts
|
|
489
|
+
const HOP_BY_HOP = new Set([
|
|
490
|
+
"connection",
|
|
491
|
+
"keep-alive",
|
|
492
|
+
"proxy-authenticate",
|
|
493
|
+
"proxy-authorization",
|
|
494
|
+
"te",
|
|
495
|
+
"trailer",
|
|
496
|
+
"transfer-encoding",
|
|
497
|
+
"upgrade",
|
|
498
|
+
"content-security-policy"
|
|
499
|
+
]);
|
|
500
|
+
async function proxyRequest(req, res, opts) {
|
|
501
|
+
const companyHost = `${opts.company}.fluid.app`;
|
|
502
|
+
const headers = {};
|
|
503
|
+
for (const [k, v] of Object.entries(req.headers)) if (!HOP_BY_HOP.has(k.toLowerCase()) && typeof v === "string") headers[k] = v;
|
|
504
|
+
headers["host"] = companyHost;
|
|
505
|
+
headers["x-fluid-theme"] = String(opts.themeId);
|
|
506
|
+
headers["user-agent"] = "Fluid CLI";
|
|
507
|
+
headers["accept-encoding"] = "identity";
|
|
508
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
509
|
+
url.searchParams.set("_fd", "0");
|
|
510
|
+
url.searchParams.set("pb", "0");
|
|
511
|
+
const pending = opts.pendingFiles?.() ?? [];
|
|
512
|
+
const isGet = req.method === "GET" || req.method === "HEAD";
|
|
513
|
+
let method = req.method ?? "GET";
|
|
514
|
+
let body;
|
|
515
|
+
if (pending.length > 0 && isGet) {
|
|
516
|
+
method = "POST";
|
|
517
|
+
const params = new URLSearchParams();
|
|
518
|
+
params.set("_method", req.method ?? "GET");
|
|
519
|
+
for (const f of pending) params.set(`replace_templates[${f.relativePath}]`, f.read());
|
|
520
|
+
const token = getAuthToken();
|
|
521
|
+
if (token) headers["authorization"] = `Bearer ${token}`;
|
|
522
|
+
headers["content-type"] = "application/x-www-form-urlencoded";
|
|
523
|
+
body = params.toString();
|
|
524
|
+
headers["content-length"] = String(Buffer.byteLength(body));
|
|
525
|
+
} else if (!isGet) {
|
|
526
|
+
body = await readBody(req);
|
|
527
|
+
if (body.length > 0) headers["content-length"] = String(body.length);
|
|
528
|
+
}
|
|
529
|
+
return new Promise((resolve, reject) => {
|
|
530
|
+
const options = {
|
|
531
|
+
hostname: companyHost,
|
|
532
|
+
port: 443,
|
|
533
|
+
path: url.pathname + (url.search || ""),
|
|
534
|
+
method,
|
|
535
|
+
headers
|
|
536
|
+
};
|
|
537
|
+
const proxyReq = https.request(options, (proxyRes) => {
|
|
538
|
+
const isHtml = (proxyRes.headers["content-type"] ?? "").includes("text/html");
|
|
539
|
+
const responseHeaders = {};
|
|
540
|
+
for (const [k, v] of Object.entries(proxyRes.headers)) if (!HOP_BY_HOP.has(k.toLowerCase()) && v !== void 0) responseHeaders[k] = v;
|
|
541
|
+
if (isHtml) {
|
|
542
|
+
const chunks = [];
|
|
543
|
+
proxyRes.on("data", (chunk) => chunks.push(chunk));
|
|
544
|
+
proxyRes.on("end", () => {
|
|
545
|
+
let html = Buffer.concat(chunks).toString("utf-8");
|
|
546
|
+
html = injectHotReload(html, opts.reloadMode);
|
|
547
|
+
responseHeaders["content-length"] = String(Buffer.byteLength(html));
|
|
548
|
+
res.writeHead(proxyRes.statusCode ?? 200, responseHeaders);
|
|
549
|
+
res.end(html);
|
|
550
|
+
resolve();
|
|
551
|
+
});
|
|
552
|
+
} else {
|
|
553
|
+
res.writeHead(proxyRes.statusCode ?? 200, responseHeaders);
|
|
554
|
+
proxyRes.pipe(res);
|
|
555
|
+
proxyRes.on("end", resolve);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
proxyReq.on("error", (err) => {
|
|
559
|
+
reject(err);
|
|
560
|
+
});
|
|
561
|
+
if (body) proxyReq.write(body);
|
|
562
|
+
proxyReq.end();
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
function readBody(req) {
|
|
566
|
+
return new Promise((resolve, reject) => {
|
|
567
|
+
const chunks = [];
|
|
568
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
569
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
570
|
+
req.on("error", reject);
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/theme/dev-server/watcher.ts
|
|
575
|
+
function watchTheme(root, handler) {
|
|
576
|
+
const watcher = chokidar.watch(root.root, {
|
|
577
|
+
ignoreInitial: true,
|
|
578
|
+
ignored: (filePath) => {
|
|
579
|
+
if (filePath.includes("node_modules")) return true;
|
|
580
|
+
try {
|
|
581
|
+
const rel = relative(root.root, filePath);
|
|
582
|
+
return (rel.split(/[\\/]/).pop() ?? "").startsWith(".") || root.ignore.ignore(rel);
|
|
583
|
+
} catch {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
persistent: true,
|
|
588
|
+
awaitWriteFinish: {
|
|
589
|
+
stabilityThreshold: 50,
|
|
590
|
+
pollInterval: 10
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
let pending = Promise.resolve();
|
|
594
|
+
const enqueue = (fn) => {
|
|
595
|
+
pending = pending.then(fn).catch(() => {});
|
|
596
|
+
};
|
|
597
|
+
watcher.on("change", (filePath) => {
|
|
598
|
+
const rel = relative(root.root, filePath);
|
|
599
|
+
if (root.ignore.ignore(rel)) return;
|
|
600
|
+
enqueue(() => handler([root.file(filePath)], [], []));
|
|
601
|
+
});
|
|
602
|
+
watcher.on("add", (filePath) => {
|
|
603
|
+
const rel = relative(root.root, filePath);
|
|
604
|
+
if (root.ignore.ignore(rel)) return;
|
|
605
|
+
enqueue(() => handler([], [root.file(filePath)], []));
|
|
606
|
+
});
|
|
607
|
+
watcher.on("unlink", (filePath) => {
|
|
608
|
+
enqueue(() => handler([], [], [root.file(filePath)]));
|
|
609
|
+
});
|
|
610
|
+
return () => watcher.close();
|
|
611
|
+
}
|
|
612
|
+
//#endregion
|
|
613
|
+
//#region src/theme/syncer.ts
|
|
614
|
+
var Syncer = class {
|
|
615
|
+
checksums = /* @__PURE__ */ new Map();
|
|
616
|
+
constructor(api, themeId, themeRoot) {
|
|
617
|
+
this.api = api;
|
|
618
|
+
this.themeId = themeId;
|
|
619
|
+
this.themeRoot = themeRoot;
|
|
620
|
+
}
|
|
621
|
+
async fetchChecksums() {
|
|
622
|
+
const body = await this.api.get(`/api/application_themes/${this.themeId}/resources`);
|
|
623
|
+
this.updateChecksums(body.application_theme_resources ?? []);
|
|
624
|
+
}
|
|
625
|
+
updateChecksums(resources) {
|
|
626
|
+
for (const r of resources) if (r.key) this.checksums.set(r.key, r.checksum);
|
|
627
|
+
for (const key of this.checksums.keys()) if (this.checksums.has(`${key}.liquid`)) this.checksums.delete(key);
|
|
628
|
+
}
|
|
629
|
+
hasChanged(file) {
|
|
630
|
+
return file.checksum() !== this.checksums.get(file.relativePath);
|
|
631
|
+
}
|
|
632
|
+
remoteKeys() {
|
|
633
|
+
return [...this.checksums.keys()];
|
|
634
|
+
}
|
|
635
|
+
async uploadFile(file) {
|
|
636
|
+
const path = `/api/application_themes/${this.themeId}/resources`;
|
|
637
|
+
if (file.isText) await this.api.put(path, { application_theme_resource: {
|
|
638
|
+
key: file.relativePath,
|
|
639
|
+
content: file.read()
|
|
640
|
+
} });
|
|
641
|
+
else await this.uploadBinaryFile(file, path);
|
|
642
|
+
}
|
|
643
|
+
async uploadBinaryFile(file, resourcePath) {
|
|
644
|
+
const asset = (await this.api.post("/api/dam/assets", { placeholder_asset: {
|
|
645
|
+
description: `Uploaded via Fluid CLI: ${file.name}`,
|
|
646
|
+
mime_type: file.mime.name,
|
|
647
|
+
name: file.name
|
|
648
|
+
} })).asset;
|
|
649
|
+
const authBody = await this.api.post("/api/dam/assets/imagekit_auth", {});
|
|
650
|
+
const folder = this.canonicalPathToImageKitFolder(asset.canonical_path);
|
|
651
|
+
const formData = new FormData();
|
|
652
|
+
const blob = new Blob([file.readBinary()], { type: file.mime.name });
|
|
653
|
+
formData.append("file", blob, file.name);
|
|
654
|
+
formData.append("token", authBody.token);
|
|
655
|
+
formData.append("signature", authBody.signature);
|
|
656
|
+
formData.append("expire", String(authBody.expire));
|
|
657
|
+
formData.append("folder", folder);
|
|
658
|
+
formData.append("fileName", file.name);
|
|
659
|
+
formData.append("publicKey", "public_j7s4Ih9ETh/OCp41mVQH7tlXBdU=");
|
|
660
|
+
const ikResp = await fetch("https://upload.imagekit.io/api/v1/files/upload", {
|
|
661
|
+
method: "POST",
|
|
662
|
+
body: formData
|
|
663
|
+
});
|
|
664
|
+
if (!ikResp.ok) throw new Error(`ImageKit upload failed: ${ikResp.status}`);
|
|
665
|
+
const ikBody = await ikResp.json();
|
|
666
|
+
const backfillPayload = { asset: {
|
|
667
|
+
id: asset.id,
|
|
668
|
+
imagekit_file_id: ikBody.fileId,
|
|
669
|
+
imagekit_url: ikBody.url,
|
|
670
|
+
mime_type: file.mime.name,
|
|
671
|
+
name: file.name,
|
|
672
|
+
file_size: ikBody.size,
|
|
673
|
+
expected_path: asset.canonical_path
|
|
674
|
+
} };
|
|
675
|
+
if (ikBody.height) backfillPayload["asset"]["height"] = ikBody.height;
|
|
676
|
+
if (ikBody.width) backfillPayload["asset"]["width"] = ikBody.width;
|
|
677
|
+
const backfillBody = await this.api.post("/api/dam/assets/backfill_imagekit", backfillPayload);
|
|
678
|
+
await this.api.put(resourcePath, { application_theme_resource: {
|
|
679
|
+
key: file.relativePath,
|
|
680
|
+
dam_asset: {
|
|
681
|
+
dam_asset_code: backfillBody.asset.code,
|
|
682
|
+
content_type: file.mime.name,
|
|
683
|
+
content_size: ikBody.size,
|
|
684
|
+
filename: file.name,
|
|
685
|
+
handle: backfillBody.asset.code,
|
|
686
|
+
url: backfillBody.asset.default_variant_url,
|
|
687
|
+
preview_image_url: ikBody.thumbnailUrl
|
|
688
|
+
}
|
|
689
|
+
} });
|
|
690
|
+
}
|
|
691
|
+
canonicalPathToImageKitFolder(canonicalPath) {
|
|
692
|
+
const parts = canonicalPath.split(".");
|
|
693
|
+
const companyId = parts[0] ?? "unknown";
|
|
694
|
+
const category = parts[1] ?? "files";
|
|
695
|
+
const assetCode = parts[2] ?? "unknown";
|
|
696
|
+
return `${companyId}/${{
|
|
697
|
+
images: "images",
|
|
698
|
+
videos: "videos",
|
|
699
|
+
audio: "audio",
|
|
700
|
+
documents: "documents",
|
|
701
|
+
files: "files"
|
|
702
|
+
}[category] ?? "files"}/${assetCode}`;
|
|
703
|
+
}
|
|
704
|
+
async deleteRemoteFile(relativePath) {
|
|
705
|
+
await this.api.delete(`/api/application_themes/${this.themeId}/resources`, { body: { application_theme_resource: { key: relativePath } } });
|
|
706
|
+
this.checksums.delete(relativePath);
|
|
707
|
+
}
|
|
708
|
+
async downloadAll() {
|
|
709
|
+
const body = await this.api.get(`/api/application_themes/${this.themeId}/resources`);
|
|
710
|
+
this.updateChecksums(body.application_theme_resources ?? []);
|
|
711
|
+
return body.application_theme_resources ?? [];
|
|
712
|
+
}
|
|
713
|
+
async downloadBinaryAsset(url) {
|
|
714
|
+
const resp = await fetch(url);
|
|
715
|
+
if (!resp.ok) throw new Error(`Failed to download asset: ${resp.status}`);
|
|
716
|
+
return Buffer.from(await resp.arrayBuffer());
|
|
717
|
+
}
|
|
718
|
+
async uploadTheme(opts = {}) {
|
|
719
|
+
await this.fetchChecksums();
|
|
720
|
+
const localFiles = this.themeRoot.files();
|
|
721
|
+
const result = {
|
|
722
|
+
uploaded: 0,
|
|
723
|
+
deleted: 0,
|
|
724
|
+
downloaded: 0,
|
|
725
|
+
errors: []
|
|
726
|
+
};
|
|
727
|
+
const toUpload = localFiles.filter((f) => f.exists && this.hasChanged(f));
|
|
728
|
+
let done = 0;
|
|
729
|
+
for (const file of toUpload) {
|
|
730
|
+
try {
|
|
731
|
+
await this.uploadFile(file);
|
|
732
|
+
result.uploaded++;
|
|
733
|
+
} catch (e) {
|
|
734
|
+
result.errors.push(`Upload ${file.relativePath}: ${e}`);
|
|
735
|
+
}
|
|
736
|
+
opts.onProgress?.(++done, toUpload.length);
|
|
737
|
+
}
|
|
738
|
+
if (opts.delete) {
|
|
739
|
+
const localPaths = new Set(localFiles.map((f) => f.relativePath));
|
|
740
|
+
const toDelete = this.remoteKeys().filter((k) => !localPaths.has(k));
|
|
741
|
+
for (const key of toDelete) try {
|
|
742
|
+
await this.deleteRemoteFile(key);
|
|
743
|
+
result.deleted++;
|
|
744
|
+
} catch (e) {
|
|
745
|
+
result.errors.push(`Delete ${key}: ${e}`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return result;
|
|
749
|
+
}
|
|
750
|
+
async downloadTheme(opts = {}) {
|
|
751
|
+
const resources = await this.downloadAll();
|
|
752
|
+
const result = {
|
|
753
|
+
uploaded: 0,
|
|
754
|
+
deleted: 0,
|
|
755
|
+
downloaded: 0,
|
|
756
|
+
errors: []
|
|
757
|
+
};
|
|
758
|
+
let done = 0;
|
|
759
|
+
for (const resource of resources) {
|
|
760
|
+
const file = this.themeRoot.file(resource.key);
|
|
761
|
+
if (!file.absolutePath.startsWith(this.themeRoot.root + sep)) {
|
|
762
|
+
result.errors.push(`Download ${resource.key}: path traversal detected`);
|
|
763
|
+
opts.onProgress?.(++done, resources.length);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
try {
|
|
767
|
+
if (resource.resource_type === "FileResource" && resource.url) {
|
|
768
|
+
const buf = await this.downloadBinaryAsset(resource.url);
|
|
769
|
+
file.write(buf);
|
|
770
|
+
} else if (resource.content !== void 0) file.write(resource.content);
|
|
771
|
+
result.downloaded++;
|
|
772
|
+
} catch (e) {
|
|
773
|
+
result.errors.push(`Download ${resource.key}: ${e}`);
|
|
774
|
+
}
|
|
775
|
+
opts.onProgress?.(++done, resources.length);
|
|
776
|
+
}
|
|
777
|
+
if (opts.delete) {
|
|
778
|
+
const remoteKeys = new Set(resources.map((r) => r.key));
|
|
779
|
+
for (const file of this.themeRoot.files()) if (!remoteKeys.has(file.relativePath)) try {
|
|
780
|
+
const { unlinkSync } = await import("node:fs");
|
|
781
|
+
unlinkSync(file.absolutePath);
|
|
782
|
+
result.deleted++;
|
|
783
|
+
} catch {}
|
|
784
|
+
}
|
|
785
|
+
return result;
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
//#endregion
|
|
789
|
+
//#region src/theme/dev-server/index.ts
|
|
790
|
+
async function startDevServer(api, theme, themeRoot, opts, onReady) {
|
|
791
|
+
const sse = new SSEStream();
|
|
792
|
+
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
793
|
+
const pendingUpdates = /* @__PURE__ */ new Set();
|
|
794
|
+
console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
|
|
795
|
+
await syncer.uploadTheme({
|
|
796
|
+
delete: true,
|
|
797
|
+
onProgress: (done, total) => {
|
|
798
|
+
process.stdout.write(`\r Uploading ${done}/${total} files…`);
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
process.stdout.write("\n");
|
|
802
|
+
const stopWatcher = watchTheme(themeRoot, async (modified, added, removed) => {
|
|
803
|
+
const changed = [...modified, ...added];
|
|
804
|
+
for (const file of changed) {
|
|
805
|
+
pendingUpdates.add(file.relativePath);
|
|
806
|
+
try {
|
|
807
|
+
await syncer.uploadFile(file);
|
|
808
|
+
} catch (e) {
|
|
809
|
+
console.error(`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`);
|
|
810
|
+
} finally {
|
|
811
|
+
pendingUpdates.delete(file.relativePath);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
for (const file of removed) try {
|
|
815
|
+
await syncer.deleteRemoteFile(file.relativePath);
|
|
816
|
+
} catch {}
|
|
817
|
+
if (removed.length > 0) sse.broadcast(JSON.stringify({ reload_page: true }));
|
|
818
|
+
else if (changed.length > 0) sse.broadcast(JSON.stringify({ modified: changed.map((f) => f.relativePath) }));
|
|
819
|
+
});
|
|
820
|
+
const server = http.createServer(async (req, res) => {
|
|
821
|
+
if (req.url === "/hot-reload") {
|
|
822
|
+
sse.add(res);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
try {
|
|
826
|
+
await proxyRequest(req, res, {
|
|
827
|
+
company: theme.company,
|
|
828
|
+
themeId: theme.id,
|
|
829
|
+
reloadMode: opts.reloadMode,
|
|
830
|
+
pendingFiles: () => [...pendingUpdates].map((p) => themeRoot.file(p)).filter((f) => f.isText).map((f) => ({
|
|
831
|
+
relativePath: f.relativePath,
|
|
832
|
+
read: () => f.read()
|
|
833
|
+
}))
|
|
834
|
+
});
|
|
835
|
+
} catch (e) {
|
|
836
|
+
console.error(`[Proxy] ${req.method} ${req.url} → ${e}`);
|
|
837
|
+
if (!res.headersSent) {
|
|
838
|
+
res.writeHead(502);
|
|
839
|
+
res.end("Bad Gateway");
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
await new Promise((resolve, reject) => {
|
|
844
|
+
server.listen(opts.port, opts.host, () => resolve());
|
|
845
|
+
server.on("error", reject);
|
|
846
|
+
});
|
|
847
|
+
const address = `http://${opts.host}:${opts.port}`;
|
|
848
|
+
onReady?.(address);
|
|
849
|
+
return function stop() {
|
|
850
|
+
sse.close();
|
|
851
|
+
stopWatcher();
|
|
852
|
+
server.close();
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
//#endregion
|
|
856
|
+
//#region src/commands/dev.ts
|
|
857
|
+
async function ensureDevTheme(api, identifier) {
|
|
858
|
+
if (identifier) {
|
|
859
|
+
const body = await api.get("/api/application_themes");
|
|
860
|
+
const found = (body.application_themes ?? []).find((t) => String(t.id) === identifier) ?? (body.application_themes ?? []).find((t) => t.name.toLowerCase() === identifier.toLowerCase());
|
|
861
|
+
if (!found) {
|
|
862
|
+
console.error(`Theme not found: ${identifier}`);
|
|
863
|
+
process.exit(1);
|
|
864
|
+
}
|
|
865
|
+
return found;
|
|
866
|
+
}
|
|
867
|
+
const { devThemeId } = getPluginState();
|
|
868
|
+
if (devThemeId) try {
|
|
869
|
+
const body = await api.get(`/api/application_themes/${devThemeId}`);
|
|
870
|
+
if (body.application_theme) {
|
|
871
|
+
console.log(`Using existing dev theme #${devThemeId}`);
|
|
872
|
+
return body.application_theme;
|
|
873
|
+
}
|
|
874
|
+
} catch {}
|
|
875
|
+
const { hostname } = await import("node:os");
|
|
876
|
+
const name = `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50);
|
|
877
|
+
const theme = (await api.post("/api/application_themes", { application_theme: {
|
|
878
|
+
name,
|
|
879
|
+
role: "development"
|
|
880
|
+
} })).application_theme;
|
|
881
|
+
setPluginState({
|
|
882
|
+
devThemeId: theme.id,
|
|
883
|
+
devThemeName: theme.name
|
|
884
|
+
});
|
|
885
|
+
console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
|
|
886
|
+
return theme;
|
|
887
|
+
}
|
|
888
|
+
function createDevCommand() {
|
|
889
|
+
return new Command("dev").description("Start the theme dev server with hot reload").option("--host <host>", "Local server host", "127.0.0.1").option("--port <port>", "Local server port", "9292").option("-t, --theme <name-or-id>", "Use an existing theme instead of dev theme").option("-f, --force", "Skip schema validation on upload").option("--live-reload <mode>", "Reload mode: full-page | off", "full-page").option("--navigate", "Open browser navigator after server starts").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
|
|
890
|
+
requireToken();
|
|
891
|
+
const themeRoot = new ThemeRoot(opts.root);
|
|
892
|
+
if (!themeRoot.isValid()) {
|
|
893
|
+
console.error(`'${opts.root}' does not look like a theme directory.`);
|
|
894
|
+
process.exit(1);
|
|
895
|
+
}
|
|
896
|
+
const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
|
|
897
|
+
const api = createApiClient();
|
|
898
|
+
const theme = await ensureDevTheme(api, opts.theme);
|
|
899
|
+
let stop;
|
|
900
|
+
const cleanup = () => {
|
|
901
|
+
stop?.();
|
|
902
|
+
process.exit(0);
|
|
903
|
+
};
|
|
904
|
+
process.on("SIGINT", cleanup);
|
|
905
|
+
process.on("SIGTERM", cleanup);
|
|
906
|
+
const port = Number(opts.port);
|
|
907
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
908
|
+
console.error(`Invalid port: '${opts.port}'. Must be an integer between 1 and 65535.`);
|
|
909
|
+
process.exit(1);
|
|
910
|
+
}
|
|
911
|
+
stop = await startDevServer(api, {
|
|
912
|
+
id: theme.id,
|
|
913
|
+
name: theme.name,
|
|
914
|
+
company: theme.company,
|
|
915
|
+
editorUrl: theme.editor_url
|
|
916
|
+
}, themeRoot, {
|
|
917
|
+
host: opts.host,
|
|
918
|
+
port,
|
|
919
|
+
reloadMode
|
|
920
|
+
}, (address) => {
|
|
921
|
+
console.log(`\n Dev server: ${address}`);
|
|
922
|
+
if (theme.editor_url) console.log(` Web editor: ${theme.editor_url}`);
|
|
923
|
+
console.log("\n Watching for file changes…\n");
|
|
924
|
+
if (opts.navigate) import("open").then((m) => m.default(`${address}/home`));
|
|
925
|
+
});
|
|
926
|
+
await new Promise(() => {});
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
//#endregion
|
|
930
|
+
//#region src/commands/push.ts
|
|
931
|
+
async function selectTheme(api) {
|
|
932
|
+
const themes = (await api.get("/api/application_themes")).application_themes ?? [];
|
|
933
|
+
if (!themes.length) {
|
|
934
|
+
console.error("No themes found.");
|
|
935
|
+
process.exit(1);
|
|
936
|
+
}
|
|
937
|
+
const { id } = await prompts({
|
|
938
|
+
type: "select",
|
|
939
|
+
name: "id",
|
|
940
|
+
message: "Select a theme to push to",
|
|
941
|
+
choices: themes.map((t) => ({
|
|
942
|
+
title: `${t.name} (#${t.id})`,
|
|
943
|
+
value: t.id
|
|
944
|
+
}))
|
|
945
|
+
}, { onCancel: () => process.exit(130) });
|
|
946
|
+
if (!id) {
|
|
947
|
+
console.error("No theme selected.");
|
|
948
|
+
process.exit(1);
|
|
949
|
+
}
|
|
950
|
+
return themes.find((t) => t.id === id);
|
|
951
|
+
}
|
|
952
|
+
async function findTheme(api, identifier) {
|
|
953
|
+
const themes = (await api.get("/api/application_themes")).application_themes ?? [];
|
|
954
|
+
const found = themes.find((t) => String(t.id) === identifier) ?? themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
|
|
955
|
+
if (!found) {
|
|
956
|
+
console.error(`No theme found with identifier: ${identifier}`);
|
|
957
|
+
process.exit(1);
|
|
958
|
+
}
|
|
959
|
+
return found;
|
|
960
|
+
}
|
|
961
|
+
function createPushCommand() {
|
|
962
|
+
return new Command("push").description("Push local theme files to a remote theme").option("-t, --theme <name-or-id>", "Theme name or ID to push to").option("-n, --nodelete", "Do not delete remote files missing locally").option("-f, --force", "Skip schema validation").option("-p, --publish", "Publish the theme after pushing").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
|
|
963
|
+
requireToken();
|
|
964
|
+
const themeRoot = new ThemeRoot(opts.root);
|
|
965
|
+
if (!themeRoot.isValid()) {
|
|
966
|
+
console.error(`'${opts.root}' does not look like a theme directory.`);
|
|
967
|
+
process.exit(1);
|
|
968
|
+
}
|
|
969
|
+
const api = createApiClient();
|
|
970
|
+
const theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api);
|
|
971
|
+
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
972
|
+
const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
|
|
973
|
+
const result = await syncer.uploadTheme({
|
|
974
|
+
delete: !opts.nodelete,
|
|
975
|
+
onProgress: (d, total) => {
|
|
976
|
+
spinner.text = `Pushing ${d}/${total} files…`;
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
if (result.errors.length) {
|
|
980
|
+
spinner.warn(`Pushed with ${result.errors.length} error(s).`);
|
|
981
|
+
for (const e of result.errors) console.error(` ${e}`);
|
|
982
|
+
} else spinner.succeed(`Pushed ${result.uploaded} file(s), deleted ${result.deleted} remote file(s).`);
|
|
983
|
+
if (opts.publish) {
|
|
984
|
+
const pubSpinner = ora("Publishing theme…").start();
|
|
985
|
+
try {
|
|
986
|
+
await api.post(`/api/application_themes/${theme.id}/publish`);
|
|
987
|
+
pubSpinner.succeed("Theme published.");
|
|
988
|
+
} catch (e) {
|
|
989
|
+
pubSpinner.fail(`Publish failed: ${e}`);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
//#endregion
|
|
995
|
+
//#region src/commands/pull.ts
|
|
996
|
+
async function selectOrFindTheme(api, identifier) {
|
|
997
|
+
const themes = (await api.get("/api/application_themes")).application_themes ?? [];
|
|
998
|
+
if (!themes.length) {
|
|
999
|
+
console.error("No themes found.");
|
|
1000
|
+
process.exit(1);
|
|
1001
|
+
}
|
|
1002
|
+
if (identifier) {
|
|
1003
|
+
const found = themes.find((t) => String(t.id) === identifier) ?? themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
|
|
1004
|
+
if (!found) {
|
|
1005
|
+
console.error(`No theme found with identifier: ${identifier}`);
|
|
1006
|
+
process.exit(1);
|
|
1007
|
+
}
|
|
1008
|
+
return found;
|
|
1009
|
+
}
|
|
1010
|
+
const { id } = await prompts({
|
|
1011
|
+
type: "select",
|
|
1012
|
+
name: "id",
|
|
1013
|
+
message: "Select a theme to pull",
|
|
1014
|
+
choices: themes.map((t) => ({
|
|
1015
|
+
title: `${t.name} (#${t.id})`,
|
|
1016
|
+
value: t.id
|
|
1017
|
+
}))
|
|
1018
|
+
}, { onCancel: () => process.exit(130) });
|
|
1019
|
+
if (!id) {
|
|
1020
|
+
console.error("No theme selected.");
|
|
1021
|
+
process.exit(1);
|
|
1022
|
+
}
|
|
1023
|
+
return themes.find((t) => t.id === id);
|
|
1024
|
+
}
|
|
1025
|
+
function createPullCommand() {
|
|
1026
|
+
return new Command("pull").description("Pull a remote theme to your local directory").option("-t, --theme <name-or-id>", "Theme name or ID to pull").option("-n, --nodelete", "Do not delete local files missing on remote").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
|
|
1027
|
+
requireToken();
|
|
1028
|
+
const api = createApiClient();
|
|
1029
|
+
const theme = await selectOrFindTheme(api, opts.theme);
|
|
1030
|
+
const themeRoot = new ThemeRoot(opts.root);
|
|
1031
|
+
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
1032
|
+
const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
|
|
1033
|
+
const result = await syncer.downloadTheme({
|
|
1034
|
+
delete: !opts.nodelete,
|
|
1035
|
+
onProgress: (d, total) => {
|
|
1036
|
+
spinner.text = `Downloading ${d}/${total} files…`;
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
if (result.errors.length) {
|
|
1040
|
+
spinner.warn(`Pulled with ${result.errors.length} error(s).`);
|
|
1041
|
+
for (const e of result.errors) console.error(` ${e}`);
|
|
1042
|
+
} else spinner.succeed(`Downloaded ${result.downloaded} file(s), deleted ${result.deleted} local file(s).`);
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
//#endregion
|
|
1046
|
+
//#region src/commands/init.ts
|
|
1047
|
+
const DEFAULT_CLONE_URL = "git@github.com:fluid-commerce/base-theme.git";
|
|
1048
|
+
const SAFE_NAME_RE = /^[a-zA-Z0-9_][a-zA-Z0-9._-]*$/;
|
|
1049
|
+
function createInitCommand() {
|
|
1050
|
+
return new Command("init").description("Initialize a new theme by cloning the base theme").argument("[name]", "Directory name for the new theme").option("-u, --clone-url <url>", "Git URL to clone from", DEFAULT_CLONE_URL).action(async (name, opts) => {
|
|
1051
|
+
if (!name) {
|
|
1052
|
+
name = (await prompts({
|
|
1053
|
+
type: "text",
|
|
1054
|
+
name: "name",
|
|
1055
|
+
message: "Theme name"
|
|
1056
|
+
}, { onCancel: () => process.exit(130) })).name;
|
|
1057
|
+
if (!name) {
|
|
1058
|
+
console.error("No name provided.");
|
|
1059
|
+
process.exit(1);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (!SAFE_NAME_RE.test(name)) {
|
|
1063
|
+
console.error(`Invalid theme name: '${name}'. Use only letters, numbers, hyphens, underscores, and dots.`);
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
}
|
|
1066
|
+
console.log(`Cloning theme from ${opts.cloneUrl} into ${name}…`);
|
|
1067
|
+
execFileSync("git", [
|
|
1068
|
+
"clone",
|
|
1069
|
+
opts.cloneUrl,
|
|
1070
|
+
name
|
|
1071
|
+
], { stdio: "inherit" });
|
|
1072
|
+
for (const dir of [".git", ".github"]) {
|
|
1073
|
+
const path = join(name, dir);
|
|
1074
|
+
if (existsSync(path)) rmSync(path, {
|
|
1075
|
+
recursive: true,
|
|
1076
|
+
force: true
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
console.log(`\nTheme initialized in ./${name}`);
|
|
1080
|
+
console.log(`Next steps:\n cd ${name}\n fluid theme push`);
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
//#endregion
|
|
1084
|
+
//#region src/commands/navigate.ts
|
|
1085
|
+
const STATIC_ROUTES = [
|
|
1086
|
+
{
|
|
1087
|
+
label: "Home",
|
|
1088
|
+
path: "/home"
|
|
1089
|
+
},
|
|
1090
|
+
{
|
|
1091
|
+
label: "Shop",
|
|
1092
|
+
path: "/home/shop"
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
label: "Join / Sign Up",
|
|
1096
|
+
path: "/home/join"
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
label: "Cart",
|
|
1100
|
+
path: "/cart"
|
|
1101
|
+
},
|
|
1102
|
+
{
|
|
1103
|
+
label: "Blog",
|
|
1104
|
+
path: "/home/blog"
|
|
1105
|
+
},
|
|
1106
|
+
{
|
|
1107
|
+
label: "Categories (all)",
|
|
1108
|
+
path: "/home/categories"
|
|
1109
|
+
},
|
|
1110
|
+
{
|
|
1111
|
+
label: "Collections (all)",
|
|
1112
|
+
path: "/home/collections"
|
|
1113
|
+
}
|
|
1114
|
+
];
|
|
1115
|
+
const RESOURCE_ROUTES = [
|
|
1116
|
+
{
|
|
1117
|
+
label: "Category",
|
|
1118
|
+
type: "category",
|
|
1119
|
+
template: "/home/categories/%s",
|
|
1120
|
+
fallback: "/home/categories"
|
|
1121
|
+
},
|
|
1122
|
+
{
|
|
1123
|
+
label: "Collection",
|
|
1124
|
+
type: "collection",
|
|
1125
|
+
template: "/home/collections/%s",
|
|
1126
|
+
fallback: "/home/collections"
|
|
1127
|
+
},
|
|
1128
|
+
{
|
|
1129
|
+
label: "Product",
|
|
1130
|
+
type: "product",
|
|
1131
|
+
template: "/home/products/%s",
|
|
1132
|
+
fallback: "/home/shop"
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
label: "Library",
|
|
1136
|
+
type: "library",
|
|
1137
|
+
template: "/home/libraries/%s",
|
|
1138
|
+
fallback: "/home/libraries"
|
|
1139
|
+
},
|
|
1140
|
+
{
|
|
1141
|
+
label: "Post",
|
|
1142
|
+
type: "post",
|
|
1143
|
+
template: "/home/posts/%s",
|
|
1144
|
+
fallback: "/home/blog"
|
|
1145
|
+
},
|
|
1146
|
+
{
|
|
1147
|
+
label: "Media",
|
|
1148
|
+
type: "medium",
|
|
1149
|
+
template: "/home/media/%s",
|
|
1150
|
+
fallback: "/home/media"
|
|
1151
|
+
},
|
|
1152
|
+
{
|
|
1153
|
+
label: "Enrollment Pack",
|
|
1154
|
+
type: "enrollment_pack",
|
|
1155
|
+
template: "/home/enrollments/%s",
|
|
1156
|
+
fallback: "/home/join"
|
|
1157
|
+
}
|
|
1158
|
+
];
|
|
1159
|
+
function createNavigateCommand() {
|
|
1160
|
+
return new Command("navigate").description("Interactively navigate to a route in the dev server browser").option("--host <host>", "Dev server host", "127.0.0.1").option("--port <port>", "Dev server port", "9292").option("-t, --theme <id>", "Theme ID (defaults to active dev theme)").action(async (opts) => {
|
|
1161
|
+
requireToken();
|
|
1162
|
+
const themeId = opts.theme ? Number(opts.theme) : getPluginState().devThemeId;
|
|
1163
|
+
if (!themeId) {
|
|
1164
|
+
console.error("No active dev theme. Run `fluid theme dev` first, or pass --theme <id>.");
|
|
1165
|
+
process.exit(1);
|
|
1166
|
+
}
|
|
1167
|
+
const address = `http://${opts.host}:${opts.port}`;
|
|
1168
|
+
const choices = [...STATIC_ROUTES.map((r) => ({
|
|
1169
|
+
title: r.label,
|
|
1170
|
+
value: r.path
|
|
1171
|
+
})), ...RESOURCE_ROUTES.map((r) => ({
|
|
1172
|
+
title: `${r.label} (select specific)`,
|
|
1173
|
+
value: {
|
|
1174
|
+
resourceType: r.type,
|
|
1175
|
+
template: r.template,
|
|
1176
|
+
fallback: r.fallback,
|
|
1177
|
+
label: r.label
|
|
1178
|
+
}
|
|
1179
|
+
}))];
|
|
1180
|
+
const onCancel = () => process.exit(130);
|
|
1181
|
+
const { dest } = await prompts({
|
|
1182
|
+
type: "select",
|
|
1183
|
+
name: "dest",
|
|
1184
|
+
message: "Select a route",
|
|
1185
|
+
choices
|
|
1186
|
+
}, { onCancel });
|
|
1187
|
+
if (!dest) return;
|
|
1188
|
+
let path;
|
|
1189
|
+
if (typeof dest === "string") path = dest;
|
|
1190
|
+
else {
|
|
1191
|
+
const resources = (await createApiClient().get(`/api/application_themes/${themeId}/available_themeables`, {
|
|
1192
|
+
themeable: dest.resourceType,
|
|
1193
|
+
per_page: 50
|
|
1194
|
+
})).available_themeables ?? [];
|
|
1195
|
+
if (!resources.length) {
|
|
1196
|
+
console.log(`No ${dest.label} resources found, using listing page.`);
|
|
1197
|
+
path = dest.fallback;
|
|
1198
|
+
} else {
|
|
1199
|
+
const { slug } = await prompts({
|
|
1200
|
+
type: "select",
|
|
1201
|
+
name: "slug",
|
|
1202
|
+
message: `Select a ${dest.label.toLowerCase()}`,
|
|
1203
|
+
choices: resources.map((r) => ({
|
|
1204
|
+
title: r.title ?? r.slug,
|
|
1205
|
+
value: r.slug
|
|
1206
|
+
}))
|
|
1207
|
+
}, { onCancel });
|
|
1208
|
+
path = dest.template.replace("%s", slug);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
const url = `${address}${path}`;
|
|
1212
|
+
console.log(`\nNavigating to: ${url}\n`);
|
|
1213
|
+
const open = (await import("open")).default;
|
|
1214
|
+
await open(url);
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
//#endregion
|
|
1218
|
+
//#region src/commands/theme.ts
|
|
1219
|
+
function registerThemeCommand(ctx) {
|
|
1220
|
+
const cmd = new Command("theme").description("Theme developer workflow — dev server, push, pull, init");
|
|
1221
|
+
cmd.addCommand(createDevCommand());
|
|
1222
|
+
cmd.addCommand(createPushCommand());
|
|
1223
|
+
cmd.addCommand(createPullCommand());
|
|
1224
|
+
cmd.addCommand(createInitCommand());
|
|
1225
|
+
cmd.addCommand(createNavigateCommand());
|
|
1226
|
+
ctx.program.addCommand(cmd);
|
|
1227
|
+
}
|
|
1228
|
+
//#endregion
|
|
1229
|
+
//#region src/index.ts
|
|
1230
|
+
const plugin = {
|
|
1231
|
+
name: "@fluid-app/fluid-cli-theme-dev",
|
|
1232
|
+
version: "0.1.0",
|
|
1233
|
+
register(ctx) {
|
|
1234
|
+
registerThemeCommand(ctx);
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
//#endregion
|
|
1238
|
+
export { plugin as default };
|
|
1239
|
+
|
|
1240
|
+
//# sourceMappingURL=index.mjs.map
|