@feeef.dev/cli 0.2.1 → 0.2.3

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.
@@ -7,7 +7,7 @@
7
7
  * Components may be:
8
8
  * - Flat page-level: `pages/<page>/components/hero.tsx` → published as `sections.main`
9
9
  * - Legacy sections: `pages/<page>/sections/<id>/components/…`
10
- * - Flat or folder entries under any `components/` dir (`hero.tsx` or `hero/component.tsx`)
10
+ * - Flat or folder entries under any `components/` dir (`hero.tsx` or `hero/hero.tsx`)
11
11
  */
12
12
 
13
13
  import fs from "node:fs";
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * Entries may be:
6
6
  * - Flat file: `components/hero.tsx` (or `.jsx` / `.json`)
7
- * - Folder: `components/hero/component.tsx` (+ optional nested slots/children)
7
+ * - Folder: `components/hero/hero.tsx` (+ optional nested slots/children)
8
+ * - Legacy folder: `components/hero/component.tsx`
8
9
  */
9
10
 
10
11
  import fs from "node:fs";
@@ -64,6 +65,7 @@ export function isFlatComponentFile(root) {
64
65
 
65
66
  /**
66
67
  * Resolve the TSX/JSX source file for a component root (dir or flat file).
68
+ * Prefer `<id>/<id>.tsx`; fall back to legacy `component.tsx`.
67
69
  * @param {string} root Absolute path to component folder or flat `.tsx`/`.jsx`/`.json`
68
70
  * @returns {string | null}
69
71
  */
@@ -73,6 +75,11 @@ export function resolveComponentEntry(root) {
73
75
  if (FLAT_CODE_EXT.has(ext)) return root;
74
76
  return null;
75
77
  }
78
+ const id = path.basename(root);
79
+ const namedTsx = path.join(root, `${id}.tsx`);
80
+ const namedJsx = path.join(root, `${id}.jsx`);
81
+ if (fs.existsSync(namedTsx)) return namedTsx;
82
+ if (fs.existsSync(namedJsx)) return namedJsx;
76
83
  const tsx = path.join(root, "component.tsx");
77
84
  const jsx = path.join(root, "component.jsx");
78
85
  if (fs.existsSync(tsx)) return tsx;
@@ -80,6 +87,23 @@ export function resolveComponentEntry(root) {
80
87
  return null;
81
88
  }
82
89
 
90
+ /**
91
+ * Resolve folder meta JSON (`<id>/<id>.json` or legacy `component.json`).
92
+ * @param {string} root Absolute component folder
93
+ * @returns {string | null}
94
+ */
95
+ export function resolveComponentMetaJson(root) {
96
+ if (isFlatComponentFile(root)) {
97
+ return path.extname(root).toLowerCase() === ".json" ? root : null;
98
+ }
99
+ const id = path.basename(root);
100
+ const named = path.join(root, `${id}.json`);
101
+ if (fs.existsSync(named)) return named;
102
+ const legacy = path.join(root, "component.json");
103
+ if (fs.existsSync(legacy)) return legacy;
104
+ return null;
105
+ }
106
+
83
107
  /**
84
108
  * List component entries under a `components/` (or `children/` / `slots/<id>/`) dir.
85
109
  * Supports flat `name.tsx|jsx|json` and legacy `name/` folders.
@@ -454,28 +478,88 @@ export function loadComponentMeta(root) {
454
478
  }
455
479
  }
456
480
 
457
- const jsonPath = path.join(root, "component.json");
458
- if (fs.existsSync(jsonPath)) {
481
+ const jsonPath = resolveComponentMetaJson(root);
482
+ if (jsonPath) {
459
483
  const meta = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
460
484
  return { meta, source: "json", entry, jsonPath };
461
485
  }
462
486
 
463
487
  throw new Error(
464
- `Missing component meta in ${rel(root)} — add export const meta in component.tsx / <name>.tsx or component.json`,
488
+ `Missing component meta in ${rel(root)} — add export const meta in <name>/<name>.tsx or <name>.json`,
465
489
  );
466
490
  }
467
491
 
468
492
  /**
469
- * Serialize meta as a TS `export const meta = …` preamble for unpack.
493
+ * Indent a multi-line JSON value so it sits inside an object literal.
494
+ * @param {unknown} value
495
+ * @param {number} indent
496
+ */
497
+ function indentJson(value, indent = 2) {
498
+ const pad = " ".repeat(indent);
499
+ return JSON.stringify(value, null, 2)
500
+ .split("\n")
501
+ .map((line, i) => (i === 0 ? line : pad + line))
502
+ .join("\n");
503
+ }
504
+
505
+ /**
506
+ * Serialize meta as a typed TS preamble for unpack (blank contract).
507
+ * Emits `propsSchema` + `satisfies FeeefComponentMeta` + `FeeefLivePropsOf`.
470
508
  * @param {Record<string, any>} meta
471
509
  */
472
510
  export function formatMetaExport(meta) {
473
511
  const clean = { ...meta };
474
512
  delete clean.$schema;
475
513
  delete clean.code;
476
- // Keep order + COMPONENT_META_KEYS + any extra catalog fields already on meta
477
- const json = JSON.stringify(clean, null, 2);
478
- return `export const meta = ${json} as const;\n\n`;
514
+ delete clean.sourcePath;
515
+
516
+ const propsSchema =
517
+ clean.propsSchema && typeof clean.propsSchema === "object"
518
+ ? clean.propsSchema
519
+ : {};
520
+ delete clean.propsSchema;
521
+
522
+ if (!Object.prototype.hasOwnProperty.call(clean, "props")) {
523
+ clean.props = {};
524
+ }
525
+
526
+ /** Preferred key order for readable authoring files. */
527
+ const preferred = [
528
+ "order",
529
+ "type",
530
+ "instanceId",
531
+ "title",
532
+ "props",
533
+ "slotsSchema",
534
+ "slotsLayout",
535
+ "children",
536
+ "slots",
537
+ "refId",
538
+ "refVersion",
539
+ ];
540
+ const keys = [
541
+ ...preferred.filter((k) => Object.prototype.hasOwnProperty.call(clean, k)),
542
+ ...Object.keys(clean).filter((k) => !preferred.includes(k)),
543
+ ];
544
+
545
+ const lines = [];
546
+ for (const key of keys) {
547
+ if (key === "props") {
548
+ // propsSchema identifier sits just before props (blank hero order).
549
+ lines.push(` propsSchema,`);
550
+ }
551
+ lines.push(` ${key}: ${indentJson(clean[key], 2)},`);
552
+ }
553
+ if (!keys.includes("props")) {
554
+ lines.push(` propsSchema,`);
555
+ }
556
+
557
+ return (
558
+ `const propsSchema = ${JSON.stringify(propsSchema, null, 2)} as const;\n\n` +
559
+ `export const meta = {\n${lines.join("\n")}\n` +
560
+ `} as const satisfies FeeefComponentMeta<typeof propsSchema>;\n\n` +
561
+ `type Props = FeeefLivePropsOf<typeof propsSchema>;\n\n`
562
+ );
479
563
  }
480
564
 
481
565
  export { COMPONENT_META_KEYS };
@@ -1,15 +1,21 @@
1
1
  /**
2
2
  * Unpack a Lithium TemplateData JSON blob into a folder-based source tree.
3
3
  *
4
- * Layout:
4
+ * Layout (always flat page components — rebuilds as sections.main):
5
5
  * <out>/
6
6
  * feeef.template.json
7
7
  * props.json
8
+ * shared/components/<id>.tsx | <id>/<id>.tsx (from sourcePath shared/…)
9
+ * library/components/<id>… (mirror of shared)
8
10
  * pages/<pageId>/page.json
9
- * pages/<pageId>/components/ (main-only pages — preferred)
10
- * <name>.tsx | <name>.json | <name>/…
11
- * pages/<pageId>/sections/<sectionId>/components/ (legacy multi-section)
12
- *
11
+ * pages/<pageId>/components/
12
+ * <name>.tsx | <name>.json | <name>/<name>.tsx | <name>/<name>.json
13
+ *
14
+ * Legacy multi-section keys in the blob are flattened into one ordered stack
15
+ * under `components/` so marketplace clones match the blank authoring contract.
16
+ *
17
+ * Components whose `sourcePath` starts with `shared/components/<id>` are
18
+ * promoted to the theme shared library; placements become `$ref` stubs.
13
19
  */
14
20
 
15
21
  import fs from "node:fs";
@@ -60,150 +66,337 @@ function sanitizeComponentSourceForEditor(code) {
60
66
  }
61
67
 
62
68
  /**
63
- * Write one component (and nested slots/children).
64
- * Leaf customs `<name>.tsx`; leaf built-ins → `<name>.json`;
65
- * nested trees → `<name>/` folder (legacy layout).
66
- *
69
+ * @param {unknown} sourcePath
70
+ * @returns {string | null}
71
+ */
72
+ function sharedIdFromSourcePath(sourcePath) {
73
+ if (typeof sourcePath !== "string" || !sourcePath.trim()) return null;
74
+ const norm = sourcePath.replace(/\\/g, "/").replace(/^\.\//, "");
75
+ const m = /^shared\/components\/([^/]+)/.exec(norm);
76
+ return m ? m[1] : null;
77
+ }
78
+
79
+ /**
67
80
  * @param {Record<string, any>} component
68
- * @param {string} destPath Intended path without extension (`…/components/hero`)
69
81
  * @param {number} order
70
- * @param {{ files: number }} stats
82
+ * @param {string} schemaBase Dir used for relative $schema
71
83
  */
72
- function writeComponent(component, destPath, order, stats) {
73
- const children = component.children;
74
- const hasChildFolders = Array.isArray(children) && children.length > 0;
75
-
76
- const slots = component.slots;
77
- const slotEntries =
78
- slots && typeof slots === "object" && !Array.isArray(slots)
79
- ? Object.entries(slots)
80
- : [];
81
- const hasSlotFolders = slotEntries.some(
82
- ([, list]) => Array.isArray(list) && list.length > 0,
83
- );
84
- const needsFolder = hasChildFolders || hasSlotFolders;
85
-
86
- const schemaBase = needsFolder ? destPath : path.dirname(destPath);
84
+ function buildMeta(component, order, schemaBase) {
87
85
  /** @type {Record<string, any>} */
88
86
  const meta = {
89
87
  $schema: path.relative(schemaBase, SCHEMA_ABS).split(path.sep).join("/"),
90
88
  order,
91
89
  };
92
90
  for (const key of COMPONENT_META_KEYS) {
93
- // Preserve explicit nulls (Dawn/editor blobs often include them).
94
91
  if (Object.prototype.hasOwnProperty.call(component, key)) {
95
92
  meta[key] = component[key];
96
93
  }
97
94
  }
98
- // Preserve unknown top-level keys (forward-compat) except tree/code.
99
95
  for (const [key, value] of Object.entries(component)) {
100
96
  if (
101
97
  key === "code" ||
102
98
  key === "children" ||
103
99
  key === "slots" ||
104
100
  key === "order" ||
101
+ key === "sourcePath" ||
105
102
  COMPONENT_META_KEYS.includes(key)
106
103
  ) {
107
104
  continue;
108
105
  }
109
106
  if (value !== undefined) meta[key] = value;
110
107
  }
108
+ return meta;
109
+ }
110
+
111
+ /**
112
+ * @param {Record<string, any>} component
113
+ */
114
+ function componentNeedsFolder(component) {
115
+ const children = component.children;
116
+ const hasChildFolders = Array.isArray(children) && children.length > 0;
117
+ const slots = component.slots;
118
+ const slotEntries =
119
+ slots && typeof slots === "object" && !Array.isArray(slots)
120
+ ? Object.entries(slots)
121
+ : [];
122
+ const hasSlotFolders = slotEntries.some(
123
+ ([, list]) => Array.isArray(list) && list.length > 0,
124
+ );
125
+ return {
126
+ needsFolder: hasChildFolders || hasSlotFolders,
127
+ hasChildFolders,
128
+ hasSlotFolders,
129
+ slotEntries,
130
+ children,
131
+ slots,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Write custom TSX body (typed meta + App source).
137
+ * @param {string} filePath
138
+ * @param {Record<string, any>} meta
139
+ * @param {string} code
140
+ * @param {{ files: number }} stats
141
+ */
142
+ function writeCustomTsx(filePath, meta, code, stats) {
143
+ const metaForExport = { ...meta };
144
+ delete metaForExport.$schema;
145
+ const tsx = `${formatMetaExport(metaForExport)}${sanitizeComponentSourceForEditor(code)}`;
146
+ writeText(filePath, tsx);
147
+ stats.files += 1;
148
+ }
149
+
150
+ /**
151
+ * Ensure shared (+ library mirror) definition exists once.
152
+ * Shared leaves are flat `.tsx`; shells with only schemas stay flat too
153
+ * (slot *contents* live on placements).
154
+ *
155
+ * @param {string} sharedId
156
+ * @param {Record<string, any>} component
157
+ * @param {{ outDir: string, sharedWritten: Set<string>, stats: { files: number } }} ctx
158
+ */
159
+ function ensureSharedDefinition(sharedId, component, ctx) {
160
+ if (ctx.sharedWritten.has(sharedId)) return;
161
+ ctx.sharedWritten.add(sharedId);
162
+
163
+ const hasCode =
164
+ typeof component.code === "string" && component.code.trim().length > 0;
165
+
166
+ /** Canonical shared: no page-local slots/children trees. */
167
+ const canonical = { ...component };
168
+ delete canonical.sourcePath;
169
+ if (Array.isArray(canonical.children) && canonical.children.length > 0) {
170
+ canonical.children = [];
171
+ }
172
+ if (canonical.slots && typeof canonical.slots === "object") {
173
+ /** @type {Record<string, any[]>} */
174
+ const empty = {};
175
+ for (const key of Object.keys(canonical.slots)) {
176
+ empty[key] = [];
177
+ }
178
+ canonical.slots = empty;
179
+ }
180
+
181
+ for (const kind of ["shared", "library"]) {
182
+ const destPath = path.join(ctx.outDir, kind, "components", sharedId);
183
+ const schemaBase = path.dirname(destPath);
184
+ const meta = buildMeta(canonical, 0, schemaBase);
185
+ delete meta.instanceId;
186
+ meta.instanceId = null;
187
+ if (Object.prototype.hasOwnProperty.call(canonical, "children")) {
188
+ meta.children = canonical.children ?? null;
189
+ }
190
+ if (Object.prototype.hasOwnProperty.call(canonical, "slots")) {
191
+ meta.slots = canonical.slots ?? null;
192
+ }
193
+
194
+ if (hasCode) {
195
+ writeCustomTsx(`${destPath}.tsx`, meta, component.code, ctx.stats);
196
+ } else {
197
+ if (Object.prototype.hasOwnProperty.call(component, "code")) {
198
+ meta.code = component.code ?? null;
199
+ }
200
+ writeJson(`${destPath}.json`, meta);
201
+ ctx.stats.files += 1;
202
+ }
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Write nested slots/children under a folder placement.
208
+ * @param {Record<string, any>} component
209
+ * @param {string} dir
210
+ * @param {ReturnType<typeof componentNeedsFolder>} tree
211
+ * @param {{ outDir: string, sharedWritten: Set<string>, stats: { files: number } }} ctx
212
+ */
213
+ function writeNestedTrees(component, dir, tree, ctx) {
214
+ if (tree.hasChildFolders) {
215
+ const used = new Set();
216
+ tree.children.forEach((child, i) => {
217
+ if (!child || typeof child !== "object") return;
218
+ const name = folderNameForComponent(child, i, used);
219
+ writeComponent(child, path.join(dir, "children", name), i, ctx);
220
+ });
221
+ }
222
+
223
+ if (tree.hasSlotFolders) {
224
+ for (const [slotId, list] of tree.slotEntries) {
225
+ if (!Array.isArray(list) || list.length === 0) continue;
226
+ const used = new Set();
227
+ list.forEach((child, i) => {
228
+ if (!child || typeof child !== "object") return;
229
+ const name = folderNameForComponent(child, i, used);
230
+ writeComponent(child, path.join(dir, "slots", slotId, name), i, ctx);
231
+ });
232
+ }
233
+ const emptySlots = {};
234
+ let anyEmpty = false;
235
+ for (const [slotId, list] of tree.slotEntries) {
236
+ if (!Array.isArray(list) || list.length === 0) {
237
+ emptySlots[slotId] = Array.isArray(list) ? list : [];
238
+ anyEmpty = true;
239
+ }
240
+ }
241
+ if (anyEmpty) {
242
+ writeJson(path.join(dir, "slots.empty.json"), emptySlots);
243
+ ctx.stats.files += 1;
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Write one component (and nested slots/children).
250
+ * Leaf customs → `<name>.tsx`; leaf built-ins → `<name>.json`;
251
+ * nested trees → `<name>/<name>.tsx` (or `.json` for `$ref` / built-ins).
252
+ *
253
+ * @param {Record<string, any>} component
254
+ * @param {string} destPath Intended path without extension (`…/components/hero`)
255
+ * @param {number} order
256
+ * @param {{ outDir: string, sharedWritten: Set<string>, stats: { files: number } }} ctx
257
+ */
258
+ function writeComponent(component, destPath, order, ctx) {
259
+ const tree = componentNeedsFolder(component);
260
+ const sharedId = sharedIdFromSourcePath(component.sourcePath);
261
+ const id = path.basename(destPath);
262
+
263
+ if (sharedId) {
264
+ ensureSharedDefinition(sharedId, component, ctx);
265
+
266
+ /** @type {Record<string, any>} */
267
+ const stub = {
268
+ $schema: path
269
+ .relative(
270
+ tree.needsFolder ? destPath : path.dirname(destPath),
271
+ SCHEMA_ABS,
272
+ )
273
+ .split(path.sep)
274
+ .join("/"),
275
+ order,
276
+ $ref: `shared.${sharedId}`,
277
+ };
278
+ if (component.instanceId != null) stub.instanceId = component.instanceId;
279
+ if (component.title != null) stub.title = component.title;
280
+ if (component.props && typeof component.props === "object") {
281
+ stub.props = component.props;
282
+ }
283
+
284
+ if (!tree.needsFolder) {
285
+ writeJson(`${destPath}.json`, stub);
286
+ ctx.stats.files += 1;
287
+ return;
288
+ }
289
+
290
+ const dir = destPath;
291
+ fs.mkdirSync(dir, { recursive: true });
292
+ writeJson(path.join(dir, `${id}.json`), stub);
293
+ ctx.stats.files += 1;
294
+ writeNestedTrees(component, dir, tree, ctx);
295
+ return;
296
+ }
297
+
298
+ const schemaBase = tree.needsFolder ? destPath : path.dirname(destPath);
299
+ const meta = buildMeta(component, order, schemaBase);
111
300
 
112
- // Nested trees: non-empty → folders; empty/null → keep on meta for round-trip.
113
301
  if (Object.prototype.hasOwnProperty.call(component, "children")) {
114
- if (hasChildFolders) {
302
+ if (tree.hasChildFolders) {
115
303
  delete meta.children;
116
304
  } else {
117
- meta.children = children ?? null;
305
+ meta.children = tree.children ?? null;
118
306
  }
119
307
  }
120
308
 
121
309
  if (Object.prototype.hasOwnProperty.call(component, "slots")) {
122
- if (hasSlotFolders) {
310
+ if (tree.hasSlotFolders) {
123
311
  delete meta.slots;
124
312
  } else {
125
- meta.slots = slots ?? null;
313
+ meta.slots = tree.slots ?? null;
126
314
  }
127
315
  }
128
316
 
129
317
  const hasCode =
130
318
  typeof component.code === "string" && component.code.trim().length > 0;
131
319
 
132
- if (!needsFolder) {
133
- // Flat leaf: hero.tsx or hero.json
320
+ if (!tree.needsFolder) {
134
321
  if (hasCode) {
135
- const metaForExport = { ...meta };
136
- delete metaForExport.$schema;
137
- const tsx = `${formatMetaExport(metaForExport)}${sanitizeComponentSourceForEditor(component.code)}`;
138
- writeText(`${destPath}.tsx`, tsx);
139
- stats.files += 1;
322
+ writeCustomTsx(`${destPath}.tsx`, meta, component.code, ctx.stats);
140
323
  } else {
141
324
  if (Object.prototype.hasOwnProperty.call(component, "code")) {
142
325
  meta.code = component.code ?? null;
143
326
  }
144
327
  writeJson(`${destPath}.json`, meta);
145
- stats.files += 1;
328
+ ctx.stats.files += 1;
146
329
  }
147
330
  return;
148
331
  }
149
332
 
150
- // Folder layout for nested children/slots
333
+ // Folder layout: `<name>/<name>.tsx` (not legacy component.tsx)
151
334
  const dir = destPath;
152
335
  fs.mkdirSync(dir, { recursive: true });
153
336
 
154
337
  if (hasCode) {
155
- const metaForExport = { ...meta };
156
- delete metaForExport.$schema;
157
- const tsx = `${formatMetaExport(metaForExport)}${sanitizeComponentSourceForEditor(component.code)}`;
158
- writeText(path.join(dir, "component.tsx"), tsx);
159
- stats.files += 1;
338
+ writeCustomTsx(path.join(dir, `${id}.tsx`), meta, component.code, ctx.stats);
160
339
  } else {
161
340
  if (Object.prototype.hasOwnProperty.call(component, "code")) {
162
341
  meta.code = component.code ?? null;
163
342
  }
164
- writeJson(path.join(dir, "component.json"), meta);
165
- stats.files += 1;
343
+ writeJson(path.join(dir, `${id}.json`), meta);
344
+ ctx.stats.files += 1;
166
345
  }
167
346
 
168
- if (hasChildFolders) {
169
- const used = new Set();
170
- children.forEach((child, i) => {
171
- if (!child || typeof child !== "object") return;
172
- const name = folderNameForComponent(child, i, used);
173
- writeComponent(child, path.join(dir, "children", name), i, stats);
174
- });
175
- }
347
+ writeNestedTrees(component, dir, tree, ctx);
348
+ }
176
349
 
177
- if (hasSlotFolders) {
178
- for (const [slotId, list] of slotEntries) {
179
- if (!Array.isArray(list) || list.length === 0) {
180
- continue;
181
- }
182
- const used = new Set();
183
- list.forEach((child, i) => {
184
- if (!child || typeof child !== "object") return;
185
- const name = folderNameForComponent(child, i, used);
186
- writeComponent(
187
- child,
188
- path.join(dir, "slots", slotId, name),
189
- i,
190
- stats,
191
- );
192
- });
193
- }
194
- const emptySlots = {};
195
- let anyEmpty = false;
196
- for (const [slotId, list] of slotEntries) {
197
- if (!Array.isArray(list) || list.length === 0) {
198
- emptySlots[slotId] = Array.isArray(list) ? list : [];
199
- anyEmpty = true;
200
- }
201
- }
202
- if (anyEmpty) {
203
- writeJson(path.join(dir, "slots.empty.json"), emptySlots);
204
- stats.files += 1;
350
+ /**
351
+ * Stable order when coalescing legacy multi-section blobs into one flat stack.
352
+ * Unknown keys sort alphabetically after these.
353
+ */
354
+ const SECTION_FLATTEN_ORDER = [
355
+ "header",
356
+ "announcement",
357
+ "top",
358
+ "start",
359
+ "hero",
360
+ "collection_header",
361
+ "sidebar",
362
+ "main",
363
+ "body",
364
+ "form",
365
+ "order_form",
366
+ "products",
367
+ "contact_details",
368
+ "contact_form",
369
+ "map",
370
+ "end",
371
+ "bottom",
372
+ "footer",
373
+ ];
374
+
375
+ /**
376
+ * Collect components from all section keys into one ordered list.
377
+ * @param {Record<string, any>} sections
378
+ * @returns {any[]}
379
+ */
380
+ function flattenSectionComponents(sections) {
381
+ if (!sections || typeof sections !== "object") return [];
382
+ const keys = Object.keys(sections).sort((a, b) => {
383
+ const ia = SECTION_FLATTEN_ORDER.indexOf(a);
384
+ const ib = SECTION_FLATTEN_ORDER.indexOf(b);
385
+ const ra = ia === -1 ? SECTION_FLATTEN_ORDER.length : ia;
386
+ const rb = ib === -1 ? SECTION_FLATTEN_ORDER.length : ib;
387
+ if (ra !== rb) return ra - rb;
388
+ return a.localeCompare(b);
389
+ });
390
+ /** @type {any[]} */
391
+ const out = [];
392
+ for (const key of keys) {
393
+ const list = sections[key]?.components;
394
+ if (!Array.isArray(list)) continue;
395
+ for (const comp of list) {
396
+ if (comp && typeof comp === "object") out.push(comp);
205
397
  }
206
398
  }
399
+ return out;
207
400
  }
208
401
 
209
402
  /**
@@ -236,6 +429,8 @@ export function unpackTemplate({ inputPath, outDir, force = false }) {
236
429
 
237
430
  const pageIds = Object.keys(data.pages);
238
431
  const stats = { files: 0, pages: pageIds.length, components: 0 };
432
+ /** @type {{ outDir: string, sharedWritten: Set<string>, stats: { files: number } }} */
433
+ const ctx = { outDir, sharedWritten: new Set(), stats };
239
434
 
240
435
  writeJson(path.join(outDir, "feeef.template.json"), {
241
436
  $schema: "../../template-kit/schemas/template.schema.json",
@@ -268,57 +463,22 @@ export function unpackTemplate({ inputPath, outDir, force = false }) {
268
463
  const sections =
269
464
  page.sections && typeof page.sections === "object" ? page.sections : {};
270
465
 
271
- // Main-only pages flat `pages/<id>/components/` (sections optional on disk).
272
- // Multi-section pages legacy `sections/<id>/components/`.
273
- const sectionKeys = Object.keys(sections);
274
- const nonEmptyKeys = sectionKeys.filter((id) => {
275
- const list = sections[id]?.components;
276
- return Array.isArray(list) && list.length > 0;
466
+ // Always unpack to flat `pages/<id>/components/` (sections.main on build).
467
+ // Coalesce legacy multi-section keys into one ordered stack.
468
+ const components = flattenSectionComponents(sections);
469
+ const componentsDir = path.join(pageDir, "components");
470
+ fs.mkdirSync(componentsDir, { recursive: true });
471
+ const used = new Set();
472
+ components.forEach((comp, i) => {
473
+ if (!comp || typeof comp !== "object") return;
474
+ const name = folderNameForComponent(comp, i, used);
475
+ writeComponent(comp, path.join(componentsDir, name), i, ctx);
476
+ stats.components += 1;
277
477
  });
278
- const useFlatPageComponents =
279
- nonEmptyKeys.length === 0
280
- ? sectionKeys.length === 0 ||
281
- (sectionKeys.length === 1 && sectionKeys[0] === "main")
282
- : nonEmptyKeys.length === 1 && nonEmptyKeys[0] === "main";
283
-
284
- if (useFlatPageComponents) {
285
- const components = Array.isArray(sections.main?.components)
286
- ? sections.main.components
287
- : [];
288
- const componentsDir = path.join(pageDir, "components");
289
- fs.mkdirSync(componentsDir, { recursive: true });
290
- const used = new Set();
291
- components.forEach((comp, i) => {
292
- if (!comp || typeof comp !== "object") return;
293
- const name = folderNameForComponent(comp, i, used);
294
- writeComponent(comp, path.join(componentsDir, name), i, stats);
295
- stats.components += 1;
296
- });
297
- continue;
298
- }
299
-
300
- for (const [sectionId, section] of Object.entries(sections)) {
301
- const components = Array.isArray(section?.components)
302
- ? section.components
303
- : [];
304
- const sectionDir = path.join(pageDir, "sections", sectionId);
305
- // Always create the section folder so empty sections round-trip.
306
- fs.mkdirSync(path.join(sectionDir, "components"), { recursive: true });
307
-
308
- const used = new Set();
309
- components.forEach((comp, i) => {
310
- if (!comp || typeof comp !== "object") return;
311
- const name = folderNameForComponent(comp, i, used);
312
- writeComponent(
313
- comp,
314
- path.join(sectionDir, "components", name),
315
- i,
316
- stats,
317
- );
318
- stats.components += 1;
319
- });
320
- }
321
478
  }
322
479
 
323
- return stats;
480
+ return {
481
+ ...stats,
482
+ sharedCount: ctx.sharedWritten.size,
483
+ };
324
484
  }