fimo 0.9.0-experimental.10 → 0.9.0-experimental.11

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.
@@ -68,5 +68,69 @@ export interface PageSingleDraft {
68
68
  pageId: string;
69
69
  schema: Record<string, unknown>;
70
70
  document: Record<string, unknown>;
71
+ /** Section components left OUT of the single because they read as
72
+ * illustrations/product demos, not editable content — the agent confirms
73
+ * each is decorative (exempts it) or pulls it back into the model. */
74
+ illustrations?: string[];
71
75
  }
72
- export declare function buildPageSingleDraft(draft: PageStructureDraft, labelValues: Record<string, string>): PageSingleDraft | null;
76
+ /**
77
+ * A section whose visible text is a DENSE PILE of copy strings reads as an
78
+ * ILLUSTRATION / product demo (a fake dashboard, a device mockup, an animated
79
+ * terminal), not editable content — e.g. a UI mock with 75 strings (column
80
+ * headers, buttons, status chips, plus a little fake body prose). Modeling
81
+ * that as 75 CMS fields is noise that makes a page uneditable.
82
+ *
83
+ * The primary signal is SIZE (framework-neutral: no real editable section has
84
+ * 30+ flat sibling copy fields — real content is small or structured into
85
+ * collections). A section that is SUBSTANTIALLY prose is exempt (an article /
86
+ * legal page is content, however long). For a medium pile (8-29 strings) the
87
+ * component NAME must corroborate. Conservative by design — a false positive
88
+ * silently drops editable content, so the caller SURFACES the flag for the
89
+ * agent to confirm rather than deleting it silently.
90
+ */
91
+ export declare function isIllustrationSection(section: PageSectionDraft, labelValues: Record<string, string>): boolean;
92
+ export declare function buildPageSingleDraft(draft: PageStructureDraft, labelValues: Record<string, string>, contentConfirmed?: ReadonlySet<string>): PageSingleDraft | null;
93
+ /**
94
+ * A section the detector reads as an illustration/product-demo, with enough
95
+ * context for the human to confirm in a glance: WHERE it lives (`file`,
96
+ * `component`), WHAT strings it holds (`labelKeys` + a few `samples`). The
97
+ * interview turns each into an `IllustrationDecisionRecord`; keyed by
98
+ * `(file, component)` so the decision survives the agent's rename pass.
99
+ */
100
+ export interface IllustrationCandidate {
101
+ file: string;
102
+ component: string;
103
+ labelKeys: string[];
104
+ samples: string[];
105
+ }
106
+ /**
107
+ * Every section (top-level or nested) the illustration detector flags across
108
+ * the page-structure drafts, deduped by `(file, component)` — the same file
109
+ * rendered on several pages is one candidate, one question. Rich payload, not
110
+ * just a name: the caller relays it to the human and, on `drop`, exempts the
111
+ * `labelKeys` from the coverage/parity gates.
112
+ */
113
+ export declare function collectIllustrationCandidates(drafts: PageStructureDraft[], labelValues: Record<string, string>): IllustrationCandidate[];
114
+ /**
115
+ * A section the structural detector left as a page-local block (a default
116
+ * Component) but whose content model is genuinely in question: its component
117
+ * renders on 2+ pages, so the owner may know it is actually the SAME content
118
+ * everywhere (a Single Type) or a repeated managed entry (a Collection Type) —
119
+ * a call `markSharedSections` cannot make when the copies live in different
120
+ * files. Single-page sections are unambiguously Components and never surface.
121
+ */
122
+ export interface ContentModelCandidate {
123
+ file: string;
124
+ component: string;
125
+ /** How many page drafts render this component — the ambiguity signal. */
126
+ pages: number;
127
+ samples: string[];
128
+ }
129
+ /**
130
+ * The content-model classification candidates: non-shared, non-illustration
131
+ * sections whose component appears across 2+ page drafts. Deduped by
132
+ * `(file, component)`; the interview asks which are Single Types / Collection
133
+ * Types and leaves the rest as Components. Shared chrome (auto Single Type) and
134
+ * detected collections (auto Collection Type) never reach here.
135
+ */
136
+ export declare function collectContentModelCandidates(drafts: PageStructureDraft[], labelValues: Record<string, string>, contentConfirmed?: ReadonlySet<string>): ContentModelCandidate[];
@@ -2,7 +2,12 @@ import { readFileSync, statSync } from 'node:fs';
2
2
  import { 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')
@@ -149,7 +154,67 @@ export function countDraftSections(draft) {
149
154
  }
150
155
  const labelFieldName = (key) => camel(key.split('.').pop() ?? key);
151
156
  const scalarType = (value) => value.length > 80 || value.includes('\n') ? 'text' : 'string';
152
- function sectionBlock(section, labelValues) {
157
+ /** Names that corroborate (never trigger alone) an illustration/demo. */
158
+ const ILLUSTRATION_NAME = /mock|demo|preview|illustration|visual|showcase|diagram|orbit|screenshot|animation|graphic|dashboard|widget|figure/i;
159
+ /** A section with this many flat sibling copy strings is a dump, not content:
160
+ * real editable sections stay small (a hero has ~3 fields, a card ~3), and
161
+ * genuinely repeated content is a collection/block, not N sibling labels. A
162
+ * UI mock reproduced in code (a fake dashboard, a device frame, a terminal
163
+ * demo) is the usual culprit — one carried 75 strings in the wild. */
164
+ const ILLUSTRATION_DENSE = 30;
165
+ const ILLUSTRATION_MIN_LABELS = 8;
166
+ const wordCount = (value) => value.trim().split(/\s+/).filter(Boolean).length;
167
+ /** Editable prose: a heading + paragraph section has some; a UI-chrome dump
168
+ * (a product mock's nav/table/button strings) is mostly tiny fragments. */
169
+ const looksLikeProse = (value) => {
170
+ const words = wordCount(value);
171
+ return words >= 8 || (words >= 5 && /[.!?]/.test(value.trim()));
172
+ };
173
+ /**
174
+ * A section whose visible text is a DENSE PILE of copy strings reads as an
175
+ * ILLUSTRATION / product demo (a fake dashboard, a device mockup, an animated
176
+ * terminal), not editable content — e.g. a UI mock with 75 strings (column
177
+ * headers, buttons, status chips, plus a little fake body prose). Modeling
178
+ * that as 75 CMS fields is noise that makes a page uneditable.
179
+ *
180
+ * The primary signal is SIZE (framework-neutral: no real editable section has
181
+ * 30+ flat sibling copy fields — real content is small or structured into
182
+ * collections). A section that is SUBSTANTIALLY prose is exempt (an article /
183
+ * legal page is content, however long). For a medium pile (8-29 strings) the
184
+ * component NAME must corroborate. Conservative by design — a false positive
185
+ * silently drops editable content, so the caller SURFACES the flag for the
186
+ * agent to confirm rather than deleting it silently.
187
+ */
188
+ export function isIllustrationSection(section, labelValues) {
189
+ const values = section.labelKeys.map((key) => labelValues[key] ?? '').filter((value) => value.trim().length > 0);
190
+ if (values.length < ILLUSTRATION_MIN_LABELS) {
191
+ return false;
192
+ }
193
+ // Substantially prose → a text/article/legal section, not a UI dump.
194
+ const proseRatio = values.filter(looksLikeProse).length / values.length;
195
+ if (proseRatio > 0.4) {
196
+ return false;
197
+ }
198
+ // A dense pile is a dump on size alone — the confident trigger.
199
+ if (values.length >= ILLUSTRATION_DENSE) {
200
+ return true;
201
+ }
202
+ // Medium pile: only when the component name says mock/demo AND it is mostly
203
+ // tiny fragments — keeps the false-positive rate near zero.
204
+ const shortRatio = values.filter((value) => wordCount(value) <= 3).length / values.length;
205
+ return ILLUSTRATION_NAME.test(section.component) && shortRatio >= 0.7;
206
+ }
207
+ const sectionIdentity = (section) => `${section.file}::${section.component}`;
208
+ /**
209
+ * A section reads as an illustration to leave OUT of the model — UNLESS the
210
+ * owner confirmed in the interview that it IS editable content
211
+ * (`wrap.illustrations` disposition `content`, passed as `contentConfirmed`).
212
+ * That override is the whole point of asking, so it must beat the detector.
213
+ */
214
+ function isDroppableIllustration(section, labelValues, contentConfirmed) {
215
+ return isIllustrationSection(section, labelValues) && !contentConfirmed.has(sectionIdentity(section));
216
+ }
217
+ function sectionBlock(section, labelValues, illustrations, contentConfirmed) {
153
218
  const fields = {};
154
219
  const value = {};
155
220
  let fieldCount = 0;
@@ -192,7 +257,11 @@ function sectionBlock(section, labelValues) {
192
257
  if (child.shared) {
193
258
  continue;
194
259
  }
195
- const block = sectionBlock(child, labelValues);
260
+ if (isDroppableIllustration(child, labelValues, contentConfirmed)) {
261
+ illustrations.push(child.component);
262
+ continue;
263
+ }
264
+ const block = sectionBlock(child, labelValues, illustrations, contentConfirmed);
196
265
  if (!block || fields[child.key]) {
197
266
  continue;
198
267
  }
@@ -205,15 +274,24 @@ function sectionBlock(section, labelValues) {
205
274
  }
206
275
  return { fields, value, fieldCount };
207
276
  }
208
- export function buildPageSingleDraft(draft, labelValues) {
277
+ export function buildPageSingleDraft(draft, labelValues, contentConfirmed = new Set()) {
209
278
  const fields = {};
210
279
  const document = {};
280
+ const illustrations = [];
211
281
  let totalFields = 0;
212
282
  for (const section of draft.sections) {
213
283
  if (section.shared || fields[section.key]) {
214
284
  continue;
215
285
  }
216
- const block = sectionBlock(section, labelValues);
286
+ // Illustration/demo sections (a product mock's UI-chrome strings) are noise
287
+ // in a CMS — leave them out of the single. Surfaced, never silently lost.
288
+ // The interview's `content` override folds one back in (from the next run,
289
+ // once the decision is recorded — singles are built before the interview).
290
+ if (isDroppableIllustration(section, labelValues, contentConfirmed)) {
291
+ illustrations.push(section.component);
292
+ continue;
293
+ }
294
+ const block = sectionBlock(section, labelValues, illustrations, contentConfirmed);
217
295
  if (!block) {
218
296
  continue;
219
297
  }
@@ -230,5 +308,86 @@ export function buildPageSingleDraft(draft, labelValues) {
230
308
  pageId: draft.pageId,
231
309
  schema: { uid: draft.single, name: draft.single, isSingleton: true, fields },
232
310
  document,
311
+ ...(illustrations.length > 0 ? { illustrations: [...new Set(illustrations)] } : {}),
312
+ };
313
+ }
314
+ /** How many sample strings a candidate carries so a human can judge in 3s. */
315
+ const SECTION_SAMPLE_COUNT = 6;
316
+ const sectionSamples = (section, labelValues) => section.labelKeys
317
+ .map((key) => labelValues[key] ?? '')
318
+ .filter((value) => value.trim().length > 0)
319
+ .slice(0, SECTION_SAMPLE_COUNT);
320
+ /**
321
+ * Every section (top-level or nested) the illustration detector flags across
322
+ * the page-structure drafts, deduped by `(file, component)` — the same file
323
+ * rendered on several pages is one candidate, one question. Rich payload, not
324
+ * just a name: the caller relays it to the human and, on `drop`, exempts the
325
+ * `labelKeys` from the coverage/parity gates.
326
+ */
327
+ export function collectIllustrationCandidates(drafts, labelValues) {
328
+ const byIdentity = new Map();
329
+ const walk = (sections) => {
330
+ for (const section of sections) {
331
+ if (isIllustrationSection(section, labelValues)) {
332
+ const identity = `${section.file}::${section.component}`;
333
+ if (!byIdentity.has(identity)) {
334
+ byIdentity.set(identity, {
335
+ file: section.file,
336
+ component: section.component,
337
+ labelKeys: [...section.labelKeys],
338
+ samples: sectionSamples(section, labelValues),
339
+ });
340
+ }
341
+ }
342
+ walk(section.children ?? []);
343
+ }
233
344
  };
345
+ for (const draft of drafts) {
346
+ walk(draft.sections);
347
+ }
348
+ return [...byIdentity.values()];
349
+ }
350
+ /**
351
+ * The content-model classification candidates: non-shared, non-illustration
352
+ * sections whose component appears across 2+ page drafts. Deduped by
353
+ * `(file, component)`; the interview asks which are Single Types / Collection
354
+ * Types and leaves the rest as Components. Shared chrome (auto Single Type) and
355
+ * detected collections (auto Collection Type) never reach here.
356
+ */
357
+ export function collectContentModelCandidates(drafts, labelValues, contentConfirmed = new Set()) {
358
+ const eligible = (section) => !section.shared && !isDroppableIllustration(section, labelValues, contentConfirmed);
359
+ const pagesByComponent = new Map();
360
+ const count = (pageId, sections) => {
361
+ for (const section of sections) {
362
+ if (eligible(section)) {
363
+ pagesByComponent.set(section.component, (pagesByComponent.get(section.component) ?? new Set()).add(pageId));
364
+ }
365
+ count(pageId, section.children ?? []);
366
+ }
367
+ };
368
+ for (const draft of drafts) {
369
+ count(draft.pageId, draft.sections);
370
+ }
371
+ const byIdentity = new Map();
372
+ const collect = (sections) => {
373
+ for (const section of sections) {
374
+ const pages = pagesByComponent.get(section.component)?.size ?? 0;
375
+ if (eligible(section) && pages >= 2) {
376
+ const identity = `${section.file}::${section.component}`;
377
+ if (!byIdentity.has(identity)) {
378
+ byIdentity.set(identity, {
379
+ file: section.file,
380
+ component: section.component,
381
+ pages,
382
+ samples: sectionSamples(section, labelValues),
383
+ });
384
+ }
385
+ }
386
+ collect(section.children ?? []);
387
+ }
388
+ };
389
+ for (const draft of drafts) {
390
+ collect(draft.sections);
391
+ }
392
+ return [...byIdentity.values()];
234
393
  }
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.11",
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.11",
25
25
  "input-otp": "^1.4.2",
26
26
  "isbot": "^5",
27
27
  "lucide-react": "^0.577.0",