@allegrocdp/preview 0.1.0-dev.6
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 +89 -0
- package/dist/cli.js +362 -0
- package/dist/client/assets/index-CDDVjT8Y.js +137 -0
- package/dist/client/index.html +1016 -0
- package/dist/server.js +353 -0
- package/package.json +38 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import chokidar from "chokidar";
|
|
5
|
+
import express from "express";
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
7
|
+
import open from "open";
|
|
8
|
+
import { basename, isAbsolute, join, relative, resolve } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
var __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
11
|
+
var BOILERPLATE_HTML = `<div class="signup">
|
|
12
|
+
<div class="signup__eyebrow">Newsletter</div>
|
|
13
|
+
<h2 class="signup__headline">{% headline %}</h2>
|
|
14
|
+
<p class="signup__sub">{% subheadline %}</p>
|
|
15
|
+
<form class="signup__form" onsubmit="return false">
|
|
16
|
+
<div class="signup__row">
|
|
17
|
+
<input class="signup__input" type="email" placeholder="{% input_placeholder %}" />
|
|
18
|
+
<button class="signup__btn" type="submit">{% button_text %}</button>
|
|
19
|
+
</div>
|
|
20
|
+
<p class="signup__note">{% privacy_note %}</p>
|
|
21
|
+
</form>
|
|
22
|
+
</div>
|
|
23
|
+
`;
|
|
24
|
+
var BOILERPLATE_CSS = `*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
25
|
+
|
|
26
|
+
.signup {
|
|
27
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
|
|
28
|
+
-webkit-font-smoothing: antialiased;
|
|
29
|
+
padding: 16px;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.signup__eyebrow {
|
|
33
|
+
font-size: 11px;
|
|
34
|
+
font-weight: 700;
|
|
35
|
+
letter-spacing: 0.1em;
|
|
36
|
+
text-transform: uppercase;
|
|
37
|
+
color: #6366f1;
|
|
38
|
+
margin-bottom: 8px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.signup__headline {
|
|
42
|
+
font-size: clamp(20px, 4vw, 26px);
|
|
43
|
+
font-weight: 800;
|
|
44
|
+
color: #0f172a;
|
|
45
|
+
line-height: 1.2;
|
|
46
|
+
letter-spacing: -0.02em;
|
|
47
|
+
margin-bottom: 8px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.signup__sub {
|
|
51
|
+
font-size: 14px;
|
|
52
|
+
color: #64748b;
|
|
53
|
+
line-height: 1.6;
|
|
54
|
+
margin-bottom: 20px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.signup__row {
|
|
58
|
+
display: flex;
|
|
59
|
+
gap: 8px;
|
|
60
|
+
flex-wrap: wrap;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.signup__input {
|
|
64
|
+
flex: 1;
|
|
65
|
+
min-width: 180px;
|
|
66
|
+
height: 42px;
|
|
67
|
+
padding: 0 12px;
|
|
68
|
+
border: 1.5px solid #e2e8f0;
|
|
69
|
+
border-radius: 8px;
|
|
70
|
+
font-size: 14px;
|
|
71
|
+
color: #0f172a;
|
|
72
|
+
background: #f8fafc;
|
|
73
|
+
outline: none;
|
|
74
|
+
transition: border-color 0.15s, background 0.15s;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.signup__input:focus { border-color: #6366f1; background: #fff; }
|
|
78
|
+
.signup__input::placeholder { color: #94a3b8; }
|
|
79
|
+
|
|
80
|
+
.signup__btn {
|
|
81
|
+
height: 42px;
|
|
82
|
+
padding: 0 20px;
|
|
83
|
+
background: #6366f1;
|
|
84
|
+
color: #fff;
|
|
85
|
+
border: none;
|
|
86
|
+
border-radius: 8px;
|
|
87
|
+
font-size: 14px;
|
|
88
|
+
font-weight: 600;
|
|
89
|
+
font-family: inherit;
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
white-space: nowrap;
|
|
92
|
+
transition: background 0.15s;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.signup__btn:hover { background: #4f46e5; }
|
|
96
|
+
.signup__btn:active { opacity: 0.9; }
|
|
97
|
+
|
|
98
|
+
.signup__note {
|
|
99
|
+
margin-top: 10px;
|
|
100
|
+
font-size: 12px;
|
|
101
|
+
color: #94a3b8;
|
|
102
|
+
}
|
|
103
|
+
`;
|
|
104
|
+
var BOILERPLATE_FIELDS = [
|
|
105
|
+
{ slug: "headline", label: "Headline", type: "text", default_value: "Stay in the know" },
|
|
106
|
+
{
|
|
107
|
+
slug: "subheadline",
|
|
108
|
+
label: "Subheadline",
|
|
109
|
+
type: "textarea",
|
|
110
|
+
default_value: "Get the latest stories delivered to your inbox."
|
|
111
|
+
},
|
|
112
|
+
{ slug: "input_placeholder", label: "Input placeholder", type: "text", default_value: "Enter your email" },
|
|
113
|
+
{ slug: "button_text", label: "Button text", type: "text", default_value: "Subscribe" },
|
|
114
|
+
{ slug: "privacy_note", label: "Privacy note", type: "text", default_value: "No spam. Unsubscribe anytime." }
|
|
115
|
+
];
|
|
116
|
+
var sseClients = /* @__PURE__ */ new Set();
|
|
117
|
+
function broadcast(event) {
|
|
118
|
+
for (const client of sseClients) {
|
|
119
|
+
client.write(`event: ${event}
|
|
120
|
+
data: {}
|
|
121
|
+
|
|
122
|
+
`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function broadcastReload() {
|
|
126
|
+
broadcast("reload");
|
|
127
|
+
}
|
|
128
|
+
function broadcastConfig() {
|
|
129
|
+
broadcast("config");
|
|
130
|
+
}
|
|
131
|
+
function readTemplateMeta(dir) {
|
|
132
|
+
const jsonPath = join(dir, "template.json");
|
|
133
|
+
if (!existsSync(jsonPath)) {
|
|
134
|
+
return { name: basename(dir) };
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
|
138
|
+
return { name: parsed.name ?? basename(dir) };
|
|
139
|
+
} catch {
|
|
140
|
+
return { name: basename(dir) };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function isTemplateDir(dir) {
|
|
144
|
+
return existsSync(join(dir, "index.html"));
|
|
145
|
+
}
|
|
146
|
+
function scanTemplates(baseDir) {
|
|
147
|
+
const results = [];
|
|
148
|
+
if (isTemplateDir(baseDir)) {
|
|
149
|
+
const meta = readTemplateMeta(baseDir);
|
|
150
|
+
results.push({ slug: ".", name: meta.name });
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
|
|
154
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const sub = join(baseDir, entry.name);
|
|
158
|
+
if (!isTemplateDir(sub)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const meta = readTemplateMeta(sub);
|
|
162
|
+
results.push({ slug: entry.name, name: meta.name });
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
}
|
|
166
|
+
return results;
|
|
167
|
+
}
|
|
168
|
+
function readTemplateFiles(dir) {
|
|
169
|
+
const read = (filename) => {
|
|
170
|
+
const filePath = join(dir, filename);
|
|
171
|
+
return existsSync(filePath) ? readFileSync(filePath, "utf-8") : "";
|
|
172
|
+
};
|
|
173
|
+
let fields = [];
|
|
174
|
+
let name = basename(dir);
|
|
175
|
+
let fieldsParseError = null;
|
|
176
|
+
const jsonPath = join(dir, "template.json");
|
|
177
|
+
if (existsSync(jsonPath)) {
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
|
180
|
+
fields = parsed.fields ?? [];
|
|
181
|
+
name = parsed.name ?? name;
|
|
182
|
+
} catch (err) {
|
|
183
|
+
fieldsParseError = err instanceof Error ? err.message : "Invalid JSON in template.json";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
html: read("index.html"),
|
|
188
|
+
css: read("index.css"),
|
|
189
|
+
js: read("index.js"),
|
|
190
|
+
fields,
|
|
191
|
+
name,
|
|
192
|
+
fieldsParseError
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
async function startServer(templatePath, port = 0, tenant = "") {
|
|
196
|
+
const baseDir = resolve(process.cwd(), templatePath);
|
|
197
|
+
if (!existsSync(baseDir)) {
|
|
198
|
+
console.error(`Directory not found: ${baseDir}`);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
let templates = scanTemplates(baseDir);
|
|
202
|
+
const app = express();
|
|
203
|
+
const clientDistDir = join(__dirname, "client");
|
|
204
|
+
app.use(express.static(clientDistDir));
|
|
205
|
+
app.get("/api/config", (_req, res) => {
|
|
206
|
+
res.json({ defaultSlug: templates[0]?.slug ?? null, templates, tenant });
|
|
207
|
+
});
|
|
208
|
+
app.get("/api/template", (req, res) => {
|
|
209
|
+
const slug = req.query.slug ?? ".";
|
|
210
|
+
if (slug !== "." && !templates.some((template) => template.slug === slug)) {
|
|
211
|
+
res.status(404).json({ error: "Template not found" });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const targetDir = slug === "." ? baseDir : join(baseDir, slug);
|
|
215
|
+
const resolvedBaseDir = resolve(baseDir);
|
|
216
|
+
const resolvedDir = resolve(targetDir);
|
|
217
|
+
const relativePath = relative(resolvedBaseDir, resolvedDir);
|
|
218
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
219
|
+
res.status(400).json({ error: "Invalid template path" });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (!existsSync(resolvedDir) || !isTemplateDir(resolvedDir)) {
|
|
223
|
+
res.status(404).json({ error: "Template not found" });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
res.json(readTemplateFiles(resolvedDir));
|
|
227
|
+
});
|
|
228
|
+
app.post("/api/template/create", express.json(), (req, res) => {
|
|
229
|
+
const body = req.body;
|
|
230
|
+
const slug = body.slug;
|
|
231
|
+
if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
|
|
232
|
+
res.status(400).json({
|
|
233
|
+
error: "Slug must be lowercase letters, numbers, and hyphens only (e.g. my-template)."
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const targetDir = join(baseDir, slug);
|
|
238
|
+
if (existsSync(targetDir)) {
|
|
239
|
+
res.status(400).json({ error: `A template named "${slug}" already exists.` });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
mkdirSync(targetDir);
|
|
244
|
+
writeFileSync(join(targetDir, "index.html"), BOILERPLATE_HTML);
|
|
245
|
+
writeFileSync(join(targetDir, "index.css"), BOILERPLATE_CSS);
|
|
246
|
+
writeFileSync(
|
|
247
|
+
join(targetDir, "template.json"),
|
|
248
|
+
JSON.stringify({ name: slug, fields: BOILERPLATE_FIELDS }, null, 2) + "\n"
|
|
249
|
+
);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.error("[allegro-preview] Failed to create template:", err);
|
|
252
|
+
res.status(500).json({ error: "Failed to create template files." });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
rescan();
|
|
256
|
+
res.status(201).json({ slug });
|
|
257
|
+
});
|
|
258
|
+
app.get("/api/events", (req, res) => {
|
|
259
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
260
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
261
|
+
res.setHeader("Connection", "keep-alive");
|
|
262
|
+
res.flushHeaders();
|
|
263
|
+
sseClients.add(res);
|
|
264
|
+
req.on("close", () => {
|
|
265
|
+
sseClients.delete(res);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
const indexHtmlPath = join(clientDistDir, "index.html");
|
|
269
|
+
app.get("/*path", (_req, res) => {
|
|
270
|
+
if (existsSync(indexHtmlPath)) {
|
|
271
|
+
res.type("html").send(readFileSync(indexHtmlPath));
|
|
272
|
+
} else {
|
|
273
|
+
res.status(503).send(
|
|
274
|
+
'Preview client build artifacts not found. Ensure the client is built (for example, by running "npm run build" in this package during development).'
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
const TEMPLATE_FILES = ["index.html", "index.css", "index.js", "template.json"];
|
|
279
|
+
function templateFilePaths(dir) {
|
|
280
|
+
return TEMPLATE_FILES.map((f) => join(dir, f));
|
|
281
|
+
}
|
|
282
|
+
const fileWatcher = chokidar.watch(
|
|
283
|
+
templates.flatMap((t) => templateFilePaths(t.slug === "." ? baseDir : join(baseDir, t.slug))),
|
|
284
|
+
{ ignoreInitial: true }
|
|
285
|
+
);
|
|
286
|
+
fileWatcher.on("change", (changedPath) => {
|
|
287
|
+
console.log(`Changed: ${changedPath}`);
|
|
288
|
+
broadcastReload();
|
|
289
|
+
});
|
|
290
|
+
fileWatcher.on("add", (addedPath) => {
|
|
291
|
+
console.log(`Added: ${addedPath}`);
|
|
292
|
+
if (basename(addedPath) === "index.html") {
|
|
293
|
+
rescan();
|
|
294
|
+
}
|
|
295
|
+
broadcastReload();
|
|
296
|
+
});
|
|
297
|
+
const dirWatcher = chokidar.watch(baseDir, { depth: 1, ignoreInitial: true });
|
|
298
|
+
function rescan() {
|
|
299
|
+
templates = scanTemplates(baseDir);
|
|
300
|
+
console.log(`Templates updated: ${templates.map((t) => t.slug).join(", ") || "(none)"}`);
|
|
301
|
+
broadcastConfig();
|
|
302
|
+
}
|
|
303
|
+
dirWatcher.on("addDir", (dirPath) => {
|
|
304
|
+
if (dirPath === baseDir) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
setTimeout(() => {
|
|
308
|
+
rescan();
|
|
309
|
+
fileWatcher.add(templateFilePaths(dirPath));
|
|
310
|
+
}, 300);
|
|
311
|
+
});
|
|
312
|
+
dirWatcher.on("unlinkDir", (dirPath) => {
|
|
313
|
+
if (dirPath === baseDir) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
rescan();
|
|
317
|
+
});
|
|
318
|
+
dirWatcher.on("add", (filePath) => {
|
|
319
|
+
if (basename(filePath) === "index.html") {
|
|
320
|
+
rescan();
|
|
321
|
+
fileWatcher.add(templateFilePaths(join(filePath, "..")));
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
const bindServer = (p) => new Promise((resolve2, reject) => {
|
|
325
|
+
const server = app.listen(p);
|
|
326
|
+
server.once("listening", () => {
|
|
327
|
+
const addr = server.address();
|
|
328
|
+
const assignedPort2 = typeof addr === "object" && addr ? addr.port : p;
|
|
329
|
+
resolve2({ server, assignedPort: assignedPort2 });
|
|
330
|
+
});
|
|
331
|
+
server.once("error", reject);
|
|
332
|
+
});
|
|
333
|
+
let assignedPort;
|
|
334
|
+
try {
|
|
335
|
+
({ assignedPort } = await bindServer(port));
|
|
336
|
+
} catch (err) {
|
|
337
|
+
const nodeErr = err;
|
|
338
|
+
if (nodeErr.code === "EADDRINUSE" && port !== 0) {
|
|
339
|
+
console.log(`Port ${port} is in use, using a random available port instead.`);
|
|
340
|
+
({ assignedPort } = await bindServer(0));
|
|
341
|
+
} else {
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const url = `http://localhost:${assignedPort}`;
|
|
346
|
+
console.log(`
|
|
347
|
+
allegro-preview \u2192 ${url}
|
|
348
|
+
`);
|
|
349
|
+
void open(url);
|
|
350
|
+
}
|
|
351
|
+
export {
|
|
352
|
+
startServer
|
|
353
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "@allegrocdp/preview",
|
|
4
|
+
"version": "0.1.0-dev.6",
|
|
5
|
+
"description": "Local dev server for previewing Allegro templates",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"allegro-preview": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "npm run build:client && npm run build:cli",
|
|
15
|
+
"build:client": "vite build",
|
|
16
|
+
"build:cli": "tsup",
|
|
17
|
+
"dev": "vite",
|
|
18
|
+
"types": "tsc --noEmit",
|
|
19
|
+
"format": "prettier --write src/",
|
|
20
|
+
"format:check": "prettier --check src/"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"chokidar": "^4.0.3",
|
|
24
|
+
"commander": "^13.1.0",
|
|
25
|
+
"express": "^5.1.0",
|
|
26
|
+
"open": "^10.1.2"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@alleyinteractive/allegro-platform": "*",
|
|
30
|
+
"@types/express": "^5.0.1",
|
|
31
|
+
"@types/node": "^22.13.5",
|
|
32
|
+
"prettier": "^3.4.2",
|
|
33
|
+
"prettier-plugin-organize-imports": "^4.1.0",
|
|
34
|
+
"tsup": "^8.4.0",
|
|
35
|
+
"typescript": "^5.7.2",
|
|
36
|
+
"vite": "^7.0.4"
|
|
37
|
+
}
|
|
38
|
+
}
|