fimo 0.9.0-experimental.1 → 0.9.0-experimental.11

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 +298 -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 +577 -269
  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 +136 -0
  75. package/dist/scripts/routes/page-structure.js +393 -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
@@ -1,5 +1,6 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Suspense, useCallback, useEffect, useMemo } from 'react';
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query';
3
+ import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
3
4
  import { ErrorBoundary } from 'react-error-boundary';
4
5
  import { useLocation, useNavigate } from 'react-router';
5
6
  // @ts-ignore - virtual module provided by fimoConfigPlugin in fimo/vite
@@ -59,6 +60,27 @@ function PreviewBridge() {
59
60
  }, [navigate, sendRoutes, sendLocationChange]);
60
61
  return null;
61
62
  }
63
+ /**
64
+ * Template projects mount their own QueryClientProvider in root.tsx, but an
65
+ * ADOPTED app wired straight onto FimoProviders (react-router library SPAs)
66
+ * has none — and the inner runtime (RefreshContentProvider, content clients)
67
+ * calls useQueryClient(). Mount a fallback client only when no provider
68
+ * exists above, so both worlds work and template caches stay untouched.
69
+ */
70
+ function EnsureQueryClient({ children }) {
71
+ let existing = null;
72
+ try {
73
+ existing = useQueryClient();
74
+ }
75
+ catch {
76
+ existing = null;
77
+ }
78
+ const [fallbackClient] = useState(() => new QueryClient());
79
+ if (existing) {
80
+ return _jsx(_Fragment, { children: children });
81
+ }
82
+ return _jsx(QueryClientProvider, { client: fallbackClient, children: children });
83
+ }
62
84
  export function FimoProviders({ children, locale }) {
63
85
  const location = useLocation();
64
86
  const localeResolution = useMemo(() => resolveFimoLocale({ pathname: location.pathname }, fimoConfig), [location.pathname]);
@@ -71,9 +93,9 @@ export function FimoProviders({ children, locale }) {
71
93
  document.documentElement.lang = toHtmlLang(activeLocale);
72
94
  document.documentElement.dir = activeDir;
73
95
  }, [activeDir, activeLocale]);
74
- return (_jsx(ErrorBoundary, { FallbackComponent: AppErrorBoundary, onError: onError, children: _jsx(FimoLocaleProvider, { value: {
75
- ...localeResolution,
76
- locale: activeLocale,
77
- dir: activeDir,
78
- }, children: _jsx(DataSourcesProvider, { children: _jsx(ScreenshotProvider, { children: _jsx(ViteLifecycles, { children: _jsxs(RefreshContentProvider, { children: [_jsx(PreviewBridge, {}), _jsx(OptionalTracking, { routes: getDisplayRoutes() }), _jsx(Suspense, { children: children })] }) }) }) }) }) }));
96
+ return (_jsx(ErrorBoundary, { FallbackComponent: AppErrorBoundary, onError: onError, children: _jsx(EnsureQueryClient, { children: _jsx(FimoLocaleProvider, { value: {
97
+ ...localeResolution,
98
+ locale: activeLocale,
99
+ dir: activeDir,
100
+ }, children: _jsx(DataSourcesProvider, { children: _jsx(ScreenshotProvider, { children: _jsx(ViteLifecycles, { children: _jsxs(RefreshContentProvider, { children: [_jsx(PreviewBridge, {}), _jsx(OptionalTracking, { routes: getDisplayRoutes() }), _jsx(Suspense, { children: children })] }) }) }) }) }) }) }));
79
101
  }
@@ -0,0 +1,2 @@
1
+ export declare function BuiltWithBadge(): import("react").ReactPortal | null;
2
+ //# sourceMappingURL=BuiltWithBadge.d.ts.map
@@ -0,0 +1,68 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ const STORAGE_KEY = 'fimoBadgeDismissed';
5
+ export function BuiltWithBadge() {
6
+ const hideFimoBadge = __FIMO_HIDE_BADGE__;
7
+ const [visible, setVisible] = useState(false);
8
+ useEffect(() => {
9
+ try {
10
+ const dismissed = sessionStorage.getItem(STORAGE_KEY) === 'true';
11
+ setVisible(!dismissed);
12
+ }
13
+ catch {
14
+ setVisible(true);
15
+ }
16
+ }, []);
17
+ if (hideFimoBadge) {
18
+ return null;
19
+ } // hide fimo badge config
20
+ // @ts-ignore
21
+ if (!import.meta.env.PROD) {
22
+ return null;
23
+ } // only show in prod
24
+ if (!visible) {
25
+ return null;
26
+ }
27
+ if (typeof document === 'undefined') {
28
+ return null;
29
+ }
30
+ return createPortal(_jsx("div", { style: {
31
+ position: 'fixed',
32
+ right: '16px',
33
+ bottom: '16px',
34
+ zIndex: 100000,
35
+ margin: 0,
36
+ }, children: _jsxs("div", { style: {
37
+ display: 'flex',
38
+ alignItems: 'center',
39
+ gap: '8px',
40
+ borderRadius: '9999px',
41
+ color: '#fff',
42
+ boxShadow: '0 12px 30px rgba(0, 0, 0, 0.35)',
43
+ padding: '8px 12px',
44
+ fontSize: '12px',
45
+ lineHeight: 1.2,
46
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
47
+ fontFamily: 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif',
48
+ }, children: [_jsxs("a", { href: "https://fimo.ai", target: "_blank", rel: "noopener noreferrer", style: {
49
+ display: 'inline-flex',
50
+ alignItems: 'center',
51
+ gap: '8px',
52
+ color: '#fff',
53
+ textDecoration: 'none',
54
+ }, children: [_jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "18", height: "18", viewBox: "0 0 24 12", fill: "none", children: _jsx("path", { d: "M22.8569 4.85715H14.9866L15.9132 3.58186C16.5027 2.77062 17.4534 2.28627 18.4561 2.28627H22.8569C23.4882 2.28627 23.9999 1.7745 23.9999 1.14328C23.9999 0.512061 23.4884 0 22.8569 0H18.4561C16.7239 0 15.082 0.836671 14.0639 2.23798L12.1611 4.85715H6.9979C5.2657 4.85715 3.62379 5.69382 2.60567 7.09513L0.360255 10.1852C-0.0109321 10.6958 0.10251 11.4108 0.613142 11.7817C0.816023 11.9291 1.05119 12 1.28408 12C1.63755 12 1.98616 11.8366 2.20962 11.5288L4.45474 8.43872C5.04424 7.62748 5.99493 7.14314 6.99762 7.14314H10.5L8.28977 10.1852C7.91858 10.6958 8.03203 11.4108 8.54266 11.7817C8.74554 11.9291 8.98071 12 9.2136 12C9.56707 12 9.91568 11.8366 10.1391 11.5288L12.3843 8.43872C12.9738 7.62748 13.9244 7.14314 14.9271 7.14314H22.8566C23.4879 7.14314 23.9996 6.63136 23.9996 6.00014C23.9996 5.36893 23.4879 4.85715 22.8566 4.85715H22.8569Z", fill: "white" }) }), _jsx("span", { style: { fontWeight: 600 }, children: "Built with Fimo" })] }), _jsx("button", { "aria-label": "Dismiss badge", style: {
55
+ color: '#ddd',
56
+ background: 'transparent',
57
+ cursor: 'pointer',
58
+ border: 'none',
59
+ padding: 0,
60
+ lineHeight: 0,
61
+ }, onClick: () => {
62
+ try {
63
+ sessionStorage.setItem(STORAGE_KEY, 'true');
64
+ }
65
+ catch { }
66
+ setVisible(false);
67
+ }, type: "button", children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: [_jsx("path", { d: "M18 6 6 18" }), _jsx("path", { d: "m6 6 12 12" })] }) })] }) }), document.body);
68
+ }
@@ -3,7 +3,11 @@ import { type FimoRichText, type FimoRichTextComponents } from '../lib/index.js'
3
3
  type RichTextProps = {
4
4
  value: FimoRichText;
5
5
  components?: FimoRichTextComponents;
6
+ /** Wrapper element. Use `span` when the block sits inside phrasing
7
+ * content (a `<p>`): a div there is invalid HTML — the browser closes
8
+ * the paragraph early and React trashes the hydrated tree. */
9
+ as?: 'div' | 'span';
6
10
  } & Omit<ComponentPropsWithoutRef<'div'>, 'children'>;
7
- export declare function RichText({ value, components, ...props }: RichTextProps): import("react/jsx-runtime").JSX.Element;
11
+ export declare function RichText({ value, components, as: Wrapper, ...props }: RichTextProps): import("react/jsx-runtime").JSX.Element;
8
12
  export {};
9
13
  //# sourceMappingURL=RichText.d.ts.map
@@ -128,8 +128,8 @@ function renderNode(node, components, key) {
128
128
  }
129
129
  return _jsx(Fragment, { children: children }, key);
130
130
  }
131
- export function RichText({ value, components, ...props }) {
131
+ export function RichText({ value, components, as: Wrapper = 'div', ...props }) {
132
132
  const source = value[FIMO_SOURCE];
133
133
  const { value: liveContent, ref } = useFimoLiveValue(value.content);
134
- return (_jsx("div", { ref: ref, ...props, "data-fimo-source": source, "data-fimo-richtext": "", children: renderNode(liveContent, components, 0) }));
134
+ return (_jsx(Wrapper, { ref: ref, ...props, "data-fimo-source": source, "data-fimo-richtext": "", children: renderNode(liveContent, components, 0) }));
135
135
  }
@@ -1,18 +1,11 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useMemo, useState } from 'react';
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { getFimoDefaultLocale, getFimoLocales } from '../i18n/locale.js';
5
- import { fetchLabelBundle, serializeBundle } from '../shared/labels-fetch.pure.js';
5
+ import { fetchLabelBundle } from '../shared/labels-fetch.pure.js';
6
+ import { fetchWrappedContent } from '../wrapped/content-fetch.pure.js';
6
7
  import { WrappedProviders } from '../wrapped/WrappedProviders.js';
7
- /** Browser-safe stable signature (labelSignature uses Buffer, server-only). */
8
- function bundleSignature(bundle) {
9
- const serialized = serializeBundle(bundle);
10
- let hash = 5381;
11
- for (let i = 0; i < serialized.length; i++) {
12
- hash = ((hash << 5) + hash + serialized.charCodeAt(i)) | 0;
13
- }
14
- return `spa-${hash}`;
15
- }
8
+ import { readInitialSpaContent, spaBundleSignature, writeSpaContentCache } from './spa-content-cache.js';
16
9
  /**
17
10
  * Fimo providers for wrapped CLIENT-RENDERED React apps (Vite SPAs). There is
18
11
  * no server pass to load labels in, so this adapter fetches the dictionary in
@@ -33,31 +26,97 @@ function bundleSignature(bundle) {
33
26
  * router without depending on one.
34
27
  */
35
28
  export function FimoSpaProviders({ config, apiUrl, previewScriptUrl, media, richText, locale, children, }) {
36
- const [labels, setLabels] = useState(undefined);
29
+ // Synchronous first paint: baked SSG payload or the last visit's cached
30
+ // one renders IMMEDIATELY (the SDK owns rendering — no fallback flash
31
+ // while the network round-trips); the fetches below revalidate it.
32
+ const initial = useMemo(() => readInitialSpaContent(apiUrl), [apiUrl]);
33
+ const [labels, setLabels] = useState(initial?.labels ?? undefined);
34
+ const [content, setContent] = useState(initial?.content ?? undefined);
35
+ const latestRef = useRef({
36
+ labels: initial?.labels ?? undefined,
37
+ content: initial?.content ?? undefined,
38
+ });
37
39
  useEffect(() => {
38
40
  if (!apiUrl) {
39
41
  return;
40
42
  }
41
- let cancelled = false;
42
- const defaultLocale = getFimoDefaultLocale(config);
43
- fetchLabelBundle(apiUrl, getFimoLocales(config), defaultLocale)
44
- .then((bundle) => {
45
- if (!cancelled) {
46
- setLabels({ ...bundle, signature: bundleSignature(bundle) });
43
+ let disposed = false;
44
+ const persist = () => {
45
+ writeSpaContentCache(apiUrl, { labels: latestRef.current.labels, content: latestRef.current.content });
46
+ };
47
+ const revalidate = () => {
48
+ const defaultLocale = getFimoDefaultLocale(config);
49
+ fetchLabelBundle(apiUrl, getFimoLocales(config), defaultLocale)
50
+ .then((bundle) => {
51
+ if (!disposed) {
52
+ const next = { ...bundle, signature: spaBundleSignature(bundle) };
53
+ latestRef.current.labels = next;
54
+ setLabels(next);
55
+ persist();
56
+ }
57
+ })
58
+ .catch(() => {
59
+ // Unreachable API: keep rendering the cached/baked values or fallbacks.
60
+ });
61
+ // Wrapped rich text and media live in the plumbing types — without this
62
+ // fetch a client-rendered app only ever shows their inline fallbacks
63
+ // and Studio edits to them render nowhere.
64
+ fetchWrappedContent(apiUrl)
65
+ .then((fetched) => {
66
+ if (!disposed) {
67
+ latestRef.current.content = fetched;
68
+ setContent(fetched);
69
+ persist();
70
+ }
71
+ })
72
+ .catch(() => {
73
+ // Unreachable API: keep rendering the cached/baked values or fallbacks.
74
+ });
75
+ };
76
+ revalidate();
77
+ // Live updates, no refresh needed:
78
+ // 1. Inside the Fimo Studio preview, every save posts
79
+ // `{ type: 'content/refresh' }` into the iframe — refetch on the spot
80
+ // (same contract the template runtime's RefreshContentProvider uses).
81
+ // 2. In a plain tab, coming back to the site (alt-tab from the Studio)
82
+ // revalidates on focus/visibility — edits appear without a reload.
83
+ let lastRun = Date.now();
84
+ const throttledRevalidate = () => {
85
+ if (Date.now() - lastRun < 1500) {
86
+ return;
87
+ }
88
+ lastRun = Date.now();
89
+ revalidate();
90
+ };
91
+ const onMessage = (event) => {
92
+ if (event.data?.type === 'content/refresh') {
93
+ lastRun = Date.now();
94
+ revalidate();
95
+ }
96
+ };
97
+ const onVisibility = () => {
98
+ if (document.visibilityState === 'visible') {
99
+ throttledRevalidate();
47
100
  }
48
- })
49
- .catch(() => {
50
- // Unreachable API: keep rendering the inline fallbacks.
51
- });
101
+ };
102
+ window.addEventListener('message', onMessage);
103
+ window.addEventListener('focus', throttledRevalidate);
104
+ document.addEventListener('visibilitychange', onVisibility);
52
105
  return () => {
53
- cancelled = true;
106
+ disposed = true;
107
+ window.removeEventListener('message', onMessage);
108
+ window.removeEventListener('focus', throttledRevalidate);
109
+ document.removeEventListener('visibilitychange', onVisibility);
54
110
  };
55
111
  }, [apiUrl, config]);
112
+ // Explicitly-passed maps win over the fetched ones (caller override).
113
+ const mergedMedia = useMemo(() => (content?.media || media ? { ...(content?.media ?? {}), ...(media ?? {}) } : undefined), [content?.media, media]);
114
+ const mergedRichText = useMemo(() => (content?.richText || richText ? { ...(content?.richText ?? {}), ...(richText ?? {}) } : undefined), [content?.richText, richText]);
56
115
  const navigation = useMemo(() => ({
57
116
  pathname: typeof window !== 'undefined' ? window.location.pathname : null,
58
117
  navigate: (to) => {
59
118
  window.location.assign(to);
60
119
  },
61
120
  }), []);
62
- return (_jsxs(WrappedProviders, { config: config, labels: labels, media: media, richText: richText, locale: locale, navigation: navigation, children: [previewScriptUrl ? _jsx("script", { type: "module", src: previewScriptUrl }) : null, children] }));
121
+ return (_jsxs(WrappedProviders, { config: config, labels: labels, media: mergedMedia, richText: mergedRichText, locale: locale, navigation: navigation, children: [previewScriptUrl ? _jsx("script", { type: "module", src: previewScriptUrl }) : null, children] }));
63
122
  }
@@ -30,4 +30,6 @@ export type { FimoLocaleResolution } from '../i18n/locale.js';
30
30
  export { getDisplayRoutes } from '../shared/display-routes.js';
31
31
  export type { DisplayRoute, ParamInfo } from '../shared/display-routes.js';
32
32
  export type { FimoConfig, FimoRouteConfig, FimoSEO } from '../shared/fimo-config.types.js';
33
+ export { fetchFimoSpaContent, serializeFimoSpaContent } from './spa-content-cache.js';
34
+ export type { SpaContentPayload } from './spa-content-cache.js';
33
35
  //# sourceMappingURL=index.d.ts.map
@@ -37,3 +37,4 @@ export { FimoLocaleProvider, useLocale } from '../i18n/FimoLocaleContext.js';
37
37
  export { getFimoDefaultLocale, getFimoLocales, getFimoTextDirection, localizeFimoPath, resolveFimoLocale, toHtmlLang, } from '../i18n/locale.js';
38
38
  // Config + routes
39
39
  export { getDisplayRoutes } from '../shared/display-routes.js';
40
+ export { fetchFimoSpaContent, serializeFimoSpaContent } from './spa-content-cache.js';
@@ -0,0 +1,38 @@
1
+ import type { FimoConfig } from '../shared/fimo-config.types.js';
2
+ import { type LabelBundle } from '../shared/labels-fetch.pure.js';
3
+ import { type WrappedContent } from '../wrapped/content-fetch.pure.js';
4
+ import type { WrappedLabels } from '../wrapped/WrappedProviders.js';
5
+ /**
6
+ * Instant first paint for client-rendered wrapped apps: the SDK controls
7
+ * rendering, so it never has to flash fallbacks while the network round
8
+ * trips. Two synchronous sources, in priority order:
9
+ *
10
+ * 1. `window.__FIMO_CONTENT__` — baked by the site's SSG/prerender step
11
+ * (`serializeFimoSpaContent`), zero flash even on the first ever visit;
12
+ * 2. the last fetched payload cached in localStorage — instant from the
13
+ * second visit on, revalidated in the background (stale-while-revalidate).
14
+ */
15
+ export interface SpaContentPayload {
16
+ labels?: WrappedLabels;
17
+ content?: WrappedContent;
18
+ }
19
+ declare global {
20
+ interface Window {
21
+ __FIMO_CONTENT__?: SpaContentPayload;
22
+ }
23
+ }
24
+ export declare function readInitialSpaContent(apiUrl: string | undefined): SpaContentPayload | null;
25
+ export declare function writeSpaContentCache(apiUrl: string, payload: SpaContentPayload): void;
26
+ /** Browser-safe stable signature (labelSignature uses Buffer, server-only). */
27
+ export declare function spaBundleSignature(bundle: LabelBundle): string;
28
+ /**
29
+ * Build-time half of the bake: fetch the exact payload the provider's
30
+ * background revalidation produces (labels with the same signature shape,
31
+ * wrapped rich text + media), ready for `serializeFimoSpaContent`. Runs
32
+ * anywhere `fetch` exists — an SSG/prerender node script included.
33
+ */
34
+ export declare function fetchFimoSpaContent(apiUrl: string, config: FimoConfig): Promise<SpaContentPayload>;
35
+ /** SSG helper: a script tag baking the payload into prerendered HTML so the
36
+ * provider hydrates it synchronously — zero content flash. */
37
+ export declare function serializeFimoSpaContent(payload: SpaContentPayload): string;
38
+ //# sourceMappingURL=spa-content-cache.d.ts.map
@@ -0,0 +1,64 @@
1
+ import { getFimoDefaultLocale, getFimoLocales } from '../i18n/locale.js';
2
+ import { fetchLabelBundle, serializeBundle } from '../shared/labels-fetch.pure.js';
3
+ import { fetchWrappedContent } from '../wrapped/content-fetch.pure.js';
4
+ const CACHE_PREFIX = 'fimo:spa-content:';
5
+ export function readInitialSpaContent(apiUrl) {
6
+ if (typeof window === 'undefined') {
7
+ return null;
8
+ }
9
+ if (window.__FIMO_CONTENT__ && typeof window.__FIMO_CONTENT__ === 'object') {
10
+ return window.__FIMO_CONTENT__;
11
+ }
12
+ if (!apiUrl) {
13
+ return null;
14
+ }
15
+ try {
16
+ const raw = window.localStorage.getItem(CACHE_PREFIX + apiUrl);
17
+ return raw ? JSON.parse(raw) : null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function writeSpaContentCache(apiUrl, payload) {
24
+ if (typeof window === 'undefined') {
25
+ return;
26
+ }
27
+ try {
28
+ window.localStorage.setItem(CACHE_PREFIX + apiUrl, JSON.stringify(payload));
29
+ }
30
+ catch {
31
+ // Quota/private mode: the cache is an optimization, never a requirement.
32
+ }
33
+ }
34
+ /** Browser-safe stable signature (labelSignature uses Buffer, server-only). */
35
+ export function spaBundleSignature(bundle) {
36
+ const serialized = serializeBundle(bundle);
37
+ let hash = 5381;
38
+ for (let i = 0; i < serialized.length; i++) {
39
+ hash = ((hash << 5) + hash + serialized.charCodeAt(i)) | 0;
40
+ }
41
+ return `spa-${hash}`;
42
+ }
43
+ /**
44
+ * Build-time half of the bake: fetch the exact payload the provider's
45
+ * background revalidation produces (labels with the same signature shape,
46
+ * wrapped rich text + media), ready for `serializeFimoSpaContent`. Runs
47
+ * anywhere `fetch` exists — an SSG/prerender node script included.
48
+ */
49
+ export async function fetchFimoSpaContent(apiUrl, config) {
50
+ const [labels, content] = await Promise.all([
51
+ fetchLabelBundle(apiUrl, getFimoLocales(config), getFimoDefaultLocale(config)).then((bundle) => ({
52
+ ...bundle,
53
+ signature: spaBundleSignature(bundle),
54
+ })),
55
+ fetchWrappedContent(apiUrl),
56
+ ]);
57
+ return { labels, content };
58
+ }
59
+ /** SSG helper: a script tag baking the payload into prerendered HTML so the
60
+ * provider hydrates it synchronously — zero content flash. */
61
+ export function serializeFimoSpaContent(payload) {
62
+ const json = JSON.stringify(payload).replace(/</g, '\\u003c');
63
+ return `<script>window.__FIMO_CONTENT__=${json}</script>`;
64
+ }
@@ -0,0 +1,22 @@
1
+ import type { FimoSEO } from '../shared/fimo-config.types.js';
2
+ /**
3
+ * The framework-neutral `fimo/seo` surface: pure per-page SEO resolution over
4
+ * the page registry (`fimo-config.json#routes`), with no framework adapter,
5
+ * no vite virtual module, and no React. Every framework's injection recipe
6
+ * (Next `generateMetadata`, an Astro head component, a SvelteKit load) calls
7
+ * `getFimoSeoForPath` server-side and maps the result to its own head API.
8
+ * SEO stays wired only when the project owner explicitly opted into
9
+ * Fimo-managed SEO — the migration never wires it by default.
10
+ */
11
+ export { resolveSeoForPath } from './resolver.js';
12
+ export type { FimoSEO, RouteSEO } from '../shared/fimo-config.types.js';
13
+ export interface GetFimoSeoOptions {
14
+ /** Project root holding fimo-config.json (default: process.cwd()). */
15
+ rootDir?: string;
16
+ /** Instance data for `{{variable}}` templates in route-level SEO (e.g. the
17
+ * entry rendered by a dynamic page: `{ "blog-post": entry }`). */
18
+ routeData?: Record<string, unknown>;
19
+ }
20
+ /** Resolve the SEO values for one page path from the page registry on disk. */
21
+ export declare function getFimoSeoForPath(pathname: string, options?: GetFimoSeoOptions): FimoSEO | undefined;
22
+ //# sourceMappingURL=neutral.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { getConfigServer } from '../shared/fimo-config.server.js';
2
+ import { resolveSeoForPath } from './resolver.js';
3
+ /**
4
+ * The framework-neutral `fimo/seo` surface: pure per-page SEO resolution over
5
+ * the page registry (`fimo-config.json#routes`), with no framework adapter,
6
+ * no vite virtual module, and no React. Every framework's injection recipe
7
+ * (Next `generateMetadata`, an Astro head component, a SvelteKit load) calls
8
+ * `getFimoSeoForPath` server-side and maps the result to its own head API.
9
+ * SEO stays wired only when the project owner explicitly opted into
10
+ * Fimo-managed SEO — the migration never wires it by default.
11
+ */
12
+ export { resolveSeoForPath } from './resolver.js';
13
+ /** Resolve the SEO values for one page path from the page registry on disk. */
14
+ export function getFimoSeoForPath(pathname, options = {}) {
15
+ const config = getConfigServer(options.rootDir ?? process.cwd());
16
+ return resolveSeoForPath(pathname, config, options.routeData);
17
+ }
@@ -4,5 +4,4 @@ import type { FimoConfig } from './fimo-config.types.js';
4
4
  * Reads JSON directly to avoid virtual module issues during config loading.
5
5
  */
6
6
  export declare function getConfigServer(rootDir: string): FimoConfig;
7
- export declare function getConfigVersion(rootDir: string): number;
8
7
  //# sourceMappingURL=fimo-config.server.d.ts.map
@@ -2,7 +2,6 @@ import { readFileSync } from 'fs';
2
2
  import { resolve } from 'path';
3
3
  // Matches the template's fimo-config.json (`version: "4"`) and the agents
4
4
  // workspace default — the fallback when a config omits the field.
5
- const LATEST_TEMPLATE_VERSION = 4;
6
5
  const defaultConfig = {
7
6
  seo: undefined,
8
7
  };
@@ -27,15 +26,3 @@ export function getConfigServer(rootDir) {
27
26
  return defaultConfig;
28
27
  }
29
28
  }
30
- export function getConfigVersion(rootDir) {
31
- try {
32
- const configPath = resolve(rootDir, 'fimo-config.json');
33
- const content = readFileSync(configPath, 'utf-8');
34
- const raw = JSON.parse(content);
35
- const parsed = Number.parseInt(String(raw.version ?? LATEST_TEMPLATE_VERSION), 10);
36
- return Number.isFinite(parsed) && parsed > 0 ? parsed : LATEST_TEMPLATE_VERSION;
37
- }
38
- catch {
39
- return LATEST_TEMPLATE_VERSION;
40
- }
41
- }
@@ -57,15 +57,40 @@ export function parseStrapiUrl(rawUrl, apiPrefix = '/api') {
57
57
  where: Object.keys(where).length > 0 ? where : undefined,
58
58
  };
59
59
  }
60
+ const isDev = () => typeof process !== 'undefined' && process.env != null && process.env.NODE_ENV !== 'production';
60
61
  /** Build a `fetch`-compatible function that serves scaffolded content from Fimo. */
61
62
  export function createFimoStrapiFetch(options, realFetch = fetch) {
62
63
  const singletons = new Set(options.singletons ?? []);
63
64
  const read = { apiUrl: options.apiUrl, rootDir: options.rootDir };
65
+ // Lowercased type-name index, used only to flag a *near-miss* fallthrough: a
66
+ // path segment that looks like a scaffolded type but doesn't match a TYPES key
67
+ // (the classic singular-vs-plural keying bug). Genuinely foreign paths
68
+ // (`/api/upload/files`) don't match and stay silent.
69
+ const typeNamesLower = new Set(Object.values(options.types).map((name) => name.toLowerCase()));
70
+ const warnedPaths = new Set();
71
+ const warnNearMiss = (segment) => {
72
+ if (!isDev() || warnedPaths.has(segment)) {
73
+ return;
74
+ }
75
+ const seg = segment.toLowerCase();
76
+ const nearMiss = typeNamesLower.has(seg) ||
77
+ typeNamesLower.has(seg.replace(/s$/, '')) ||
78
+ typeNamesLower.has(seg.replace(/ies$/, 'y'));
79
+ if (nearMiss) {
80
+ warnedPaths.add(segment);
81
+ console.warn(`[fimo/strapi] /api/${segment} looks like a scaffolded type but is not in the type map — ` +
82
+ `this read fell through to the live CMS. If a single type reads empty, its map key is likely ` +
83
+ `plural where the fetch is singular (regenerate with \`fimo migrate --scaffold-cms\`).`);
84
+ }
85
+ };
64
86
  return (async (input, init) => {
65
87
  const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
66
88
  const parsed = parseStrapiUrl(rawUrl ?? '', options.apiPrefix);
67
89
  const typeName = parsed ? options.types[parsed.pluralName] : undefined;
68
90
  if (!parsed || !typeName) {
91
+ if (parsed) {
92
+ warnNearMiss(parsed.pluralName);
93
+ }
69
94
  return realFetch(input, init); // not a mapped content read → real CMS
70
95
  }
71
96
  const entries = await fetchEntries(typeName, {
@@ -73,11 +98,29 @@ export function createFimoStrapiFetch(options, realFetch = fetch) {
73
98
  ...(parsed.where ? { where: parsed.where } : {}),
74
99
  ...(parsed.pageSize ? { limit: parsed.pageSize } : {}),
75
100
  });
101
+ const shapes = options.fieldShapes?.[typeName];
102
+ const single = Boolean(parsed.documentId) || singletons.has(typeName);
76
103
  if (entries.length === 0) {
104
+ // A FILTERED miss on a type Fimo owns (it has entries) answers with the
105
+ // honest empty envelope — the document just doesn't exist. Falling
106
+ // through here would leak every unknown-slug read (404s included) to the
107
+ // old CMS: dead-CMS timeouts, and content resurrection when it's alive.
108
+ if (parsed.where || parsed.documentId) {
109
+ const any = await fetchEntries(typeName, { ...read, limit: 1 });
110
+ if (any.length > 0) {
111
+ // Canonical Strapi shapes: collection miss → 200 `{data: []}`,
112
+ // document miss → 404 with the Strapi error body.
113
+ const empty = single
114
+ ? { data: null, error: { status: 404, name: 'NotFoundError', message: 'Not Found', details: {} } }
115
+ : fimoEntriesToStrapiList([], { pageSize: parsed.pageSize, total: 0, shapes });
116
+ return new Response(JSON.stringify(empty), {
117
+ status: single ? 404 : 200,
118
+ headers: { 'content-type': 'application/json' },
119
+ });
120
+ }
121
+ }
77
122
  return realFetch(input, init); // nothing scaffolded → fall back to the CMS
78
123
  }
79
- const shapes = options.fieldShapes?.[typeName];
80
- const single = Boolean(parsed.documentId) || singletons.has(typeName);
81
124
  const payload = single
82
125
  ? fimoEntryToStrapiSingle(parsed.documentId ? (entries.find((e) => e.id === parsed.documentId) ?? entries[0]) : entries[0], shapes)
83
126
  : fimoEntriesToStrapiList(entries, { pageSize: parsed.pageSize, total: entries.length, shapes });
@@ -17,15 +17,19 @@ export interface StrapiSingleEnvelope {
17
17
  }
18
18
  /**
19
19
  * The field-shape manifest, baked into the generated `fimo-strapi.ts` by the
20
- * migration. It carries ONLY the structural (blocks) fields — a Fimo blocks
21
- * value is always an array, but a Strapi single component reads as an object, so
22
- * the reshape needs to know which array to unwrap. Leaf fields (scalars, media,
23
- * richtext, references) never appear here; they are handled by value-inspection.
20
+ * migration. It carries the structural (blocks) fields — a Fimo blocks value
21
+ * is always an array, but a Strapi single component reads as an object, so
22
+ * the reshape needs to know which array to unwrap — plus to-many relations
23
+ * ('many-ref'), whose array invariant value-inspection cannot hold on its
24
+ * own. Other leaf fields (scalars, media, richtext, single references) never
25
+ * appear here; they are handled by value-inspection.
24
26
  * Kept in sync with `computeFieldShapes` in `scripts/cms/strapi/shapes.ts`.
25
27
  */
26
28
  export interface BlockFieldShape {
27
- /** 'single' → unwrap the one-item array to an object; 'list' → keep the array. */
28
- readonly kind: 'single' | 'list';
29
+ /** 'single' → unwrap the one-item array to an object; 'list' → keep the
30
+ * array; 'many-ref' → a to-many relation, coerced to an array whatever
31
+ * arrives (a frontend iterating a oneToMany 500s on a stray object). */
32
+ readonly kind: 'single' | 'list' | 'many-ref';
29
33
  /** Nested structural shapes for a fixed-shape block (single/repeatable component). */
30
34
  readonly fields?: FieldShapes;
31
35
  /** Per-variant nested shapes for a dynamic zone, keyed by variant uid (`_type`). */
@@ -74,6 +74,13 @@ function reshapeFieldValue(value, shape) {
74
74
  if (!shape) {
75
75
  return reshapeValue(value);
76
76
  }
77
+ if (shape.kind === 'many-ref') {
78
+ // Array invariant for to-many relations: null/undefined → [], a stray
79
+ // single handle → [handle]. Items are leaves (populated relation objects
80
+ // or reference handles), never block items.
81
+ const items = Array.isArray(value) ? value : value == null ? [] : [value];
82
+ return items.map(reshapeValue);
83
+ }
77
84
  const items = Array.isArray(value) ? value : [];
78
85
  if (shape.kind === 'single') {
79
86
  return items.length > 0 ? reshapeBlockItem(items[0], shape) : null;
@@ -7,6 +7,9 @@ interface FimoRichTextBlockProps {
7
7
  children?: ReactNode;
8
8
  components?: FimoRichTextComponents;
9
9
  className?: string;
10
+ /** Wrapper element — `span` when the block sits inside a `<p>` (a div
11
+ * there is invalid HTML and breaks hydration). */
12
+ as?: 'div' | 'span';
10
13
  }
11
14
  /**
12
15
  * Wraps formatted text (bold, italic, underline, links) so it becomes
@@ -18,6 +21,6 @@ interface FimoRichTextBlockProps {
18
21
  * <p>We help teams <strong>ship faster</strong> and <em>stay safe</em>.</p>
19
22
  * </FimoRichTextBlock>
20
23
  */
21
- export declare function FimoRichTextBlock({ id, children, components, className }: FimoRichTextBlockProps): import("react/jsx-runtime").JSX.Element;
24
+ export declare function FimoRichTextBlock({ id, children, components, className, as }: FimoRichTextBlockProps): import("react/jsx-runtime").JSX.Element;
22
25
  export {};
23
26
  //# sourceMappingURL=FimoRichTextBlock.d.ts.map
@@ -13,10 +13,10 @@ import { useWrappedRichText } from './WrappedContentContext.js';
13
13
  * <p>We help teams <strong>ship faster</strong> and <em>stay safe</em>.</p>
14
14
  * </FimoRichTextBlock>
15
15
  */
16
- export function FimoRichTextBlock({ id, children, components, className }) {
16
+ export function FimoRichTextBlock({ id, children, components, className, as }) {
17
17
  const entry = useWrappedRichText(id);
18
18
  if (!entry) {
19
19
  return _jsx(_Fragment, { children: children });
20
20
  }
21
- return (_jsx(RichText, { value: new FimoRichText(entry.content, entry.source), components: components, className: className }));
21
+ return (_jsx(RichText, { value: new FimoRichText(entry.content, entry.source), components: components, className: className, as: as }));
22
22
  }