fimo 0.9.0-experimental.1 → 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.
- package/assets/agent-templates/content-translator/scripts/write-locale-files.ts +66 -0
- package/assets/agents-starter/claude/settings.json +19 -0
- package/assets/content-templates/types-template.eta +1 -0
- package/assets/skills/fimo/references/translations.md +75 -0
- package/assets/skills/fimo-migration/SKILL.md +2 -0
- package/assets/skills/fimo-migration/references/manifest.md +11 -0
- package/assets/skills/fimo-migration/references/pages.md +298 -0
- package/assets/skills/fimo-migration/references/wrap.md +45 -0
- package/dist/build/vite/plugins/translations.js +28 -10
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +577 -269
- package/dist/runtime/app/FimoProviders.js +29 -7
- package/dist/runtime/core/BuiltWithBadge.d.ts +2 -0
- package/dist/runtime/core/BuiltWithBadge.js +68 -0
- package/dist/runtime/primitives/components/RichText.d.ts +5 -1
- package/dist/runtime/primitives/components/RichText.js +2 -2
- package/dist/runtime/react/FimoSpaProviders.js +83 -24
- package/dist/runtime/react/index.d.ts +2 -0
- package/dist/runtime/react/index.js +1 -0
- package/dist/runtime/react/spa-content-cache.d.ts +38 -0
- package/dist/runtime/react/spa-content-cache.js +64 -0
- package/dist/runtime/seo/neutral.d.ts +22 -0
- package/dist/runtime/seo/neutral.js +17 -0
- package/dist/runtime/shared/fimo-config.server.d.ts +0 -1
- package/dist/runtime/shared/fimo-config.server.js +0 -13
- package/dist/runtime/strapi/client.js +45 -2
- package/dist/runtime/strapi/reshape.d.ts +10 -6
- package/dist/runtime/strapi/reshape.js +7 -0
- package/dist/runtime/wrapped/FimoRichTextBlock.d.ts +4 -1
- package/dist/runtime/wrapped/FimoRichTextBlock.js +2 -2
- package/dist/runtime/wrapped/content-fetch.pure.d.ts +20 -0
- package/dist/runtime/wrapped/content-fetch.pure.js +106 -0
- package/dist/runtime/wrapped/server.d.ts +5 -2
- package/dist/runtime/wrapped/server.js +15 -45
- package/dist/scripts/apply-wrap-templates.js +10 -5
- package/dist/scripts/apply-wrap.d.ts +29 -2
- package/dist/scripts/apply-wrap.js +69 -7
- package/dist/scripts/cms/example-content.js +16 -13
- package/dist/scripts/cms/strapi/schema-map.js +16 -4
- package/dist/scripts/cms/strapi/shapes.d.ts +7 -5
- package/dist/scripts/cms/strapi/shapes.js +10 -0
- package/dist/scripts/cms/strapi/wiring.js +1 -1
- package/dist/scripts/detect-framework.js +6 -0
- package/dist/scripts/draft-rows.js +44 -1
- package/dist/scripts/draft-schemas.js +64 -5
- package/dist/scripts/frameworks/astro/routes.js +8 -19
- package/dist/scripts/frameworks/discover.d.ts +19 -6
- package/dist/scripts/frameworks/discover.js +113 -3
- package/dist/scripts/frameworks/discovered-route.d.ts +9 -0
- package/dist/scripts/frameworks/discovered-route.js +17 -0
- package/dist/scripts/frameworks/next/rewire.d.ts +13 -0
- package/dist/scripts/frameworks/next/rewire.js +26 -2
- package/dist/scripts/frameworks/next/routes.d.ts +13 -0
- package/dist/scripts/frameworks/next/routes.js +55 -13
- package/dist/scripts/frameworks/nuxt/routes.js +2 -18
- package/dist/scripts/frameworks/remix/routes.js +4 -18
- package/dist/scripts/frameworks/sveltekit/routes.js +4 -19
- package/dist/scripts/frameworks/vite/routes.d.ts +5 -0
- package/dist/scripts/frameworks/vite/routes.js +59 -9
- package/dist/scripts/frameworks/vite/wiring.d.ts +12 -0
- package/dist/scripts/frameworks/vite/wiring.js +68 -23
- package/dist/scripts/lib/humanize.d.ts +14 -0
- package/dist/scripts/lib/humanize.js +54 -0
- package/dist/scripts/lib/validation-report.d.ts +0 -3
- package/dist/scripts/lib/validation-report.js +0 -9
- package/dist/scripts/render-parity/capture.d.ts +12 -4
- package/dist/scripts/render-parity/capture.js +94 -24
- package/dist/scripts/routes/bindings.d.ts +93 -0
- package/dist/scripts/routes/bindings.js +117 -0
- package/dist/scripts/routes/label-prefixes.d.ts +49 -0
- package/dist/scripts/routes/label-prefixes.js +136 -0
- package/dist/scripts/routes/module-resolve.d.ts +20 -0
- package/dist/scripts/routes/module-resolve.js +78 -0
- package/dist/scripts/routes/page-structure.d.ts +136 -0
- package/dist/scripts/routes/page-structure.js +393 -0
- package/dist/scripts/scan-wrappable.d.ts +5 -1
- package/dist/scripts/scan-wrappable.js +126 -3
- package/package.json +5 -1
- package/templates/react-router/package.json +1 -1
- package/templates/react-router/translations/en.json +1 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { CollectionCandidate } from '../scan-wrappable.js';
|
|
2
|
+
/**
|
|
3
|
+
* Page-structure drafts — the machine's proposed NESTED model of a page.
|
|
4
|
+
* The scanner already sees everything the model needs: the page file's
|
|
5
|
+
* section components (capitalized JSX children, in document order), each
|
|
6
|
+
* section's label keys (its file-derived prefix) and its in-code collection
|
|
7
|
+
* candidates (typed rows). Joined, that IS the page's Single: one nested
|
|
8
|
+
* block field per section, recursing into the section's own components so
|
|
9
|
+
* deep structure (a Features grid whose groups live in their own files)
|
|
10
|
+
* surfaces too. The agent refines names and shapes — it no longer decides
|
|
11
|
+
* WHETHER to model, and real marketing copy stops dying as "page furniture"
|
|
12
|
+
* exemptions.
|
|
13
|
+
*/
|
|
14
|
+
export interface PageSectionDraft {
|
|
15
|
+
/** Proposed field key on the page Single (camelCase of the component). */
|
|
16
|
+
key: string;
|
|
17
|
+
component: string;
|
|
18
|
+
file: string;
|
|
19
|
+
/** The section's label-key prefix (file-derived). */
|
|
20
|
+
prefix: string;
|
|
21
|
+
/** Staged label keys the section owns — its scalar copy fields. */
|
|
22
|
+
labelKeys: string[];
|
|
23
|
+
/** In-code collection candidates found in the section's file — its
|
|
24
|
+
* repeated block items (cards, quotes, logos), with their source rows so
|
|
25
|
+
* the Single's document can be assembled without reopening the code. */
|
|
26
|
+
collections: Array<{
|
|
27
|
+
name: string;
|
|
28
|
+
fields: string[];
|
|
29
|
+
count: number;
|
|
30
|
+
rows?: Array<Record<string, unknown>>;
|
|
31
|
+
}>;
|
|
32
|
+
/** Content-bearing components the section renders — nested blocks. */
|
|
33
|
+
children?: PageSectionDraft[];
|
|
34
|
+
/** The same file drafts on several pages — chrome. Model it ONCE as a
|
|
35
|
+
* Single Type, never as a per-page block. */
|
|
36
|
+
shared?: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface PageStructureDraft {
|
|
39
|
+
pageId: string;
|
|
40
|
+
/** Proposed Single uid (PascalCase of the page id). */
|
|
41
|
+
single: string;
|
|
42
|
+
sections: PageSectionDraft[];
|
|
43
|
+
}
|
|
44
|
+
export declare function draftPageStructures(args: {
|
|
45
|
+
cwd: string;
|
|
46
|
+
framework: string | null;
|
|
47
|
+
routes: Record<string, {
|
|
48
|
+
path?: string;
|
|
49
|
+
labelPrefixes?: unknown;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}>;
|
|
52
|
+
stagedKeys: Iterable<string>;
|
|
53
|
+
collections: CollectionCandidate[];
|
|
54
|
+
}): PageStructureDraft[];
|
|
55
|
+
/** Flat section count across the nesting — the summary's visibility number. */
|
|
56
|
+
export declare function countDraftSections(draft: PageStructureDraft): number;
|
|
57
|
+
/**
|
|
58
|
+
* The machine-BUILT page Single: a ready-to-push singleton schema plus its
|
|
59
|
+
* filled document, assembled from the draft's non-shared sections (shared
|
|
60
|
+
* sections are chrome — modeled once elsewhere). One `max: 1` block per
|
|
61
|
+
* section; the section's labels become scalar fields carrying the staged
|
|
62
|
+
* values, its repeated arrays become nested blocks carrying the source
|
|
63
|
+
* rows, its child components nest the same way. The agent renames fields
|
|
64
|
+
* to editorial names and rewires reads — it no longer composes anything.
|
|
65
|
+
*/
|
|
66
|
+
export interface PageSingleDraft {
|
|
67
|
+
uid: string;
|
|
68
|
+
pageId: string;
|
|
69
|
+
schema: Record<string, unknown>;
|
|
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[];
|
|
75
|
+
}
|
|
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[];
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { frameworkSectionStyle } from '../frameworks/discover.js';
|
|
4
|
+
import { componentImports, resolveModuleFile, resolveRouteFiles } from './label-prefixes.js';
|
|
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;
|
|
11
|
+
const MAX_DRAFT_ROWS = 50;
|
|
12
|
+
const pascal = (value) => value
|
|
13
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
14
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
15
|
+
.split(/[-_.\s]/)
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
18
|
+
.join('');
|
|
19
|
+
const camel = (value) => {
|
|
20
|
+
const p = pascal(value);
|
|
21
|
+
return p.charAt(0).toLowerCase() + p.slice(1);
|
|
22
|
+
};
|
|
23
|
+
/** Capitalized JSX tags in DOCUMENT order (deduped, first appearance). */
|
|
24
|
+
function sectionTags(code) {
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const tags = [];
|
|
27
|
+
for (const match of code.matchAll(/<([A-Z]\w*)[\s/>]/g)) {
|
|
28
|
+
if (!seen.has(match[1])) {
|
|
29
|
+
seen.add(match[1]);
|
|
30
|
+
tags.push(match[1]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return tags;
|
|
34
|
+
}
|
|
35
|
+
function draftSection(context, tag, file, depth) {
|
|
36
|
+
const rel = relative(context.cwd, file);
|
|
37
|
+
const prefix = context.style.fileScope(rel);
|
|
38
|
+
const labelKeys = context.keysByPrefix.get(prefix) ?? [];
|
|
39
|
+
const collections = (context.collectionsByFile.get(rel) ?? []).map((candidate) => ({
|
|
40
|
+
name: candidate.name,
|
|
41
|
+
fields: candidate.fields,
|
|
42
|
+
count: candidate.count,
|
|
43
|
+
...(candidate.typedRows ? { rows: candidate.typedRows.slice(0, MAX_DRAFT_ROWS) } : {}),
|
|
44
|
+
}));
|
|
45
|
+
const children = depth < MAX_SECTION_DEPTH ? draftChildSections(context, file, depth + 1) : [];
|
|
46
|
+
if (labelKeys.length === 0 && collections.length === 0 && children.length === 0) {
|
|
47
|
+
return null; // pure layout — nothing to model
|
|
48
|
+
}
|
|
49
|
+
const draft = { key: camel(tag), component: tag, file: rel, prefix, labelKeys, collections };
|
|
50
|
+
if (children.length > 0) {
|
|
51
|
+
draft.children = children;
|
|
52
|
+
}
|
|
53
|
+
return draft;
|
|
54
|
+
}
|
|
55
|
+
/** Auto-imported components (Nuxt's components/ convention): a tag with no
|
|
56
|
+
* import statement may still be a local file at a conventional path. */
|
|
57
|
+
function resolveAutoImportedComponent(context, tag) {
|
|
58
|
+
for (const dir of context.style.fallbackComponentDirs) {
|
|
59
|
+
for (const ext of context.style.extensions) {
|
|
60
|
+
const candidate = join(context.cwd, dir, `${tag}${ext}`);
|
|
61
|
+
try {
|
|
62
|
+
if (statSync(candidate).isFile()) {
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* try next */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function draftChildSections(context, file, depth) {
|
|
74
|
+
let code;
|
|
75
|
+
try {
|
|
76
|
+
code = readFileSync(file, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const imports = componentImports(code);
|
|
82
|
+
const sections = [];
|
|
83
|
+
for (const tag of sectionTags(code)) {
|
|
84
|
+
const spec = imports.get(tag);
|
|
85
|
+
const sectionFile = spec
|
|
86
|
+
? resolveModuleFile(context.cwd, file, spec, context.style.extensions)
|
|
87
|
+
: resolveAutoImportedComponent(context, tag);
|
|
88
|
+
if (!sectionFile || context.visited.has(sectionFile)) {
|
|
89
|
+
continue; // library components (Button, Link) have no local file
|
|
90
|
+
}
|
|
91
|
+
context.visited.add(sectionFile);
|
|
92
|
+
const section = draftSection(context, tag, sectionFile, depth);
|
|
93
|
+
if (section) {
|
|
94
|
+
sections.push(section);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return sections;
|
|
98
|
+
}
|
|
99
|
+
export function draftPageStructures(args) {
|
|
100
|
+
const { cwd, framework, routes, collections } = args;
|
|
101
|
+
const keysByPrefix = new Map();
|
|
102
|
+
for (const key of args.stagedKeys) {
|
|
103
|
+
const dot = key.indexOf('.');
|
|
104
|
+
if (dot > 0) {
|
|
105
|
+
const prefix = key.slice(0, dot);
|
|
106
|
+
keysByPrefix.set(prefix, [...(keysByPrefix.get(prefix) ?? []), key]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const collectionsByFile = new Map();
|
|
110
|
+
for (const candidate of collections) {
|
|
111
|
+
collectionsByFile.set(candidate.file, [...(collectionsByFile.get(candidate.file) ?? []), candidate]);
|
|
112
|
+
}
|
|
113
|
+
const style = frameworkSectionStyle(framework);
|
|
114
|
+
const drafts = [];
|
|
115
|
+
for (const { routeId, file } of resolveRouteFiles(cwd, framework, routes)) {
|
|
116
|
+
const context = { cwd, style, keysByPrefix, collectionsByFile, visited: new Set([file]) };
|
|
117
|
+
const sections = draftChildSections(context, file, 1);
|
|
118
|
+
if (sections.length > 1) {
|
|
119
|
+
drafts.push({ pageId: routeId, single: pascal(routeId), sections });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
markSharedSections(drafts);
|
|
123
|
+
return drafts;
|
|
124
|
+
}
|
|
125
|
+
/** A section file drafted on several pages is chrome — flag it so the agent
|
|
126
|
+
* models it once (Single Type) instead of nesting it under every page. */
|
|
127
|
+
function markSharedSections(drafts) {
|
|
128
|
+
const pagesByFile = new Map();
|
|
129
|
+
const index = (pageId, sections) => {
|
|
130
|
+
for (const section of sections) {
|
|
131
|
+
pagesByFile.set(section.file, (pagesByFile.get(section.file) ?? new Set()).add(pageId));
|
|
132
|
+
index(pageId, section.children ?? []);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
for (const draft of drafts) {
|
|
136
|
+
index(draft.pageId, draft.sections);
|
|
137
|
+
}
|
|
138
|
+
const flag = (sections) => {
|
|
139
|
+
for (const section of sections) {
|
|
140
|
+
if ((pagesByFile.get(section.file)?.size ?? 0) > 1) {
|
|
141
|
+
section.shared = true;
|
|
142
|
+
}
|
|
143
|
+
flag(section.children ?? []);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
for (const draft of drafts) {
|
|
147
|
+
flag(draft.sections);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Flat section count across the nesting — the summary's visibility number. */
|
|
151
|
+
export function countDraftSections(draft) {
|
|
152
|
+
const walk = (sections) => sections.reduce((total, section) => total + 1 + walk(section.children ?? []), 0);
|
|
153
|
+
return walk(draft.sections);
|
|
154
|
+
}
|
|
155
|
+
const labelFieldName = (key) => camel(key.split('.').pop() ?? key);
|
|
156
|
+
const scalarType = (value) => value.length > 80 || value.includes('\n') ? 'text' : 'string';
|
|
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) {
|
|
218
|
+
const fields = {};
|
|
219
|
+
const value = {};
|
|
220
|
+
let fieldCount = 0;
|
|
221
|
+
for (const key of section.labelKeys) {
|
|
222
|
+
const name = labelFieldName(key);
|
|
223
|
+
if (fields[name]) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const labelValue = labelValues[key] ?? '';
|
|
227
|
+
fields[name] = { type: scalarType(labelValue) };
|
|
228
|
+
value[name] = labelValue;
|
|
229
|
+
fieldCount += 1;
|
|
230
|
+
}
|
|
231
|
+
for (const collection of section.collections) {
|
|
232
|
+
const name = camel(collection.name);
|
|
233
|
+
if (fields[name]) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const itemFields = {};
|
|
237
|
+
for (const field of collection.fields) {
|
|
238
|
+
itemFields[field] = { type: 'string' };
|
|
239
|
+
}
|
|
240
|
+
if (Object.keys(itemFields).length === 0) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
fields[name] = { type: 'blocks', schema: { fields: itemFields } };
|
|
244
|
+
value[name] = (collection.rows ?? []).map((row) => {
|
|
245
|
+
const item = {};
|
|
246
|
+
for (const field of collection.fields) {
|
|
247
|
+
const cell = row[field];
|
|
248
|
+
if (cell != null && typeof cell !== 'object') {
|
|
249
|
+
item[field] = typeof cell === 'string' ? cell : String(cell);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return item;
|
|
253
|
+
});
|
|
254
|
+
fieldCount += 1;
|
|
255
|
+
}
|
|
256
|
+
for (const child of section.children ?? []) {
|
|
257
|
+
if (child.shared) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (isDroppableIllustration(child, labelValues, contentConfirmed)) {
|
|
261
|
+
illustrations.push(child.component);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const block = sectionBlock(child, labelValues, illustrations, contentConfirmed);
|
|
265
|
+
if (!block || fields[child.key]) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
fields[child.key] = { type: 'blocks', max: 1, schema: { fields: block.fields } };
|
|
269
|
+
value[child.key] = [block.value];
|
|
270
|
+
fieldCount += block.fieldCount;
|
|
271
|
+
}
|
|
272
|
+
if (Object.keys(fields).length === 0) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
return { fields, value, fieldCount };
|
|
276
|
+
}
|
|
277
|
+
export function buildPageSingleDraft(draft, labelValues, contentConfirmed = new Set()) {
|
|
278
|
+
const fields = {};
|
|
279
|
+
const document = {};
|
|
280
|
+
const illustrations = [];
|
|
281
|
+
let totalFields = 0;
|
|
282
|
+
for (const section of draft.sections) {
|
|
283
|
+
if (section.shared || fields[section.key]) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
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);
|
|
295
|
+
if (!block) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
fields[section.key] = { type: 'blocks', max: 1, schema: { fields: block.fields } };
|
|
299
|
+
document[section.key] = [block.value];
|
|
300
|
+
totalFields += block.fieldCount;
|
|
301
|
+
}
|
|
302
|
+
// A one-field page is a label, not a Single — only real structure drafts.
|
|
303
|
+
if (Object.keys(fields).length === 0 || totalFields < 3) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
uid: draft.single,
|
|
308
|
+
pageId: draft.pageId,
|
|
309
|
+
schema: { uid: draft.single, name: draft.single, isSingleton: true, fields },
|
|
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
|
+
}
|
|
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()];
|
|
393
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* user-visible attribute strings. Drives the wrap migration's coverage gate:
|
|
5
5
|
* every item must end wrapped, ignored-with-a-reason, or unresolved.
|
|
6
6
|
*/
|
|
7
|
-
export type WrappableKind = 'text' | 'image' | 'video' | 'file-link' | 'attribute';
|
|
7
|
+
export type WrappableKind = 'text' | 'formatted-text' | 'image' | 'video' | 'file-link' | 'attribute';
|
|
8
8
|
export interface WrappableItem {
|
|
9
9
|
kind: WrappableKind;
|
|
10
10
|
file: string;
|
|
@@ -97,6 +97,10 @@ export interface WrappedIds {
|
|
|
97
97
|
media: string[];
|
|
98
98
|
richText: string[];
|
|
99
99
|
files: string[];
|
|
100
|
+
/** Every wrapped key, ALL kinds, in ENCOUNTER order (per-file walk, files
|
|
101
|
+
* in walk order) — the on-page reading order the Studio Pages view sorts
|
|
102
|
+
* by. Deduplicated keeping the first occurrence; never sorted. */
|
|
103
|
+
ordered: string[];
|
|
100
104
|
}
|
|
101
105
|
export interface WrapScanResult {
|
|
102
106
|
items: WrappableItem[];
|