fimo 0.9.0-experimental.7 → 0.9.0-experimental.8

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.
Files changed (44) hide show
  1. package/assets/skills/fimo-migration/references/pages.md +140 -1
  2. package/dist/cli/bundle.json +1 -1
  3. package/dist/cli/index.js +335 -173
  4. package/dist/runtime/app/FimoProviders.js +29 -7
  5. package/dist/runtime/react/FimoSpaProviders.js +18 -1
  6. package/dist/runtime/wrapped/content-fetch.pure.d.ts +20 -0
  7. package/dist/runtime/wrapped/content-fetch.pure.js +106 -0
  8. package/dist/runtime/wrapped/server.d.ts +5 -2
  9. package/dist/runtime/wrapped/server.js +15 -45
  10. package/dist/scripts/apply-wrap-templates.js +1 -1
  11. package/dist/scripts/apply-wrap.js +26 -2
  12. package/dist/scripts/cms/strapi/wiring.js +1 -1
  13. package/dist/scripts/detect-framework.js +6 -0
  14. package/dist/scripts/draft-rows.js +36 -0
  15. package/dist/scripts/frameworks/astro/routes.js +2 -18
  16. package/dist/scripts/frameworks/discover.d.ts +19 -6
  17. package/dist/scripts/frameworks/discover.js +96 -3
  18. package/dist/scripts/frameworks/discovered-route.d.ts +9 -0
  19. package/dist/scripts/frameworks/discovered-route.js +17 -0
  20. package/dist/scripts/frameworks/next/routes.d.ts +7 -0
  21. package/dist/scripts/frameworks/next/routes.js +31 -12
  22. package/dist/scripts/frameworks/nuxt/routes.js +2 -18
  23. package/dist/scripts/frameworks/remix/routes.js +4 -18
  24. package/dist/scripts/frameworks/sveltekit/routes.js +4 -19
  25. package/dist/scripts/frameworks/vite/routes.d.ts +5 -0
  26. package/dist/scripts/frameworks/vite/routes.js +59 -9
  27. package/dist/scripts/frameworks/vite/wiring.d.ts +12 -0
  28. package/dist/scripts/frameworks/vite/wiring.js +68 -23
  29. package/dist/scripts/lib/humanize.d.ts +14 -0
  30. package/dist/scripts/lib/humanize.js +54 -0
  31. package/dist/scripts/render-parity/capture.d.ts +7 -0
  32. package/dist/scripts/render-parity/capture.js +32 -4
  33. package/dist/scripts/routes/label-prefixes.d.ts +49 -0
  34. package/dist/scripts/routes/label-prefixes.js +136 -0
  35. package/dist/scripts/routes/module-resolve.d.ts +20 -0
  36. package/dist/scripts/routes/module-resolve.js +78 -0
  37. package/dist/scripts/routes/page-structure.d.ts +72 -0
  38. package/dist/scripts/routes/page-structure.js +234 -0
  39. package/dist/scripts/scan-wrappable.d.ts +4 -0
  40. package/dist/scripts/scan-wrappable.js +15 -2
  41. package/package.json +1 -1
  42. package/templates/react-router/package.json +1 -1
  43. package/dist/scripts/lib/discover-next-routes.d.ts +0 -17
  44. package/dist/scripts/lib/discover-next-routes.js +0 -134
@@ -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
+ }
@@ -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[];
@@ -242,7 +242,7 @@ function extractTemplate(ext, code) {
242
242
  }
243
243
  function scanTemplateFile(ext, code, relPath) {
244
244
  const items = [];
245
- const wrappedIds = { labels: [], media: [], richText: [], files: [] };
245
+ const wrappedIds = { labels: [], media: [], richText: [], files: [], ordered: [] };
246
246
  let wrapped = 0;
247
247
  const { template, offset, frontmatter } = extractTemplate(ext, code);
248
248
  // t('key', ...) in frontmatter/script and in template expressions = wrapped.
@@ -250,6 +250,7 @@ function scanTemplateFile(ext, code, relPath) {
250
250
  for (const match of source.matchAll(/\bt\(\s*['"]([^'"]+)['"]/g)) {
251
251
  wrapped += 1;
252
252
  wrappedIds.labels.push(match[1]);
253
+ wrappedIds.ordered.push(match[1]);
253
254
  }
254
255
  }
255
256
  // data-fimo-source attributes = wrapped media/labels rendered via attrs.
@@ -474,7 +475,7 @@ export function scanWrappableFile(filePath, code, relPath, opts = {}) {
474
475
  const items = [];
475
476
  let wrapped = 0;
476
477
  let foreignI18n = 0;
477
- const wrappedIds = { labels: [], media: [], richText: [], files: [] };
478
+ const wrappedIds = { labels: [], media: [], richText: [], files: [], ordered: [] };
478
479
  const collections = [];
479
480
  const templateExt = filePath.match(/\.(astro|vue|svelte)$/)?.[1];
480
481
  if (templateExt) {
@@ -560,6 +561,7 @@ export function scanWrappableFile(filePath, code, relPath, opts = {}) {
560
561
  const id = attributeValue(node, 'id');
561
562
  if (kind && id) {
562
563
  wrappedIds[kind].push(id.value);
564
+ wrappedIds.ordered.push(id.value);
563
565
  }
564
566
  }
565
567
  if (!insideWrapped && name) {
@@ -694,6 +696,7 @@ export function scanWrappableFile(filePath, code, relPath, opts = {}) {
694
696
  const resolved = resolveScopedKey(node.callee.name, firstArg.value, childScopes);
695
697
  if (!resolved.unresolvable) {
696
698
  wrappedIds.labels.push(resolved.key);
699
+ wrappedIds.ordered.push(resolved.key);
697
700
  }
698
701
  }
699
702
  }
@@ -1176,6 +1179,8 @@ export function scanWrappable(rootDir, opts = {}) {
1176
1179
  richText: new Set(),
1177
1180
  files: new Set(),
1178
1181
  };
1182
+ const orderedIds = [];
1183
+ const orderedSeen = new Set();
1179
1184
  for (const file of files) {
1180
1185
  let code;
1181
1186
  try {
@@ -1196,6 +1201,12 @@ export function scanWrappable(rootDir, opts = {}) {
1196
1201
  idSets[kind].add(id);
1197
1202
  }
1198
1203
  }
1204
+ for (const id of result.wrappedIds.ordered) {
1205
+ if (!orderedSeen.has(id)) {
1206
+ orderedSeen.add(id);
1207
+ orderedIds.push(id);
1208
+ }
1209
+ }
1199
1210
  }
1200
1211
  const dataFiles = [];
1201
1212
  // Markdown/MDX documents: group per directory so a 40-post blog is ONE
@@ -1337,6 +1348,8 @@ export function scanWrappable(rootDir, opts = {}) {
1337
1348
  media: [...idSets.media].sort(),
1338
1349
  richText: [...idSets.richText].sort(),
1339
1350
  files: [...idSets.files].sort(),
1351
+ // Encounter order, never sorted — the on-page reading order.
1352
+ ordered: orderedIds,
1340
1353
  },
1341
1354
  };
1342
1355
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fimo",
3
- "version": "0.9.0-experimental.7",
3
+ "version": "0.9.0-experimental.8",
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.7",
24
+ "fimo": "0.9.0-experimental.8",
25
25
  "input-otp": "^1.4.2",
26
26
  "isbot": "^5",
27
27
  "lucide-react": "^0.577.0",
@@ -1,17 +0,0 @@
1
- export interface DiscoveredRoute {
2
- id: string;
3
- path: string;
4
- label: string;
5
- }
6
- /**
7
- * Discover page routes of a Next.js app-router directory (`app/` or
8
- * `src/app/`). Returns display routes for `fimo-config.json#routes` so the
9
- * Studio page switcher can enumerate a wrapped site's pages.
10
- */
11
- export declare function discoverNextRoutes(appDir: string): DiscoveredRoute[];
12
- /**
13
- * Pages-router discovery (`pages/` or `src/pages/`): every module is a route
14
- * except `_app`/`_document`/`_error` and `api/`. `[param]` segments map to
15
- * `:param` (the parity capture skips dynamic routes, same as app router).
16
- */
17
- export declare function discoverNextPagesRoutes(pagesDir: string): DiscoveredRoute[];
@@ -1,134 +0,0 @@
1
- import { readdirSync, statSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- const PAGE_FILES = new Set(['page.tsx', 'page.jsx', 'page.ts', 'page.js', 'page.mdx']);
4
- const titleCase = (seg) => seg
5
- .replace(/[_\-]+/g, ' ')
6
- .replace(/([a-z])([A-Z])/g, '$1 $2')
7
- .split(/\s+/)
8
- .filter(Boolean)
9
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
10
- .join(' ');
11
- function segmentToPath(segment) {
12
- // Route groups `(marketing)` don't contribute URL segments. Parallel-route
13
- // slots `@modal` and private folders `_lib` are not standalone pages.
14
- if (segment.startsWith('(')) {
15
- return '';
16
- }
17
- if (segment.startsWith('@') || segment.startsWith('_')) {
18
- return null;
19
- }
20
- const catchAll = segment.match(/^\[\.\.\.(.+)\]$/) ?? segment.match(/^\[\[\.\.\.(.+)\]\]$/);
21
- if (catchAll) {
22
- return `:${catchAll[1]}*`;
23
- }
24
- const param = segment.match(/^\[(.+)\]$/);
25
- if (param) {
26
- return `:${param[1]}`;
27
- }
28
- return segment;
29
- }
30
- /**
31
- * Discover page routes of a Next.js app-router directory (`app/` or
32
- * `src/app/`). Returns display routes for `fimo-config.json#routes` so the
33
- * Studio page switcher can enumerate a wrapped site's pages.
34
- */
35
- export function discoverNextRoutes(appDir) {
36
- const routes = [];
37
- const walk = (dir, segments) => {
38
- let entries;
39
- try {
40
- entries = readdirSync(dir);
41
- }
42
- catch {
43
- return;
44
- }
45
- if (entries.some((entry) => PAGE_FILES.has(entry))) {
46
- const parts = segments.filter((s) => s.length > 0);
47
- const routePath = `/${parts.join('/')}`.replace(/\/+/g, '/');
48
- const lastStatic = [...parts].reverse().find((p) => !p.startsWith(':'));
49
- routes.push({
50
- id: routePath === '/' ? 'home' : parts.map((p) => p.replace(/^:/, '')).join('-'),
51
- path: routePath,
52
- label: routePath === '/' ? 'Home' : titleCase(lastStatic ?? parts[parts.length - 1] ?? routePath),
53
- });
54
- }
55
- for (const entry of entries) {
56
- const full = join(dir, entry);
57
- let isDir = false;
58
- try {
59
- isDir = statSync(full).isDirectory();
60
- }
61
- catch {
62
- continue;
63
- }
64
- if (!isDir) {
65
- continue;
66
- }
67
- const mapped = segmentToPath(entry);
68
- if (mapped === null) {
69
- continue;
70
- }
71
- walk(full, [...segments, mapped]);
72
- }
73
- };
74
- walk(appDir, []);
75
- return routes.sort((a, b) => a.path.localeCompare(b.path));
76
- }
77
- const PAGES_EXTENSIONS = /\.(tsx|jsx|ts|js)$/;
78
- const PAGES_SKIP = new Set(['api']);
79
- /**
80
- * Pages-router discovery (`pages/` or `src/pages/`): every module is a route
81
- * except `_app`/`_document`/`_error` and `api/`. `[param]` segments map to
82
- * `:param` (the parity capture skips dynamic routes, same as app router).
83
- */
84
- export function discoverNextPagesRoutes(pagesDir) {
85
- const routes = [];
86
- const toSegment = (name) => name
87
- .replace(/^\[\.\.\.(.+)\]$/, ':$1')
88
- .replace(/^\[\[\.\.\.(.+)\]\]$/, ':$1')
89
- .replace(/^\[(.+)\]$/, ':$1');
90
- const walk = (dir, segments) => {
91
- let entries;
92
- try {
93
- entries = readdirSync(dir);
94
- }
95
- catch {
96
- return;
97
- }
98
- for (const entry of entries) {
99
- const full = join(dir, entry);
100
- let isDir = false;
101
- try {
102
- isDir = statSync(full).isDirectory();
103
- }
104
- catch {
105
- continue;
106
- }
107
- if (isDir) {
108
- // api/ is only special at the pages root.
109
- if (!(segments.length === 0 && PAGES_SKIP.has(entry))) {
110
- walk(full, [...segments, toSegment(entry)]);
111
- }
112
- continue;
113
- }
114
- if (!PAGES_EXTENSIONS.test(entry)) {
115
- continue;
116
- }
117
- const base = entry.replace(PAGES_EXTENSIONS, '');
118
- if (base.startsWith('_')) {
119
- continue;
120
- }
121
- const parts = base === 'index' ? segments : [...segments, toSegment(base)];
122
- const routePath = `/${parts.join('/')}`.replace(/\/+/g, '/');
123
- const lastStatic = [...parts].reverse().find((part) => !part.startsWith(':'));
124
- routes.push({
125
- id: routePath === '/' ? 'home' : parts.map((part) => part.replace(/^:/, '')).join('-'),
126
- path: routePath,
127
- label: routePath === '/' ? 'Home' : titleCase(lastStatic ?? parts[parts.length - 1] ?? routePath),
128
- });
129
- }
130
- };
131
- walk(pagesDir, []);
132
- routes.sort((a, b) => a.path.localeCompare(b.path));
133
- return routes;
134
- }