@allegrocdp/preview 0.1.0-dev.10

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 ADDED
@@ -0,0 +1,89 @@
1
+ # allegro-preview
2
+
3
+ A local dev server for previewing [Allegro](https://github.com/alleyinteractive/allegro) templates exactly as they render in production — with field substitution, device breakpoints, and article placement contexts.
4
+
5
+ ## Usage
6
+
7
+ Run from inside any Allegro template folder (one that contains `index.html`, `index.css`, `index.js`, and optionally `template.json`):
8
+
9
+ ```sh
10
+ npx @allegrocdp/preview@dev
11
+ ```
12
+
13
+ Or point it at a folder:
14
+
15
+ ```sh
16
+ npx @allegrocdp/preview@dev ./path/to/my-template
17
+ ```
18
+
19
+ The server starts, prints the URL, and opens your browser automatically.
20
+
21
+ ## Options
22
+
23
+ ```
24
+ @allegrocdp/preview [path] [options]
25
+
26
+ Arguments:
27
+ path Path to the template folder (default: current directory)
28
+
29
+ Options:
30
+ -p, --port <number> Port to listen on (default: random available port)
31
+ --tenant <url> Tenant URL to use for Allegro API requests
32
+ -h, --help Show help
33
+ ```
34
+
35
+ ## Template folder structure
36
+
37
+ ```
38
+ my-template/
39
+ ├── index.html # Template markup (supports {% field_slug %} placeholders)
40
+ ├── index.css # Template styles
41
+ ├── index.js # Template JavaScript
42
+ └── template.json # [optional] Field definitions and metadata
43
+ ```
44
+
45
+ ### `template.json` field types
46
+
47
+ ```json
48
+ {
49
+ "name": "My Template",
50
+ "fields": [
51
+ { "slug": "headline", "label": "Headline", "type": "text", "default_value": "Hello world" },
52
+ { "slug": "count", "label": "Count", "type": "number", "default_value": "3" },
53
+ { "slug": "show_cta", "label": "Show CTA", "type": "checkbox", "default_value": "true" },
54
+ { "slug": "body_copy", "label": "Body Copy", "type": "textarea", "default_value": "" }
55
+ ]
56
+ }
57
+ ```
58
+
59
+ Supported field types: `text`, `number`, `checkbox`, `textarea`.
60
+
61
+ ## Preview contexts
62
+
63
+ | Tab | Description |
64
+ |-----|-------------|
65
+ | **Standalone** | Template rendered on its own in an iframe |
66
+ | **Article · Cut** | Template injected after paragraph 3 of a mock article using the `truncate` placement method |
67
+ | **Article · Append** | Template rendered after the full content of a mock article |
68
+
69
+ ## Device breakpoints
70
+
71
+ | Device | Width |
72
+ |--------|-------|
73
+ | Desktop | 1280px |
74
+ | Tablet | 768px |
75
+ | Mobile | 390px |
76
+
77
+ ## Live reload
78
+
79
+ The server watches `index.html`, `index.css`, `index.js`, and `template.json` (if present) for changes and automatically refreshes the preview.
80
+
81
+ ## Development
82
+
83
+ ```sh
84
+ # Build the package
85
+ npm run preview:build
86
+
87
+ # Run Vite dev server for the client UI
88
+ npm run preview:dev
89
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,519 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/server.ts
7
+ import chokidar from "chokidar";
8
+ import express from "express";
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
10
+ import open from "open";
11
+ import { basename, isAbsolute, join, relative, resolve } from "path";
12
+ import { fileURLToPath } from "url";
13
+ var __dirname = fileURLToPath(new URL(".", import.meta.url));
14
+ var BOILERPLATE_HTML = `<div class="signup">
15
+ <div class="signup__eyebrow">Newsletter</div>
16
+ <h2 class="signup__headline">{% headline %}</h2>
17
+ <p class="signup__sub">{% subheadline %}</p>
18
+ <form class="signup__form" onsubmit="return false">
19
+ <div class="signup__row">
20
+ <input class="signup__input" type="email" placeholder="{% input_placeholder %}" />
21
+ <button class="signup__btn" type="submit">{% button_text %}</button>
22
+ </div>
23
+ <p class="signup__note">{% privacy_note %}</p>
24
+ </form>
25
+ </div>
26
+ `;
27
+ var BOILERPLATE_CSS = `*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
28
+
29
+ .signup {
30
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
31
+ -webkit-font-smoothing: antialiased;
32
+ padding: 16px;
33
+ }
34
+
35
+ .signup__eyebrow {
36
+ font-size: 11px;
37
+ font-weight: 700;
38
+ letter-spacing: 0.1em;
39
+ text-transform: uppercase;
40
+ color: #6366f1;
41
+ margin-bottom: 8px;
42
+ }
43
+
44
+ .signup__headline {
45
+ font-size: clamp(20px, 4vw, 26px);
46
+ font-weight: 800;
47
+ color: #0f172a;
48
+ line-height: 1.2;
49
+ letter-spacing: -0.02em;
50
+ margin-bottom: 8px;
51
+ }
52
+
53
+ .signup__sub {
54
+ font-size: 14px;
55
+ color: #64748b;
56
+ line-height: 1.6;
57
+ margin-bottom: 20px;
58
+ }
59
+
60
+ .signup__row {
61
+ display: flex;
62
+ gap: 8px;
63
+ flex-wrap: wrap;
64
+ }
65
+
66
+ .signup__input {
67
+ flex: 1;
68
+ min-width: 180px;
69
+ height: 42px;
70
+ padding: 0 12px;
71
+ border: 1.5px solid #e2e8f0;
72
+ border-radius: 8px;
73
+ font-size: 14px;
74
+ color: #0f172a;
75
+ background: #f8fafc;
76
+ outline: none;
77
+ transition: border-color 0.15s, background 0.15s;
78
+ }
79
+
80
+ .signup__input:focus { border-color: #6366f1; background: #fff; }
81
+ .signup__input::placeholder { color: #94a3b8; }
82
+
83
+ .signup__btn {
84
+ height: 42px;
85
+ padding: 0 20px;
86
+ background: #6366f1;
87
+ color: #fff;
88
+ border: none;
89
+ border-radius: 8px;
90
+ font-size: 14px;
91
+ font-weight: 600;
92
+ font-family: inherit;
93
+ cursor: pointer;
94
+ white-space: nowrap;
95
+ transition: background 0.15s;
96
+ }
97
+
98
+ .signup__btn:hover { background: #4f46e5; }
99
+ .signup__btn:active { opacity: 0.9; }
100
+
101
+ .signup__note {
102
+ margin-top: 10px;
103
+ font-size: 12px;
104
+ color: #94a3b8;
105
+ }
106
+ `;
107
+ var BOILERPLATE_FIELDS = [
108
+ { slug: "headline", label: "Headline", type: "text", default_value: "Stay in the know" },
109
+ {
110
+ slug: "subheadline",
111
+ label: "Subheadline",
112
+ type: "textarea",
113
+ default_value: "Get the latest stories delivered to your inbox."
114
+ },
115
+ { slug: "input_placeholder", label: "Input placeholder", type: "text", default_value: "Enter your email" },
116
+ { slug: "button_text", label: "Button text", type: "text", default_value: "Subscribe" },
117
+ { slug: "privacy_note", label: "Privacy note", type: "text", default_value: "No spam. Unsubscribe anytime." }
118
+ ];
119
+ var ARTICLE_PARAGRAPHS = [
120
+ "The city council voted unanimously Tuesday to approve a sweeping new infrastructure plan that would reshape public transit routes across the metropolitan area over the next decade.",
121
+ "Mayor Jordan Ellison called the decision a turning point for how the region thinks about getting around, noting that similar investments in peer cities have led to measurable reductions in traffic congestion and improved air quality.",
122
+ "The plan includes upgrades to 14 existing bus rapid transit lines, the introduction of three new light-rail corridors connecting the downtown core to outlying neighborhoods, and dedicated cycling infrastructure along six major arterials.",
123
+ "Critics, however, warn that the projected cost of $2.4 billion may be optimistic. Independent analysts suggest the final figure could climb to $3.8 billion once land acquisition, labor, and contingency costs are factored in over the full construction timeline.",
124
+ "Council member Dana Reyes, who voted in favor, acknowledged the fiscal concern but argued that inaction carries its own price, noting that congestion costs residents and businesses hundreds of millions in lost productivity each year.",
125
+ "Environmental groups praised the announcement, saying expanded transit capacity is essential to meet the stated goal of reducing transportation emissions by 45 percent before 2035.",
126
+ "Public comment sessions are scheduled for next month at community centers in all twelve districts. Construction is expected to begin no sooner than mid-next year, pending environmental review and final design approvals."
127
+ ];
128
+ function escapeAttr(s) {
129
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/>/g, "&gt;");
130
+ }
131
+ function sdkScriptTag(sdkUrl) {
132
+ if (!sdkUrl) {
133
+ return "";
134
+ }
135
+ return `<script src="${escapeAttr(sdkUrl)}" data-api-url="https://${new URL(sdkUrl).host}"></script>`;
136
+ }
137
+ function normalizeTenantSdkUrl(raw) {
138
+ const trimmed = raw.trim();
139
+ if (!trimmed) {
140
+ return "";
141
+ }
142
+ const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
143
+ return withProtocol.replace(/\/+$/, "") + "/client.js";
144
+ }
145
+ var COOKIE_NOOP_SCRIPT = `(function(){var _jar={};Object.defineProperty(document,'cookie',{configurable:true,get:function(){return Object.keys(_jar).map(function(k){return k+'='+_jar[k];}).join('; ');},set:function(str){var parts=str.split(';');var kv=parts[0].trim();var eq=kv.indexOf('=');var name=kv.slice(0,eq).trim();var val=kv.slice(eq+1).trim();var maxAge=null;for(var i=1;i<parts.length;i++){var p=parts[i].trim().toLowerCase();if(p.indexOf('max-age=')===0){maxAge=parseInt(p.split('=')[1],10);}}if(maxAge!==null&&maxAge<=0){delete _jar[name];}else{_jar[name]=val;}}});})();`;
146
+ function buildPreviewData(options) {
147
+ const json = JSON.stringify({
148
+ slug: options.slug,
149
+ html: options.html,
150
+ css: options.css,
151
+ js: options.js,
152
+ fieldValues: options.fieldValues,
153
+ tab: options.tab
154
+ });
155
+ return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
156
+ }
157
+ function standaloneShell(options) {
158
+ const cookieScript = options.blockCookies ?? true ? `<script>${COOKIE_NOOP_SCRIPT}</script>` : "";
159
+ return `<!DOCTYPE html>
160
+ <html>
161
+ <head>
162
+ <meta charset="UTF-8">
163
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
164
+ <script>window.__ALLEGRO_PREVIEW__ = true; localStorage.setItem('allegro_debug', 'true');</script>
165
+ ${cookieScript}
166
+ <style>
167
+ * { box-sizing: border-box; }
168
+ body { margin: 0; }
169
+ [data-allegro-interaction] { display: block; }
170
+ </style>
171
+ </head>
172
+ <body>
173
+ <script id="allegro-preview-data" type="application/json">${buildPreviewData(options)}</script>
174
+ ${sdkScriptTag(options.sdkUrl)}
175
+ <script src="/preview-client.js"></script>
176
+ </body>
177
+ </html>`;
178
+ }
179
+ function articleShell(options) {
180
+ const paras = ARTICLE_PARAGRAPHS.map((p) => `<p>${p}</p>`).join("\n ");
181
+ const cookieScript = options.blockCookies ?? true ? `<script>${COOKIE_NOOP_SCRIPT}</script>` : "";
182
+ return `<!DOCTYPE html>
183
+ <html>
184
+ <head>
185
+ <meta charset="UTF-8">
186
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
187
+ <script>window.__ALLEGRO_PREVIEW__ = true; localStorage.setItem('allegro_debug', 'true');</script>
188
+ ${cookieScript}
189
+ <style>
190
+ *, *::before, *::after { box-sizing: border-box; }
191
+ body { font-family: Georgia, 'Times New Roman', serif; background: #fff; color: #111;
192
+ max-width: 680px; margin: 0 auto; padding: 32px 24px 64px; line-height: 1.75;
193
+ -webkit-font-smoothing: antialiased; }
194
+ h1 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 6px; font-weight: 700; }
195
+ .meta { color: #6b7280; font-size: 0.82rem; font-family: system-ui, sans-serif; margin-bottom: 24px; }
196
+ p { margin: 0 0 1.1em; font-size: 1rem; overflow-wrap: break-word; }
197
+ [data-allegro-interaction] { display: block; margin: 24px 0; }
198
+ </style>
199
+ </head>
200
+ <body>
201
+ <script id="allegro-preview-data" type="application/json">${buildPreviewData(options)}</script>
202
+ <header>
203
+ <h1>Council Approves Decade-Long Transit Overhaul</h1>
204
+ <p class="meta">By Staff Reporter &nbsp;&middot;&nbsp; March 14, 2026</p>
205
+ </header>
206
+ <article id="allegro-preview-article">
207
+ ${paras}
208
+ </article>
209
+ ${sdkScriptTag(options.sdkUrl)}
210
+ <script src="/preview-client.js"></script>
211
+ </body>
212
+ </html>`;
213
+ }
214
+ var sseClients = /* @__PURE__ */ new Set();
215
+ function broadcast(event) {
216
+ for (const client of sseClients) {
217
+ client.write(`event: ${event}
218
+ data: {}
219
+
220
+ `);
221
+ }
222
+ }
223
+ function broadcastReload() {
224
+ broadcast("reload");
225
+ }
226
+ function broadcastConfig() {
227
+ broadcast("config");
228
+ }
229
+ function readTemplateMeta(dir) {
230
+ const jsonPath = join(dir, "template.json");
231
+ if (!existsSync(jsonPath)) {
232
+ return { name: basename(dir) };
233
+ }
234
+ try {
235
+ const parsed = JSON.parse(readFileSync(jsonPath, "utf-8"));
236
+ return { name: parsed.name ?? basename(dir) };
237
+ } catch {
238
+ return { name: basename(dir) };
239
+ }
240
+ }
241
+ function isTemplateDir(dir) {
242
+ return existsSync(join(dir, "index.html"));
243
+ }
244
+ function scanTemplates(baseDir) {
245
+ const results = [];
246
+ if (isTemplateDir(baseDir)) {
247
+ const meta = readTemplateMeta(baseDir);
248
+ results.push({ slug: ".", name: meta.name });
249
+ }
250
+ try {
251
+ for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
252
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
253
+ continue;
254
+ }
255
+ const sub = join(baseDir, entry.name);
256
+ if (!isTemplateDir(sub)) {
257
+ continue;
258
+ }
259
+ const meta = readTemplateMeta(sub);
260
+ results.push({ slug: entry.name, name: meta.name });
261
+ }
262
+ } catch {
263
+ }
264
+ return results;
265
+ }
266
+ var templateFilesCache = /* @__PURE__ */ new Map();
267
+ function invalidateTemplateCache(filePath) {
268
+ const dir = filePath.replace(/[/\\][^/\\]+$/, "");
269
+ templateFilesCache.delete(dir);
270
+ }
271
+ function readTemplateFiles(dir) {
272
+ const read = (filename) => {
273
+ const filePath = join(dir, filename);
274
+ return existsSync(filePath) ? readFileSync(filePath, "utf-8") : "";
275
+ };
276
+ let fields = [];
277
+ let name = basename(dir);
278
+ let fieldsParseError = null;
279
+ const jsonPath = join(dir, "template.json");
280
+ if (existsSync(jsonPath)) {
281
+ try {
282
+ const parsed = JSON.parse(readFileSync(jsonPath, "utf-8"));
283
+ fields = parsed.fields ?? [];
284
+ name = parsed.name ?? name;
285
+ } catch (err) {
286
+ fieldsParseError = err instanceof Error ? err.message : "Invalid JSON in template.json";
287
+ }
288
+ }
289
+ const files = {
290
+ html: read("index.html"),
291
+ css: read("index.css"),
292
+ js: read("index.js"),
293
+ fields,
294
+ name,
295
+ fieldsParseError
296
+ };
297
+ templateFilesCache.set(dir, files);
298
+ return files;
299
+ }
300
+ function readTemplateFilesCached(dir) {
301
+ return templateFilesCache.get(dir) ?? readTemplateFiles(dir);
302
+ }
303
+ async function startServer(templatePath, port = 0, tenant = "") {
304
+ const baseDir = resolve(process.cwd(), templatePath);
305
+ if (!existsSync(baseDir)) {
306
+ console.error(`Directory not found: ${baseDir}`);
307
+ process.exit(1);
308
+ }
309
+ let templates = scanTemplates(baseDir);
310
+ const app = express();
311
+ const clientDistDir = join(__dirname, "client");
312
+ app.use(express.static(clientDistDir));
313
+ app.get("/api/config", (_req, res) => {
314
+ res.json({ defaultSlug: templates[0]?.slug ?? null, templates, tenant });
315
+ });
316
+ app.get("/api/template", (req, res) => {
317
+ const slug = req.query.slug ?? ".";
318
+ if (slug !== "." && !templates.some((template) => template.slug === slug)) {
319
+ res.status(404).json({ error: "Template not found" });
320
+ return;
321
+ }
322
+ const targetDir = slug === "." ? baseDir : join(baseDir, slug);
323
+ const resolvedBaseDir = resolve(baseDir);
324
+ const resolvedDir = resolve(targetDir);
325
+ const relativePath = relative(resolvedBaseDir, resolvedDir);
326
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
327
+ res.status(400).json({ error: "Invalid template path" });
328
+ return;
329
+ }
330
+ if (!existsSync(resolvedDir) || !isTemplateDir(resolvedDir)) {
331
+ res.status(404).json({ error: "Template not found" });
332
+ return;
333
+ }
334
+ res.json(readTemplateFilesCached(resolvedDir));
335
+ });
336
+ app.post("/api/template/create", express.json(), (req, res) => {
337
+ const body = req.body;
338
+ const slug = body.slug;
339
+ if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
340
+ res.status(400).json({
341
+ error: "Slug must be lowercase letters, numbers, and hyphens only (e.g. my-template)."
342
+ });
343
+ return;
344
+ }
345
+ const targetDir = join(baseDir, slug);
346
+ if (existsSync(targetDir)) {
347
+ res.status(400).json({ error: `A template named "${slug}" already exists.` });
348
+ return;
349
+ }
350
+ try {
351
+ mkdirSync(targetDir);
352
+ writeFileSync(join(targetDir, "index.html"), BOILERPLATE_HTML);
353
+ writeFileSync(join(targetDir, "index.css"), BOILERPLATE_CSS);
354
+ writeFileSync(
355
+ join(targetDir, "template.json"),
356
+ JSON.stringify({ name: slug, fields: BOILERPLATE_FIELDS }, null, 2) + "\n"
357
+ );
358
+ } catch (err) {
359
+ console.error("[allegro-preview] Failed to create template:", err);
360
+ res.status(500).json({ error: "Failed to create template files." });
361
+ return;
362
+ }
363
+ rescan();
364
+ res.status(201).json({ slug });
365
+ });
366
+ app.get("/api/events", (req, res) => {
367
+ res.setHeader("Content-Type", "text/event-stream");
368
+ res.setHeader("Cache-Control", "no-cache");
369
+ res.setHeader("Connection", "keep-alive");
370
+ res.flushHeaders();
371
+ sseClients.add(res);
372
+ req.on("close", () => {
373
+ sseClients.delete(res);
374
+ });
375
+ });
376
+ app.get("/preview", (req, res) => {
377
+ const slug = typeof req.query.slug === "string" ? req.query.slug : "";
378
+ const tab = typeof req.query.tab === "string" ? req.query.tab : "standalone";
379
+ const sdkParam = typeof req.query.sdk === "string" ? req.query.sdk : void 0;
380
+ if (!slug) {
381
+ res.status(400).send("Missing slug");
382
+ return;
383
+ }
384
+ if (slug !== "." && !templates.some((t) => t.slug === slug)) {
385
+ res.status(404).send("Template not found");
386
+ return;
387
+ }
388
+ const targetDir = slug === "." ? baseDir : join(baseDir, slug);
389
+ const resolvedBaseDir = resolve(baseDir);
390
+ const resolvedDir = resolve(targetDir);
391
+ const relativePath = relative(resolvedBaseDir, resolvedDir);
392
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
393
+ res.status(400).send("Invalid template path");
394
+ return;
395
+ }
396
+ if (!existsSync(resolvedDir) || !isTemplateDir(resolvedDir)) {
397
+ res.status(404).send("Template not found");
398
+ return;
399
+ }
400
+ const templateFiles = readTemplateFilesCached(resolvedDir);
401
+ const fieldValues = [];
402
+ for (const [key, val] of Object.entries(req.query)) {
403
+ if (key.startsWith("f_") && typeof val === "string") {
404
+ fieldValues.push({ slug: key.slice(2), value: val });
405
+ }
406
+ }
407
+ const effectiveSdkUrl = sdkParam ?? (tenant ? normalizeTenantSdkUrl(tenant) : void 0);
408
+ const blockCookies = req.query.blockCookies !== "false";
409
+ const shellOptions = {
410
+ slug,
411
+ html: templateFiles.html,
412
+ css: templateFiles.css,
413
+ js: templateFiles.js,
414
+ fieldValues,
415
+ tab,
416
+ sdkUrl: effectiveSdkUrl,
417
+ blockCookies
418
+ };
419
+ const rendered = tab === "standalone" ? standaloneShell(shellOptions) : articleShell(shellOptions);
420
+ res.setHeader("Cache-Control", "no-store");
421
+ res.type("html").send(rendered);
422
+ });
423
+ const indexHtmlPath = join(clientDistDir, "index.html");
424
+ app.get("/*path", (_req, res) => {
425
+ if (existsSync(indexHtmlPath)) {
426
+ res.type("html").send(readFileSync(indexHtmlPath));
427
+ } else {
428
+ res.status(503).send(
429
+ 'Preview client build artifacts not found. Ensure the client is built (for example, by running "npm run build" in this package during development).'
430
+ );
431
+ }
432
+ });
433
+ const TEMPLATE_FILES = ["index.html", "index.css", "index.js", "template.json"];
434
+ function templateFilePaths(dir) {
435
+ return TEMPLATE_FILES.map((f) => join(dir, f));
436
+ }
437
+ const fileWatcher = chokidar.watch(
438
+ templates.flatMap((t) => templateFilePaths(t.slug === "." ? baseDir : join(baseDir, t.slug))),
439
+ { ignoreInitial: true }
440
+ );
441
+ fileWatcher.on("change", (changedPath) => {
442
+ console.log(`Changed: ${changedPath}`);
443
+ invalidateTemplateCache(changedPath);
444
+ if (basename(changedPath) === "template.json") {
445
+ rescan();
446
+ }
447
+ broadcastReload();
448
+ });
449
+ fileWatcher.on("add", (addedPath) => {
450
+ console.log(`Added: ${addedPath}`);
451
+ invalidateTemplateCache(addedPath);
452
+ if (basename(addedPath) === "index.html") {
453
+ rescan();
454
+ }
455
+ broadcastReload();
456
+ });
457
+ const dirWatcher = chokidar.watch(baseDir, { depth: 1, ignoreInitial: true });
458
+ function rescan() {
459
+ templates = scanTemplates(baseDir);
460
+ console.log(`Templates updated: ${templates.map((t) => t.slug).join(", ") || "(none)"}`);
461
+ broadcastConfig();
462
+ }
463
+ dirWatcher.on("addDir", (dirPath) => {
464
+ if (dirPath === baseDir) {
465
+ return;
466
+ }
467
+ setTimeout(() => {
468
+ rescan();
469
+ fileWatcher.add(templateFilePaths(dirPath));
470
+ }, 300);
471
+ });
472
+ dirWatcher.on("unlinkDir", (dirPath) => {
473
+ if (dirPath === baseDir) {
474
+ return;
475
+ }
476
+ rescan();
477
+ });
478
+ dirWatcher.on("add", (filePath) => {
479
+ if (basename(filePath) === "index.html") {
480
+ rescan();
481
+ fileWatcher.add(templateFilePaths(join(filePath, "..")));
482
+ }
483
+ });
484
+ const bindServer = (p) => new Promise((resolve2, reject) => {
485
+ const server = app.listen(p);
486
+ server.once("listening", () => {
487
+ const addr = server.address();
488
+ const assignedPort2 = typeof addr === "object" && addr ? addr.port : p;
489
+ resolve2({ server, assignedPort: assignedPort2 });
490
+ });
491
+ server.once("error", reject);
492
+ });
493
+ let assignedPort;
494
+ try {
495
+ ({ assignedPort } = await bindServer(port));
496
+ } catch (err) {
497
+ const nodeErr = err;
498
+ if (nodeErr.code === "EADDRINUSE" && port !== 0) {
499
+ console.log(`Port ${port} is in use, using a random available port instead.`);
500
+ ({ assignedPort } = await bindServer(0));
501
+ } else {
502
+ throw err;
503
+ }
504
+ }
505
+ const url = `http://localhost:${assignedPort}`;
506
+ console.log(`
507
+ allegro-preview \u2192 ${url}
508
+ `);
509
+ void open(url);
510
+ }
511
+
512
+ // src/cli.ts
513
+ var program = new Command();
514
+ program.name("allegro-preview").description("Local dev server for previewing Allegro templates").argument("[path]", "Path to the template folder", process.cwd()).option("-p, --port <number>", "Port to listen on (default: 4747, falls back to random if taken)", "4747").option("--tenant <url>", "Tenant base URL to pre-fill (overrides ALLEGRO_TENANT env var)").action(async (templatePath, options) => {
515
+ const port = parseInt(options.port, 10);
516
+ const tenant = options.tenant ?? process.env.ALLEGRO_TENANT ?? "";
517
+ await startServer(templatePath, port, tenant);
518
+ });
519
+ program.parse();
@@ -0,0 +1,14 @@
1
+ (function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))l(a);new MutationObserver(a=>{for(const s of a)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&l(o)}).observe(document,{childList:!0,subtree:!0});function n(a){const s={};return a.integrity&&(s.integrity=a.integrity),a.referrerPolicy&&(s.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?s.credentials="include":a.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function l(a){if(a.ep)return;a.ep=!0;const s=n(a);fetch(a.href,s)}})();const te={full:0,desktop:1280,tablet:768,mobile:390};function ne(e){return te[e]}let u=[],d=null,N="standalone",y="full",L=null,E={},H,F=!0,T=null,f=null,h=null;const q=document.getElementById("template-nav"),g=document.getElementById("field-list"),m=document.getElementById("preview-area"),$=document.getElementById("tab-group"),M=document.getElementById("device-group"),j=document.getElementById("toolbar-badge"),le=document.getElementById("theme-toggle"),oe=document.getElementById("theme-icon-sun"),ae=document.getElementById("theme-icon-moon"),B=document.getElementById("tenant-input"),x=document.getElementById("tenant-status"),se=document.getElementById("new-template-btn"),J=document.getElementById("new-window-btn"),k=document.getElementById("create-modal-backdrop"),c=document.getElementById("modal-slug-input"),v=document.getElementById("modal-error"),ie=document.getElementById("modal-cancel-btn"),w=document.getElementById("modal-create-btn"),P=document.getElementById("block-cookies-checkbox"),V=document.getElementById("cookie-auth-area"),S=document.getElementById("cookie-auth-user"),R=document.getElementById("cookie-auth-hint"),ce=document.getElementById("cookie-clear-btn"),b=document.getElementById("jwt-modal-backdrop"),re=document.getElementById("jwt-payload-content"),de=document.getElementById("jwt-modal-close-btn");function W(e){document.documentElement.setAttribute("data-theme",e?"dark":"light"),oe.style.display=e?"none":"",ae.style.display=e?"":"none"}function ue(){const e=localStorage.getItem("allegro-preview-theme"),t=window.matchMedia("(prefers-color-scheme: dark)").matches;W(e?e==="dark":t)}le.addEventListener("click",()=>{const e=document.documentElement.getAttribute("data-theme")!=="dark";W(e),localStorage.setItem("allegro-preview-theme",e?"dark":"light")});function me(e){const t=e.trim();return t?(/^https?:\/\//.test(t)?t:`https://${t}`).replace(/\/+$/,"")+"/client.js":""}function G(e){const t=me(e);H=t||void 0,t?(x.className="tenant-active-badge",x.textContent=t):(x.className="tenant-hint",x.textContent="Loads /client.js on the preview page"),L&&p()}B.addEventListener("input",()=>{localStorage.setItem("allegro-preview-tenant",B.value),G(B.value)});function pe(e=""){const t=localStorage.getItem("allegro-preview-tenant"),n=e||t||"";n&&(B.value=n,e&&localStorage.setItem("allegro-preview-tenant",n),G(n))}const fe=[{name:"_ALLEGROT",label:"Session JWT",isJwt:!0},{name:"_ALLEGROD",label:"Device ID"},{name:"_ALLEGROS",label:"Session ID"}];function ge(e){const t=document.cookie.split("; ").find(n=>n.startsWith(e+"="));if(!t)return null;try{return decodeURIComponent(t.split("=").slice(1).join("="))}catch{return t.split("=").slice(1).join("=")}}function ve(e){try{const t=e.split(".");if(t.length!==3)return null;const n=t[1].replace(/-/g,"+").replace(/_/g,"/"),l=atob(n);return JSON.parse(l)}catch{return null}}function C(){const e=fe.map(t=>({...t,value:ge(t.name)})).filter(t=>t.value!==null);if(S.innerHTML="",e.length===0){S.style.display="none",R.style.display="";return}R.style.display="none",S.style.display="";for(const t of e){const n=document.createElement("div");n.className="cookie-row";const l=document.createElement("div");l.className="cookie-auth-dot";const a=document.createElement("div");a.className="cookie-row-info";const s=document.createElement("div");s.className="cookie-row-label",s.textContent=t.label;const o=document.createElement("div");if(o.className="cookie-row-value",o.textContent=t.value.length>24?t.value.slice(0,24)+"…":t.value,a.appendChild(s),a.appendChild(o),n.appendChild(l),n.appendChild(a),t.isJwt){const i=document.createElement("button");i.className="cookie-view-btn",i.textContent="Decode",i.onclick=()=>{const r=ve(t.value);re.textContent=r?JSON.stringify(r,null,2):t.value,b.classList.remove("hidden")},n.appendChild(i)}S.appendChild(n)}}function he(e){F=e,localStorage.setItem("allegro-preview-block-cookies",e?"true":"false"),V.style.display=e?"none":"",e?h&&(clearInterval(h),h=null):(C(),h||(h=setInterval(C,2e3))),L&&p()}function ye(){const t=localStorage.getItem("allegro-preview-block-cookies")!=="false";P.checked=t,F=t,V.style.display=t?"none":"",t||(C(),h=setInterval(C,2e3))}P.addEventListener("change",()=>{he(P.checked)});ce.addEventListener("click",()=>{const e=document.cookie.split("; ");for(const t of e){const n=t.split("=")[0];document.cookie=`${n}=; max-age=0; path=/`}C()});de.addEventListener("click",()=>{b.classList.add("hidden")});b.addEventListener("click",e=>{e.target===b&&b.classList.add("hidden")});function Ee(){const e=window.location.hash.slice(1);return e?decodeURIComponent(e):null}function we(e){history.replaceState(null,"",`#${encodeURIComponent(e)}`)}function be(){history.replaceState(null,"",window.location.pathname)}function z(e){$.querySelectorAll(".tab-btn").forEach(t=>{t.disabled=!e}),M.querySelectorAll(".device-btn").forEach(t=>{t.disabled=!e}),J.disabled=!e}$.addEventListener("click",e=>{const t=e.target.closest(".tab-btn");!t||t.disabled||(N=t.dataset.tab,$.querySelectorAll(".tab-btn").forEach(n=>n.classList.remove("active")),t.classList.add("active"),localStorage.setItem("allegro-preview-tab",N),p())});M.addEventListener("click",e=>{const t=e.target.closest(".device-btn");!t||t.disabled||(y=t.dataset.device,M.querySelectorAll(".device-btn").forEach(n=>n.classList.remove("active")),t.classList.add("active"),localStorage.setItem("allegro-preview-device",y),p())});function I(){q.innerHTML="";for(const e of u){const t=document.createElement("button");t.className="template-nav-item"+(e.slug===d?" active":""),t.innerHTML=`<span class="template-nav-dot"></span><span class="template-nav-name">${_(e.name)}</span>`,t.addEventListener("click",()=>void D(e.slug)),q.appendChild(t)}}async function D(e){d=e,we(e),I(),await U(e)}function K(){L=null,d=null,T=null,f=null,be(),I(),z(!1),j.textContent="No template selected",j.style.opacity="0.4",g.innerHTML='<p class="no-fields">Select a template to edit fields.</p>';const e=document.createElement("div");if(e.className="selector-screen",u.length===0){e.innerHTML=`
2
+ <div class="selector-empty">
3
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
4
+ <span>No templates found in this directory.</span>
5
+ </div>`,m.innerHTML="",m.appendChild(e);return}e.innerHTML=`
6
+ <div class="selector-eyebrow">Allegro Preview</div>
7
+ <h2 class="selector-heading">Choose a template</h2>
8
+ <p class="selector-sub">Select a template to start previewing.</p>
9
+ <div class="selector-grid" id="selector-grid"></div>`,m.innerHTML="",m.appendChild(e);const t=e.querySelector("#selector-grid");for(const n of u){const l=document.createElement("button");l.className="selector-card",l.innerHTML=`
10
+ <div class="selector-card-icon">
11
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
12
+ </div>
13
+ <div class="selector-card-name">${_(n.name)}</div>
14
+ <div class="selector-card-slug">${_(n.slug)}</div>`,l.addEventListener("click",()=>void D(n.slug)),t.appendChild(l)}}const O=["text","number","checkbox","textarea"];function Y(e){if(typeof e!="object"||e===null)return!1;const t=e;return typeof t.slug=="string"&&t.slug.length>0&&O.includes(t.type)}function ke(e){if(typeof e!="object"||e===null)return`Invalid field: expected an object, got ${JSON.stringify(e)}.`;const t=e,n=[];if((typeof t.slug!="string"||t.slug.length===0)&&n.push('missing required "slug" property'),!O.includes(t.type)){const a=t.type!==void 0?`"${String(t.type)}"`:"none";n.push(`invalid type ${a} — valid types: ${O.join(", ")}`)}return`${typeof t.slug=="string"&&t.slug.length>0?`Field "${t.slug}"`:"Field"}: ${n.join("; ")}.`}function Ce(e){E={};for(const t of e)Y(t)&&(E[t.slug]=t.default_value??"")}function Le(e,t){if(g.innerHTML="",t){const n=document.createElement("div");n.className="field-error",n.textContent=`template.json parse error: ${t}`,g.appendChild(n);return}if(e.length===0){g.innerHTML='<p class="no-fields">No fields in template.json.</p>';return}for(const n of e){if(!Y(n)){const o=document.createElement("div");o.className="field-error",o.textContent=ke(n),g.appendChild(o);continue}const l=n;if(l.type==="checkbox"){const o=document.createElement("div");o.className="field-checkbox-row";const i=document.createElement("input");i.type="checkbox",i.id=`field-${l.slug}`,i.checked=l.default_value==="true"||l.default_value==="1",i.addEventListener("change",()=>{E[l.slug]=i.checked?"true":"false",p()});const r=document.createElement("label");r.className="field-label",r.htmlFor=`field-${l.slug}`,r.textContent=l.label||l.slug,o.appendChild(i),o.appendChild(r),g.appendChild(o);continue}const a=document.createElement("div");a.className="field-group";const s=document.createElement("label");if(s.className="field-label",s.htmlFor=`field-${l.slug}`,s.textContent=l.label||l.slug,a.appendChild(s),l.type==="textarea"){const o=document.createElement("textarea");o.className="field-input",o.id=`field-${l.slug}`,o.rows=3,o.value=l.default_value??"",o.addEventListener("input",()=>{E[l.slug]=o.value,p()}),a.appendChild(o)}else{const o=document.createElement("input");o.type=l.type==="number"?"number":"text",o.className="field-input",o.id=`field-${l.slug}`,o.value=l.default_value??"",o.addEventListener("input",()=>{E[l.slug]=o.value,p()}),a.appendChild(o)}g.appendChild(a)}}function Ie(){const e=new URLSearchParams;e.set("slug",d),e.set("tab",N),H&&e.set("sdk",H),F||e.set("blockCookies","false");for(const[t,n]of Object.entries(E))e.set(`f_${t}`,n);return`/preview?${e.toString()}`}function p(){if(!L||!d)return;const e=Ie();T=e;const t=ne(y),n=t===0;if(f){const i=f.parentElement,r=i.parentElement,ee=r.parentElement;ee.className=n?"preview-stage":"preview-stage preview-stage--breakpoint",r.style.width=n?"100%":"",i.style.width=n?"100%":`${t}px`,f.style.width=n?"100%":`${t}px`,f.style.maxHeight=y==="mobile"?"812px":"",f.src=e;return}m.innerHTML="";const l=document.createElement("div");l.className=n?"preview-stage":"preview-stage preview-stage--breakpoint";const a=document.createElement("div");a.className="preview-scaler";const s=document.createElement("div");s.className="preview-frame-wrapper";const o=document.createElement("iframe");o.style.height="calc(100vh - 48px)",n?(a.style.width="100%",s.style.width="100%",o.style.width="100%"):(s.style.width=`${t}px`,o.style.width=`${t}px`,y==="mobile"&&(o.style.maxHeight="812px")),o.src=e,s.appendChild(o),a.appendChild(s),l.appendChild(a),m.appendChild(l),f=o}async function U(e){try{const t=await fetch(`/api/template?slug=${encodeURIComponent(e)}`);if(!t.ok)throw new Error(`HTTP ${t.status}`);const n=await t.json();L=n,j.textContent=`Template: ${n.name}`,j.style.opacity="1",z(!0),Ce(n.fields),Le(n.fields,n.fieldsParseError),p()}catch(t){console.error("[allegro-preview] Failed to load template:",t),m.innerHTML="";const n=document.createElement("div");n.style.padding="40px",n.style.color="var(--text-muted)",n.style.fontSize="13px",n.textContent=`Failed to load template "${e}".`,m.appendChild(n)}}async function xe(){const t=await(await fetch("/api/config")).json();u=t.templates,pe(t.tenant??"");const n=Ee(),l=n&&u.find(a=>a.slug===n)?n:null;l?(d=l,I(),await U(l)):K()}function Se(){const e=localStorage.getItem("allegro-preview-tab"),t=localStorage.getItem("allegro-preview-device");e&&["standalone","append","cut"].includes(e)&&(N=e,$.querySelectorAll(".tab-btn").forEach(n=>{n.classList.toggle("active",n.dataset.tab===e)})),t&&["full","desktop","tablet","mobile"].includes(t)&&(y=t,M.querySelectorAll(".device-btn").forEach(n=>{n.classList.toggle("active",n.dataset.device===t)}))}function Q(){const e=new EventSource("/api/events");e.addEventListener("reload",()=>{d&&U(d)}),e.addEventListener("config",()=>{Be()}),e.onerror=()=>{e.close(),setTimeout(Q,2e3)}}async function Be(){u=(await(await fetch("/api/config")).json()).templates,I(),d&&!u.find(n=>n.slug===d)&&K()}function _(e){return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}const Ne=/^[a-z0-9]+(?:-[a-z0-9]+)*$/;function Te(){c.value="",v.textContent="",c.classList.remove("invalid"),w.disabled=!1,k.classList.remove("hidden"),c.focus()}function A(){k.classList.add("hidden")}function X(e){return e?Ne.test(e)?null:"Use lowercase letters, numbers, and hyphens only (e.g. my-template).":"Slug is required."}J.addEventListener("click",()=>{T&&window.open(T,"_blank")});se.addEventListener("click",Te);ie.addEventListener("click",A);k.addEventListener("click",e=>{e.target===k&&A()});document.addEventListener("keydown",e=>{e.key==="Escape"&&!k.classList.contains("hidden")&&A()});c.addEventListener("input",()=>{const e=X(c.value);e?(c.classList.add("invalid"),v.textContent=e):(c.classList.remove("invalid"),v.textContent="")});c.addEventListener("keydown",e=>{e.key==="Enter"&&Z()});w.addEventListener("click",()=>void Z());async function Z(){const e=c.value.trim(),t=X(e);if(t){c.classList.add("invalid"),v.textContent=t,c.focus();return}w.disabled=!0,v.textContent="";try{const n=await fetch("/api/template/create",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({slug:e})}),l=await n.json();if(!n.ok){v.textContent=l.error??"Failed to create template.",w.disabled=!1;return}A(),u=[...u,{slug:e,name:e}],I(),await D(e)}catch{v.textContent="Network error. Please try again.",w.disabled=!1}}ue();Se();ye();xe();Q();