fimo 0.9.0-experimental.6 → 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 (45) hide show
  1. package/assets/content-templates/types-template.eta +1 -0
  2. package/assets/skills/fimo-migration/references/pages.md +140 -1
  3. package/dist/cli/bundle.json +1 -1
  4. package/dist/cli/index.js +339 -174
  5. package/dist/runtime/app/FimoProviders.js +29 -7
  6. package/dist/runtime/react/FimoSpaProviders.js +18 -1
  7. package/dist/runtime/wrapped/content-fetch.pure.d.ts +20 -0
  8. package/dist/runtime/wrapped/content-fetch.pure.js +106 -0
  9. package/dist/runtime/wrapped/server.d.ts +5 -2
  10. package/dist/runtime/wrapped/server.js +15 -45
  11. package/dist/scripts/apply-wrap-templates.js +1 -1
  12. package/dist/scripts/apply-wrap.js +26 -2
  13. package/dist/scripts/cms/strapi/wiring.js +1 -1
  14. package/dist/scripts/detect-framework.js +6 -0
  15. package/dist/scripts/draft-rows.js +36 -0
  16. package/dist/scripts/frameworks/astro/routes.js +2 -18
  17. package/dist/scripts/frameworks/discover.d.ts +19 -6
  18. package/dist/scripts/frameworks/discover.js +113 -3
  19. package/dist/scripts/frameworks/discovered-route.d.ts +9 -0
  20. package/dist/scripts/frameworks/discovered-route.js +17 -0
  21. package/dist/scripts/frameworks/next/routes.d.ts +7 -0
  22. package/dist/scripts/frameworks/next/routes.js +31 -12
  23. package/dist/scripts/frameworks/nuxt/routes.js +2 -18
  24. package/dist/scripts/frameworks/remix/routes.js +4 -18
  25. package/dist/scripts/frameworks/sveltekit/routes.js +4 -19
  26. package/dist/scripts/frameworks/vite/routes.d.ts +5 -0
  27. package/dist/scripts/frameworks/vite/routes.js +59 -9
  28. package/dist/scripts/frameworks/vite/wiring.d.ts +12 -0
  29. package/dist/scripts/frameworks/vite/wiring.js +68 -23
  30. package/dist/scripts/lib/humanize.d.ts +14 -0
  31. package/dist/scripts/lib/humanize.js +54 -0
  32. package/dist/scripts/render-parity/capture.d.ts +7 -0
  33. package/dist/scripts/render-parity/capture.js +32 -4
  34. package/dist/scripts/routes/label-prefixes.d.ts +49 -0
  35. package/dist/scripts/routes/label-prefixes.js +136 -0
  36. package/dist/scripts/routes/module-resolve.d.ts +20 -0
  37. package/dist/scripts/routes/module-resolve.js +78 -0
  38. package/dist/scripts/routes/page-structure.d.ts +72 -0
  39. package/dist/scripts/routes/page-structure.js +234 -0
  40. package/dist/scripts/scan-wrappable.d.ts +5 -1
  41. package/dist/scripts/scan-wrappable.js +126 -3
  42. package/package.json +1 -1
  43. package/templates/react-router/package.json +1 -1
  44. package/dist/scripts/lib/discover-next-routes.d.ts +0 -17
  45. 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
+ }
@@ -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
- if (node.type === 'JSXText' && !insideWrapped) {
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.6",
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.6",
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
- }