fimo 0.9.0-experimental.4 → 0.9.0-experimental.6

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 (31) hide show
  1. package/assets/agent-templates/content-translator/scripts/write-locale-files.ts +66 -0
  2. package/assets/agents-starter/claude/settings.json +19 -0
  3. package/assets/skills/fimo/references/translations.md +75 -0
  4. package/assets/skills/fimo-cli/references/create.md +90 -0
  5. package/assets/skills/fimo-cli/references/translations.md +43 -0
  6. package/assets/skills/fimo-migration/SKILL.md +2 -0
  7. package/assets/skills/fimo-migration/references/manifest.md +11 -0
  8. package/assets/skills/fimo-migration/references/pages.md +95 -0
  9. package/assets/skills/fimo-migration/references/wrap.md +29 -0
  10. package/dist/cli/bundle.json +1 -1
  11. package/dist/cli/index.js +171 -112
  12. package/dist/runtime/core/BuiltWithBadge.d.ts +2 -0
  13. package/dist/runtime/core/BuiltWithBadge.js +68 -0
  14. package/dist/runtime/seo/neutral.d.ts +22 -0
  15. package/dist/runtime/seo/neutral.js +17 -0
  16. package/dist/runtime/strapi/client.js +45 -2
  17. package/dist/scripts/cms/example-content.js +8 -1
  18. package/dist/scripts/cms/strapi/schema-map.js +13 -3
  19. package/dist/scripts/frameworks/next/rewire.d.ts +13 -0
  20. package/dist/scripts/frameworks/next/rewire.js +26 -2
  21. package/dist/scripts/frameworks/next/routes.d.ts +6 -0
  22. package/dist/scripts/frameworks/next/routes.js +24 -1
  23. package/dist/scripts/inject-translations.d.ts +6 -0
  24. package/dist/scripts/inject-translations.js +168 -0
  25. package/dist/scripts/lib/discover-next-routes.d.ts +17 -0
  26. package/dist/scripts/lib/discover-next-routes.js +134 -0
  27. package/dist/scripts/routes/bindings.d.ts +93 -0
  28. package/dist/scripts/routes/bindings.js +117 -0
  29. package/package.json +5 -1
  30. package/templates/react-router/package.json +1 -1
  31. package/templates/react-router/translations/en.json +1 -0
@@ -0,0 +1,134 @@
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
+ }
@@ -0,0 +1,93 @@
1
+ import type { CmsCollectionSchema } from '../cms/types.js';
2
+ /**
3
+ * Page-binding evidence for the page registry (`fimo-config.json#routes`).
4
+ * A dynamic page template (`/blog/:slug`) is only navigable in the Studio
5
+ * preview once its content param carries a binding
6
+ * `{ schema, queryField, displayField }` — the schema whose entries
7
+ * materialize the page instances. No binding can be inferred with certainty,
8
+ * so this module grades evidence: `strong` bindings are written by the
9
+ * machine, `candidate` ones ship to the agent prompt as ready-to-apply
10
+ * suggestions. Neutral: evidence comes in as plain route/schema facts; the
11
+ * framework and CMS specifics stay in their adapters.
12
+ */
13
+ export interface PageParamBinding {
14
+ schema: string;
15
+ queryField: string;
16
+ displayField: string;
17
+ }
18
+ export interface PageBinding {
19
+ routeId: string;
20
+ path: string;
21
+ param: string;
22
+ binding: PageParamBinding;
23
+ confidence: 'strong' | 'candidate';
24
+ source: 'rewire' | 'cms-wiring';
25
+ }
26
+ export interface UnboundPage {
27
+ id: string;
28
+ path: string;
29
+ candidates: PageBinding[];
30
+ }
31
+ export interface PageBindingsResult {
32
+ /** Written into the registry by the caller. */
33
+ strong: PageBinding[];
34
+ /** Dynamic pages left without params — with any candidate suggestions. */
35
+ unbound: UnboundPage[];
36
+ /** Dynamic pages that already carry params (manual or a prior run). */
37
+ bound: Array<{
38
+ id: string;
39
+ path: string;
40
+ }>;
41
+ }
42
+ /** A list page known to render a collection (the page-rewire join). */
43
+ export interface ListPageEvidence {
44
+ routePath: string | null;
45
+ schema: string;
46
+ }
47
+ export interface SchemaFieldsLike {
48
+ readonly [field: string]: {
49
+ readonly type?: string;
50
+ };
51
+ }
52
+ export declare function isLocaleParam(name: string): boolean;
53
+ interface ParsedDynamicPath {
54
+ /** The trailing content param name (without `:`/`*`). */
55
+ contentParam: string;
56
+ /** Route path minus the trailing content-param segment. */
57
+ parentPath: string;
58
+ }
59
+ /**
60
+ * A bindable dynamic path = optional locale-ish params + ONE trailing content
61
+ * param (catch-all or plain). Anything else (multiple content params) needs
62
+ * judgment beyond a single binding.
63
+ */
64
+ export declare function parseDynamicPath(path: string): ParsedDynamicPath | null;
65
+ /** First title-ish string field of a schema — the picker label the Studio
66
+ * shows. Null when the schema has none (binding downgrades to candidate). */
67
+ export declare function displayFieldFor(fields: SchemaFieldsLike | undefined): string | null;
68
+ /** CMS collections that structurally look like page-builder page types: a
69
+ * top-level dynamic-zone (blocks oneOf) field. Structural, never name-based. */
70
+ export declare function cmsPageTypeEvidence(schemas: readonly CmsCollectionSchema[]): Array<{
71
+ schema: string;
72
+ fields: SchemaFieldsLike;
73
+ }>;
74
+ export declare function collectPageBindings(options: {
75
+ routes: Record<string, {
76
+ path?: string;
77
+ params?: unknown;
78
+ }>;
79
+ /** Rewired list pages (route-space, from the framework adapter). */
80
+ listPages?: readonly ListPageEvidence[];
81
+ /** Structural CMS page types (from `cmsPageTypeEvidence`), only when the
82
+ * CMS reads are actually wired to Fimo. */
83
+ cmsPageTypes?: ReadonlyArray<{
84
+ schema: string;
85
+ fields: SchemaFieldsLike;
86
+ }>;
87
+ /** Uids still provisional (machine drafts not yet adopted) — their bindings
88
+ * are candidates: `adopt-drafts` will rename them. */
89
+ provisionalUids?: ReadonlySet<string>;
90
+ /** Known fields per schema, for the displayField pick. */
91
+ schemaFields?: Record<string, SchemaFieldsLike>;
92
+ }): PageBindingsResult;
93
+ export {};
@@ -0,0 +1,117 @@
1
+ /** Params that identify a locale, not content — never schema-bound. The
2
+ * Studio preview substitutes content params only; locale params are a known
3
+ * limitation documented in the prompt. Mirrors the parity capture's list. */
4
+ const LOCALE_PARAMS = /^(locale|lang|language)$/i;
5
+ export function isLocaleParam(name) {
6
+ return LOCALE_PARAMS.test(name);
7
+ }
8
+ const TITLE_FIELDS = /^(title|name|label|heading)$/i;
9
+ /**
10
+ * A bindable dynamic path = optional locale-ish params + ONE trailing content
11
+ * param (catch-all or plain). Anything else (multiple content params) needs
12
+ * judgment beyond a single binding.
13
+ */
14
+ export function parseDynamicPath(path) {
15
+ const params = [...path.matchAll(/:([^/*]+)\*?/g)].map((match) => match[1]);
16
+ if (params.length === 0) {
17
+ return null;
18
+ }
19
+ const contentParams = params.filter((param) => !LOCALE_PARAMS.test(param));
20
+ if (contentParams.length !== 1) {
21
+ return null;
22
+ }
23
+ const contentParam = contentParams[0];
24
+ if (!new RegExp(`/:${contentParam}\\*?$`).test(path)) {
25
+ return null; // the content param must be the trailing segment
26
+ }
27
+ const parentPath = path.replace(new RegExp(`/:${contentParam}\\*?$`), '') || '/';
28
+ return { contentParam, parentPath };
29
+ }
30
+ function isCatchAll(path, param) {
31
+ return path.endsWith(`:${param}*`);
32
+ }
33
+ /** First title-ish string field of a schema — the picker label the Studio
34
+ * shows. Null when the schema has none (binding downgrades to candidate). */
35
+ export function displayFieldFor(fields) {
36
+ for (const [name, def] of Object.entries(fields ?? {})) {
37
+ if (TITLE_FIELDS.test(name) && (def.type === undefined || def.type === 'string')) {
38
+ return name;
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ /** CMS collections that structurally look like page-builder page types: a
44
+ * top-level dynamic-zone (blocks oneOf) field. Structural, never name-based. */
45
+ export function cmsPageTypeEvidence(schemas) {
46
+ return schemas
47
+ .filter((schema) => !schema.isSingleton &&
48
+ Object.values(schema.fields).some((field) => field.type === 'blocks' && 'oneOf' in field.schema))
49
+ .map((schema) => ({ schema: schema.name, fields: schema.fields }));
50
+ }
51
+ export function collectPageBindings(options) {
52
+ const { routes, listPages = [], cmsPageTypes = [], provisionalUids, schemaFields = {} } = options;
53
+ const strong = [];
54
+ const unbound = [];
55
+ const bound = [];
56
+ for (const [id, route] of Object.entries(routes)) {
57
+ const path = route.path;
58
+ if (typeof path !== 'string' || !path.includes(':')) {
59
+ continue;
60
+ }
61
+ if (route.params && typeof route.params === 'object' && Object.keys(route.params).length > 0) {
62
+ bound.push({ id, path });
63
+ continue;
64
+ }
65
+ const params = [...path.matchAll(/:([^/*]+)\*?/g)].map((match) => match[1]);
66
+ if (params.every((param) => isLocaleParam(param))) {
67
+ continue; // locale-only page — nothing to bind, effectively static
68
+ }
69
+ const parsed = parseDynamicPath(path);
70
+ if (!parsed) {
71
+ unbound.push({ id, path, candidates: [] });
72
+ continue;
73
+ }
74
+ const candidates = [];
75
+ const make = (schema, source, baseConfidence) => {
76
+ const fields = schemaFields[schema] ?? cmsPageTypes.find((type) => type.schema === schema)?.fields;
77
+ const display = displayFieldFor(fields);
78
+ const confidence = provisionalUids?.has(schema) || display === null ? 'candidate' : baseConfidence;
79
+ return {
80
+ routeId: id,
81
+ path,
82
+ param: parsed.contentParam,
83
+ binding: { schema, queryField: 'slug', displayField: display ?? 'slug' },
84
+ confidence,
85
+ source,
86
+ };
87
+ };
88
+ // Rewire join: a list page rendering collection X at the dynamic page's
89
+ // parent path is strong evidence the detail param resolves against X.
90
+ const parents = listPages.filter((page) => page.routePath === parsed.parentPath);
91
+ const parentSchemas = [...new Set(parents.map((page) => page.schema))];
92
+ if (parentSchemas.length === 1) {
93
+ candidates.push(make(parentSchemas[0], 'rewire', 'strong'));
94
+ }
95
+ else {
96
+ for (const schema of parentSchemas) {
97
+ candidates.push(make(schema, 'rewire', 'candidate'));
98
+ }
99
+ }
100
+ // CMS-structural join: a catch-all page of a CMS-wired site renders the
101
+ // CMS's page-builder type. Unique page-builder type → strong.
102
+ if (isCatchAll(path, parsed.contentParam) && cmsPageTypes.length > 0) {
103
+ const unique = cmsPageTypes.length === 1;
104
+ for (const type of cmsPageTypes) {
105
+ candidates.push(make(type.schema, 'cms-wiring', unique ? 'strong' : 'candidate'));
106
+ }
107
+ }
108
+ const winner = candidates.find((candidate) => candidate.confidence === 'strong');
109
+ if (winner && candidates.filter((candidate) => candidate.confidence === 'strong').length === 1) {
110
+ strong.push(winner);
111
+ }
112
+ else {
113
+ unbound.push({ id, path, candidates });
114
+ }
115
+ }
116
+ return { strong, unbound, bound };
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fimo",
3
- "version": "0.9.0-experimental.4",
3
+ "version": "0.9.0-experimental.6",
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"
@@ -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.4",
24
+ "fimo": "0.9.0-experimental.6",
25
25
  "input-otp": "^1.4.2",
26
26
  "isbot": "^5",
27
27
  "lucide-react": "^0.577.0",