nodality 1.0.165 → 1.0.167

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.
Files changed (100) hide show
  1. package/bin/nodality.js +279 -8
  2. package/dist/bundle.umd.js +1 -1
  3. package/dist/finalresult.esm.js +1 -1
  4. package/dist/index.cjs.js +1 -1
  5. package/dist/index.esm.js +1 -1
  6. package/layout/animator.js +1 -1
  7. package/layout/audio.js +1 -1
  8. package/layout/audionew.js +1 -1
  9. package/layout/base-2.js +1 -1
  10. package/layout/base.js +1 -1
  11. package/layout/beta-desktop-bar.js +1 -1
  12. package/layout/beta-mobile-bar.js +1 -1
  13. package/layout/box.js +1 -1
  14. package/layout/button.js +1 -1
  15. package/layout/cards.js +1 -1
  16. package/layout/center.js +1 -1
  17. package/layout/checkbox.js +1 -1
  18. package/layout/circle.js +1 -1
  19. package/layout/clean-row.js +1 -1
  20. package/layout/code.js +1 -1
  21. package/layout/container.js +1 -1
  22. package/layout/custom.js +1 -1
  23. package/layout/div-image.js +1 -1
  24. package/layout/dropdown-2025.js +1 -1
  25. package/layout/dropdown.js +1 -1
  26. package/layout/empty-element.js +1 -1
  27. package/layout/external-stylesheet.js +1 -1
  28. package/layout/flex-card.js +1 -1
  29. package/layout/flex-grid.js +1 -1
  30. package/layout/flex-row.js +1 -1
  31. package/layout/footer.js +1 -1
  32. package/layout/form-components/custom.js +1 -1
  33. package/layout/form-components/data-list.js +1 -1
  34. package/layout/form-components/floating-input.js +1 -1
  35. package/layout/form-components/form-all.js +1 -1
  36. package/layout/form-components/form.js +1 -1
  37. package/layout/form-components/image-picker.js +1 -1
  38. package/layout/form-components/picker.js +1 -1
  39. package/layout/form-components/radio.js +1 -1
  40. package/layout/form-components/radiogroup.js +1 -1
  41. package/layout/form-components/range.js +1 -1
  42. package/layout/free.js +1 -1
  43. package/layout/grid-new.js +1 -1
  44. package/layout/grid-switcher.js +1 -1
  45. package/layout/grid.js +1 -1
  46. package/layout/group.js +1 -1
  47. package/layout/header.js +1 -1
  48. package/layout/horizontal-scroller.js +1 -1
  49. package/layout/image-old.js +1 -1
  50. package/layout/image.js +1 -1
  51. package/layout/index.js +14 -2
  52. package/layout/label.js +1 -1
  53. package/layout/link.js +1 -1
  54. package/layout/list-OLD.js +1 -1
  55. package/layout/list.js +1 -1
  56. package/layout/meta-adder.js +1 -1
  57. package/layout/modal-2025.js +1 -1
  58. package/layout/modernwrap.js +1 -1
  59. package/layout/multiswitcher.js +1 -1
  60. package/layout/multiswitcherBeta.js +1 -1
  61. package/layout/nav-bar.js +1 -1
  62. package/layout/nav-factor/custom-div.js +1 -1
  63. package/layout/navBar-OLD.js +1 -1
  64. package/layout/new-flat-adder.js +1 -1
  65. package/layout/new-nav-bar.js +1 -1
  66. package/layout/offset-container.js +1 -1
  67. package/layout/polygon.js +1 -1
  68. package/layout/prerender-site.js +1 -1
  69. package/layout/prerender.js +1 -1
  70. package/layout/progress.js +1 -1
  71. package/layout/row.js +1 -1
  72. package/layout/saved-new-nav-bar.js +1 -1
  73. package/layout/scroll-video.js +1 -1
  74. package/layout/side-bar.js +1 -1
  75. package/layout/side-nav-bar.js +1 -1
  76. package/layout/simple-bar.js +1 -1
  77. package/layout/slider-2025.js +1 -1
  78. package/layout/spacer.js +1 -1
  79. package/layout/stack.js +1 -1
  80. package/layout/styler.js +1 -1
  81. package/layout/svg.js +1 -1
  82. package/layout/switcher.js +1 -1
  83. package/layout/table.js +1 -1
  84. package/layout/text-field.js +1 -1
  85. package/layout/text.js +1 -1
  86. package/layout/ulist.js +1 -1
  87. package/layout/video.js +1 -1
  88. package/layout/without-new.js +1 -1
  89. package/layout/wrap.js +1 -1
  90. package/layout/zoom-card.js +1 -1
  91. package/lib/card-getter.js +1 -1
  92. package/lib/designer.js +1 -1
  93. package/lib/element-mapper.js +1 -1
  94. package/lib/keyframe-animation.js +1 -1
  95. package/lib/link-getter.js +1 -1
  96. package/lib/scroll-video.js +1 -1
  97. package/lib/stacker.js +1 -1
  98. package/lib/theme.js +1 -1
  99. package/lib/transform-anim.js +1 -1
  100. package/package.json +1 -1
package/bin/nodality.js CHANGED
@@ -40,6 +40,9 @@ function showUsage() {
40
40
  console.log(`Usage:
41
41
  nodality prerender [flags] # SSG: render upload/*.html in place
42
42
  nodality compile [src/<file>.js] [flags] # Emit Designer output as a companion file
43
+ nodality fanout # Generate per-item pages from JSON (reads
44
+ # the \`fanout\` block in nodality.config.json;
45
+ # runs automatically before \`prerender\`)
43
46
  nodality help
44
47
 
45
48
  Compile flags:
@@ -162,6 +165,218 @@ function loadConfigFile(cwd) {
162
165
  }
163
166
  }
164
167
 
168
+ // ─── Fanout (data-driven page expansion) ────────────────────────
169
+
170
+ /**
171
+ * Generate one HTML + entry-script pair per item in a JSON dataset.
172
+ * The classic use case is a "detail" template that takes a query
173
+ * parameter at runtime (`/detail.html?id=helix`) and the SEO problem
174
+ * that crawlers see an empty mount for every product. Fanout reads
175
+ * the data once at build time, walks an array within it, and writes
176
+ * a per-item static page that the prerender step then turns into
177
+ * fully populated HTML.
178
+ *
179
+ * Configured in `nodality.config.json`:
180
+ *
181
+ * "fanout": [
182
+ * {
183
+ * "template": "detail.html",
184
+ * "data": "products.json",
185
+ * "items": "categories[].products", // dot path with [] for flatMap
186
+ * "id": "id", // field on each item
187
+ * "title": "SLS3 — {name}", // {name} = item.name, {id} etc.
188
+ * "entry": "pages/detail.js", // exported renderDetailPage(id)
189
+ * "bodyAttr": "data-product-id" // attr on <body> for the id
190
+ * }
191
+ * ]
192
+ *
193
+ * Output:
194
+ * • `upload/<basename>-<id>.html` — clone of the template, with
195
+ * `<title>` substituted and `<body data-...="<id>">`.
196
+ * • `upload/pages/<basename>-<id>.js` — thin wrapper that imports
197
+ * the entry's exported renderer and invokes it with this id.
198
+ *
199
+ * Stale outputs from a previous run (orphaned products etc.) are
200
+ * cleaned up before regenerating. The expansion runs once at the
201
+ * start of `nodality prerender` whenever the config has a `fanout`
202
+ * block; you can also invoke it standalone via `nodality fanout`.
203
+ */
204
+ function runFanout(cwd, uploadDir, fanoutConfig) {
205
+ if (!Array.isArray(fanoutConfig) || fanoutConfig.length === 0) return 0;
206
+
207
+ let totalWrote = 0;
208
+ for (const spec of fanoutConfig) {
209
+ const { template, data, items, id = "id", title, entry, bodyAttr = "data-product-id" } = spec;
210
+ if (!template || !data) {
211
+ console.warn(`[nodality] fanout: skipping spec without template/data`);
212
+ continue;
213
+ }
214
+
215
+ const tplPath = path.join(uploadDir, template);
216
+ const dataPath = path.join(uploadDir, data);
217
+ if (!fs.existsSync(tplPath)) {
218
+ console.error(`[nodality] fanout: template missing: ${template}`);
219
+ process.exit(1);
220
+ }
221
+ if (!fs.existsSync(dataPath)) {
222
+ console.error(`[nodality] fanout: data missing: ${data}`);
223
+ process.exit(1);
224
+ }
225
+
226
+ const json = JSON.parse(fs.readFileSync(dataPath, "utf8"));
227
+ const list = resolveItemsPath(json, items ?? "");
228
+ if (!Array.isArray(list) || list.length === 0) {
229
+ console.warn(`[nodality] fanout: ${data} produced no items at path "${items}"`);
230
+ continue;
231
+ }
232
+
233
+ const templateHtml = fs.readFileSync(tplPath, "utf8");
234
+ const base = path.basename(template, ".html");
235
+ const pagesDir = path.join(uploadDir, "pages");
236
+ fs.mkdirSync(pagesDir, { recursive: true });
237
+
238
+ // Clean stale outputs from a previous run so removing an item
239
+ // also removes its page. Match by prefix; original template
240
+ // (e.g. `detail.html` itself) is never touched.
241
+ for (const f of fs.readdirSync(uploadDir)) {
242
+ if (new RegExp(`^${escapeRegex(base)}-.+\\.html$`).test(f)) {
243
+ fs.unlinkSync(path.join(uploadDir, f));
244
+ }
245
+ }
246
+ for (const f of fs.readdirSync(pagesDir)) {
247
+ if (new RegExp(`^${escapeRegex(base)}-.+\\.js$`).test(f)) {
248
+ fs.unlinkSync(path.join(pagesDir, f));
249
+ }
250
+ }
251
+
252
+ // Discover the entry file to mirror per-item if not given.
253
+ const entryRel = entry ?? `pages/${base}.js`;
254
+ const entryFull = path.join(uploadDir, entryRel);
255
+ if (!fs.existsSync(entryFull)) {
256
+ console.error(`[nodality] fanout: entry script missing: ${entryRel}`);
257
+ process.exit(1);
258
+ }
259
+ const entryBase = path.basename(entryRel, ".js");
260
+ const entryDir = path.dirname(entryRel);
261
+
262
+ let wrote = 0;
263
+ for (const item of list) {
264
+ const itemId = item?.[id];
265
+ if (!itemId || !/^[A-Za-z0-9][A-Za-z0-9-_]*$/.test(String(itemId))) {
266
+ console.warn(`[nodality] fanout: skipping item with bad ${id}: ${JSON.stringify(itemId)}`);
267
+ continue;
268
+ }
269
+
270
+ // HTML: rewrite <title>, inject body data-attr, point script src
271
+ // at the per-item entry.
272
+ const resolvedTitle = title
273
+ ? interpolate(title, item)
274
+ : String(item?.name ?? itemId);
275
+ const escTitle = escapeHtml(resolvedTitle);
276
+
277
+ let html = templateHtml
278
+ .replace(/<title>[^<]*<\/title>/i, `<title>${escTitle}</title>`)
279
+ .replace(/<body(\s[^>]*)?>/i, (_, attrs = "") => {
280
+ const cleaned = (attrs ?? "").replace(
281
+ new RegExp(`\\s+${escapeRegex(bodyAttr)}="[^"]*"`),
282
+ "",
283
+ );
284
+ return `<body${cleaned} ${bodyAttr}="${itemId}">`;
285
+ });
286
+
287
+ // Rewrite the original `<script src=".../pages/<entryBase>.js">`
288
+ // to point at the per-item wrapper.
289
+ const srcRx = new RegExp(
290
+ `(<script[^>]*src=")([^"]*\\/?)${escapeRegex(entryBase)}\\.js("[^>]*>)`,
291
+ "i",
292
+ );
293
+ html = html.replace(srcRx, `$1$2${entryBase}-${itemId}.js$3`);
294
+
295
+ fs.writeFileSync(path.join(uploadDir, `${base}-${itemId}.html`), html);
296
+
297
+ // JS wrapper. The user's entry must export an async function
298
+ // named `renderDetailPage` (or, generically, the camelCase
299
+ // form of the basename + "Page"). We call it with the id.
300
+ const fnName = entryBase
301
+ .split(/[-_]/)
302
+ .map((p, i) => (i === 0 ? p : p[0].toUpperCase() + p.slice(1)))
303
+ .join("") + "Page"; // detail → renderDetailPage? No — detailPage
304
+ // To minimise convention surprises, default to the underscored
305
+ // `render<Pascal>Page` form too. Try both: the wrapper imports
306
+ // whichever the entry exports.
307
+ const pascal = entryBase
308
+ .split(/[-_]/)
309
+ .map((p) => p[0].toUpperCase() + p.slice(1))
310
+ .join("");
311
+ const wrapperBody = `// Auto-generated by \`nodality fanout\`.
312
+ // Per-item entry — invokes the renderer in ${entryRel}
313
+ // with this item's id baked in.
314
+ import * as mod from "./${path.basename(entryRel)}";
315
+
316
+ const fn = mod.render${pascal}Page ?? mod.${fnName} ?? mod.default;
317
+ if (typeof fn !== "function") {
318
+ throw new Error(
319
+ "[fanout] ${entryRel} must export render${pascal}Page(id) " +
320
+ "(or a default async function). nodality fanout could not find one.",
321
+ );
322
+ }
323
+ await fn(${JSON.stringify(String(itemId))});
324
+ `;
325
+ fs.writeFileSync(
326
+ path.join(pagesDir, `${entryBase}-${itemId}.js`),
327
+ wrapperBody,
328
+ );
329
+ wrote++;
330
+ }
331
+
332
+ console.log(`[nodality] fanout: ${template} × ${data} → ${wrote} page(s)`);
333
+ totalWrote += wrote;
334
+ }
335
+ return totalWrote;
336
+ }
337
+
338
+ /** Resolve a dot-path with `[]` segments to an array of items. */
339
+ function resolveItemsPath(data, path) {
340
+ if (!path) return Array.isArray(data) ? data : [];
341
+ const tokens = path.split(/\.|(\[\])/).filter(Boolean);
342
+ let acc = data;
343
+ for (const tok of tokens) {
344
+ if (tok === "[]") {
345
+ if (!Array.isArray(acc)) return [];
346
+ continue;
347
+ }
348
+ if (Array.isArray(acc)) {
349
+ acc = acc.flatMap((x) => (x != null ? [x[tok]] : [])).filter((x) => x != null);
350
+ // After accessing a field through an array, the result is an
351
+ // array of values (possibly nested). Don't auto-flatten — the
352
+ // user can put another `[]` after if they want.
353
+ } else if (acc != null) {
354
+ acc = acc[tok];
355
+ } else {
356
+ return [];
357
+ }
358
+ }
359
+ return Array.isArray(acc) ? acc.flat(Infinity).filter((x) => x != null) : [];
360
+ }
361
+
362
+ /** Replace `{field}` in a template with item[field]. */
363
+ function interpolate(template, item) {
364
+ return template.replace(/\{([^}]+)\}/g, (_, key) => {
365
+ const v = key.split(".").reduce((acc, k) => (acc == null ? acc : acc[k]), item);
366
+ return v == null ? "" : String(v);
367
+ });
368
+ }
369
+
370
+ function escapeRegex(s) {
371
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
372
+ }
373
+
374
+ function escapeHtml(s) {
375
+ return String(s).replace(/[<>&"']/g, (c) =>
376
+ c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === "&" ? "&amp;" : c === '"' ? "&quot;" : "&#39;",
377
+ );
378
+ }
379
+
165
380
  // ─── Page auto-discovery ────────────────────────────────────────
166
381
 
167
382
  /**
@@ -258,6 +473,16 @@ async function runPrerender(rawArgs) {
258
473
  );
259
474
  }
260
475
 
476
+ // Data-driven page fanout. When the config declares a `fanout`
477
+ // block (per-product detail pages, per-article blog entries, etc.),
478
+ // expand it BEFORE auto-discovery so the generated HTMLs are picked
479
+ // up alongside the hand-written ones. Subprocess child renders skip
480
+ // fanout — the parent already wrote the files and re-running inside
481
+ // each locale would just thrash them.
482
+ if (!process.env.NODALITY_SSG_LOCALE && Array.isArray(fileConfig.fanout)) {
483
+ runFanout(cwd, uploadDir, fileConfig.fanout);
484
+ }
485
+
261
486
  // Explicit `pages` from config wins over auto-discovery. The
262
487
  // discovery rules (pages/<base>.js, <base>.js) can't infer
263
488
  // irregular pairs like h7-nodality's `index.html → app.js`; for
@@ -386,6 +611,34 @@ async function runCompile(rawArgs) {
386
611
  .map((line) => `${line};`)
387
612
  .join("\n\n");
388
613
 
614
+ // Derive the import list from the actual class names used in the
615
+ // emitted statements rather than dumping a hardcoded superset.
616
+ // nodality only exports a known subset of class names; importing a
617
+ // non-exported name (e.g. `Form`, `Polygon`) trips ESM's named-export
618
+ // verification at parse time and breaks the file.
619
+ const NODALITY_EXPORTS = new Set([
620
+ "ElementMapper", "Animator",
621
+ "Text", "Image", "Link", "FlexRow", "UINavBar",
622
+ "Free", "NAudio", "Progress", "Center", "Code",
623
+ "Stack", "Wrapper", "Svg", "MetaAdder", "Table",
624
+ "Dropdown", "Modal", "TextField", "Card", "Wrap",
625
+ "FlexGrid", "ZoomCard", "CustomDivRenderer", "SideBar",
626
+ "SideNav", "SimpleBar", "DesktopBar", "MobileBar",
627
+ "Switcher", "Spacer", "HScroller", "Checkbox", "Base",
628
+ "FilePickera", "Picker", "Range", "RadioGroup", "DataList",
629
+ "Button", "Des", "LinkStyler", "CardGen", "KeyframeAnim",
630
+ "TransformAnim", "Stacker", "ScrollVideo", "Theme",
631
+ "AreaSwitcher", "Video", "UList", "Slider",
632
+ "Polygon", "Circle", "FloatingInput", "Form",
633
+ ]);
634
+ const used = new Set();
635
+ for (const line of emitted) {
636
+ for (const m of line.matchAll(/\bnew\s+([A-Z]\w*)/g)) {
637
+ if (NODALITY_EXPORTS.has(m[1])) used.add(m[1]);
638
+ }
639
+ }
640
+ const importList = [...used].sort().join(", ") || "Text";
641
+
389
642
  const out = `// Auto-emitted by \`nodality compile\` from ${srcRel}.
390
643
  // This is the imperative form the Designer would have shown in the
391
644
  // \`code: true\` panel. It is a throwaway artifact — diff it against
@@ -393,14 +646,7 @@ async function runCompile(rawArgs) {
393
646
  // then refine by hand. Re-run \`nodality compile\` whenever you
394
647
  // sketch new pieces in ${srcRel}.
395
648
 
396
- import {
397
- Text, Image, Link, FlexRow, FlexGrid, Wrapper, Center, Stack,
398
- Card, ZoomCard, Switcher, MobileBar, DesktopBar, SideNav, UINavBar,
399
- Dropdown, Modal, Table, Spacer, HScroller, Polygon, Circle, UList,
400
- Free, Audio, Progress, Code, MetaAdder, TextField,
401
- FloatingInput, Range, RadioGroup, Picker, FilePickera, DataList,
402
- Base, Form, Button, Slider, Video, Checkbox,
403
- } from "nodality";
649
+ import { ${importList} } from "nodality";
404
650
 
405
651
  ${body}
406
652
  `;
@@ -420,6 +666,29 @@ ${body}
420
666
  }
421
667
  }
422
668
 
669
+ // ─── fanout subcommand (standalone) ────────────────────────────
670
+
671
+ /**
672
+ * Run the fanout expansion standalone (without prerender) — useful
673
+ * for debugging the generated files before kicking off a full SSG.
674
+ * Reads the `fanout` block from nodality.config.json.
675
+ */
676
+ async function runFanoutStandalone(rawArgs) {
677
+ const cwd = process.cwd();
678
+ const flags = parseFlags(rawArgs);
679
+ const fileConfig = loadConfigFile(cwd);
680
+ const uploadDir = path.resolve(cwd, flags.upload || fileConfig.uploadDir || "upload");
681
+
682
+ if (!Array.isArray(fileConfig.fanout) || fileConfig.fanout.length === 0) {
683
+ console.error(`[nodality] fanout: no \`fanout\` block in nodality.config.json`);
684
+ process.exit(1);
685
+ }
686
+ const wrote = runFanout(cwd, uploadDir, fileConfig.fanout);
687
+ if (wrote === 0) {
688
+ console.warn(`[nodality] fanout: 0 page(s) generated — check config & data`);
689
+ }
690
+ }
691
+
423
692
  // ─── Dispatch ──────────────────────────────────────────────────
424
693
 
425
694
  async function main() {
@@ -441,6 +710,8 @@ async function main() {
441
710
  await runPrerender(rest);
442
711
  } else if (command === "compile") {
443
712
  await runCompile(rest);
713
+ } else if (command === "fanout") {
714
+ await runFanoutStandalone(rest);
444
715
  } else {
445
716
  console.error(`[nodality] Unknown command: ${command}`);
446
717
  showUsage();