fimo 0.9.0-experimental.10 → 0.9.0-experimental.12

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.
@@ -1,5 +1,11 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { join, relative } from 'node:path';
3
+ /** A Vite SPA declares its entry with a root `index.html`; a library that only
4
+ * bundles with Vite has none. This is what separates a deployable SITE from a
5
+ * package that merely depends on react-router / vite / vue. */
6
+ function hasHtmlEntry(cwd) {
7
+ return existsSync(join(cwd, 'index.html'));
8
+ }
3
9
  /**
4
10
  * Detect the framework of an existing site from its package.json and layout.
5
11
  * Used by `fimo init` (adopt-existing-site) and the wrap migration to decide
@@ -29,7 +35,16 @@ export function detectFramework(cwd) {
29
35
  const pagesDir = ['pages', 'src/pages'].find((dir) => existsSync(join(cwd, dir)));
30
36
  return { framework: 'next', appDir, pagesDir };
31
37
  }
32
- if (deps['react-router'] || deps['@react-router/dev']) {
38
+ // A react-router SITE is either framework mode (`@react-router/dev`) or a Vite
39
+ // SPA (an `index.html` entry). The `react-router` RUNTIME lib alone is a
40
+ // LIBRARY signal — the CLI, component libs and the `fimo/react-router` adapter
41
+ // all depend on it without being a site — so it must be paired with an app
42
+ // entry, or every workspace package reads as react-router (the monorepo picker
43
+ // bug: apps/cli mislabeled and offered as "your site").
44
+ if (deps['@react-router/dev']) {
45
+ return { framework: 'react-router' };
46
+ }
47
+ if (deps['react-router'] && hasHtmlEntry(cwd)) {
33
48
  return { framework: 'react-router' };
34
49
  }
35
50
  if (deps['@remix-run/react']) {
@@ -49,13 +64,15 @@ export function detectFramework(cwd) {
49
64
  const pagesDir = ['src/routes'].find((dir) => existsSync(join(cwd, dir)));
50
65
  return { framework: 'sveltekit', pagesDir };
51
66
  }
52
- if (deps.vue && deps.vite) {
67
+ if (deps.vue && deps.vite && hasHtmlEntry(cwd)) {
53
68
  return { framework: 'vue' };
54
69
  }
55
- if (deps.vite) {
70
+ if (deps.vite && hasHtmlEntry(cwd)) {
56
71
  // A react dependency guarantees the JSX runtime: the --apply codemod and
57
72
  // react-router route discovery are safe. Bare vite (Solid, Preact, lit)
58
- // stays hands-off.
73
+ // stays hands-off. Vite is also just a build tool libraries use, so an
74
+ // app entry (index.html) is required — a lib that bundles with Vite is not
75
+ // a site.
59
76
  return { framework: 'vite', react: Boolean(deps.react) };
60
77
  }
61
78
  // Known frameworks we can NAME but not wrap yet — lets `fimo init` route
@@ -86,6 +103,37 @@ const WORKSPACE_SKIP = new Set([
86
103
  '.vercel',
87
104
  'coverage',
88
105
  ]);
106
+ /**
107
+ * Directory names that hold NON-site packages — fixtures, examples, evals,
108
+ * scaffolding templates, internal tooling. Pruned from the picker so a
109
+ * monorepo's real sites aren't buried under dozens of candidates (the Fimo
110
+ * workspace surfaced 22, mostly `evals/*`, their `_references/*` mirrors, and
111
+ * `tools/*`). Generic monorepo conventions, not a project-specific list.
112
+ * Auto-discovery only: `fimo init <path>` still adopts any explicit directory.
113
+ */
114
+ const NON_SITE_DIRS = new Set([
115
+ 'examples',
116
+ 'example',
117
+ 'fixtures',
118
+ '__fixtures__',
119
+ '__tests__',
120
+ 'test',
121
+ 'tests',
122
+ 'e2e',
123
+ 'evals',
124
+ 'eval',
125
+ 'benchmarks',
126
+ 'bench',
127
+ 'demo',
128
+ 'demos',
129
+ 'sandbox',
130
+ 'sandboxes',
131
+ 'playground',
132
+ 'tools',
133
+ 'scripts',
134
+ 'templates',
135
+ 'template',
136
+ ]);
89
137
  /** How deep below the root the scan looks for a framework app (`apps/ui` is
90
138
  * depth 2, `packages/frontend/web` is depth 3). */
91
139
  const MAX_SCAN_DEPTH = 3;
@@ -122,7 +170,13 @@ export function findAdoptableApps(rootDir) {
122
170
  return;
123
171
  }
124
172
  for (const entry of entries) {
125
- if (entry.isDirectory() && !WORKSPACE_SKIP.has(entry.name) && !entry.name.startsWith('.')) {
173
+ if (entry.isDirectory() &&
174
+ !WORKSPACE_SKIP.has(entry.name) &&
175
+ !NON_SITE_DIRS.has(entry.name.toLowerCase()) &&
176
+ // `.`-prefixed (dotdirs) and `_`-prefixed (internal / `_references`
177
+ // reference mirrors) are never the user's deployable site.
178
+ !entry.name.startsWith('.') &&
179
+ !entry.name.startsWith('_')) {
126
180
  walk(join(dir, entry.name), depth + 1);
127
181
  }
128
182
  }
@@ -34,6 +34,11 @@ export interface PageSectionDraft {
34
34
  /** The same file drafts on several pages — chrome. Model it ONCE as a
35
35
  * Single Type, never as a per-page block. */
36
36
  shared?: boolean;
37
+ /** The page component's OWN inline content (hero, stats, FAQ rendered
38
+ * directly in the page file, not in an imported child). It aggregates
39
+ * several on-page sections, so it is exempt from illustration detection
40
+ * (its many short labels are real content, not a UI mock). */
41
+ pageOwn?: boolean;
37
42
  }
38
43
  export interface PageStructureDraft {
39
44
  pageId: string;
@@ -68,5 +73,91 @@ export interface PageSingleDraft {
68
73
  pageId: string;
69
74
  schema: Record<string, unknown>;
70
75
  document: Record<string, unknown>;
76
+ /** Section components left OUT of the single because they read as
77
+ * illustrations/product demos, not editable content — the agent confirms
78
+ * each is decorative (exempts it) or pulls it back into the model. */
79
+ illustrations?: string[];
71
80
  }
72
- export declare function buildPageSingleDraft(draft: PageStructureDraft, labelValues: Record<string, string>): PageSingleDraft | null;
81
+ /**
82
+ * A section whose visible text is a DENSE PILE of copy strings reads as an
83
+ * ILLUSTRATION / product demo (a fake dashboard, a device mockup, an animated
84
+ * terminal), not editable content — e.g. a UI mock with 75 strings (column
85
+ * headers, buttons, status chips, plus a little fake body prose). Modeling
86
+ * that as 75 CMS fields is noise that makes a page uneditable.
87
+ *
88
+ * The primary signal is SIZE (framework-neutral: no real editable section has
89
+ * 30+ flat sibling copy fields — real content is small or structured into
90
+ * collections). A section that is SUBSTANTIALLY prose is exempt (an article /
91
+ * legal page is content, however long). For a medium pile (8-29 strings) the
92
+ * component NAME must corroborate. Conservative by design — a false positive
93
+ * silently drops editable content, so the caller SURFACES the flag for the
94
+ * agent to confirm rather than deleting it silently.
95
+ */
96
+ export declare function isIllustrationSection(section: PageSectionDraft, labelValues: Record<string, string>): boolean;
97
+ export declare function buildPageSingleDraft(draft: PageStructureDraft, labelValues: Record<string, string>, contentConfirmed?: ReadonlySet<string>): PageSingleDraft | null;
98
+ /**
99
+ * The machine-built Single Type for a shared chrome section (header, footer,
100
+ * nav, cookie banner): its structure AND content are identical on every page,
101
+ * so it is modeled ONCE and referenced. `markSharedSections` flags these; today
102
+ * `buildPageSingleDraft` just SKIPS them, so they die as orphan flat labels —
103
+ * this builds the Single the agent pushes instead.
104
+ */
105
+ export interface SharedSingleDraft {
106
+ uid: string;
107
+ component: string;
108
+ file: string;
109
+ schema: Record<string, unknown>;
110
+ document: Record<string, unknown>;
111
+ }
112
+ /**
113
+ * One Single Type per unique shared chrome section (deduped by
114
+ * `(file, component)` — the same header on N pages is ONE Single). Reuses
115
+ * `sectionBlock` so a header's nav/logo/labels become real fields; a section
116
+ * that yields no fields (a pure decorative wrapper) is skipped rather than
117
+ * emitting an empty Single.
118
+ */
119
+ export declare function buildSharedSingleDrafts(drafts: PageStructureDraft[], labelValues: Record<string, string>): SharedSingleDraft[];
120
+ /**
121
+ * A section the detector reads as an illustration/product-demo, with enough
122
+ * context for the human to confirm in a glance: WHERE it lives (`file`,
123
+ * `component`), WHAT strings it holds (`labelKeys` + a few `samples`). The
124
+ * interview turns each into an `IllustrationDecisionRecord`; keyed by
125
+ * `(file, component)` so the decision survives the agent's rename pass.
126
+ */
127
+ export interface IllustrationCandidate {
128
+ file: string;
129
+ component: string;
130
+ labelKeys: string[];
131
+ samples: string[];
132
+ }
133
+ /**
134
+ * Every section (top-level or nested) the illustration detector flags across
135
+ * the page-structure drafts, deduped by `(file, component)` — the same file
136
+ * rendered on several pages is one candidate, one question. Rich payload, not
137
+ * just a name: the caller relays it to the human and, on `drop`, exempts the
138
+ * `labelKeys` from the coverage/parity gates.
139
+ */
140
+ export declare function collectIllustrationCandidates(drafts: PageStructureDraft[], labelValues: Record<string, string>): IllustrationCandidate[];
141
+ /**
142
+ * A section the structural detector left as a page-local block (a default
143
+ * Component) but whose content model is genuinely in question: its component
144
+ * renders on 2+ pages, so the owner may know it is actually the SAME content
145
+ * everywhere (a Single Type) or a repeated managed entry (a Collection Type) —
146
+ * a call `markSharedSections` cannot make when the copies live in different
147
+ * files. Single-page sections are unambiguously Components and never surface.
148
+ */
149
+ export interface ContentModelCandidate {
150
+ file: string;
151
+ component: string;
152
+ /** How many page drafts render this component — the ambiguity signal. */
153
+ pages: number;
154
+ samples: string[];
155
+ }
156
+ /**
157
+ * The content-model classification candidates: non-shared, non-illustration
158
+ * sections whose component appears across 2+ page drafts. Deduped by
159
+ * `(file, component)`; the interview asks which are Single Types / Collection
160
+ * Types and leaves the rest as Components. Shared chrome (auto Single Type) and
161
+ * detected collections (auto Collection Type) never reach here.
162
+ */
163
+ export declare function collectContentModelCandidates(drafts: PageStructureDraft[], labelValues: Record<string, string>, contentConfirmed?: ReadonlySet<string>): ContentModelCandidate[];
@@ -1,8 +1,13 @@
1
1
  import { readFileSync, statSync } from 'node:fs';
2
- import { join, relative } from 'node:path';
2
+ import { basename, join, relative } from 'node:path';
3
3
  import { frameworkSectionStyle } from '../frameworks/discover.js';
4
4
  import { componentImports, resolveModuleFile, resolveRouteFiles } from './label-prefixes.js';
5
- const MAX_SECTION_DEPTH = 3;
5
+ // How deep the section recursion nests components into blocks. The platform
6
+ // caps structured-content nesting at 7 and the page Single wraps each top
7
+ // section as a block (one level), so 6 keeps the deepest child inside the
8
+ // limit while mirroring real page-builders that nest 5-6 deep (a features
9
+ // grid → groups → cards → …). Was 3, which flattened deep structure.
10
+ const MAX_SECTION_DEPTH = 6;
6
11
  const MAX_DRAFT_ROWS = 50;
7
12
  const pascal = (value) => value
8
13
  .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
@@ -91,6 +96,30 @@ function draftChildSections(context, file, depth) {
91
96
  }
92
97
  return sections;
93
98
  }
99
+ /**
100
+ * The page component's OWN inline content — hero copy, stats, an inline FAQ —
101
+ * lives under the page FILE's label prefix and is never an imported child, so
102
+ * `draftChildSections` skips it and it strands as orphan flat labels. Draft it
103
+ * as ONE section (its detected collections + labels, NO child recursion — the
104
+ * imported children are already separate top-level sections). Marked `pageOwn`
105
+ * so illustration detection doesn't mistake its many short labels for a mock.
106
+ */
107
+ function draftPageOwnSection(context, file) {
108
+ const rel = relative(context.cwd, file);
109
+ const prefix = context.style.fileScope(rel);
110
+ const labelKeys = context.keysByPrefix.get(prefix) ?? [];
111
+ const collections = (context.collectionsByFile.get(rel) ?? []).map((candidate) => ({
112
+ name: candidate.name,
113
+ fields: candidate.fields,
114
+ count: candidate.count,
115
+ ...(candidate.typedRows ? { rows: candidate.typedRows.slice(0, MAX_DRAFT_ROWS) } : {}),
116
+ }));
117
+ if (labelKeys.length === 0 && collections.length === 0) {
118
+ return null;
119
+ }
120
+ const component = pascal(basename(rel).replace(/\.\w+$/, ''));
121
+ return { key: camel(component), component, file: rel, prefix, labelKeys, collections, pageOwn: true };
122
+ }
94
123
  export function draftPageStructures(args) {
95
124
  const { cwd, framework, routes, collections } = args;
96
125
  const keysByPrefix = new Map();
@@ -109,8 +138,13 @@ export function draftPageStructures(args) {
109
138
  const drafts = [];
110
139
  for (const { routeId, file } of resolveRouteFiles(cwd, framework, routes)) {
111
140
  const context = { cwd, style, keysByPrefix, collectionsByFile, visited: new Set([file]) };
112
- const sections = draftChildSections(context, file, 1);
113
- if (sections.length > 1) {
141
+ const childSections = draftChildSections(context, file, 1);
142
+ const ownSection = draftPageOwnSection(context, file);
143
+ const sections = ownSection ? [ownSection, ...childSections] : childSections;
144
+ // buildPageSingleDraft's `totalFields >= 3` is the real triviality filter,
145
+ // so drafting any page with content (>= 1 section) is safe — and it is what
146
+ // lets an inline-only page (all content in the page file) get a Single.
147
+ if (sections.length >= 1) {
114
148
  drafts.push({ pageId: routeId, single: pascal(routeId), sections });
115
149
  }
116
150
  }
@@ -149,11 +183,94 @@ export function countDraftSections(draft) {
149
183
  }
150
184
  const labelFieldName = (key) => camel(key.split('.').pop() ?? key);
151
185
  const scalarType = (value) => value.length > 80 || value.includes('\n') ? 'text' : 'string';
152
- function sectionBlock(section, labelValues) {
186
+ /** Names that corroborate (never trigger alone) an illustration/demo. */
187
+ const ILLUSTRATION_NAME = /mock|demo|preview|illustration|visual|showcase|diagram|orbit|screenshot|animation|graphic|dashboard|widget|figure/i;
188
+ /** A section with this many flat sibling copy strings is a dump, not content:
189
+ * real editable sections stay small (a hero has ~3 fields, a card ~3), and
190
+ * genuinely repeated content is a collection/block, not N sibling labels. A
191
+ * UI mock reproduced in code (a fake dashboard, a device frame, a terminal
192
+ * demo) is the usual culprit — one carried 75 strings in the wild. */
193
+ const ILLUSTRATION_DENSE = 30;
194
+ const ILLUSTRATION_MIN_LABELS = 8;
195
+ const wordCount = (value) => value.trim().split(/\s+/).filter(Boolean).length;
196
+ /** Editable prose: a heading + paragraph section has some; a UI-chrome dump
197
+ * (a product mock's nav/table/button strings) is mostly tiny fragments. */
198
+ const looksLikeProse = (value) => {
199
+ const words = wordCount(value);
200
+ return words >= 8 || (words >= 5 && /[.!?]/.test(value.trim()));
201
+ };
202
+ /**
203
+ * A section whose visible text is a DENSE PILE of copy strings reads as an
204
+ * ILLUSTRATION / product demo (a fake dashboard, a device mockup, an animated
205
+ * terminal), not editable content — e.g. a UI mock with 75 strings (column
206
+ * headers, buttons, status chips, plus a little fake body prose). Modeling
207
+ * that as 75 CMS fields is noise that makes a page uneditable.
208
+ *
209
+ * The primary signal is SIZE (framework-neutral: no real editable section has
210
+ * 30+ flat sibling copy fields — real content is small or structured into
211
+ * collections). A section that is SUBSTANTIALLY prose is exempt (an article /
212
+ * legal page is content, however long). For a medium pile (8-29 strings) the
213
+ * component NAME must corroborate. Conservative by design — a false positive
214
+ * silently drops editable content, so the caller SURFACES the flag for the
215
+ * agent to confirm rather than deleting it silently.
216
+ */
217
+ export function isIllustrationSection(section, labelValues) {
218
+ // The page's own aggregate content is many sections at once, not a single
219
+ // component's UI mock — never read it as an illustration.
220
+ if (section.pageOwn) {
221
+ return false;
222
+ }
223
+ const values = section.labelKeys.map((key) => labelValues[key] ?? '').filter((value) => value.trim().length > 0);
224
+ if (values.length < ILLUSTRATION_MIN_LABELS) {
225
+ return false;
226
+ }
227
+ // Substantially prose → a text/article/legal section, not a UI dump.
228
+ const proseRatio = values.filter(looksLikeProse).length / values.length;
229
+ if (proseRatio > 0.4) {
230
+ return false;
231
+ }
232
+ // A dense pile is a dump on size alone — the confident trigger.
233
+ if (values.length >= ILLUSTRATION_DENSE) {
234
+ return true;
235
+ }
236
+ // Medium pile: only when the component name says mock/demo AND it is mostly
237
+ // tiny fragments — keeps the false-positive rate near zero.
238
+ const shortRatio = values.filter((value) => wordCount(value) <= 3).length / values.length;
239
+ return ILLUSTRATION_NAME.test(section.component) && shortRatio >= 0.7;
240
+ }
241
+ const sectionIdentity = (section) => `${section.file}::${section.component}`;
242
+ /**
243
+ * A section reads as an illustration to leave OUT of the model — UNLESS the
244
+ * owner confirmed in the interview that it IS editable content
245
+ * (`wrap.illustrations` disposition `content`, passed as `contentConfirmed`).
246
+ * That override is the whole point of asking, so it must beat the detector.
247
+ */
248
+ function isDroppableIllustration(section, labelValues, contentConfirmed) {
249
+ return isIllustrationSection(section, labelValues) && !contentConfirmed.has(sectionIdentity(section));
250
+ }
251
+ function sectionBlock(section, labelValues, illustrations, contentConfirmed) {
153
252
  const fields = {};
154
253
  const value = {};
155
254
  let fieldCount = 0;
255
+ // Partition the section's labels: a key with a nested path
256
+ // (`<prefix>.<group>.<item>.<field>`) is a COLLECTION encoded in the label
257
+ // keys (`ecosystem-items.github.name`, `…react.name`, …). Flattening it by
258
+ // last segment would collapse every item to one `name` field and silently
259
+ // drop the rest — group it into a repeatable block. Single-segment keys stay
260
+ // scalar fields.
261
+ const groups = new Map();
262
+ const relOf = (key) => key.startsWith(`${section.prefix}.`) ? key.slice(section.prefix.length + 1) : key;
156
263
  for (const key of section.labelKeys) {
264
+ const parts = relOf(key).split('.');
265
+ if (parts.length >= 3) {
266
+ const [group, item, ...fieldParts] = parts;
267
+ const itemMap = groups.get(group) ?? new Map();
268
+ const itemFields = itemMap.get(item) ?? {};
269
+ itemFields[camel(fieldParts.join('-'))] = labelValues[key] ?? '';
270
+ itemMap.set(item, itemFields);
271
+ groups.set(group, itemMap);
272
+ continue;
273
+ }
157
274
  const name = labelFieldName(key);
158
275
  if (fields[name]) {
159
276
  continue;
@@ -163,6 +280,21 @@ function sectionBlock(section, labelValues) {
163
280
  value[name] = labelValue;
164
281
  fieldCount += 1;
165
282
  }
283
+ for (const [group, itemMap] of groups) {
284
+ const name = camel(group);
285
+ if (fields[name]) {
286
+ continue;
287
+ }
288
+ const itemFields = {};
289
+ for (const itemValue of itemMap.values()) {
290
+ for (const fieldName of Object.keys(itemValue)) {
291
+ itemFields[fieldName] = { type: 'string' };
292
+ }
293
+ }
294
+ fields[name] = { type: 'blocks', schema: { fields: itemFields } };
295
+ value[name] = [...itemMap.values()];
296
+ fieldCount += 1;
297
+ }
166
298
  for (const collection of section.collections) {
167
299
  const name = camel(collection.name);
168
300
  if (fields[name]) {
@@ -192,7 +324,11 @@ function sectionBlock(section, labelValues) {
192
324
  if (child.shared) {
193
325
  continue;
194
326
  }
195
- const block = sectionBlock(child, labelValues);
327
+ if (isDroppableIllustration(child, labelValues, contentConfirmed)) {
328
+ illustrations.push(child.component);
329
+ continue;
330
+ }
331
+ const block = sectionBlock(child, labelValues, illustrations, contentConfirmed);
196
332
  if (!block || fields[child.key]) {
197
333
  continue;
198
334
  }
@@ -205,15 +341,24 @@ function sectionBlock(section, labelValues) {
205
341
  }
206
342
  return { fields, value, fieldCount };
207
343
  }
208
- export function buildPageSingleDraft(draft, labelValues) {
344
+ export function buildPageSingleDraft(draft, labelValues, contentConfirmed = new Set()) {
209
345
  const fields = {};
210
346
  const document = {};
347
+ const illustrations = [];
211
348
  let totalFields = 0;
212
349
  for (const section of draft.sections) {
213
350
  if (section.shared || fields[section.key]) {
214
351
  continue;
215
352
  }
216
- const block = sectionBlock(section, labelValues);
353
+ // Illustration/demo sections (a product mock's UI-chrome strings) are noise
354
+ // in a CMS — leave them out of the single. Surfaced, never silently lost.
355
+ // The interview's `content` override folds one back in (from the next run,
356
+ // once the decision is recorded — singles are built before the interview).
357
+ if (isDroppableIllustration(section, labelValues, contentConfirmed)) {
358
+ illustrations.push(section.component);
359
+ continue;
360
+ }
361
+ const block = sectionBlock(section, labelValues, illustrations, contentConfirmed);
217
362
  if (!block) {
218
363
  continue;
219
364
  }
@@ -230,5 +375,123 @@ export function buildPageSingleDraft(draft, labelValues) {
230
375
  pageId: draft.pageId,
231
376
  schema: { uid: draft.single, name: draft.single, isSingleton: true, fields },
232
377
  document,
378
+ ...(illustrations.length > 0 ? { illustrations: [...new Set(illustrations)] } : {}),
379
+ };
380
+ }
381
+ /**
382
+ * One Single Type per unique shared chrome section (deduped by
383
+ * `(file, component)` — the same header on N pages is ONE Single). Reuses
384
+ * `sectionBlock` so a header's nav/logo/labels become real fields; a section
385
+ * that yields no fields (a pure decorative wrapper) is skipped rather than
386
+ * emitting an empty Single.
387
+ */
388
+ export function buildSharedSingleDrafts(drafts, labelValues) {
389
+ const byIdentity = new Map();
390
+ const walk = (sections) => {
391
+ for (const section of sections) {
392
+ if (section.shared) {
393
+ const identity = `${section.file}::${section.component}`;
394
+ if (!byIdentity.has(identity)) {
395
+ const block = sectionBlock(section, labelValues, [], new Set());
396
+ if (block && Object.keys(block.fields).length > 0) {
397
+ const uid = pascal(section.component);
398
+ byIdentity.set(identity, {
399
+ uid,
400
+ component: section.component,
401
+ file: section.file,
402
+ schema: { uid, name: uid, isSingleton: true, fields: block.fields },
403
+ document: block.value,
404
+ });
405
+ }
406
+ }
407
+ // A shared section owns its whole subtree — don't descend for more.
408
+ continue;
409
+ }
410
+ walk(section.children ?? []);
411
+ }
412
+ };
413
+ for (const draft of drafts) {
414
+ walk(draft.sections);
415
+ }
416
+ return [...byIdentity.values()];
417
+ }
418
+ /** How many sample strings a candidate carries so a human can judge in 3s. */
419
+ const SECTION_SAMPLE_COUNT = 6;
420
+ const sectionSamples = (section, labelValues) => section.labelKeys
421
+ .map((key) => labelValues[key] ?? '')
422
+ .filter((value) => value.trim().length > 0)
423
+ .slice(0, SECTION_SAMPLE_COUNT);
424
+ /**
425
+ * Every section (top-level or nested) the illustration detector flags across
426
+ * the page-structure drafts, deduped by `(file, component)` — the same file
427
+ * rendered on several pages is one candidate, one question. Rich payload, not
428
+ * just a name: the caller relays it to the human and, on `drop`, exempts the
429
+ * `labelKeys` from the coverage/parity gates.
430
+ */
431
+ export function collectIllustrationCandidates(drafts, labelValues) {
432
+ const byIdentity = new Map();
433
+ const walk = (sections) => {
434
+ for (const section of sections) {
435
+ if (isIllustrationSection(section, labelValues)) {
436
+ const identity = `${section.file}::${section.component}`;
437
+ if (!byIdentity.has(identity)) {
438
+ byIdentity.set(identity, {
439
+ file: section.file,
440
+ component: section.component,
441
+ labelKeys: [...section.labelKeys],
442
+ samples: sectionSamples(section, labelValues),
443
+ });
444
+ }
445
+ }
446
+ walk(section.children ?? []);
447
+ }
233
448
  };
449
+ for (const draft of drafts) {
450
+ walk(draft.sections);
451
+ }
452
+ return [...byIdentity.values()];
453
+ }
454
+ /**
455
+ * The content-model classification candidates: non-shared, non-illustration
456
+ * sections whose component appears across 2+ page drafts. Deduped by
457
+ * `(file, component)`; the interview asks which are Single Types / Collection
458
+ * Types and leaves the rest as Components. Shared chrome (auto Single Type) and
459
+ * detected collections (auto Collection Type) never reach here.
460
+ */
461
+ export function collectContentModelCandidates(drafts, labelValues, contentConfirmed = new Set()) {
462
+ const eligible = (section) => !section.shared && !isDroppableIllustration(section, labelValues, contentConfirmed);
463
+ const pagesByComponent = new Map();
464
+ const count = (pageId, sections) => {
465
+ for (const section of sections) {
466
+ if (eligible(section)) {
467
+ pagesByComponent.set(section.component, (pagesByComponent.get(section.component) ?? new Set()).add(pageId));
468
+ }
469
+ count(pageId, section.children ?? []);
470
+ }
471
+ };
472
+ for (const draft of drafts) {
473
+ count(draft.pageId, draft.sections);
474
+ }
475
+ const byIdentity = new Map();
476
+ const collect = (sections) => {
477
+ for (const section of sections) {
478
+ const pages = pagesByComponent.get(section.component)?.size ?? 0;
479
+ if (eligible(section) && pages >= 2) {
480
+ const identity = `${section.file}::${section.component}`;
481
+ if (!byIdentity.has(identity)) {
482
+ byIdentity.set(identity, {
483
+ file: section.file,
484
+ component: section.component,
485
+ pages,
486
+ samples: sectionSamples(section, labelValues),
487
+ });
488
+ }
489
+ }
490
+ collect(section.children ?? []);
491
+ }
492
+ };
493
+ for (const draft of drafts) {
494
+ collect(draft.sections);
495
+ }
496
+ return [...byIdentity.values()];
234
497
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fimo",
3
- "version": "0.9.0-experimental.10",
3
+ "version": "0.9.0-experimental.12",
4
4
  "description": "Fimo CLI - create, deploy, and manage Fimo projects",
5
5
  "bin": {
6
6
  "fimo": "dist/cli/index.js"
@@ -21,7 +21,7 @@
21
21
  "cmdk": "^1.1.1",
22
22
  "date-fns": "^4.1.0",
23
23
  "embla-carousel-react": "^8.6.0",
24
- "fimo": "0.9.0-experimental.10",
24
+ "fimo": "0.9.0-experimental.12",
25
25
  "input-otp": "^1.4.2",
26
26
  "isbot": "^5",
27
27
  "lucide-react": "^0.577.0",