fimo 0.9.0-experimental.1 → 0.9.0-experimental.10
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 +234 -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 +484 -243
- 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 +72 -0
- package/dist/scripts/routes/page-structure.js +234 -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,72 @@
|
|
|
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
|
+
}
|
|
72
|
+
export declare function buildPageSingleDraft(draft: PageStructureDraft, labelValues: Record<string, string>): PageSingleDraft | null;
|
|
@@ -0,0 +1,234 @@
|
|
|
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
|
+
const MAX_SECTION_DEPTH = 3;
|
|
6
|
+
const MAX_DRAFT_ROWS = 50;
|
|
7
|
+
const pascal = (value) => value
|
|
8
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
9
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
10
|
+
.split(/[-_.\s]/)
|
|
11
|
+
.filter(Boolean)
|
|
12
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
13
|
+
.join('');
|
|
14
|
+
const camel = (value) => {
|
|
15
|
+
const p = pascal(value);
|
|
16
|
+
return p.charAt(0).toLowerCase() + p.slice(1);
|
|
17
|
+
};
|
|
18
|
+
/** Capitalized JSX tags in DOCUMENT order (deduped, first appearance). */
|
|
19
|
+
function sectionTags(code) {
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
const tags = [];
|
|
22
|
+
for (const match of code.matchAll(/<([A-Z]\w*)[\s/>]/g)) {
|
|
23
|
+
if (!seen.has(match[1])) {
|
|
24
|
+
seen.add(match[1]);
|
|
25
|
+
tags.push(match[1]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return tags;
|
|
29
|
+
}
|
|
30
|
+
function draftSection(context, tag, file, depth) {
|
|
31
|
+
const rel = relative(context.cwd, file);
|
|
32
|
+
const prefix = context.style.fileScope(rel);
|
|
33
|
+
const labelKeys = context.keysByPrefix.get(prefix) ?? [];
|
|
34
|
+
const collections = (context.collectionsByFile.get(rel) ?? []).map((candidate) => ({
|
|
35
|
+
name: candidate.name,
|
|
36
|
+
fields: candidate.fields,
|
|
37
|
+
count: candidate.count,
|
|
38
|
+
...(candidate.typedRows ? { rows: candidate.typedRows.slice(0, MAX_DRAFT_ROWS) } : {}),
|
|
39
|
+
}));
|
|
40
|
+
const children = depth < MAX_SECTION_DEPTH ? draftChildSections(context, file, depth + 1) : [];
|
|
41
|
+
if (labelKeys.length === 0 && collections.length === 0 && children.length === 0) {
|
|
42
|
+
return null; // pure layout — nothing to model
|
|
43
|
+
}
|
|
44
|
+
const draft = { key: camel(tag), component: tag, file: rel, prefix, labelKeys, collections };
|
|
45
|
+
if (children.length > 0) {
|
|
46
|
+
draft.children = children;
|
|
47
|
+
}
|
|
48
|
+
return draft;
|
|
49
|
+
}
|
|
50
|
+
/** Auto-imported components (Nuxt's components/ convention): a tag with no
|
|
51
|
+
* import statement may still be a local file at a conventional path. */
|
|
52
|
+
function resolveAutoImportedComponent(context, tag) {
|
|
53
|
+
for (const dir of context.style.fallbackComponentDirs) {
|
|
54
|
+
for (const ext of context.style.extensions) {
|
|
55
|
+
const candidate = join(context.cwd, dir, `${tag}${ext}`);
|
|
56
|
+
try {
|
|
57
|
+
if (statSync(candidate).isFile()) {
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* try next */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
function draftChildSections(context, file, depth) {
|
|
69
|
+
let code;
|
|
70
|
+
try {
|
|
71
|
+
code = readFileSync(file, 'utf8');
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const imports = componentImports(code);
|
|
77
|
+
const sections = [];
|
|
78
|
+
for (const tag of sectionTags(code)) {
|
|
79
|
+
const spec = imports.get(tag);
|
|
80
|
+
const sectionFile = spec
|
|
81
|
+
? resolveModuleFile(context.cwd, file, spec, context.style.extensions)
|
|
82
|
+
: resolveAutoImportedComponent(context, tag);
|
|
83
|
+
if (!sectionFile || context.visited.has(sectionFile)) {
|
|
84
|
+
continue; // library components (Button, Link) have no local file
|
|
85
|
+
}
|
|
86
|
+
context.visited.add(sectionFile);
|
|
87
|
+
const section = draftSection(context, tag, sectionFile, depth);
|
|
88
|
+
if (section) {
|
|
89
|
+
sections.push(section);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return sections;
|
|
93
|
+
}
|
|
94
|
+
export function draftPageStructures(args) {
|
|
95
|
+
const { cwd, framework, routes, collections } = args;
|
|
96
|
+
const keysByPrefix = new Map();
|
|
97
|
+
for (const key of args.stagedKeys) {
|
|
98
|
+
const dot = key.indexOf('.');
|
|
99
|
+
if (dot > 0) {
|
|
100
|
+
const prefix = key.slice(0, dot);
|
|
101
|
+
keysByPrefix.set(prefix, [...(keysByPrefix.get(prefix) ?? []), key]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const collectionsByFile = new Map();
|
|
105
|
+
for (const candidate of collections) {
|
|
106
|
+
collectionsByFile.set(candidate.file, [...(collectionsByFile.get(candidate.file) ?? []), candidate]);
|
|
107
|
+
}
|
|
108
|
+
const style = frameworkSectionStyle(framework);
|
|
109
|
+
const drafts = [];
|
|
110
|
+
for (const { routeId, file } of resolveRouteFiles(cwd, framework, routes)) {
|
|
111
|
+
const context = { cwd, style, keysByPrefix, collectionsByFile, visited: new Set([file]) };
|
|
112
|
+
const sections = draftChildSections(context, file, 1);
|
|
113
|
+
if (sections.length > 1) {
|
|
114
|
+
drafts.push({ pageId: routeId, single: pascal(routeId), sections });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
markSharedSections(drafts);
|
|
118
|
+
return drafts;
|
|
119
|
+
}
|
|
120
|
+
/** A section file drafted on several pages is chrome — flag it so the agent
|
|
121
|
+
* models it once (Single Type) instead of nesting it under every page. */
|
|
122
|
+
function markSharedSections(drafts) {
|
|
123
|
+
const pagesByFile = new Map();
|
|
124
|
+
const index = (pageId, sections) => {
|
|
125
|
+
for (const section of sections) {
|
|
126
|
+
pagesByFile.set(section.file, (pagesByFile.get(section.file) ?? new Set()).add(pageId));
|
|
127
|
+
index(pageId, section.children ?? []);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
for (const draft of drafts) {
|
|
131
|
+
index(draft.pageId, draft.sections);
|
|
132
|
+
}
|
|
133
|
+
const flag = (sections) => {
|
|
134
|
+
for (const section of sections) {
|
|
135
|
+
if ((pagesByFile.get(section.file)?.size ?? 0) > 1) {
|
|
136
|
+
section.shared = true;
|
|
137
|
+
}
|
|
138
|
+
flag(section.children ?? []);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
for (const draft of drafts) {
|
|
142
|
+
flag(draft.sections);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Flat section count across the nesting — the summary's visibility number. */
|
|
146
|
+
export function countDraftSections(draft) {
|
|
147
|
+
const walk = (sections) => sections.reduce((total, section) => total + 1 + walk(section.children ?? []), 0);
|
|
148
|
+
return walk(draft.sections);
|
|
149
|
+
}
|
|
150
|
+
const labelFieldName = (key) => camel(key.split('.').pop() ?? key);
|
|
151
|
+
const scalarType = (value) => value.length > 80 || value.includes('\n') ? 'text' : 'string';
|
|
152
|
+
function sectionBlock(section, labelValues) {
|
|
153
|
+
const fields = {};
|
|
154
|
+
const value = {};
|
|
155
|
+
let fieldCount = 0;
|
|
156
|
+
for (const key of section.labelKeys) {
|
|
157
|
+
const name = labelFieldName(key);
|
|
158
|
+
if (fields[name]) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const labelValue = labelValues[key] ?? '';
|
|
162
|
+
fields[name] = { type: scalarType(labelValue) };
|
|
163
|
+
value[name] = labelValue;
|
|
164
|
+
fieldCount += 1;
|
|
165
|
+
}
|
|
166
|
+
for (const collection of section.collections) {
|
|
167
|
+
const name = camel(collection.name);
|
|
168
|
+
if (fields[name]) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const itemFields = {};
|
|
172
|
+
for (const field of collection.fields) {
|
|
173
|
+
itemFields[field] = { type: 'string' };
|
|
174
|
+
}
|
|
175
|
+
if (Object.keys(itemFields).length === 0) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
fields[name] = { type: 'blocks', schema: { fields: itemFields } };
|
|
179
|
+
value[name] = (collection.rows ?? []).map((row) => {
|
|
180
|
+
const item = {};
|
|
181
|
+
for (const field of collection.fields) {
|
|
182
|
+
const cell = row[field];
|
|
183
|
+
if (cell != null && typeof cell !== 'object') {
|
|
184
|
+
item[field] = typeof cell === 'string' ? cell : String(cell);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return item;
|
|
188
|
+
});
|
|
189
|
+
fieldCount += 1;
|
|
190
|
+
}
|
|
191
|
+
for (const child of section.children ?? []) {
|
|
192
|
+
if (child.shared) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const block = sectionBlock(child, labelValues);
|
|
196
|
+
if (!block || fields[child.key]) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
fields[child.key] = { type: 'blocks', max: 1, schema: { fields: block.fields } };
|
|
200
|
+
value[child.key] = [block.value];
|
|
201
|
+
fieldCount += block.fieldCount;
|
|
202
|
+
}
|
|
203
|
+
if (Object.keys(fields).length === 0) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return { fields, value, fieldCount };
|
|
207
|
+
}
|
|
208
|
+
export function buildPageSingleDraft(draft, labelValues) {
|
|
209
|
+
const fields = {};
|
|
210
|
+
const document = {};
|
|
211
|
+
let totalFields = 0;
|
|
212
|
+
for (const section of draft.sections) {
|
|
213
|
+
if (section.shared || fields[section.key]) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const block = sectionBlock(section, labelValues);
|
|
217
|
+
if (!block) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
fields[section.key] = { type: 'blocks', max: 1, schema: { fields: block.fields } };
|
|
221
|
+
document[section.key] = [block.value];
|
|
222
|
+
totalFields += block.fieldCount;
|
|
223
|
+
}
|
|
224
|
+
// A one-field page is a label, not a Single — only real structure drafts.
|
|
225
|
+
if (Object.keys(fields).length === 0 || totalFields < 3) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
uid: draft.single,
|
|
230
|
+
pageId: draft.pageId,
|
|
231
|
+
schema: { uid: draft.single, name: draft.single, isSingleton: true, fields },
|
|
232
|
+
document,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -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[];
|
|
@@ -118,6 +118,36 @@ const WRAPPABLE_ATTRIBUTES = new Set(['placeholder', 'title', 'aria-label']);
|
|
|
118
118
|
const FILE_LINK_PATTERN = /\.(pdf|zip|docx?|xlsx?|pptx?|csv|mp4|mov|webm)$/i;
|
|
119
119
|
const SKIP_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', '.fimo']);
|
|
120
120
|
const MIN_TEXT_LENGTH = 3;
|
|
121
|
+
/**
|
|
122
|
+
* Prose containers whose children mix raw text with inline elements or
|
|
123
|
+
* expressions. Wrapping such text node-by-node produces FRAGMENT labels
|
|
124
|
+
* (", and agencies building…") that nobody can find or edit as a sentence —
|
|
125
|
+
* the whole block merges into ONE `formatted-text` item instead (agent work:
|
|
126
|
+
* the fimo`` tagged template or RichText, never the auto-wrap codemod).
|
|
127
|
+
*/
|
|
128
|
+
const PROSE_TAGS = new Set([
|
|
129
|
+
'p',
|
|
130
|
+
'h1',
|
|
131
|
+
'h2',
|
|
132
|
+
'h3',
|
|
133
|
+
'h4',
|
|
134
|
+
'h5',
|
|
135
|
+
'h6',
|
|
136
|
+
'li',
|
|
137
|
+
'blockquote',
|
|
138
|
+
'figcaption',
|
|
139
|
+
'dt',
|
|
140
|
+
'dd',
|
|
141
|
+
'label',
|
|
142
|
+
'legend',
|
|
143
|
+
'td',
|
|
144
|
+
'th',
|
|
145
|
+
'caption',
|
|
146
|
+
'summary',
|
|
147
|
+
]);
|
|
148
|
+
function meaningfulLength(text) {
|
|
149
|
+
return text.replace(/[^\p{L}\p{N}]/gu, '').length;
|
|
150
|
+
}
|
|
121
151
|
const PREVIEW_LENGTH = 60;
|
|
122
152
|
function toPreview(value) {
|
|
123
153
|
const collapsed = value.replace(/\s+/g, ' ').trim();
|
|
@@ -212,7 +242,7 @@ function extractTemplate(ext, code) {
|
|
|
212
242
|
}
|
|
213
243
|
function scanTemplateFile(ext, code, relPath) {
|
|
214
244
|
const items = [];
|
|
215
|
-
const wrappedIds = { labels: [], media: [], richText: [], files: [] };
|
|
245
|
+
const wrappedIds = { labels: [], media: [], richText: [], files: [], ordered: [] };
|
|
216
246
|
let wrapped = 0;
|
|
217
247
|
const { template, offset, frontmatter } = extractTemplate(ext, code);
|
|
218
248
|
// t('key', ...) in frontmatter/script and in template expressions = wrapped.
|
|
@@ -220,6 +250,7 @@ function scanTemplateFile(ext, code, relPath) {
|
|
|
220
250
|
for (const match of source.matchAll(/\bt\(\s*['"]([^'"]+)['"]/g)) {
|
|
221
251
|
wrapped += 1;
|
|
222
252
|
wrappedIds.labels.push(match[1]);
|
|
253
|
+
wrappedIds.ordered.push(match[1]);
|
|
223
254
|
}
|
|
224
255
|
}
|
|
225
256
|
// data-fimo-source attributes = wrapped media/labels rendered via attrs.
|
|
@@ -444,7 +475,7 @@ export function scanWrappableFile(filePath, code, relPath, opts = {}) {
|
|
|
444
475
|
const items = [];
|
|
445
476
|
let wrapped = 0;
|
|
446
477
|
let foreignI18n = 0;
|
|
447
|
-
const wrappedIds = { labels: [], media: [], richText: [], files: [] };
|
|
478
|
+
const wrappedIds = { labels: [], media: [], richText: [], files: [], ordered: [] };
|
|
448
479
|
const collections = [];
|
|
449
480
|
const templateExt = filePath.match(/\.(astro|vue|svelte)$/)?.[1];
|
|
450
481
|
if (templateExt) {
|
|
@@ -465,6 +496,50 @@ export function scanWrappableFile(filePath, code, relPath, opts = {}) {
|
|
|
465
496
|
function detectCollection(declarator) {
|
|
466
497
|
detectCollectionFromDeclarator(declarator, code, 0, relPath, collections);
|
|
467
498
|
}
|
|
499
|
+
// Text nodes absorbed into a merged formatted-text item (keyed by offset) —
|
|
500
|
+
// the JSXText visitor skips them so fragments never become labels.
|
|
501
|
+
const mergedText = new Set();
|
|
502
|
+
function containsWrappedContent(node) {
|
|
503
|
+
if (!node || typeof node !== 'object') {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
if (node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 't') {
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
if (node.type === 'TaggedTemplateExpression' && node.tag?.type === 'Identifier' && node.tag.name === 'fimo') {
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
if (node.type === 'JSXElement') {
|
|
513
|
+
const name = jsxElementName(node);
|
|
514
|
+
if (name && WRAPPING_COMPONENTS.has(name)) {
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
for (const key of Object.keys(node)) {
|
|
519
|
+
const child = node[key];
|
|
520
|
+
if (Array.isArray(child)) {
|
|
521
|
+
if (child.some((item) => containsWrappedContent(item))) {
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
else if (typeof child === 'object' && child !== null && containsWrappedContent(child)) {
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
/** Every JSXText inside a prose element, recursing through inline children
|
|
532
|
+
* (inside a `<p>`, nested elements are inline by HTML rules). */
|
|
533
|
+
function collectProseTextNodes(node, out) {
|
|
534
|
+
for (const child of node.children ?? []) {
|
|
535
|
+
if (child.type === 'JSXText') {
|
|
536
|
+
out.push(child);
|
|
537
|
+
}
|
|
538
|
+
else if (child.type === 'JSXElement') {
|
|
539
|
+
collectProseTextNodes(child, out);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
468
543
|
function visit(node, insideWrapped, parentTag, scopes) {
|
|
469
544
|
if (!node || typeof node !== 'object') {
|
|
470
545
|
return;
|
|
@@ -486,6 +561,7 @@ export function scanWrappableFile(filePath, code, relPath, opts = {}) {
|
|
|
486
561
|
const id = attributeValue(node, 'id');
|
|
487
562
|
if (kind && id) {
|
|
488
563
|
wrappedIds[kind].push(id.value);
|
|
564
|
+
wrappedIds.ordered.push(id.value);
|
|
489
565
|
}
|
|
490
566
|
}
|
|
491
567
|
if (!insideWrapped && name) {
|
|
@@ -548,7 +624,43 @@ export function scanWrappableFile(filePath, code, relPath, opts = {}) {
|
|
|
548
624
|
}
|
|
549
625
|
}
|
|
550
626
|
}
|
|
551
|
-
|
|
627
|
+
// Mixed prose detection runs on the ELEMENT (before its children are
|
|
628
|
+
// visited): a prose tag whose direct children interleave raw text with
|
|
629
|
+
// inline elements/expressions becomes ONE formatted-text item, and its
|
|
630
|
+
// text nodes are recorded so the JSXText visitor below skips them.
|
|
631
|
+
if (node.type === 'JSXElement' && !insideWrapped) {
|
|
632
|
+
const proseName = jsxElementName(node);
|
|
633
|
+
if (proseName && PROSE_TAGS.has(proseName)) {
|
|
634
|
+
const children = node.children ?? [];
|
|
635
|
+
const directText = children.filter((child) => child.type === 'JSXText' && meaningfulLength(String(child.value ?? '')) > 0);
|
|
636
|
+
const inline = children.filter((child) => child.type === 'JSXElement' ||
|
|
637
|
+
(child.type === 'JSXExpressionContainer' && child.expression?.type !== 'JSXEmptyExpression'));
|
|
638
|
+
if (directText.length > 0 &&
|
|
639
|
+
inline.length > 0 &&
|
|
640
|
+
!directText.some((child) => mergedText.has(child.start)) &&
|
|
641
|
+
!children.some((child) => containsWrappedContent(child))) {
|
|
642
|
+
const textNodes = [];
|
|
643
|
+
collectProseTextNodes(node, textNodes);
|
|
644
|
+
const combined = textNodes.map((textNode) => String(textNode.value ?? '')).join(' ');
|
|
645
|
+
if (meaningfulLength(combined) >= MIN_TEXT_LENGTH) {
|
|
646
|
+
for (const textNode of textNodes) {
|
|
647
|
+
if (typeof textNode.start === 'number') {
|
|
648
|
+
mergedText.add(textNode.start);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const inner = code.slice(node.openingElement?.end ?? node.start ?? 0, node.closingElement?.start ?? node.end ?? 0);
|
|
652
|
+
items.push({
|
|
653
|
+
kind: 'formatted-text',
|
|
654
|
+
file: relPath,
|
|
655
|
+
line: lineOf(code, node.start ?? 0),
|
|
656
|
+
preview: toPreview(inner),
|
|
657
|
+
span: { start: node.start, end: node.end, parentTag: proseName },
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (node.type === 'JSXText' && !insideWrapped && !mergedText.has(node.start)) {
|
|
552
664
|
const text = String(node.value ?? '');
|
|
553
665
|
const meaningful = text.replace(/[^\p{L}\p{N}]/gu, '');
|
|
554
666
|
if (meaningful.length >= MIN_TEXT_LENGTH) {
|
|
@@ -584,6 +696,7 @@ export function scanWrappableFile(filePath, code, relPath, opts = {}) {
|
|
|
584
696
|
const resolved = resolveScopedKey(node.callee.name, firstArg.value, childScopes);
|
|
585
697
|
if (!resolved.unresolvable) {
|
|
586
698
|
wrappedIds.labels.push(resolved.key);
|
|
699
|
+
wrappedIds.ordered.push(resolved.key);
|
|
587
700
|
}
|
|
588
701
|
}
|
|
589
702
|
}
|
|
@@ -1066,6 +1179,8 @@ export function scanWrappable(rootDir, opts = {}) {
|
|
|
1066
1179
|
richText: new Set(),
|
|
1067
1180
|
files: new Set(),
|
|
1068
1181
|
};
|
|
1182
|
+
const orderedIds = [];
|
|
1183
|
+
const orderedSeen = new Set();
|
|
1069
1184
|
for (const file of files) {
|
|
1070
1185
|
let code;
|
|
1071
1186
|
try {
|
|
@@ -1086,6 +1201,12 @@ export function scanWrappable(rootDir, opts = {}) {
|
|
|
1086
1201
|
idSets[kind].add(id);
|
|
1087
1202
|
}
|
|
1088
1203
|
}
|
|
1204
|
+
for (const id of result.wrappedIds.ordered) {
|
|
1205
|
+
if (!orderedSeen.has(id)) {
|
|
1206
|
+
orderedSeen.add(id);
|
|
1207
|
+
orderedIds.push(id);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1089
1210
|
}
|
|
1090
1211
|
const dataFiles = [];
|
|
1091
1212
|
// Markdown/MDX documents: group per directory so a 40-post blog is ONE
|
|
@@ -1227,6 +1348,8 @@ export function scanWrappable(rootDir, opts = {}) {
|
|
|
1227
1348
|
media: [...idSets.media].sort(),
|
|
1228
1349
|
richText: [...idSets.richText].sort(),
|
|
1229
1350
|
files: [...idSets.files].sort(),
|
|
1351
|
+
// Encounter order, never sorted — the on-page reading order.
|
|
1352
|
+
ordered: orderedIds,
|
|
1230
1353
|
},
|
|
1231
1354
|
};
|
|
1232
1355
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fimo",
|
|
3
|
-
"version": "0.9.0-experimental.
|
|
3
|
+
"version": "0.9.0-experimental.10",
|
|
4
4
|
"description": "Fimo CLI - create, deploy, and manage Fimo projects",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fimo": "dist/cli/index.js"
|
|
@@ -58,6 +58,10 @@
|
|
|
58
58
|
"types": "./dist/runtime/strapi/index.d.ts",
|
|
59
59
|
"import": "./dist/runtime/strapi/index.js"
|
|
60
60
|
},
|
|
61
|
+
"./seo": {
|
|
62
|
+
"types": "./dist/runtime/seo/neutral.d.ts",
|
|
63
|
+
"import": "./dist/runtime/seo/neutral.js"
|
|
64
|
+
},
|
|
61
65
|
"./remix": {
|
|
62
66
|
"types": "./dist/runtime/remix/index.d.ts",
|
|
63
67
|
"import": "./dist/runtime/remix/index.js"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|