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.
Files changed (80) 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/content-templates/types-template.eta +1 -0
  4. package/assets/skills/fimo/references/translations.md +75 -0
  5. package/assets/skills/fimo-migration/SKILL.md +2 -0
  6. package/assets/skills/fimo-migration/references/manifest.md +11 -0
  7. package/assets/skills/fimo-migration/references/pages.md +234 -0
  8. package/assets/skills/fimo-migration/references/wrap.md +45 -0
  9. package/dist/build/vite/plugins/translations.js +28 -10
  10. package/dist/cli/bundle.json +1 -1
  11. package/dist/cli/index.js +484 -243
  12. package/dist/runtime/app/FimoProviders.js +29 -7
  13. package/dist/runtime/core/BuiltWithBadge.d.ts +2 -0
  14. package/dist/runtime/core/BuiltWithBadge.js +68 -0
  15. package/dist/runtime/primitives/components/RichText.d.ts +5 -1
  16. package/dist/runtime/primitives/components/RichText.js +2 -2
  17. package/dist/runtime/react/FimoSpaProviders.js +83 -24
  18. package/dist/runtime/react/index.d.ts +2 -0
  19. package/dist/runtime/react/index.js +1 -0
  20. package/dist/runtime/react/spa-content-cache.d.ts +38 -0
  21. package/dist/runtime/react/spa-content-cache.js +64 -0
  22. package/dist/runtime/seo/neutral.d.ts +22 -0
  23. package/dist/runtime/seo/neutral.js +17 -0
  24. package/dist/runtime/shared/fimo-config.server.d.ts +0 -1
  25. package/dist/runtime/shared/fimo-config.server.js +0 -13
  26. package/dist/runtime/strapi/client.js +45 -2
  27. package/dist/runtime/strapi/reshape.d.ts +10 -6
  28. package/dist/runtime/strapi/reshape.js +7 -0
  29. package/dist/runtime/wrapped/FimoRichTextBlock.d.ts +4 -1
  30. package/dist/runtime/wrapped/FimoRichTextBlock.js +2 -2
  31. package/dist/runtime/wrapped/content-fetch.pure.d.ts +20 -0
  32. package/dist/runtime/wrapped/content-fetch.pure.js +106 -0
  33. package/dist/runtime/wrapped/server.d.ts +5 -2
  34. package/dist/runtime/wrapped/server.js +15 -45
  35. package/dist/scripts/apply-wrap-templates.js +10 -5
  36. package/dist/scripts/apply-wrap.d.ts +29 -2
  37. package/dist/scripts/apply-wrap.js +69 -7
  38. package/dist/scripts/cms/example-content.js +16 -13
  39. package/dist/scripts/cms/strapi/schema-map.js +16 -4
  40. package/dist/scripts/cms/strapi/shapes.d.ts +7 -5
  41. package/dist/scripts/cms/strapi/shapes.js +10 -0
  42. package/dist/scripts/cms/strapi/wiring.js +1 -1
  43. package/dist/scripts/detect-framework.js +6 -0
  44. package/dist/scripts/draft-rows.js +44 -1
  45. package/dist/scripts/draft-schemas.js +64 -5
  46. package/dist/scripts/frameworks/astro/routes.js +8 -19
  47. package/dist/scripts/frameworks/discover.d.ts +19 -6
  48. package/dist/scripts/frameworks/discover.js +113 -3
  49. package/dist/scripts/frameworks/discovered-route.d.ts +9 -0
  50. package/dist/scripts/frameworks/discovered-route.js +17 -0
  51. package/dist/scripts/frameworks/next/rewire.d.ts +13 -0
  52. package/dist/scripts/frameworks/next/rewire.js +26 -2
  53. package/dist/scripts/frameworks/next/routes.d.ts +13 -0
  54. package/dist/scripts/frameworks/next/routes.js +55 -13
  55. package/dist/scripts/frameworks/nuxt/routes.js +2 -18
  56. package/dist/scripts/frameworks/remix/routes.js +4 -18
  57. package/dist/scripts/frameworks/sveltekit/routes.js +4 -19
  58. package/dist/scripts/frameworks/vite/routes.d.ts +5 -0
  59. package/dist/scripts/frameworks/vite/routes.js +59 -9
  60. package/dist/scripts/frameworks/vite/wiring.d.ts +12 -0
  61. package/dist/scripts/frameworks/vite/wiring.js +68 -23
  62. package/dist/scripts/lib/humanize.d.ts +14 -0
  63. package/dist/scripts/lib/humanize.js +54 -0
  64. package/dist/scripts/lib/validation-report.d.ts +0 -3
  65. package/dist/scripts/lib/validation-report.js +0 -9
  66. package/dist/scripts/render-parity/capture.d.ts +12 -4
  67. package/dist/scripts/render-parity/capture.js +94 -24
  68. package/dist/scripts/routes/bindings.d.ts +93 -0
  69. package/dist/scripts/routes/bindings.js +117 -0
  70. package/dist/scripts/routes/label-prefixes.d.ts +49 -0
  71. package/dist/scripts/routes/label-prefixes.js +136 -0
  72. package/dist/scripts/routes/module-resolve.d.ts +20 -0
  73. package/dist/scripts/routes/module-resolve.js +78 -0
  74. package/dist/scripts/routes/page-structure.d.ts +72 -0
  75. package/dist/scripts/routes/page-structure.js +234 -0
  76. package/dist/scripts/scan-wrappable.d.ts +5 -1
  77. package/dist/scripts/scan-wrappable.js +126 -3
  78. package/package.json +5 -1
  79. package/templates/react-router/package.json +1 -1
  80. package/templates/react-router/translations/en.json +1 -0
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tool: write-locale-files
3
+ *
4
+ * For locale-keyed JSON files in `src/locales/<locale>.json`, fill in
5
+ * missing keys from the default-locale file using the configured
6
+ * translation provider. Run after translate-entries when projects mix
7
+ * CMS-driven copy with hardcoded i18n strings.
8
+ */
9
+
10
+ import { promises as fs } from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ interface Catalog {
14
+ [key: string]: string;
15
+ }
16
+
17
+ async function readCatalog(file: string): Promise<Catalog> {
18
+ try {
19
+ const text = await fs.readFile(file, 'utf8');
20
+ return JSON.parse(text) as Catalog;
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ async function writeCatalog(file: string, catalog: Catalog): Promise<void> {
27
+ await fs.writeFile(file, JSON.stringify(catalog, null, 2) + '\n', 'utf8');
28
+ }
29
+
30
+ async function translate(text: string, _to: string): Promise<string> {
31
+ // Stub — provider-specific call.
32
+ return text;
33
+ }
34
+
35
+ async function main(): Promise<void> {
36
+ const defaultLocale = process.env.FIMO_DEFAULT_LOCALE ?? 'en';
37
+ const targetLocales = (process.env.FIMO_TARGET_LOCALES ?? '').split(',').filter(Boolean);
38
+ const localesDir = process.env.FIMO_LOCALES_DIR ?? 'src/locales';
39
+
40
+ const defaultFile = path.join(localesDir, `${defaultLocale}.json`);
41
+ const defaultCatalog = await readCatalog(defaultFile);
42
+
43
+ for (const target of targetLocales) {
44
+ const targetFile = path.join(localesDir, `${target}.json`);
45
+ const targetCatalog = await readCatalog(targetFile);
46
+ let dirty = false;
47
+
48
+ for (const [key, sourceValue] of Object.entries(defaultCatalog)) {
49
+ if (targetCatalog[key]) {
50
+ continue;
51
+ }
52
+ targetCatalog[key] = await translate(sourceValue, target);
53
+ dirty = true;
54
+ }
55
+
56
+ if (dirty) {
57
+ await writeCatalog(targetFile, targetCatalog);
58
+ console.log(`Wrote ${targetFile}`);
59
+ }
60
+ }
61
+ }
62
+
63
+ main().catch((err) => {
64
+ console.error(err);
65
+ process.exit(1);
66
+ });
@@ -0,0 +1,19 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(fimo:*)",
5
+ "Bash(npx fimo:*)",
6
+ "Bash(pnpm:*)",
7
+ "Bash(pnpm dlx:*)",
8
+ "Bash(npx:*)",
9
+ "Bash(git status)",
10
+ "Bash(git status:*)",
11
+ "Bash(git diff)",
12
+ "Bash(git diff:*)",
13
+ "Bash(git log:*)",
14
+ "Bash(git add:*)",
15
+ "Bash(git restore:*)",
16
+ "Bash(mkdir:*)"
17
+ ]
18
+ }
19
+ }
@@ -8,6 +8,7 @@
8
8
  * }
9
9
  */
10
10
  %>
11
+ // @ts-nocheck — generated internals are exempt from the app's own tsconfig strictness; exported types still work
11
12
  // AUTO-GENERATED ⚠️ – do not edit by hand
12
13
  import { type Fields, type FimoBoolean, type FimoMedia, type FimoPolymorphicReference, type FimoReference, type FimoRichText, type FimoRichTextContent, type FimoString, type Populate, type PopulatedResult, type ProjectedWithPopulate, type Query, type Sort, type Where, type WithReferences } from "fimo/ui";
13
14
  <%
@@ -0,0 +1,75 @@
1
+ # Translations — package surface
2
+
3
+ > For the CLI workflow (`fimo validate`, when to run validation, what sync does), see **`fimo-cli/references/translations.md`**.
4
+
5
+ This file covers the **package surface**: the `t()` helper, the `useTranslations()` hook, and the code-side rules for wrapping strings.
6
+
7
+ ## The rule
8
+
9
+ Every piece of **user-facing static text** in the app (page titles, button labels, nav items, form placeholders, empty-state copy, alt text, meta tags) MUST be wrapped in `t('key', 'Default text')`.
10
+
11
+ Raw string literals in JSX are a bug — they won't show up in the Fimo admin as editable content and won't be translatable. **Hard-coded JSX text (`<h1>Welcome</h1>`) skips the entire i18n + inline-editing pipeline and becomes invisible to the editor.**
12
+
13
+ ## Setup (already wired)
14
+
15
+ Translations are wired automatically by the `fimo/vite` build plugin. It exposes a `virtual:translations` module sourced from `<projectRoot>/translations/en.json`, and `useTranslations()` reads from that virtual module — there's no provider to add or remove. The app entry is `src/root.tsx`, which wraps the app in `<FimoProviders>` from `fimo/react-router`; you never import `translations/en.json` in app code. Pages and components only call `useTranslations()`.
16
+
17
+ ## Usage
18
+
19
+ ```tsx
20
+ import { useTranslations, Text } from 'fimo/ui';
21
+
22
+ export function Hero() {
23
+ const { t } = useTranslations();
24
+ return (
25
+ <section>
26
+ <h1>
27
+ <Text value={t('hero.title', 'Welcome to Acme')} />
28
+ </h1>
29
+ <p>
30
+ <Text value={t('hero.subtitle', 'The fastest way to ship your idea.')} />
31
+ </p>
32
+ <input placeholder={String(t('hero.emailPlaceholder', 'you@example.com'))} />
33
+ <img alt={String(t('hero.imageAlt', 'Team collaborating'))} src="..." />
34
+ </section>
35
+ );
36
+ }
37
+ ```
38
+
39
+ - `t(key, default)` returns a `FimoString` (wrapped for source tracking). Render it via `<Text value={...} />` inside JSX.
40
+ - For HTML attributes (`placeholder`, `alt`, `title`, `aria-label`) cast with `String(t(...))` — the attribute can't accept a `FimoString` object directly.
41
+
42
+ ## Hard rules (enforced by the pre-build linter)
43
+
44
+ **The first argument to `t()` MUST be a string literal.** Dynamic keys break source tracking and fail the lint step.
45
+
46
+ ```tsx
47
+ // ❌ BAD — variable key
48
+ t(item.key, item.label);
49
+
50
+ // ❌ BAD — template with expression
51
+ t(`nav.${page}`, 'Page');
52
+
53
+ // ✅ GOOD — put the t() call where the literal lives
54
+ const items = [{ label: t('nav.home', 'Home') }, { label: t('nav.about', 'About') }];
55
+ items.map((item) => <li>{item.label}</li>);
56
+ ```
57
+
58
+ ## Key naming
59
+
60
+ - Dot-namespaced, lowercase, kebab or camelCase leaves: `hero.title`, `nav.signIn`, `footer.copyright`, `errors.required`.
61
+ - Namespace by **page/section/component**, not by feature (`contact.form.submitLabel`, not `forms.contactSubmit`).
62
+ - The default (second arg) is the English copy — make it final, not a TODO; it ships to production if no translation exists for the active locale.
63
+
64
+ ## What NOT to translate
65
+
66
+ - Dynamic content from entries or forms — that's already stored in the DB (content via schemas, submissions via forms).
67
+ - Content-type field values rendered via `fimo/ui` primitives (`<Text>`, `<RichText>`, …) — those carry their own admin-editing metadata.
68
+ - Purely decorative / non-text (icons, logos).
69
+ - Developer-only strings (`console.log`, error messages thrown for debugging).
70
+
71
+ ## Rule of thumb
72
+
73
+ If a human reader sees the text AND the project owner might want to reword it in the admin, it goes through `t()`. When in doubt: wrap it.
74
+
75
+ After adding or changing `t()` calls, run `fimo validate` (or rely on `fimo deploy` doing it). See `fimo-cli/references/translations.md`.
@@ -117,6 +117,8 @@ node <this-skill>/scripts/write-migration-report.mjs <project-root>
117
117
  - `references/wrap.md` - wrap mode: adopt a working JS codebase and wrap its
118
118
  content in place (default for source-backed JS sites).
119
119
  - `references/manifest.md` - canonical migration ledger schema.
120
+ - `references/pages.md` - the page registry (templates, schema bindings,
121
+ instances), Studio preview navigation, and the opt-in per-page SEO recipes.
120
122
  - `references/content.md` - CMS, entries, references, and source value rules.
121
123
  - `references/media.md` - media inventory, upload, dedupe, and replacement rules.
122
124
  - `references/forms.md` - form classification and Fimo replacement rules.
@@ -66,11 +66,22 @@ rebuild migrations:
66
66
  "wrappedMedia": 3,
67
67
  "wrappedRichText": 1
68
68
  },
69
+ "seo": { "decision": "external", "decidedBy": "default" },
69
70
  "planPath": ".fimo/migration/plan.json"
70
71
  }
71
72
  }
72
73
  ```
73
74
 
75
+ `wrap.seo` records who owns the site's SEO metadata — a USER decision, like
76
+ `wrap.dataSources`: `decision` is `"fimo"` (Fimo manages per-page SEO,
77
+ editable in the Studio) or `"external"` (the code / an external SEO tool
78
+ keeps ownership — the DEFAULT, nothing is ever wired on it). `decidedBy` is
79
+ `"default"` until the user actually answered; relay defaulted records and
80
+ rewrite them with `decidedBy: "user"`. Page bindings themselves live in the
81
+ page registry (`fimo-config.json#routes` →
82
+ `params: { "<param>": { "schema", "queryField", "displayField" } }`), not in
83
+ the manifest — see `references/pages.md`.
84
+
74
85
  `coverage` mirrors the final `fimo migrate wrap --check` output: `wrappable`
75
86
  must be 0 (or fully accounted for by `ignored` + `unresolvedItems`, each with a
76
87
  ledger entry). `seeded` counts must match what `fimo labels list` /
@@ -0,0 +1,234 @@
1
+ # Pages — the page registry, Preview navigation, and opt-in SEO
2
+
3
+ A **Page** is a navigable URL surface of the site, in two forms:
4
+
5
+ - **Page template** — what the app's routing declares: `/pricing` (static),
6
+ `/blog/:slug` (dynamic). The app's CODE is the source of truth for page
7
+ structure; `fimo migrate` discovers templates per framework and reconciles
8
+ them into the registry, `fimo-config.json#routes` (manual entries are
9
+ preserved). Param syntax is ALWAYS `:param` / catch-all `:param*` — never
10
+ `[param]`.
11
+ - **Page instance** — a concrete page: template × content entry
12
+ (`/blog/:slug` × `hello-world`). Instances are NEVER stored; they derive
13
+ from entries through the template's binding, so they cannot drift.
14
+
15
+ The registry entry shape:
16
+
17
+ ```json
18
+ "blog-post": {
19
+ "label": "Blog post",
20
+ "path": "/blog/:slug",
21
+ "params": { "slug": { "schema": "BlogPost", "queryField": "slug", "displayField": "title" } },
22
+ "seo": { "title": "{{blog-post.title}} — {{siteName}}" }
23
+ }
24
+ ```
25
+
26
+ - `params[<param>]` is the **binding**: the schema whose entries materialize
27
+ the instances. `queryField` = the entry field substituted into the URL
28
+ (entry `slug` metadata always qualifies); `displayField` = the label the
29
+ Studio's page picker shows.
30
+ - The Studio preview navigates ONLY what the registry describes: static pages
31
+ as-is, dynamic pages once bound. An unbound dynamic page navigates to a
32
+ literal `/blog/:slug` URL — broken by definition.
33
+ - Locale params (`:locale`, `:lang`) are never schema-bound; tooling
34
+ substitutes the project's default locale. (Studio locale switching for
35
+ dynamic pages is a platform follow-up, not your problem.)
36
+
37
+ ## Completing the bindings (agent workflow)
38
+
39
+ `fimo migrate` writes strong-evidence bindings itself (a rewired parent list
40
+ page; a wired CMS with a single page-builder type) and lists candidates +
41
+ unbound pages in the generated prompt. Your job:
42
+
43
+ 1. `npx fimo pages list` — see every template and its status
44
+ (`static | dynamic:bound | dynamic:unbound`).
45
+ 2. For each candidate/unbound page, find the schema whose entries the page
46
+ renders (read the page component's fetch). Apply with
47
+ `npx fimo pages set <id> --params '{"<param>":{"schema":"<Type>","queryField":"slug","displayField":"<field>"}}'`
48
+ — it validates the schema and both fields against the server. Never
49
+ hand-edit the JSON. If drafts are not adopted yet, run
50
+ `schemas adopt-drafts` FIRST and bind with the FINAL names.
51
+ 3. Add pages the scanner missed (SPA relative/wildcard routes):
52
+ `npx fimo pages set <id> --path /the/path --label "Label"`.
53
+ Never repeat the folder/category in the label: the `/features/agents` page
54
+ is labeled "Agents", NOT "Feature: Agents" — the Studio tree already nests
55
+ it under Features.
56
+ Labels must read like a human wrote them: acronyms uppercase ("Agentic
57
+ CMS", "AI Stack", "SEO GEO" — never "Agentic Cms"), no two sibling pages
58
+ with the same name (a bound template is named after its schema — "Blog
59
+ Post" next to the "Blog" index; the machine does this when it binds, fix
60
+ any leftover with `--label`).
61
+ When the site's NAV groups flat pages editorially (a "Resources" menu over
62
+ /for-developers, /for-founders…), mirror it with
63
+ `npx fimo pages set <id> --group "Resources"` — the Studio Pages tree
64
+ folds them under that group even without a shared URL segment.
65
+ 4. Map each page to its label keys when they diverge. The Studio shows a
66
+ page's labels by key prefix, defaulting to `<pageId>.` — but wrapped keys
67
+ derive from FILE names (`AiCms.tsx` → `ai-cms.*`) or data files
68
+ (`personas.*`), which can differ from the URL-derived page id
69
+ (`agentic-cms`). A page whose Studio view is EMPTY almost always means
70
+ this mismatch. `fimo migrate` PREFILLS `labelPrefixes` wherever it can
71
+ prove the join (a staged key appears verbatim in the route's component
72
+ file) and the prompt § Routes & pages lists every UNCLAIMED prefix left.
73
+ Your job: place each unclaimed prefix — claim it with
74
+ `npx fimo pages set <id> --label-prefix "<prefix>"` (comma-separated for
75
+ several; claims MERGE into the existing list so machine-prefilled
76
+ prefixes survive, `""` clears the whole list) when one page renders those keys, or leave it
77
+ deliberately when it is shared (chrome, multi-page data files) and say so
78
+ in your report. No prefix may end up merely forgotten.
79
+ 5. Give each page the Lucide icon that best matches its CONTENT:
80
+ `npx fimo pages set <id> --icon <kebab-name>` (pricing → `tag`, blog →
81
+ `newspaper`, contact → `mail`, legal pages → `scale`, team/about →
82
+ `users`, docs → `book-open`). Read the page component to judge. Use
83
+ well-known Lucide names only — an unknown name silently falls back to
84
+ the default file icon in the Studio. Skip the flag when nothing clearly
85
+ fits.
86
+ 6. Verify: `npx fimo pages list --expand` materializes every instance —
87
+ each page the site's nav links to must appear as a real URL.
88
+
89
+ ## Shared chrome → Single Types (the content-model rule)
90
+
91
+ A site's content model must read like a CMS, not like an implementation:
92
+
93
+ - **Chrome** (header, footer, nav, menus, banners) is edited as ONE thing →
94
+ a SINGLE TYPE per piece (`src/schemas/<Name>.json` with
95
+ `"isSingleton": true`, real fields mirroring the source: nav items as an
96
+ array/blocks field, logo as media), pushed with `npx fimo schemas push`,
97
+ its ONE document written from the source values with
98
+ `npx fimo documents set <Name> --body '<json>'` (singletons REJECT the
99
+ entries endpoints), component rewired to read it. Never a loose label
100
+ pile, never a collection.
101
+ - **Page-scoped structure** (a pricing page's FAQ list) → a component array
102
+ on that page's Single, or a collection + reference when genuinely reused
103
+ across pages. Never an orphan collection nobody can situate. CLAIM the
104
+ single on its page (`npx fimo pages set pricing --single Pricing`) — it
105
+ then edits inline in the page's Studio view and leaves the Singles tab.
106
+ - **Real repeating content** (blog posts, team members) → a collection, with
107
+ HUMAN slugs derived from the display field (never `faq-item-<hash>`), and
108
+ schema field order matching reading order (question before answer).
109
+ - **A page's SECTIONS are its Single's nested blocks.** A marketing page is
110
+ a sequence of sections (hero, logo strip, features grid with grouped
111
+ cards, testimonials, integrations, stats, FAQ) — model the page as ONE
112
+ Single whose fields are nested block components mirroring that structure
113
+ (blocks nest: a `features` field holds groups, each group holds cards).
114
+ Section card lists are NEVER "page furniture" exemptions when they carry
115
+ real copy — an exemption hides marketing content from the editor, which
116
+ is a failed migration for that section. Claim the Single on its page.
117
+ **The machine BUILDS this model for you**: `fimo migrate` writes, per
118
+ multi-section page, an analysis draft (`.fimo/migration/page-drafts/`,
119
+ regenerate with `npx fimo pages draft`) AND a ready page Single
120
+ (`.fimo/migration/page-singles/<Uid>.json` — singleton schema + document
121
+ filled with the staged label values and the source block rows). Sections
122
+ flagged `"shared": true` render on several pages — that is chrome: model
123
+ each once as a Single Type, never as a per-page block. The `wrap --check`
124
+ Content-model gate fails while a built Single is neither claimed
125
+ (`pages set <id> --single <Uid>`) nor recorded under
126
+ `wrap.modelDeviations` (`{"page": "<id>", "reason": "…"}`) — a reason the
127
+ user would accept; "faster as labels" is not one. Your work per page:
128
+ rename machine field keys to editorial names, push, `documents set`,
129
+ rewire the reads with source fallback, claim.
130
+
131
+ **Worked real example** (the Fimo homepage, the model the user ratified):
132
+ the page renders hero → logo strip → features grid (3 groups, each with
133
+ cards) → testimonials → integrations → stats bar → FAQ. Its Single:
134
+
135
+ ```json
136
+ {
137
+ "uid": "Home",
138
+ "isSingleton": true,
139
+ "fields": {
140
+ "builtOnTheBest": {
141
+ "type": "blocks",
142
+ "schema": { "fields": { "name": { "type": "string" }, "domain": { "type": "string" } } }
143
+ },
144
+ "features": {
145
+ "type": "blocks",
146
+ "schema": {
147
+ "fields": {
148
+ "title": { "type": "string" },
149
+ "subtitle": { "type": "string" },
150
+ "cards": {
151
+ "type": "blocks",
152
+ "schema": { "fields": { "title": { "type": "string" }, "description": { "type": "text" } } }
153
+ }
154
+ }
155
+ }
156
+ },
157
+ "testimonials": {
158
+ "type": "blocks",
159
+ "schema": {
160
+ "fields": {
161
+ "quote": { "type": "text" },
162
+ "metricValue": { "type": "string" },
163
+ "metricLabel": { "type": "string" },
164
+ "author": { "type": "string" },
165
+ "role": { "type": "string" }
166
+ }
167
+ }
168
+ },
169
+ "ecosystem": {
170
+ "type": "blocks",
171
+ "max": 1,
172
+ "schema": {
173
+ "fields": {
174
+ "title": { "type": "string" },
175
+ "stats": { "type": "blocks", "schema": { "fields": { "label": { "type": "string" } } } }
176
+ }
177
+ }
178
+ },
179
+ "faqs": { "type": "blocks", "schema": { "fields": { "q": { "type": "string" }, "a": { "type": "text" } } } }
180
+ }
181
+ }
182
+ ```
183
+
184
+ Note what made it right: block names are the on-page section titles
185
+ ("Built on top of the best" → `builtOnTheBest`), field names are ROLES
186
+ (`title`, `quote`, `author`) never copy slugs, a logo strip carries its
187
+ ITEMS (not just its heading), computed live values (stat numbers) stay in
188
+ code, and the whole thing was claimed on the page. Smart deviations were
189
+ reported, not silent.
190
+
191
+ - **Plumbing** (`WrappedRichText`, `WrappedMedia`) is storage — the Studio
192
+ hides it from Collections and shows the entries under their pages; never
193
+ present it as content in your report.
194
+
195
+ ## SEO — a USER decision, never wired by default
196
+
197
+ Who owns SEO metadata is recorded in `.fimo/migration/manifest.json` →
198
+ `wrap.seo` (`{ "decision": "fimo" | "external", "decidedBy": "user" | "default" }`).
199
+ The default is ALWAYS `external` (nothing touched — the site's code or an
200
+ external SEO tool keeps ownership). When the prompt shows the decision as
201
+ DEFAULTED, relay it to the user once; only an explicit user "fimo" unlocks
202
+ wiring. Never migrate, edit, or inject metadata on the default.
203
+
204
+ When the user opted into Fimo-managed SEO, the engine is the neutral
205
+ `fimo/seo` export — pure resolution over the registry, no framework coupling:
206
+
207
+ ```ts
208
+ import { getFimoSeoForPath } from 'fimo/seo';
209
+ const seo = getFimoSeoForPath(pathname, { routeData: { 'blog-post': entry } });
210
+ // seo.title / seo.description / seo.image / seo.robots …
211
+ ```
212
+
213
+ Per-framework injection (map the resolved values to the framework's head API):
214
+
215
+ - **Next (app router)** — in the layout or page, `generateMetadata()` calls
216
+ `getFimoSeoForPath` with the resolved pathname (+ the entry as `routeData`
217
+ on dynamic pages) and returns `{ title, description, openGraph: {...} }`.
218
+ - **React Router** — already shipped: `loadFimoMetaData` / `buildFimoMeta` /
219
+ `fimoHtmlProps` from `fimo/react-router`.
220
+ - **Astro** — a head partial calling `getFimoSeoForPath(Astro.url.pathname)`
221
+ server-side and rendering the `<meta>` tags.
222
+ - **Nuxt** — `useSeoMeta()` fed from a server util wrapping `getFimoSeoForPath`.
223
+ - **SvelteKit** — resolve in `+layout.server` load, render in the root
224
+ layout's `<svelte:head>`.
225
+
226
+ Route-level `seo` blocks support `{{variables}}` resolved against
227
+ `routeData` — per-INSTANCE metadata from one template.
228
+
229
+ ## Sitemap / robots
230
+
231
+ Once bindings exist, `getFimoPaths` (from `fimo/paths`) materializes the full
232
+ instance list for prerender/sitemap use. It reads the API base URL from
233
+ `VITE_API_URL` (a plain env var or `.env` line works outside Vite). The
234
+ `fimo pages list --expand` output is the same list, CLI-side.
@@ -318,6 +318,35 @@ four gates pass:
318
318
  SPA); the gate enforces the rest. Enforced only once items are wrapped;
319
319
  Next pages-router-only projects get a warning, not a failure.
320
320
 
321
+ **When a CMS was scaffolded, the four gates do NOT prove the reads resolve from
322
+ Fimo.** Parity drift from real→example content is expected and gets ignored, so
323
+ a wiring that silently falls through to the live CMS passes every gate while the
324
+ page renders the CMS's own content (or 404s when the CMS is down). This is the
325
+ CMS analog of the mandatory browser check — prove Fimo _ownership_ by hand:
326
+ load the homepage plus one page per scaffolded type and confirm the rendered
327
+ value is a scaffolded EXAMPLE marker (`Title example 1`, `Meta Title example 1`,
328
+ a `<type>-example-1` slug), NOT real CMS content and NOT a 404. Then confirm in
329
+ Studio Edit mode that a scaffolded block is selectable, not just rendered (a
330
+ block item with no stable id renders but cannot be edited — editability is the
331
+ whole point). **A single type (`Global`, `Header`, `Footer`) that reads empty
332
+ while collections render is the singular-vs-plural keying bug**: the generated
333
+ `fimo-strapi.ts` must key single types by their singular path (`'global'`, not
334
+ `'globals'`), and in dev the adapter logs `/api/global looks like a scaffolded
335
+ type but is not in the type map` on that fallthrough. Fix it in the CLI
336
+ (`scripts/cms/strapi/schema-map.ts`) and regenerate with
337
+ `fimo migrate --scaffold-cms` — never hand-edit the generated module.
338
+
339
+ **Populate real routes** (scaffolded CMS only): example entries carry generated
340
+ slugs, so only unfiltered reads (the homepage) resolve — every real route the
341
+ site links to misses its slug filter and 404s (the adapter answers a filtered
342
+ miss on a Fimo-owned type with an empty envelope; it never leaks the read to
343
+ the old CMS). Harvest the site's real slugs (live production `sitemap.xml`,
344
+ else nav/footer links in the code, else ask the user) and create one entry of
345
+ the routed type per linked route — matching slug + path field, distinct title,
346
+ varied block subset. Then click through the nav: every linked page renders,
347
+ none 404. This is navigability, not the content migration (the real pull is a
348
+ later, separate step).
349
+
321
350
  **Config-driven sites** (all copy in a data file, templates render only
322
351
  expressions): the scanner lists copy-bearing `.json`/`.yml` files and
323
352
  markdown groups in the plan (`dataFiles`) and the coverage gate fails until
@@ -594,6 +623,22 @@ framework — nothing above this section should name a specific framework.
594
623
  </FimoSpaProviders>;
595
624
  ```
596
625
 
626
+ - Instant content: the provider paints synchronously from
627
+ `window.__FIMO_CONTENT__` (baked) or the last visit's localStorage cache,
628
+ then revalidates in the background (Studio preview saves and window focus
629
+ trigger refetches — no reload needed). Only the FIRST EVER visit to a
630
+ non-baked site flashes inline fallbacks for one network round trip. If the
631
+ project prerenders its `index.html` (any SSG/prerender step), close that
632
+ gap by baking the payload at build time:
633
+
634
+ ```js
635
+ // e.g. scripts/bake-fimo-content.mjs — run AFTER the build/prerender step
636
+ import { fetchFimoSpaContent, serializeFimoSpaContent } from 'fimo/react';
637
+
638
+ const payload = await fetchFimoSpaContent(process.env.VITE_API_URL, config);
639
+ html = html.replace('</head>', `${serializeFimoSpaContent(payload)}</head>`);
640
+ ```
641
+
597
642
  - Routes: no file convention — the scan reads LITERAL ABSOLUTE paths from
598
643
  react-router (`<Route path="/x">` and `createBrowserRouter([{ path: '/x' }])`)
599
644
  into the fimo-config route registry. Relative child paths, `index` routes,
@@ -14,6 +14,12 @@ export default function translationsPlugin() {
14
14
  let reconnectTimer;
15
15
  let refreshTimer;
16
16
  let pollTimer;
17
+ // Set on closeBundle. Without it, closing a socket fires its own onclose,
18
+ // which schedules a reconnect AFTER the cleanup ran — resurrecting the
19
+ // event sockets forever and pinning the event loop. That is exactly how a
20
+ // prerender script using `createServer({ middlewareMode: true })` finished
21
+ // its work and then never exited.
22
+ let disposed = false;
17
23
  const refreshLabels = async () => {
18
24
  bundle = await loadLabelBundle(rootDir);
19
25
  return bundle;
@@ -49,6 +55,9 @@ export default function translationsPlugin() {
49
55
  });
50
56
  };
51
57
  const scheduleRefresh = () => {
58
+ if (disposed) {
59
+ return;
60
+ }
52
61
  if (refreshTimer) {
53
62
  clearTimeout(refreshTimer);
54
63
  }
@@ -56,6 +65,17 @@ export default function translationsPlugin() {
56
65
  void refreshLabelsAndInvalidateIfChanged().catch(() => undefined);
57
66
  }, 150);
58
67
  };
68
+ const adoptEventSockets = (sockets) => {
69
+ if (disposed) {
70
+ // The server closed while the async connect was in flight — sockets
71
+ // landing now must close immediately or they outlive the cleanup.
72
+ for (const socket of sockets) {
73
+ socket.close();
74
+ }
75
+ return;
76
+ }
77
+ eventSockets = sockets;
78
+ };
59
79
  return {
60
80
  name: 'fimo-virtual-translations',
61
81
  enforce: 'pre',
@@ -76,11 +96,7 @@ export default function translationsPlugin() {
76
96
  devServer = server;
77
97
  server.watcher.add([join(rootDir, 'fimo-config.json'), join(rootDir, '.env'), join(rootDir, '.env.local')]);
78
98
  void refreshLabelsAndInvalidateIfChanged().catch(() => undefined);
79
- connectLabelEvents(rootDir, scheduleRefresh)
80
- .then((sockets) => {
81
- eventSockets = sockets;
82
- })
83
- .catch(() => undefined);
99
+ connectLabelEvents(rootDir, scheduleRefresh).then(adoptEventSockets).catch(() => undefined);
84
100
  pollTimer = setInterval(() => {
85
101
  void refreshLabelsAndInvalidateIfChanged().catch(() => undefined);
86
102
  }, 2000);
@@ -126,6 +142,9 @@ export default function translationsPlugin() {
126
142
  return [];
127
143
  },
128
144
  closeBundle() {
145
+ // Order matters: dispose FIRST so the onclose handlers fired by the
146
+ // socket closes below (and any in-flight connect) cannot reschedule.
147
+ disposed = true;
129
148
  for (const socket of eventSockets) {
130
149
  socket.close();
131
150
  }
@@ -192,12 +211,11 @@ export default function translationsPlugin() {
192
211
  }
193
212
  };
194
213
  socket.onclose = () => {
214
+ if (disposed) {
215
+ return;
216
+ }
195
217
  reconnectTimer = setTimeout(() => {
196
- void connectLabelEvents(root, onLabelsChanged)
197
- .then((nextSockets) => {
198
- eventSockets = nextSockets;
199
- })
200
- .catch(() => undefined);
218
+ void connectLabelEvents(root, onLabelsChanged).then(adoptEventSockets).catch(() => undefined);
201
219
  }, 2000);
202
220
  };
203
221
  socket.onerror = () => undefined;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "bundled": true,
3
3
  "bundler": "esbuild",
4
- "bundledAt": "2026-07-23T17:15:28.604Z",
4
+ "bundledAt": "2026-07-29T15:37:14.073Z",
5
5
  "cliVersion": "0.8.0",
6
6
  "external": [
7
7
  "oxc-parser",