@allegrocdp/preview 0.1.0-dev.12 → 0.1.0-dev.14

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 CHANGED
@@ -58,13 +58,47 @@ my-template/
58
58
 
59
59
  Supported field types: `text`, `number`, `checkbox`, `textarea`.
60
60
 
61
- ## Preview contexts
61
+ ## Scenarios
62
+
63
+ `allegro-preview` ships three built-in scenarios:
62
64
 
63
65
  | Tab | Description |
64
66
  |-----|-------------|
65
- | **Standalone** | Template rendered on its own in an iframe |
67
+ | **Standalone** | Template rendered on its own in an isolated iframe |
68
+ | **Article · Append** | Template injected after the full content of a mock article |
66
69
  | **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 |
70
+
71
+ ### Custom scenarios
72
+
73
+ Define your own scenarios by creating an `.allegro-preview/` folder at the root of your templates directory, with one subfolder per scenario:
74
+
75
+ ```
76
+ .allegro-preview/
77
+ ├── paywalled-article/
78
+ │ ├── scenario.json # required: name + placement config
79
+ │ ├── host.html # optional: your mock host page markup
80
+ │ └── host.css # optional: host page styles
81
+ └── homepage-rail/
82
+ ├── scenario.json
83
+ └── host.html
84
+ ```
85
+
86
+ Each `scenario.json` specifies the scenario name and placement parameters:
87
+
88
+ ```json
89
+ {
90
+ "name": "Paywalled Article",
91
+ "target_selector": "#article-body",
92
+ "placement_method": "truncate",
93
+ "truncation_unit": "paragraphs",
94
+ "truncation_count": 4,
95
+ "truncation_style": "cut"
96
+ }
97
+ ```
98
+
99
+ When `host.html` is present, the template is injected into `target_selector` using `placement_method`. Without `host.html`, the template renders standalone. Custom scenarios appear as additional tabs alongside the built-in ones and live-reload when edited.
100
+
101
+ > **Note:** The `.allegro-preview/` folder is ignored by the GitHub template sync — it never becomes a template and never produces a sync warning.
68
102
 
69
103
  ## Device breakpoints
70
104
 
package/dist/cli.js CHANGED
@@ -27,6 +27,58 @@ window.__ALLEGRO_TEMPLATE_RESOLVER__ = async function (slug) {
27
27
  }
28
28
  };`;
29
29
 
30
+ // src/scenarios.ts
31
+ function emptyPlacement() {
32
+ return {
33
+ target_selector: null,
34
+ placement_method: null,
35
+ truncation_unit: null,
36
+ truncation_count: null,
37
+ truncation_style: null
38
+ };
39
+ }
40
+ var ARTICLE_TARGET_SELECTOR = "#allegro-preview-article";
41
+ var BUILT_IN_SCENARIOS = [
42
+ {
43
+ id: "standalone",
44
+ name: "Standalone",
45
+ builtIn: true,
46
+ hostKind: "none",
47
+ placement: emptyPlacement()
48
+ },
49
+ {
50
+ id: "append",
51
+ name: "Article \xB7 Append",
52
+ builtIn: true,
53
+ hostKind: "article",
54
+ placement: {
55
+ target_selector: ARTICLE_TARGET_SELECTOR,
56
+ placement_method: "append",
57
+ truncation_unit: null,
58
+ truncation_count: null,
59
+ truncation_style: null
60
+ }
61
+ },
62
+ {
63
+ id: "cut",
64
+ name: "Article \xB7 Cut",
65
+ builtIn: true,
66
+ hostKind: "article",
67
+ placement: {
68
+ target_selector: ARTICLE_TARGET_SELECTOR,
69
+ placement_method: "truncate",
70
+ truncation_unit: "paragraphs",
71
+ truncation_count: 3,
72
+ truncation_style: "cut"
73
+ }
74
+ }
75
+ ];
76
+
77
+ // src/slugify.ts
78
+ function slugify(name) {
79
+ return name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
80
+ }
81
+
30
82
  // src/server.ts
31
83
  var __dirname = fileURLToPath(new URL(".", import.meta.url));
32
84
  var BOILERPLATE_HTML = `<div class="signup">
@@ -127,7 +179,7 @@ var BOILERPLATE_FIELDS = [
127
179
  {
128
180
  slug: "subheadline",
129
181
  label: "Subheadline",
130
- type: "textarea",
182
+ type: "text",
131
183
  default_value: "Get the latest stories delivered to your inbox."
132
184
  },
133
185
  { slug: "input_placeholder", label: "Input placeholder", type: "text", default_value: "Enter your email" },
@@ -222,7 +274,8 @@ function buildPreviewData(options) {
222
274
  css: options.css,
223
275
  js: options.js,
224
276
  fieldValues: options.fieldValues,
225
- tab: options.tab
277
+ tab: options.scenario.id,
278
+ placement: options.scenario.placement
226
279
  });
227
280
  return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
228
281
  }
@@ -288,6 +341,33 @@ function articleShell(options) {
288
341
  </body>
289
342
  </html>`;
290
343
  }
344
+ function customHostShell(options) {
345
+ const cookieScript = options.blockCookies ?? true ? `<script>${COOKIE_NOOP_SCRIPT}</script>` : "";
346
+ const hostCss = options.scenario.hostCss ? `<style>${options.scenario.hostCss}</style>` : "";
347
+ const hostHtml = options.scenario.hostHtml ?? "";
348
+ return `<!DOCTYPE html>
349
+ <html>
350
+ <head>
351
+ <meta charset="UTF-8">
352
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
353
+ <script>${PREVIEW_BOOTSTRAP_SCRIPT}</script>
354
+ ${cookieScript}
355
+ <style>
356
+ *, *::before, *::after { box-sizing: border-box; }
357
+ body { margin: 0; }
358
+ [data-allegro-interaction] { display: block; }
359
+ </style>
360
+ ${previewCssTag(options.previewCss)}
361
+ ${hostCss}
362
+ </head>
363
+ <body>
364
+ <script id="allegro-preview-data" type="application/json">${buildPreviewData(options)}</script>
365
+ ${hostHtml}
366
+ ${sdkScriptTag(options.sdkUrl)}
367
+ <script src="/preview-client.js"></script>
368
+ </body>
369
+ </html>`;
370
+ }
291
371
  var sseClients = /* @__PURE__ */ new Set();
292
372
  function broadcast(event) {
293
373
  for (const client of sseClients) {
@@ -310,7 +390,9 @@ function readTemplateMeta(dir) {
310
390
  }
311
391
  try {
312
392
  const parsed = JSON.parse(readFileSync(jsonPath, "utf-8"));
313
- return { name: parsed.name ?? basename(dir) };
393
+ return {
394
+ name: typeof parsed.name === "string" && parsed.name.trim() !== "" ? parsed.name : basename(dir)
395
+ };
314
396
  } catch {
315
397
  return { name: basename(dir) };
316
398
  }
@@ -377,6 +459,109 @@ function readTemplateFiles(dir) {
377
459
  function readTemplateFilesCached(dir) {
378
460
  return templateFilesCache.get(dir) ?? readTemplateFiles(dir);
379
461
  }
462
+ var SCENARIOS_DIRNAME = ".allegro-preview";
463
+ function parsePlacement(raw) {
464
+ const placement = emptyPlacement();
465
+ if (typeof raw.target_selector === "string") {
466
+ placement.target_selector = raw.target_selector;
467
+ }
468
+ if (raw.placement_method === "truncate" || raw.placement_method === "append") {
469
+ placement.placement_method = raw.placement_method;
470
+ }
471
+ if (raw.truncation_unit === "paragraphs") {
472
+ placement.truncation_unit = raw.truncation_unit;
473
+ }
474
+ if (typeof raw.truncation_count === "number" && Number.isFinite(raw.truncation_count)) {
475
+ placement.truncation_count = raw.truncation_count;
476
+ }
477
+ if (raw.truncation_style === "cut") {
478
+ placement.truncation_style = raw.truncation_style;
479
+ }
480
+ return placement;
481
+ }
482
+ function readCustomScenario(scenariosDir, id) {
483
+ const dir = join(scenariosDir, id);
484
+ const jsonPath = join(dir, "scenario.json");
485
+ const base = {
486
+ id,
487
+ name: id,
488
+ builtIn: false,
489
+ hostKind: "none",
490
+ placement: emptyPlacement(),
491
+ parseError: null
492
+ };
493
+ let raw;
494
+ try {
495
+ raw = JSON.parse(readFileSync(jsonPath, "utf-8"));
496
+ } catch (err) {
497
+ base.parseError = err instanceof Error ? err.message : "Invalid JSON in scenario.json";
498
+ return base;
499
+ }
500
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
501
+ base.parseError = "scenario.json must be a JSON object";
502
+ return base;
503
+ }
504
+ if (typeof raw.name === "string" && raw.name.length > 0) {
505
+ base.name = raw.name;
506
+ }
507
+ base.placement = parsePlacement(raw);
508
+ const hostHtmlPath = join(dir, "host.html");
509
+ if (existsSync(hostHtmlPath)) {
510
+ try {
511
+ base.hostKind = "custom";
512
+ base.hostHtml = readFileSync(hostHtmlPath, "utf-8");
513
+ const hostCssPath = join(dir, "host.css");
514
+ base.hostCss = existsSync(hostCssPath) ? readFileSync(hostCssPath, "utf-8") : "";
515
+ } catch (err) {
516
+ base.parseError = err instanceof Error ? err.message : "Failed to read scenario host files";
517
+ return base;
518
+ }
519
+ }
520
+ return base;
521
+ }
522
+ function scanCustomScenarios(baseDir) {
523
+ const scenariosDir = join(baseDir, SCENARIOS_DIRNAME);
524
+ if (!existsSync(scenariosDir)) {
525
+ return [];
526
+ }
527
+ const results = [];
528
+ try {
529
+ for (const entry of readdirSync(scenariosDir, { withFileTypes: true })) {
530
+ if (!entry.isDirectory()) {
531
+ continue;
532
+ }
533
+ if (!existsSync(join(scenariosDir, entry.name, "scenario.json"))) {
534
+ continue;
535
+ }
536
+ const scenario = readCustomScenario(scenariosDir, entry.name);
537
+ if (scenario.parseError) {
538
+ console.warn(`[allegro-preview] Skipping scenario "${entry.name}": ${scenario.parseError}`);
539
+ continue;
540
+ }
541
+ results.push(scenario);
542
+ }
543
+ } catch {
544
+ }
545
+ return results;
546
+ }
547
+ function listScenarioSummaries(customScenarios) {
548
+ return [
549
+ ...BUILT_IN_SCENARIOS.map((s) => ({ id: s.id, name: s.name })),
550
+ ...customScenarios.map((s) => ({ id: s.id, name: s.name }))
551
+ ];
552
+ }
553
+ function resolveScenario(baseDir, id) {
554
+ const builtIn = BUILT_IN_SCENARIOS.find((s) => s.id === id);
555
+ if (builtIn) {
556
+ return builtIn;
557
+ }
558
+ const scenariosDir = join(baseDir, SCENARIOS_DIRNAME);
559
+ if (existsSync(join(scenariosDir, id, "scenario.json"))) {
560
+ const scenario = readCustomScenario(scenariosDir, id);
561
+ return scenario.parseError ? null : scenario;
562
+ }
563
+ return null;
564
+ }
380
565
  async function startServer(templatePath, port = 0, tenant = "") {
381
566
  const baseDir = resolve(process.cwd(), templatePath);
382
567
  if (!existsSync(baseDir)) {
@@ -384,11 +569,17 @@ async function startServer(templatePath, port = 0, tenant = "") {
384
569
  process.exit(1);
385
570
  }
386
571
  let templates = scanTemplates(baseDir);
572
+ let customScenarios = scanCustomScenarios(baseDir);
387
573
  const app = express();
388
574
  const clientDistDir = join(__dirname, "client");
389
575
  app.use(express.static(clientDistDir));
390
576
  app.get("/api/config", (_req, res) => {
391
- res.json({ defaultSlug: templates[0]?.slug ?? null, templates, tenant });
577
+ res.json({
578
+ defaultSlug: templates[0]?.slug ?? null,
579
+ templates,
580
+ tenant,
581
+ scenarios: listScenarioSummaries(customScenarios)
582
+ });
392
583
  });
393
584
  app.get("/api/template", (req, res) => {
394
585
  const slug = req.query.slug ?? ".";
@@ -412,16 +603,28 @@ async function startServer(templatePath, port = 0, tenant = "") {
412
603
  });
413
604
  app.post("/api/template/create", express.json(), (req, res) => {
414
605
  const body = req.body;
415
- const slug = body.slug;
416
- if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
417
- res.status(400).json({
418
- error: "Slug must be lowercase letters, numbers, and hyphens only (e.g. my-template)."
419
- });
606
+ const name = typeof body.name === "string" ? body.name.trim() : "";
607
+ if (name === "") {
608
+ res.status(400).json({ error: "Template name is required." });
609
+ return;
610
+ }
611
+ const slug = slugify(name);
612
+ if (slug === "") {
613
+ res.status(400).json({ error: "Template name must contain at least one letter or number." });
614
+ return;
615
+ }
616
+ const existing = scanTemplates(baseDir);
617
+ if (existing.some((t) => t.slug === slug)) {
618
+ res.status(400).json({ error: `A template with the slug "${slug}" already exists.` });
619
+ return;
620
+ }
621
+ if (existing.some((t) => t.name.toLowerCase() === name.toLowerCase())) {
622
+ res.status(400).json({ error: `A template named "${name}" already exists.` });
420
623
  return;
421
624
  }
422
625
  const targetDir = join(baseDir, slug);
423
626
  if (existsSync(targetDir)) {
424
- res.status(400).json({ error: `A template named "${slug}" already exists.` });
627
+ res.status(400).json({ error: `A template with the slug "${slug}" already exists.` });
425
628
  return;
426
629
  }
427
630
  try {
@@ -430,7 +633,15 @@ async function startServer(templatePath, port = 0, tenant = "") {
430
633
  writeFileSync(join(targetDir, "index.css"), BOILERPLATE_CSS);
431
634
  writeFileSync(
432
635
  join(targetDir, "template.json"),
433
- JSON.stringify({ name: slug, fields: BOILERPLATE_FIELDS }, null, 2) + "\n"
636
+ JSON.stringify(
637
+ {
638
+ $schema: "https://docs.allegrocdp.com/template.schema.json",
639
+ name,
640
+ fields: BOILERPLATE_FIELDS
641
+ },
642
+ null,
643
+ 2
644
+ ) + "\n"
434
645
  );
435
646
  } catch (err) {
436
647
  console.error("[allegro-preview] Failed to create template:", err);
@@ -438,7 +649,7 @@ async function startServer(templatePath, port = 0, tenant = "") {
438
649
  return;
439
650
  }
440
651
  rescan();
441
- res.status(201).json({ slug });
652
+ res.status(201).json({ slug, name });
442
653
  });
443
654
  app.get("/api/events", (req, res) => {
444
655
  res.setHeader("Content-Type", "text/event-stream");
@@ -454,6 +665,15 @@ async function startServer(templatePath, port = 0, tenant = "") {
454
665
  const slug = typeof req.query.slug === "string" ? req.query.slug : "";
455
666
  const tab = typeof req.query.tab === "string" ? req.query.tab : "standalone";
456
667
  const sdkParam = typeof req.query.sdk === "string" ? req.query.sdk : void 0;
668
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(tab)) {
669
+ res.status(404).send("Scenario not found");
670
+ return;
671
+ }
672
+ const scenario = resolveScenario(baseDir, tab);
673
+ if (!scenario) {
674
+ res.status(404).send("Scenario not found");
675
+ return;
676
+ }
457
677
  if (!slug) {
458
678
  res.status(400).send("Missing slug");
459
679
  return;
@@ -498,12 +718,12 @@ async function startServer(templatePath, port = 0, tenant = "") {
498
718
  css: templateFiles.css,
499
719
  js: templateFiles.js,
500
720
  fieldValues,
501
- tab,
721
+ scenario,
502
722
  sdkUrl: effectiveSdkUrl,
503
723
  blockCookies,
504
724
  previewCss
505
725
  };
506
- const rendered = tab === "standalone" ? standaloneShell(shellOptions) : articleShell(shellOptions);
726
+ const rendered = scenario.hostKind === "custom" ? customHostShell(shellOptions) : scenario.hostKind === "article" ? articleShell(shellOptions) : standaloneShell(shellOptions);
507
727
  res.setHeader("Cache-Control", "no-store");
508
728
  res.type("html").send(rendered);
509
729
  });
@@ -542,6 +762,17 @@ async function startServer(templatePath, port = 0, tenant = "") {
542
762
  broadcastReload();
543
763
  });
544
764
  const dirWatcher = chokidar.watch(baseDir, { depth: 1, ignoreInitial: true });
765
+ const scenariosWatcher = chokidar.watch(join(baseDir, SCENARIOS_DIRNAME), {
766
+ ignoreInitial: true
767
+ });
768
+ function rescanScenarios() {
769
+ customScenarios = scanCustomScenarios(baseDir);
770
+ console.log(`Scenarios updated: ${customScenarios.map((s) => s.id).join(", ") || "(none)"}`);
771
+ broadcastConfig();
772
+ }
773
+ scenariosWatcher.on("all", () => {
774
+ rescanScenarios();
775
+ });
545
776
  function rescan() {
546
777
  templates = scanTemplates(baseDir);
547
778
  console.log(`Templates updated: ${templates.map((t) => t.slug).join(", ") || "(none)"}`);
@@ -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)}})();function V(e){return e.normalize("NFKD").replace(/[\u0300-\u036f]/g,"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")}const ae={full:0,desktop:1280,tablet:768,mobile:390};function se(e){return ae[e]}let d=[],L=[],m=null,r="standalone",w="full",y=null,b={},P,D=!0,M=null,g=null,E=null;const J=document.getElementById("template-nav"),v=document.getElementById("field-list"),f=document.getElementById("preview-area"),I=document.getElementById("tab-group"),j=document.getElementById("device-group"),H=document.getElementById("toolbar-badge"),ie=document.getElementById("theme-toggle"),ce=document.getElementById("theme-icon-sun"),re=document.getElementById("theme-icon-moon"),$=document.getElementById("tenant-input"),B=document.getElementById("tenant-status"),de=document.getElementById("new-template-btn"),W=document.getElementById("new-window-btn"),x=document.getElementById("create-modal-backdrop"),c=document.getElementById("modal-name-input"),G=document.getElementById("modal-slug-hint"),h=document.getElementById("modal-error"),ue=document.getElementById("modal-cancel-btn"),k=document.getElementById("modal-create-btn"),O=document.getElementById("block-cookies-checkbox"),z=document.getElementById("cookie-auth-area"),T=document.getElementById("cookie-auth-user"),R=document.getElementById("cookie-auth-hint"),me=document.getElementById("cookie-clear-btn"),C=document.getElementById("jwt-modal-backdrop"),pe=document.getElementById("jwt-payload-content"),fe=document.getElementById("jwt-modal-close-btn");function K(e){document.documentElement.setAttribute("data-theme",e?"dark":"light"),ce.style.display=e?"none":"",re.style.display=e?"":"none"}function ge(){const e=localStorage.getItem("allegro-preview-theme"),t=window.matchMedia("(prefers-color-scheme: dark)").matches;K(e?e==="dark":t)}ie.addEventListener("click",()=>{const e=document.documentElement.getAttribute("data-theme")!=="dark";K(e),localStorage.setItem("allegro-preview-theme",e?"dark":"light")});function ve(e){const t=e.trim();return t?(/^https?:\/\//.test(t)?t:`https://${t}`).replace(/\/+$/,"")+"/client.js":""}function Y(e){const t=ve(e);P=t||void 0,t?(B.className="tenant-active-badge",B.textContent=t):(B.className="tenant-hint",B.textContent="Loads /client.js on the preview page"),y&&p()}$.addEventListener("input",()=>{localStorage.setItem("allegro-preview-tenant",$.value),Y($.value)});function he(e=""){const t=localStorage.getItem("allegro-preview-tenant"),n=e||t||"";n&&($.value=n,e&&localStorage.setItem("allegro-preview-tenant",n),Y(n))}const ye=[{name:"_ALLEGROT",label:"Session JWT",isJwt:!0},{name:"_ALLEGROD",label:"Device ID"},{name:"_ALLEGROS",label:"Session ID"}];function Ee(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 we(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 S(){const e=ye.map(t=>({...t,value:Ee(t.name)})).filter(t=>t.value!==null);if(T.innerHTML="",e.length===0){T.style.display="none",R.style.display="";return}R.style.display="none",T.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 u=we(t.value);pe.textContent=u?JSON.stringify(u,null,2):t.value,C.classList.remove("hidden")},n.appendChild(i)}T.appendChild(n)}}function be(e){D=e,localStorage.setItem("allegro-preview-block-cookies",e?"true":"false"),z.style.display=e?"none":"",e?E&&(clearInterval(E),E=null):(S(),E||(E=setInterval(S,2e3))),y&&p()}function ke(){const t=localStorage.getItem("allegro-preview-block-cookies")!=="false";O.checked=t,D=t,z.style.display=t?"none":"",t||(S(),E=setInterval(S,2e3))}O.addEventListener("change",()=>{be(O.checked)});me.addEventListener("click",()=>{const e=document.cookie.split("; ");for(const t of e){const n=t.split("=")[0];document.cookie=`${n}=; max-age=0; path=/`}S()});fe.addEventListener("click",()=>{C.classList.add("hidden")});C.addEventListener("click",e=>{e.target===C&&C.classList.add("hidden")});function Ce(){const e=window.location.hash.slice(1);return e?decodeURIComponent(e):null}function Le(e){history.replaceState(null,"",`#${encodeURIComponent(e)}`)}function Ie(){history.replaceState(null,"",window.location.pathname)}function Q(e){I.querySelectorAll(".tab-btn").forEach(t=>{t.disabled=!e}),j.querySelectorAll(".device-btn").forEach(t=>{t.disabled=!e}),W.disabled=!e}function X(){I.innerHTML="";for(const e of L){const t=document.createElement("button");t.className="tab-btn"+(e.id===r?" active":""),t.dataset.tab=e.id,t.textContent=e.name,t.disabled=y===null,I.appendChild(t)}}I.addEventListener("click",e=>{const t=e.target.closest(".tab-btn");!t||t.disabled||(r=t.dataset.tab,I.querySelectorAll(".tab-btn").forEach(n=>n.classList.remove("active")),t.classList.add("active"),localStorage.setItem("allegro-preview-tab",r),p())});j.addEventListener("click",e=>{const t=e.target.closest(".device-btn");!t||t.disabled||(w=t.dataset.device,j.querySelectorAll(".device-btn").forEach(n=>n.classList.remove("active")),t.classList.add("active"),localStorage.setItem("allegro-preview-device",w),p())});function N(){J.innerHTML="";for(const e of d){const t=document.createElement("button");t.className="template-nav-item"+(e.slug===m?" active":""),t.innerHTML=`<span class="template-nav-dot"></span><span class="template-nav-name">${_(e.name)}</span>`,t.addEventListener("click",()=>void q(e.slug)),J.appendChild(t)}}async function q(e){m=e,Le(e),N(),await U(e)}function Z(){y=null,m=null,M=null,g=null,Ie(),N(),Q(!1),H.textContent="No template selected",H.style.opacity="0.4",v.innerHTML='<p class="no-fields">Select a template to edit fields.</p>';const e=document.createElement("div");if(e.className="selector-screen",d.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>`,f.innerHTML="",f.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>`,f.innerHTML="",f.appendChild(e);const t=e.querySelector("#selector-grid");for(const n of d){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 q(n.slug)),t.appendChild(l)}}const F=["text","number","checkbox","textarea"];function ee(e){if(typeof e!="object"||e===null)return!1;const t=e;return typeof t.slug=="string"&&t.slug.length>0&&F.includes(t.type)}function xe(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'),!F.includes(t.type)){const a=t.type!==void 0?`"${String(t.type)}"`:"none";n.push(`invalid type ${a} — valid types: ${F.join(", ")}`)}return`${typeof t.slug=="string"&&t.slug.length>0?`Field "${t.slug}"`:"Field"}: ${n.join("; ")}.`}function Se(e){b={};for(const t of e)ee(t)&&(b[t.slug]=t.default_value??"")}function Ne(e,t){if(v.innerHTML="",t){const n=document.createElement("div");n.className="field-error",n.textContent=`template.json parse error: ${t}`,v.appendChild(n);return}if(e.length===0){v.innerHTML='<p class="no-fields">No fields in template.json.</p>';return}for(const n of e){if(!ee(n)){const o=document.createElement("div");o.className="field-error",o.textContent=xe(n),v.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",()=>{b[l.slug]=i.checked?"true":"false",p()});const u=document.createElement("label");u.className="field-label",u.htmlFor=`field-${l.slug}`,u.textContent=l.label||l.slug,o.appendChild(i),o.appendChild(u),v.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",()=>{b[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",()=>{b[l.slug]=o.value,p()}),a.appendChild(o)}v.appendChild(a)}}function Be(){const e=new URLSearchParams;e.set("slug",m),e.set("tab",r),P&&e.set("sdk",P),D||e.set("blockCookies","false");for(const[t,n]of Object.entries(b))e.set(`f_${t}`,n);return`/preview?${e.toString()}`}function p(){if(!y||!m)return;const e=Be();M=e;const t=se(w),n=t===0;if(g){const i=g.parentElement,u=i.parentElement,oe=u.parentElement;oe.className=n?"preview-stage":"preview-stage preview-stage--breakpoint",u.style.width=n?"100%":"",i.style.width=n?"100%":`${t}px`,g.style.width=n?"100%":`${t}px`,g.style.maxHeight=w==="mobile"?"812px":"",g.src=e;return}f.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`,w==="mobile"&&(o.style.maxHeight="812px")),o.src=e,s.appendChild(o),a.appendChild(s),l.appendChild(a),f.appendChild(l),g=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();y=n,H.textContent=`Template: ${n.name}`,H.style.opacity="1",Q(!0),Se(n.fields),Ne(n.fields,n.fieldsParseError),p()}catch(t){console.error("[allegro-preview] Failed to load template:",t),f.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}".`,f.appendChild(n)}}async function Te(){const t=await(await fetch("/api/config")).json();d=t.templates,L=t.scenarios??[],L.some(a=>a.id===r)||(r="standalone",localStorage.setItem("allegro-preview-tab",r)),X(),he(t.tenant??"");const n=Ce(),l=n&&d.find(a=>a.slug===n)?n:null;l?(m=l,N(),await U(l)):Z()}function $e(){const e=localStorage.getItem("allegro-preview-tab"),t=localStorage.getItem("allegro-preview-device");e&&(r=e),t&&["full","desktop","tablet","mobile"].includes(t)&&(w=t,j.querySelectorAll(".device-btn").forEach(n=>{n.classList.toggle("active",n.dataset.device===t)}))}function te(){const e=new EventSource("/api/events");e.addEventListener("reload",()=>{m&&U(m)}),e.addEventListener("config",()=>{Me()}),e.onerror=()=>{e.close(),setTimeout(te,2e3)}}async function Me(){const t=await(await fetch("/api/config")).json();d=t.templates,L=t.scenarios??[],L.some(n=>n.id===r)||(r="standalone",localStorage.setItem("allegro-preview-tab",r),y&&p()),X(),N(),m&&!d.find(n=>n.slug===m)&&Z()}function _(e){return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function je(){c.value="",h.textContent="",G.textContent="",c.classList.remove("invalid"),k.disabled=!0,x.classList.remove("hidden"),c.focus()}function A(){x.classList.add("hidden")}function ne(e){if(!e)return"Template name is required.";const t=V(e);return t?d.some(n=>n.slug===t)?`A template with the slug "${t}" already exists.`:d.some(n=>n.name.toLowerCase()===e.toLowerCase())?`A template named "${e}" already exists.`:null:"Template name must contain at least one letter or number."}W.addEventListener("click",()=>{M&&window.open(M,"_blank")});de.addEventListener("click",je);ue.addEventListener("click",A);x.addEventListener("click",e=>{e.target===x&&A()});document.addEventListener("keydown",e=>{e.key==="Escape"&&!x.classList.contains("hidden")&&A()});c.addEventListener("input",()=>{const e=c.value.trim(),t=V(e);G.textContent=t?`Slug: ${t}`:"";const n=ne(e);k.disabled=n!==null,n&&e?(c.classList.add("invalid"),h.textContent=n):(c.classList.remove("invalid"),h.textContent="")});c.addEventListener("keydown",e=>{e.key==="Enter"&&le()});k.addEventListener("click",()=>void le());async function le(){const e=c.value.trim(),t=ne(e);if(t){c.classList.add("invalid"),h.textContent=t,c.focus();return}k.disabled=!0,h.textContent="";try{const n=await fetch("/api/template/create",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:e})}),l=await n.json();if(!n.ok||!l.slug){h.textContent=l.error??"Failed to create template.",k.disabled=!1;return}A(),d=[...d,{slug:l.slug,name:l.name??e}],N(),await q(l.slug)}catch{h.textContent="Network error. Please try again.",k.disabled=!1}}ge();$e();ke();Te();te();
@@ -283,6 +283,12 @@
283
283
  border-color: #f87171;
284
284
  }
285
285
 
286
+ .modal-hint {
287
+ margin-top: 6px;
288
+ font-size: 12px;
289
+ color: #94a3b8;
290
+ }
291
+
286
292
  .modal-error {
287
293
  font-size: 11px;
288
294
  color: #f87171;
@@ -1008,7 +1014,7 @@
1008
1014
  display: block;
1009
1015
  }
1010
1016
  </style>
1011
- <script type="module" crossorigin src="/assets/app-D1XS_DDX.js"></script>
1017
+ <script type="module" crossorigin src="/assets/app-C--PVKMq.js"></script>
1012
1018
  </head>
1013
1019
  <body>
1014
1020
  <aside class="sidebar">
@@ -1138,11 +1144,7 @@
1138
1144
 
1139
1145
  <div class="main">
1140
1146
  <div class="toolbar">
1141
- <div class="tab-group" id="tab-group">
1142
- <button class="tab-btn active" data-tab="standalone" disabled>Standalone</button>
1143
- <button class="tab-btn" data-tab="append" disabled>Article &middot; Append</button>
1144
- <button class="tab-btn" data-tab="cut" disabled>Article &middot; Cut</button>
1145
- </div>
1147
+ <div class="tab-group" id="tab-group"></div>
1146
1148
  <div class="toolbar-sep"></div>
1147
1149
  <span class="template-badge" id="toolbar-badge" style="opacity: 0.4">No template selected</span>
1148
1150
  <div class="device-group" id="device-group">
@@ -1250,15 +1252,16 @@
1250
1252
  <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
1251
1253
  <div class="modal-title" id="modal-title">New Template</div>
1252
1254
  <div class="modal-field">
1253
- <label class="modal-label" for="modal-slug-input">Template slug</label>
1255
+ <label class="modal-label" for="modal-name-input">Template name</label>
1254
1256
  <input
1255
1257
  class="modal-input"
1256
- id="modal-slug-input"
1258
+ id="modal-name-input"
1257
1259
  type="text"
1258
- placeholder="my-template"
1260
+ placeholder="My template"
1259
1261
  autocomplete="off"
1260
1262
  spellcheck="false"
1261
1263
  />
1264
+ <div class="modal-hint" id="modal-slug-hint"></div>
1262
1265
  <div class="modal-error" id="modal-error"></div>
1263
1266
  </div>
1264
1267
  <div class="modal-actions">
@@ -1 +1 @@
1
- function r(){const e=document.getElementById("allegro-preview-data");if(!e||!e.textContent)throw new Error("[allegro-preview] Missing #allegro-preview-data element");return JSON.parse(e.textContent)}function o(e){const t=e.tab==="cut"||e.tab==="append";return{type:"template",template_id:e.slug,target_selector:t?"#allegro-preview-article":null,field_values:e.fieldValues,placement_method:e.tab==="cut"?"truncate":e.tab==="append"?"append":null,truncation_unit:e.tab==="cut"?"paragraphs":null,truncation_count:e.tab==="cut"?3:null,truncation_style:e.tab==="cut"?"cut":null,template:{id:e.slug,title:e.slug,slug:e.slug,html:e.html,css:e.css,js:e.js,external_css_urls:[]}}}window.allegro=window.allegro||[];window.allegro.push(e=>{const t=r(),l=o(t);e.interaction.renderActions([l],{skipDelays:!0}).catch(n=>{console.error("[allegro-preview] renderActions failed:",n)})});
1
+ function r(){const e=document.getElementById("allegro-preview-data");if(!e||!e.textContent)throw new Error("[allegro-preview] Missing #allegro-preview-data element");return JSON.parse(e.textContent)}function o(e){const t=e.placement;return{type:"template",template_id:e.slug,target_selector:t.target_selector,field_values:e.fieldValues,placement_method:t.placement_method,truncation_unit:t.truncation_unit,truncation_count:t.truncation_count,truncation_style:t.truncation_style,template:{id:e.slug,title:e.slug,slug:e.slug,html:e.html,css:e.css,js:e.js,external_css_urls:[]}}}window.allegro=window.allegro||[];window.allegro.push(e=>{const t=r(),n=o(t);e.interaction.renderActions([n],{skipDelays:!0}).catch(l=>{console.error("[allegro-preview] renderActions failed:",l)})});
package/dist/server.js CHANGED
@@ -24,6 +24,58 @@ window.__ALLEGRO_TEMPLATE_RESOLVER__ = async function (slug) {
24
24
  }
25
25
  };`;
26
26
 
27
+ // src/scenarios.ts
28
+ function emptyPlacement() {
29
+ return {
30
+ target_selector: null,
31
+ placement_method: null,
32
+ truncation_unit: null,
33
+ truncation_count: null,
34
+ truncation_style: null
35
+ };
36
+ }
37
+ var ARTICLE_TARGET_SELECTOR = "#allegro-preview-article";
38
+ var BUILT_IN_SCENARIOS = [
39
+ {
40
+ id: "standalone",
41
+ name: "Standalone",
42
+ builtIn: true,
43
+ hostKind: "none",
44
+ placement: emptyPlacement()
45
+ },
46
+ {
47
+ id: "append",
48
+ name: "Article \xB7 Append",
49
+ builtIn: true,
50
+ hostKind: "article",
51
+ placement: {
52
+ target_selector: ARTICLE_TARGET_SELECTOR,
53
+ placement_method: "append",
54
+ truncation_unit: null,
55
+ truncation_count: null,
56
+ truncation_style: null
57
+ }
58
+ },
59
+ {
60
+ id: "cut",
61
+ name: "Article \xB7 Cut",
62
+ builtIn: true,
63
+ hostKind: "article",
64
+ placement: {
65
+ target_selector: ARTICLE_TARGET_SELECTOR,
66
+ placement_method: "truncate",
67
+ truncation_unit: "paragraphs",
68
+ truncation_count: 3,
69
+ truncation_style: "cut"
70
+ }
71
+ }
72
+ ];
73
+
74
+ // src/slugify.ts
75
+ function slugify(name) {
76
+ return name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
77
+ }
78
+
27
79
  // src/server.ts
28
80
  var __dirname = fileURLToPath(new URL(".", import.meta.url));
29
81
  var BOILERPLATE_HTML = `<div class="signup">
@@ -124,7 +176,7 @@ var BOILERPLATE_FIELDS = [
124
176
  {
125
177
  slug: "subheadline",
126
178
  label: "Subheadline",
127
- type: "textarea",
179
+ type: "text",
128
180
  default_value: "Get the latest stories delivered to your inbox."
129
181
  },
130
182
  { slug: "input_placeholder", label: "Input placeholder", type: "text", default_value: "Enter your email" },
@@ -219,7 +271,8 @@ function buildPreviewData(options) {
219
271
  css: options.css,
220
272
  js: options.js,
221
273
  fieldValues: options.fieldValues,
222
- tab: options.tab
274
+ tab: options.scenario.id,
275
+ placement: options.scenario.placement
223
276
  });
224
277
  return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
225
278
  }
@@ -285,6 +338,33 @@ function articleShell(options) {
285
338
  </body>
286
339
  </html>`;
287
340
  }
341
+ function customHostShell(options) {
342
+ const cookieScript = options.blockCookies ?? true ? `<script>${COOKIE_NOOP_SCRIPT}</script>` : "";
343
+ const hostCss = options.scenario.hostCss ? `<style>${options.scenario.hostCss}</style>` : "";
344
+ const hostHtml = options.scenario.hostHtml ?? "";
345
+ return `<!DOCTYPE html>
346
+ <html>
347
+ <head>
348
+ <meta charset="UTF-8">
349
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
350
+ <script>${PREVIEW_BOOTSTRAP_SCRIPT}</script>
351
+ ${cookieScript}
352
+ <style>
353
+ *, *::before, *::after { box-sizing: border-box; }
354
+ body { margin: 0; }
355
+ [data-allegro-interaction] { display: block; }
356
+ </style>
357
+ ${previewCssTag(options.previewCss)}
358
+ ${hostCss}
359
+ </head>
360
+ <body>
361
+ <script id="allegro-preview-data" type="application/json">${buildPreviewData(options)}</script>
362
+ ${hostHtml}
363
+ ${sdkScriptTag(options.sdkUrl)}
364
+ <script src="/preview-client.js"></script>
365
+ </body>
366
+ </html>`;
367
+ }
288
368
  var sseClients = /* @__PURE__ */ new Set();
289
369
  function broadcast(event) {
290
370
  for (const client of sseClients) {
@@ -307,7 +387,9 @@ function readTemplateMeta(dir) {
307
387
  }
308
388
  try {
309
389
  const parsed = JSON.parse(readFileSync(jsonPath, "utf-8"));
310
- return { name: parsed.name ?? basename(dir) };
390
+ return {
391
+ name: typeof parsed.name === "string" && parsed.name.trim() !== "" ? parsed.name : basename(dir)
392
+ };
311
393
  } catch {
312
394
  return { name: basename(dir) };
313
395
  }
@@ -374,6 +456,109 @@ function readTemplateFiles(dir) {
374
456
  function readTemplateFilesCached(dir) {
375
457
  return templateFilesCache.get(dir) ?? readTemplateFiles(dir);
376
458
  }
459
+ var SCENARIOS_DIRNAME = ".allegro-preview";
460
+ function parsePlacement(raw) {
461
+ const placement = emptyPlacement();
462
+ if (typeof raw.target_selector === "string") {
463
+ placement.target_selector = raw.target_selector;
464
+ }
465
+ if (raw.placement_method === "truncate" || raw.placement_method === "append") {
466
+ placement.placement_method = raw.placement_method;
467
+ }
468
+ if (raw.truncation_unit === "paragraphs") {
469
+ placement.truncation_unit = raw.truncation_unit;
470
+ }
471
+ if (typeof raw.truncation_count === "number" && Number.isFinite(raw.truncation_count)) {
472
+ placement.truncation_count = raw.truncation_count;
473
+ }
474
+ if (raw.truncation_style === "cut") {
475
+ placement.truncation_style = raw.truncation_style;
476
+ }
477
+ return placement;
478
+ }
479
+ function readCustomScenario(scenariosDir, id) {
480
+ const dir = join(scenariosDir, id);
481
+ const jsonPath = join(dir, "scenario.json");
482
+ const base = {
483
+ id,
484
+ name: id,
485
+ builtIn: false,
486
+ hostKind: "none",
487
+ placement: emptyPlacement(),
488
+ parseError: null
489
+ };
490
+ let raw;
491
+ try {
492
+ raw = JSON.parse(readFileSync(jsonPath, "utf-8"));
493
+ } catch (err) {
494
+ base.parseError = err instanceof Error ? err.message : "Invalid JSON in scenario.json";
495
+ return base;
496
+ }
497
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
498
+ base.parseError = "scenario.json must be a JSON object";
499
+ return base;
500
+ }
501
+ if (typeof raw.name === "string" && raw.name.length > 0) {
502
+ base.name = raw.name;
503
+ }
504
+ base.placement = parsePlacement(raw);
505
+ const hostHtmlPath = join(dir, "host.html");
506
+ if (existsSync(hostHtmlPath)) {
507
+ try {
508
+ base.hostKind = "custom";
509
+ base.hostHtml = readFileSync(hostHtmlPath, "utf-8");
510
+ const hostCssPath = join(dir, "host.css");
511
+ base.hostCss = existsSync(hostCssPath) ? readFileSync(hostCssPath, "utf-8") : "";
512
+ } catch (err) {
513
+ base.parseError = err instanceof Error ? err.message : "Failed to read scenario host files";
514
+ return base;
515
+ }
516
+ }
517
+ return base;
518
+ }
519
+ function scanCustomScenarios(baseDir) {
520
+ const scenariosDir = join(baseDir, SCENARIOS_DIRNAME);
521
+ if (!existsSync(scenariosDir)) {
522
+ return [];
523
+ }
524
+ const results = [];
525
+ try {
526
+ for (const entry of readdirSync(scenariosDir, { withFileTypes: true })) {
527
+ if (!entry.isDirectory()) {
528
+ continue;
529
+ }
530
+ if (!existsSync(join(scenariosDir, entry.name, "scenario.json"))) {
531
+ continue;
532
+ }
533
+ const scenario = readCustomScenario(scenariosDir, entry.name);
534
+ if (scenario.parseError) {
535
+ console.warn(`[allegro-preview] Skipping scenario "${entry.name}": ${scenario.parseError}`);
536
+ continue;
537
+ }
538
+ results.push(scenario);
539
+ }
540
+ } catch {
541
+ }
542
+ return results;
543
+ }
544
+ function listScenarioSummaries(customScenarios) {
545
+ return [
546
+ ...BUILT_IN_SCENARIOS.map((s) => ({ id: s.id, name: s.name })),
547
+ ...customScenarios.map((s) => ({ id: s.id, name: s.name }))
548
+ ];
549
+ }
550
+ function resolveScenario(baseDir, id) {
551
+ const builtIn = BUILT_IN_SCENARIOS.find((s) => s.id === id);
552
+ if (builtIn) {
553
+ return builtIn;
554
+ }
555
+ const scenariosDir = join(baseDir, SCENARIOS_DIRNAME);
556
+ if (existsSync(join(scenariosDir, id, "scenario.json"))) {
557
+ const scenario = readCustomScenario(scenariosDir, id);
558
+ return scenario.parseError ? null : scenario;
559
+ }
560
+ return null;
561
+ }
377
562
  async function startServer(templatePath, port = 0, tenant = "") {
378
563
  const baseDir = resolve(process.cwd(), templatePath);
379
564
  if (!existsSync(baseDir)) {
@@ -381,11 +566,17 @@ async function startServer(templatePath, port = 0, tenant = "") {
381
566
  process.exit(1);
382
567
  }
383
568
  let templates = scanTemplates(baseDir);
569
+ let customScenarios = scanCustomScenarios(baseDir);
384
570
  const app = express();
385
571
  const clientDistDir = join(__dirname, "client");
386
572
  app.use(express.static(clientDistDir));
387
573
  app.get("/api/config", (_req, res) => {
388
- res.json({ defaultSlug: templates[0]?.slug ?? null, templates, tenant });
574
+ res.json({
575
+ defaultSlug: templates[0]?.slug ?? null,
576
+ templates,
577
+ tenant,
578
+ scenarios: listScenarioSummaries(customScenarios)
579
+ });
389
580
  });
390
581
  app.get("/api/template", (req, res) => {
391
582
  const slug = req.query.slug ?? ".";
@@ -409,16 +600,28 @@ async function startServer(templatePath, port = 0, tenant = "") {
409
600
  });
410
601
  app.post("/api/template/create", express.json(), (req, res) => {
411
602
  const body = req.body;
412
- const slug = body.slug;
413
- if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
414
- res.status(400).json({
415
- error: "Slug must be lowercase letters, numbers, and hyphens only (e.g. my-template)."
416
- });
603
+ const name = typeof body.name === "string" ? body.name.trim() : "";
604
+ if (name === "") {
605
+ res.status(400).json({ error: "Template name is required." });
606
+ return;
607
+ }
608
+ const slug = slugify(name);
609
+ if (slug === "") {
610
+ res.status(400).json({ error: "Template name must contain at least one letter or number." });
611
+ return;
612
+ }
613
+ const existing = scanTemplates(baseDir);
614
+ if (existing.some((t) => t.slug === slug)) {
615
+ res.status(400).json({ error: `A template with the slug "${slug}" already exists.` });
616
+ return;
617
+ }
618
+ if (existing.some((t) => t.name.toLowerCase() === name.toLowerCase())) {
619
+ res.status(400).json({ error: `A template named "${name}" already exists.` });
417
620
  return;
418
621
  }
419
622
  const targetDir = join(baseDir, slug);
420
623
  if (existsSync(targetDir)) {
421
- res.status(400).json({ error: `A template named "${slug}" already exists.` });
624
+ res.status(400).json({ error: `A template with the slug "${slug}" already exists.` });
422
625
  return;
423
626
  }
424
627
  try {
@@ -427,7 +630,15 @@ async function startServer(templatePath, port = 0, tenant = "") {
427
630
  writeFileSync(join(targetDir, "index.css"), BOILERPLATE_CSS);
428
631
  writeFileSync(
429
632
  join(targetDir, "template.json"),
430
- JSON.stringify({ name: slug, fields: BOILERPLATE_FIELDS }, null, 2) + "\n"
633
+ JSON.stringify(
634
+ {
635
+ $schema: "https://docs.allegrocdp.com/template.schema.json",
636
+ name,
637
+ fields: BOILERPLATE_FIELDS
638
+ },
639
+ null,
640
+ 2
641
+ ) + "\n"
431
642
  );
432
643
  } catch (err) {
433
644
  console.error("[allegro-preview] Failed to create template:", err);
@@ -435,7 +646,7 @@ async function startServer(templatePath, port = 0, tenant = "") {
435
646
  return;
436
647
  }
437
648
  rescan();
438
- res.status(201).json({ slug });
649
+ res.status(201).json({ slug, name });
439
650
  });
440
651
  app.get("/api/events", (req, res) => {
441
652
  res.setHeader("Content-Type", "text/event-stream");
@@ -451,6 +662,15 @@ async function startServer(templatePath, port = 0, tenant = "") {
451
662
  const slug = typeof req.query.slug === "string" ? req.query.slug : "";
452
663
  const tab = typeof req.query.tab === "string" ? req.query.tab : "standalone";
453
664
  const sdkParam = typeof req.query.sdk === "string" ? req.query.sdk : void 0;
665
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(tab)) {
666
+ res.status(404).send("Scenario not found");
667
+ return;
668
+ }
669
+ const scenario = resolveScenario(baseDir, tab);
670
+ if (!scenario) {
671
+ res.status(404).send("Scenario not found");
672
+ return;
673
+ }
454
674
  if (!slug) {
455
675
  res.status(400).send("Missing slug");
456
676
  return;
@@ -495,12 +715,12 @@ async function startServer(templatePath, port = 0, tenant = "") {
495
715
  css: templateFiles.css,
496
716
  js: templateFiles.js,
497
717
  fieldValues,
498
- tab,
718
+ scenario,
499
719
  sdkUrl: effectiveSdkUrl,
500
720
  blockCookies,
501
721
  previewCss
502
722
  };
503
- const rendered = tab === "standalone" ? standaloneShell(shellOptions) : articleShell(shellOptions);
723
+ const rendered = scenario.hostKind === "custom" ? customHostShell(shellOptions) : scenario.hostKind === "article" ? articleShell(shellOptions) : standaloneShell(shellOptions);
504
724
  res.setHeader("Cache-Control", "no-store");
505
725
  res.type("html").send(rendered);
506
726
  });
@@ -539,6 +759,17 @@ async function startServer(templatePath, port = 0, tenant = "") {
539
759
  broadcastReload();
540
760
  });
541
761
  const dirWatcher = chokidar.watch(baseDir, { depth: 1, ignoreInitial: true });
762
+ const scenariosWatcher = chokidar.watch(join(baseDir, SCENARIOS_DIRNAME), {
763
+ ignoreInitial: true
764
+ });
765
+ function rescanScenarios() {
766
+ customScenarios = scanCustomScenarios(baseDir);
767
+ console.log(`Scenarios updated: ${customScenarios.map((s) => s.id).join(", ") || "(none)"}`);
768
+ broadcastConfig();
769
+ }
770
+ scenariosWatcher.on("all", () => {
771
+ rescanScenarios();
772
+ });
542
773
  function rescan() {
543
774
  templates = scanTemplates(baseDir);
544
775
  console.log(`Templates updated: ${templates.map((t) => t.slug).join(", ") || "(none)"}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@allegrocdp/preview",
4
- "version": "0.1.0-dev.12",
4
+ "version": "0.1.0-dev.14",
5
5
  "description": "Local dev server for previewing Allegro templates",
6
6
  "type": "module",
7
7
  "bin": {
@@ -1,14 +0,0 @@
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();