fimo 0.8.0 → 0.9.0-experimental.1

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 (190) hide show
  1. package/assets/content-templates/hooks-template.eta +4 -2
  2. package/assets/content-templates/types-template.eta +66 -1
  3. package/assets/skills/fimo/SKILL.md +1 -1
  4. package/assets/skills/fimo/references/content.md +86 -15
  5. package/assets/skills/fimo-cli/SKILL.md +6 -2
  6. package/assets/skills/fimo-cli/references/content.md +61 -2
  7. package/assets/skills/fimo-cli/references/integrations/firecrawl.md +20 -4
  8. package/assets/skills/fimo-cli/references/integrations.md +5 -1
  9. package/assets/skills/fimo-cli/references/webhooks.md +162 -0
  10. package/assets/skills/fimo-migration/SKILL.md +22 -2
  11. package/assets/skills/fimo-migration/references/content.md +24 -0
  12. package/assets/skills/fimo-migration/references/manifest.md +35 -0
  13. package/assets/skills/fimo-migration/references/qa.md +24 -2
  14. package/assets/skills/fimo-migration/references/workflow.md +12 -1
  15. package/assets/skills/fimo-migration/references/wrap.md +617 -0
  16. package/assets/skills/fimo-studio/SKILL.md +5 -3
  17. package/assets/skills/fimo-studio/references/cms.md +9 -1
  18. package/assets/skills/fimo-studio/references/visual-editing.md +5 -3
  19. package/assets/skills/fimo-studio/references/webhooks.md +42 -0
  20. package/dist/build/vite/plugins/translations.d.ts +2 -8
  21. package/dist/build/vite/plugins/translations.js +3 -121
  22. package/dist/cli/bundle.json +1 -1
  23. package/dist/cli/index.js +1051 -144
  24. package/dist/runtime/app/FimoProviders.d.ts +1 -11
  25. package/dist/runtime/app/FimoProviders.js +2 -23
  26. package/dist/runtime/astro/index.d.ts +2 -0
  27. package/dist/runtime/astro/index.js +3 -0
  28. package/dist/runtime/astro/server.d.ts +3 -0
  29. package/dist/runtime/astro/server.js +6 -0
  30. package/dist/runtime/content/client/internal.js +1 -0
  31. package/dist/runtime/content/client/types.d.ts +16 -0
  32. package/dist/runtime/i18n/FimoLocaleContext.js +1 -0
  33. package/dist/runtime/next/FimoNextProviders.d.ts +24 -0
  34. package/dist/runtime/next/FimoNextProviders.js +20 -0
  35. package/dist/runtime/next/FimoNextScripts.d.ts +10 -0
  36. package/dist/runtime/next/FimoNextScripts.js +19 -0
  37. package/dist/runtime/next/index.d.ts +14 -0
  38. package/dist/runtime/next/index.js +14 -0
  39. package/dist/runtime/next/server.d.ts +2 -0
  40. package/dist/runtime/next/server.js +4 -0
  41. package/dist/runtime/nuxt/composables.d.ts +9 -0
  42. package/dist/runtime/nuxt/composables.js +57 -0
  43. package/dist/runtime/nuxt/index.d.ts +3 -0
  44. package/dist/runtime/nuxt/index.js +6 -0
  45. package/dist/runtime/nuxt/server.d.ts +3 -0
  46. package/dist/runtime/nuxt/server.js +6 -0
  47. package/dist/runtime/primitives/labels-hook.d.ts +33 -0
  48. package/dist/runtime/primitives/labels-hook.js +41 -0
  49. package/dist/runtime/primitives/labels-store.d.ts +12 -0
  50. package/dist/runtime/primitives/labels-store.js +60 -0
  51. package/dist/runtime/primitives/lib/useFimoLiveValue.d.ts +5 -3
  52. package/dist/runtime/primitives/lib/useFimoLiveValue.js +11 -4
  53. package/dist/runtime/primitives/translations.d.ts +1 -27
  54. package/dist/runtime/primitives/translations.js +7 -85
  55. package/dist/runtime/react/FimoSpaProviders.d.ts +36 -0
  56. package/dist/runtime/react/FimoSpaProviders.js +63 -0
  57. package/dist/runtime/react/index.d.ts +33 -0
  58. package/dist/runtime/react/index.js +39 -0
  59. package/dist/runtime/remix/FimoRemixProviders.d.ts +16 -0
  60. package/dist/runtime/remix/FimoRemixProviders.js +12 -0
  61. package/dist/runtime/remix/FimoRemixScripts.d.ts +10 -0
  62. package/dist/runtime/remix/FimoRemixScripts.js +19 -0
  63. package/dist/runtime/remix/index.d.ts +12 -0
  64. package/dist/runtime/remix/index.js +11 -0
  65. package/dist/runtime/remix/server.d.ts +2 -0
  66. package/dist/runtime/remix/server.js +2 -0
  67. package/dist/runtime/shared/display-routes.d.ts +14 -0
  68. package/dist/runtime/shared/display-routes.js +25 -0
  69. package/dist/runtime/shared/fimo-config.server.js +1 -0
  70. package/dist/runtime/shared/fimo-config.types.d.ts +2 -0
  71. package/dist/runtime/shared/i18n-messages.d.ts +29 -0
  72. package/dist/runtime/shared/i18n-messages.js +79 -0
  73. package/dist/runtime/shared/labels-fetch.pure.d.ts +17 -0
  74. package/dist/runtime/shared/labels-fetch.pure.js +71 -0
  75. package/dist/runtime/shared/labels-fetch.server.d.ts +7 -0
  76. package/dist/runtime/shared/labels-fetch.server.js +97 -0
  77. package/dist/runtime/shared/labels-t.pure.d.ts +2 -0
  78. package/dist/runtime/shared/labels-t.pure.js +10 -0
  79. package/dist/runtime/strapi/client.d.ts +30 -0
  80. package/dist/runtime/strapi/client.js +89 -0
  81. package/dist/runtime/strapi/index.d.ts +5 -0
  82. package/dist/runtime/strapi/index.js +6 -0
  83. package/dist/runtime/strapi/reshape.d.ts +46 -0
  84. package/dist/runtime/strapi/reshape.js +114 -0
  85. package/dist/runtime/svelte/index.d.ts +3 -0
  86. package/dist/runtime/svelte/index.js +7 -0
  87. package/dist/runtime/svelte/server.d.ts +3 -0
  88. package/dist/runtime/svelte/server.js +6 -0
  89. package/dist/runtime/vue/index.d.ts +3 -0
  90. package/dist/runtime/vue/index.js +5 -0
  91. package/dist/runtime/vue/server.d.ts +3 -0
  92. package/dist/runtime/vue/server.js +6 -0
  93. package/dist/runtime/wrapped/FimoFileLink.d.ts +17 -0
  94. package/dist/runtime/wrapped/FimoFileLink.js +19 -0
  95. package/dist/runtime/wrapped/FimoMediaImage.d.ts +14 -0
  96. package/dist/runtime/wrapped/FimoMediaImage.js +20 -0
  97. package/dist/runtime/wrapped/FimoMediaVideo.d.ts +14 -0
  98. package/dist/runtime/wrapped/FimoMediaVideo.js +20 -0
  99. package/dist/runtime/wrapped/FimoRichTextBlock.d.ts +23 -0
  100. package/dist/runtime/wrapped/FimoRichTextBlock.js +22 -0
  101. package/dist/runtime/wrapped/FimoText.d.ts +22 -0
  102. package/dist/runtime/wrapped/FimoText.js +20 -0
  103. package/dist/runtime/wrapped/WrappedContentContext.d.ts +28 -0
  104. package/dist/runtime/wrapped/WrappedContentContext.js +30 -0
  105. package/dist/runtime/wrapped/WrappedProviders.d.ts +33 -0
  106. package/dist/runtime/wrapped/WrappedProviders.js +101 -0
  107. package/dist/runtime/wrapped/dom.d.ts +4 -0
  108. package/dist/runtime/wrapped/dom.js +23 -0
  109. package/dist/runtime/wrapped/server.d.ts +77 -0
  110. package/dist/runtime/wrapped/server.js +237 -0
  111. package/dist/scripts/apply-wrap-templates.d.ts +61 -0
  112. package/dist/scripts/apply-wrap-templates.js +132 -0
  113. package/dist/scripts/apply-wrap.d.ts +46 -0
  114. package/dist/scripts/apply-wrap.js +278 -0
  115. package/dist/scripts/cms/discover.d.ts +2 -0
  116. package/dist/scripts/cms/discover.js +17 -0
  117. package/dist/scripts/cms/example-content.d.ts +33 -0
  118. package/dist/scripts/cms/example-content.js +162 -0
  119. package/dist/scripts/cms/strapi/detect.d.ts +2 -0
  120. package/dist/scripts/cms/strapi/detect.js +186 -0
  121. package/dist/scripts/cms/strapi/index.d.ts +3 -0
  122. package/dist/scripts/cms/strapi/index.js +25 -0
  123. package/dist/scripts/cms/strapi/list-source.d.ts +17 -0
  124. package/dist/scripts/cms/strapi/list-source.js +90 -0
  125. package/dist/scripts/cms/strapi/schema-map.d.ts +9 -0
  126. package/dist/scripts/cms/strapi/schema-map.js +277 -0
  127. package/dist/scripts/cms/strapi/shapes.d.ts +23 -0
  128. package/dist/scripts/cms/strapi/shapes.js +38 -0
  129. package/dist/scripts/cms/strapi/touchpoints.d.ts +31 -0
  130. package/dist/scripts/cms/strapi/touchpoints.js +145 -0
  131. package/dist/scripts/cms/strapi/wire-client.d.ts +15 -0
  132. package/dist/scripts/cms/strapi/wire-client.js +107 -0
  133. package/dist/scripts/cms/strapi/wiring.d.ts +17 -0
  134. package/dist/scripts/cms/strapi/wiring.js +46 -0
  135. package/dist/scripts/cms/types.d.ts +145 -0
  136. package/dist/scripts/cms/types.js +11 -0
  137. package/dist/scripts/detect-framework.d.ts +39 -0
  138. package/dist/scripts/detect-framework.js +133 -0
  139. package/dist/scripts/draft-rows.d.ts +19 -0
  140. package/dist/scripts/draft-rows.js +135 -0
  141. package/dist/scripts/draft-schemas.d.ts +27 -0
  142. package/dist/scripts/draft-schemas.js +188 -0
  143. package/dist/scripts/extract-translations.js +16 -11
  144. package/dist/scripts/frameworks/astro/routes.d.ts +3 -0
  145. package/dist/scripts/frameworks/astro/routes.js +55 -0
  146. package/dist/scripts/frameworks/astro/template-style.d.ts +7 -0
  147. package/dist/scripts/frameworks/astro/template-style.js +19 -0
  148. package/dist/scripts/frameworks/discover.d.ts +70 -0
  149. package/dist/scripts/frameworks/discover.js +178 -0
  150. package/dist/scripts/frameworks/next/rewire.d.ts +30 -0
  151. package/dist/scripts/frameworks/next/rewire.js +258 -0
  152. package/dist/scripts/frameworks/next/routes.d.ts +17 -0
  153. package/dist/scripts/frameworks/next/routes.js +134 -0
  154. package/dist/scripts/frameworks/next/wiring.d.ts +33 -0
  155. package/dist/scripts/frameworks/next/wiring.js +186 -0
  156. package/dist/scripts/frameworks/nuxt/routes.d.ts +3 -0
  157. package/dist/scripts/frameworks/nuxt/routes.js +54 -0
  158. package/dist/scripts/frameworks/nuxt/template-style.d.ts +9 -0
  159. package/dist/scripts/frameworks/nuxt/template-style.js +27 -0
  160. package/dist/scripts/frameworks/remix/routes.d.ts +3 -0
  161. package/dist/scripts/frameworks/remix/routes.js +50 -0
  162. package/dist/scripts/frameworks/support.d.ts +75 -0
  163. package/dist/scripts/frameworks/support.js +165 -0
  164. package/dist/scripts/frameworks/sveltekit/routes.d.ts +3 -0
  165. package/dist/scripts/frameworks/sveltekit/routes.js +50 -0
  166. package/dist/scripts/frameworks/sveltekit/template-style.d.ts +9 -0
  167. package/dist/scripts/frameworks/sveltekit/template-style.js +54 -0
  168. package/dist/scripts/frameworks/vite/routes.d.ts +15 -0
  169. package/dist/scripts/frameworks/vite/routes.js +93 -0
  170. package/dist/scripts/frameworks/vite/wiring.d.ts +10 -0
  171. package/dist/scripts/frameworks/vite/wiring.js +99 -0
  172. package/dist/scripts/i18n-namespace.d.ts +27 -0
  173. package/dist/scripts/i18n-namespace.js +155 -0
  174. package/dist/scripts/lib/validation-report.d.ts +1 -0
  175. package/dist/scripts/lib/validation-report.js +3 -0
  176. package/dist/scripts/render-parity/capture.d.ts +56 -0
  177. package/dist/scripts/render-parity/capture.js +366 -0
  178. package/dist/scripts/render-parity/compare.d.ts +59 -0
  179. package/dist/scripts/render-parity/compare.js +0 -0
  180. package/dist/scripts/render-parity/normalize-html.d.ts +13 -0
  181. package/dist/scripts/render-parity/normalize-html.js +223 -0
  182. package/dist/scripts/render-parity/types.d.ts +41 -0
  183. package/dist/scripts/render-parity/types.js +1 -0
  184. package/dist/scripts/scan-wrappable.d.ts +196 -0
  185. package/dist/scripts/scan-wrappable.js +1232 -0
  186. package/dist/scripts/validate-route-metadata.js +11 -0
  187. package/dist/scripts/validate-schemas.js +42 -1
  188. package/package.json +84 -4
  189. package/release.json +2 -2
  190. package/templates/react-router/package.json +1 -1
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Shared next-intl namespace resolution for the two independent label-key
3
+ * scanners (`extract-translations` for `fimo validate`, `scan-wrappable` for
4
+ * `migrate wrap --check`). Both must record the SAME key form the i18n
5
+ * ingestion stores, or the seeding gate can never go green.
6
+ *
7
+ * `const t = useTranslations("plans.checkout")` scopes every `t("buyNow")` in
8
+ * that component to `plans.checkout.buyNow`. Root-scoped `useTranslations()`
9
+ * (namespace `""`) and server `getTranslations` are handled; a bare `t(...)`
10
+ * with no binding (Fimo's own label call) keeps its key unchanged.
11
+ */
12
+ export const isFunctionLike = (node) => node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression';
13
+ /** Collect `useTranslations`/`getTranslations` bindings declared in a function's
14
+ * direct body: variable name → namespace (`""` for root scope). */
15
+ const unwrapAwait = (node) => (node?.type === 'AwaitExpression' ? node.argument : node);
16
+ /**
17
+ * The namespace of a `useTranslations`/`getTranslations` call, or null if the
18
+ * node is not such a call. Handles the string arg (`getTranslations("seo")`),
19
+ * the object arg (`getTranslations({ locale, namespace: "seo" })`), and no arg
20
+ * (root scope → `""`). A dynamic namespace resolves to `""` (best effort).
21
+ */
22
+ function translationCallNamespace(node) {
23
+ if (node?.type !== 'CallExpression' || node.callee?.type !== 'Identifier') {
24
+ return null;
25
+ }
26
+ if (node.callee.name !== 'useTranslations' && node.callee.name !== 'getTranslations') {
27
+ return null;
28
+ }
29
+ const arg = node.arguments?.[0];
30
+ if (!arg) {
31
+ return '';
32
+ }
33
+ if (arg.type === 'Literal' && typeof arg.value === 'string') {
34
+ return arg.value;
35
+ }
36
+ if (arg.type === 'ObjectExpression') {
37
+ for (const prop of arg.properties ?? []) {
38
+ const keyName = prop?.key?.name ?? prop?.key?.value;
39
+ if (keyName === 'namespace' && prop.value?.type === 'Literal' && typeof prop.value.value === 'string') {
40
+ return prop.value.value;
41
+ }
42
+ }
43
+ }
44
+ return '';
45
+ }
46
+ /** Mark every identifier bound by a parameter pattern (plain, defaulted, or
47
+ * destructured — `t`, `t = x`, `{ t }`, `[t]`) as unresolvable (null). */
48
+ function markParamBindings(node, bindings) {
49
+ if (!node) {
50
+ return;
51
+ }
52
+ switch (node.type) {
53
+ case 'Identifier':
54
+ if (!bindings.has(node.name)) {
55
+ bindings.set(node.name, null);
56
+ }
57
+ break;
58
+ case 'AssignmentPattern':
59
+ markParamBindings(node.left, bindings);
60
+ break;
61
+ case 'RestElement':
62
+ markParamBindings(node.argument, bindings);
63
+ break;
64
+ case 'ArrayPattern':
65
+ for (const element of node.elements ?? []) {
66
+ markParamBindings(element, bindings);
67
+ }
68
+ break;
69
+ case 'ObjectPattern':
70
+ for (const prop of node.properties ?? []) {
71
+ markParamBindings(prop.type === 'RestElement' ? prop.argument : prop.value, bindings);
72
+ }
73
+ break;
74
+ default:
75
+ break;
76
+ }
77
+ }
78
+ export function collectScopedBindings(fnNode) {
79
+ const bindings = new Map();
80
+ const body = fnNode.body;
81
+ const statements = body && Array.isArray(body.body) ? body.body : [];
82
+ for (const stmt of statements) {
83
+ if (stmt?.type !== 'VariableDeclaration') {
84
+ continue;
85
+ }
86
+ for (const decl of stmt.declarations ?? []) {
87
+ const init = unwrapAwait(decl?.init);
88
+ // `const t = [await] useTranslations/getTranslations(...)`
89
+ if (decl?.id?.type === 'Identifier') {
90
+ const namespace = translationCallNamespace(init);
91
+ if (namespace !== null) {
92
+ bindings.set(decl.id.name, namespace);
93
+ }
94
+ continue;
95
+ }
96
+ // `const [a, t] = await Promise.all([...])` or `= use(Promise.all([...]))`
97
+ if (decl?.id?.type === 'ArrayPattern') {
98
+ // Unwrap React's `use()` wrapper around the promise.
99
+ const source = init?.type === 'CallExpression' && init.callee?.type === 'Identifier' && init.callee.name === 'use'
100
+ ? unwrapAwait(init.arguments?.[0])
101
+ : init;
102
+ const promiseAll = source?.type === 'CallExpression' &&
103
+ source.callee?.type === 'MemberExpression' &&
104
+ source.callee.object?.name === 'Promise' &&
105
+ source.callee.property?.name === 'all';
106
+ const elements = promiseAll
107
+ ? source.arguments?.[0]?.type === 'ArrayExpression'
108
+ ? source.arguments[0].elements
109
+ : null
110
+ : source?.type === 'ArrayExpression'
111
+ ? source.elements
112
+ : null;
113
+ if (elements) {
114
+ decl.id.elements?.forEach((el, index) => {
115
+ if (el?.type !== 'Identifier') {
116
+ return;
117
+ }
118
+ const namespace = translationCallNamespace(unwrapAwait(elements[index]));
119
+ if (namespace !== null) {
120
+ bindings.set(el.name, namespace);
121
+ }
122
+ });
123
+ }
124
+ }
125
+ }
126
+ }
127
+ // Function parameters are bound by the CALLER: a `t` passed in carries the
128
+ // caller's namespace, unknowable here. Mark them null so their t() calls are
129
+ // counted as covered but never demanded by the seeding gate as a bare leaf.
130
+ for (const param of fnNode.params ?? []) {
131
+ markParamBindings(param, bindings);
132
+ }
133
+ return bindings;
134
+ }
135
+ /**
136
+ * Resolve a `<name>("leaf")` call to its full key given the scope stack
137
+ * (innermost first).
138
+ * - `scoped`: the callee is a resolved `useTranslations` binding — namespace
139
+ * prepended (unless root `""`).
140
+ * - `unresolvable`: the callee is a passed-in parameter — its namespace lives in
141
+ * the caller and cannot be known here, so the caller should count the call as
142
+ * covered but NOT demand the (bare) key.
143
+ */
144
+ export function resolveScopedKey(calleeName, leafKey, scopes) {
145
+ for (let i = scopes.length - 1; i >= 0; i -= 1) {
146
+ if (scopes[i].has(calleeName)) {
147
+ const namespace = scopes[i].get(calleeName);
148
+ if (namespace === null || namespace === undefined) {
149
+ return { key: leafKey, scoped: false, unresolvable: true };
150
+ }
151
+ return { key: namespace ? `${namespace}.${leafKey}` : leafKey, scoped: true, unresolvable: false };
152
+ }
153
+ }
154
+ return { key: leafKey, scoped: false, unresolvable: false };
155
+ }
@@ -23,6 +23,7 @@ export interface PhaseResult {
23
23
  skipped?: {
24
24
  reason: string;
25
25
  };
26
+ warnings?: string[];
26
27
  }
27
28
  export interface ValidationReport {
28
29
  ok: boolean;
@@ -76,6 +76,9 @@ function formatPhaseHuman(phase) {
76
76
  out.push(` ${parts.join(', ')}`);
77
77
  }
78
78
  }
79
+ for (const warning of phase.warnings ?? []) {
80
+ out.push(` ⚠ ${warning}`);
81
+ }
79
82
  for (const err of phase.errors) {
80
83
  const loc = err.file ? ` (${err.file}${err.line ? `:${err.line}` : ''}${err.column ? `:${err.column}` : ''})` : '';
81
84
  out.push(` [${err.kind}] ${err.rule}: ${err.message}${loc}`);
@@ -0,0 +1,56 @@
1
+ import { type ParityBaseline } from './types.js';
2
+ export interface CaptureOptions {
3
+ cwd: string;
4
+ packageManager: string;
5
+ routes: Array<{
6
+ path: string;
7
+ }>;
8
+ framework: string | null;
9
+ bootTimeoutMs?: number;
10
+ routeTimeoutMs?: number;
11
+ /** Reuse an already-running dev server instead of booting one: the warm
12
+ * server recompiles incrementally, so repeated checks cost seconds. The
13
+ * server is probed once and never stopped. */
14
+ serverOrigin?: string;
15
+ /** Concrete values for dynamic route params so `[locale]`-style routes are
16
+ * actually captured instead of skipped. A route resolves (and is captured)
17
+ * only when EVERY `:param` in it has a value here; both baseline and check
18
+ * read the same values, so the comparison stays symmetric. */
19
+ paramValues?: Record<string, string>;
20
+ onProgress?: (message: string) => void;
21
+ }
22
+ /** Substitute known params into a `:param` route path; returns the concrete
23
+ * path, or null when a param has no value (still unresolvable → skip). */
24
+ export declare function resolveRoutePath(routePath: string, paramValues: Record<string, string>): string | null;
25
+ export declare class CaptureError extends Error {
26
+ readonly stderrTail: string;
27
+ constructor(message: string, stderrTail?: string);
28
+ }
29
+ export declare function findFreePort(): Promise<number>;
30
+ /**
31
+ * Build workspace packages the app needs BEFORE booting its dev server. Fimo
32
+ * runs the app's bare `next dev`, but in a Turborepo/pnpm monorepo the
33
+ * internal deps (`@repo/*`) aren't built by that — `turbo dev` would build
34
+ * them first. This detects unbuilt internal deps and runs the monorepo's
35
+ * build once (the root `build` script, else `turbo run build`); turbo's cache
36
+ * makes an already-built repo near-instant. A build failure is surfaced with
37
+ * an actionable message instead of a cryptic `Cannot find module` at boot.
38
+ */
39
+ export declare function ensureWorkspaceDepsBuilt(cwd: string, packageManager: string, onProgress?: (message: string) => void): void;
40
+ interface DevServer {
41
+ origin: string;
42
+ stderrTail: () => string;
43
+ stop: () => Promise<void>;
44
+ }
45
+ export declare function startDevServer(opts: {
46
+ cwd: string;
47
+ packageManager: string;
48
+ bootTimeoutMs: number;
49
+ }): Promise<DevServer>;
50
+ /**
51
+ * Boot the project's own dev server, capture every static route's normalized
52
+ * signature, and tear the server down. Baseline and check both run through
53
+ * this identical pipeline — that symmetry is the gate's correctness property.
54
+ */
55
+ export declare function captureParityBaseline(opts: CaptureOptions): Promise<ParityBaseline>;
56
+ export {};
@@ -0,0 +1,366 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import net from 'node:net';
4
+ import path from 'node:path';
5
+ import { extractRouteSignature } from './normalize-html.js';
6
+ import { PARITY_BASELINE_VERSION } from './types.js';
7
+ /** Substitute known params into a `:param` route path; returns the concrete
8
+ * path, or null when a param has no value (still unresolvable → skip). */
9
+ export function resolveRoutePath(routePath, paramValues) {
10
+ const resolved = routePath.replace(/:([A-Za-z0-9_]+)/g, (_, name) => paramValues[name] ? encodeURIComponent(paramValues[name]) : `:${name}`);
11
+ return resolved.includes(':') ? null : resolved;
12
+ }
13
+ const DEFAULT_BOOT_TIMEOUT_MS = 90_000;
14
+ const DEFAULT_ROUTE_TIMEOUT_MS = 60_000;
15
+ const READINESS_POLL_MS = 500;
16
+ const KILL_GRACE_MS = 5_000;
17
+ export class CaptureError extends Error {
18
+ stderrTail;
19
+ constructor(message, stderrTail = '') {
20
+ super(message);
21
+ this.name = 'CaptureError';
22
+ this.stderrTail = stderrTail;
23
+ }
24
+ }
25
+ export function findFreePort() {
26
+ return new Promise((resolve, reject) => {
27
+ const server = net.createServer();
28
+ server.once('error', reject);
29
+ server.listen(0, '127.0.0.1', () => {
30
+ const address = server.address();
31
+ const port = typeof address === 'object' && address ? address.port : 0;
32
+ server.close(() => resolve(port));
33
+ });
34
+ });
35
+ }
36
+ function preflight(cwd) {
37
+ if (!fs.existsSync(path.join(cwd, 'node_modules'))) {
38
+ throw new CaptureError('node_modules is missing — run your package manager install first.');
39
+ }
40
+ try {
41
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
42
+ if (!pkg.scripts?.dev) {
43
+ throw new CaptureError('No `dev` script in package.json — the parity capture boots your dev server.');
44
+ }
45
+ }
46
+ catch (error) {
47
+ if (error instanceof CaptureError) {
48
+ throw error;
49
+ }
50
+ throw new CaptureError('Could not read package.json.');
51
+ }
52
+ }
53
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
54
+ /** The monorepo root above `cwd` (pnpm-workspace / turbo / npm workspaces),
55
+ * or null when `cwd` is standalone. */
56
+ function workspaceRoot(cwd) {
57
+ let dir = cwd;
58
+ for (let depth = 0; depth < 6; depth += 1) {
59
+ const parent = path.dirname(dir);
60
+ if (parent === dir) {
61
+ break;
62
+ }
63
+ dir = parent;
64
+ if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) || fs.existsSync(path.join(dir, 'turbo.json'))) {
65
+ return dir;
66
+ }
67
+ try {
68
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
69
+ if (pkg.workspaces) {
70
+ return dir;
71
+ }
72
+ }
73
+ catch {
74
+ // keep walking up
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+ /** Internal workspace packages this app depends on (`workspace:` / `catalog:`
80
+ * protocol) whose build output is missing — a bare `next dev` fails on
81
+ * `Cannot find module '@repo/…/dist/…'` until they are built. */
82
+ function unbuiltWorkspaceDeps(cwd) {
83
+ let pkg;
84
+ try {
85
+ pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ const deps = Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })
91
+ .filter(([, version]) => typeof version === 'string' && (version.startsWith('workspace:') || version.startsWith('catalog:')))
92
+ .map(([name]) => name);
93
+ return deps.filter((name) => {
94
+ const depDir = path.join(cwd, 'node_modules', name);
95
+ try {
96
+ const depPkg = JSON.parse(fs.readFileSync(path.join(depDir, 'package.json'), 'utf8'));
97
+ const entry = depPkg.module ?? depPkg.main;
98
+ if (typeof entry === 'string') {
99
+ return !fs.existsSync(path.join(depDir, entry));
100
+ }
101
+ // exports-only package: treat a missing dist/ as unbuilt.
102
+ return Boolean(depPkg.exports) && !fs.existsSync(path.join(depDir, 'dist'));
103
+ }
104
+ catch {
105
+ return false; // can't resolve the link → don't force a build
106
+ }
107
+ });
108
+ }
109
+ /**
110
+ * Build workspace packages the app needs BEFORE booting its dev server. Fimo
111
+ * runs the app's bare `next dev`, but in a Turborepo/pnpm monorepo the
112
+ * internal deps (`@repo/*`) aren't built by that — `turbo dev` would build
113
+ * them first. This detects unbuilt internal deps and runs the monorepo's
114
+ * build once (the root `build` script, else `turbo run build`); turbo's cache
115
+ * makes an already-built repo near-instant. A build failure is surfaced with
116
+ * an actionable message instead of a cryptic `Cannot find module` at boot.
117
+ */
118
+ export function ensureWorkspaceDepsBuilt(cwd, packageManager, onProgress) {
119
+ const root = workspaceRoot(cwd);
120
+ if (!root) {
121
+ return;
122
+ }
123
+ const unbuilt = unbuiltWorkspaceDeps(cwd);
124
+ if (unbuilt.length === 0) {
125
+ return;
126
+ }
127
+ onProgress?.(`building workspace packages (${unbuilt.slice(0, 3).join(', ')}${unbuilt.length > 3 ? '…' : ''})`);
128
+ let rootBuild = null;
129
+ try {
130
+ const rootPkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
131
+ if (rootPkg.scripts?.build) {
132
+ rootBuild = { command: packageManager, args: ['run', 'build'] };
133
+ }
134
+ }
135
+ catch {
136
+ // fall through to turbo
137
+ }
138
+ if (!rootBuild && fs.existsSync(path.join(root, 'turbo.json'))) {
139
+ rootBuild = { command: 'npx', args: ['turbo', 'run', 'build'] };
140
+ }
141
+ if (!rootBuild) {
142
+ throw new CaptureError(`Workspace packages (${unbuilt.join(', ')}) must be built before the dev server can boot, and the monorepo has no root \`build\` script or turbo config to run. Build them yourself, then re-run \`npx fimo migrate\`.`);
143
+ }
144
+ const result = spawnSync(rootBuild.command, rootBuild.args, {
145
+ cwd: root,
146
+ timeout: 300_000,
147
+ encoding: 'utf8',
148
+ env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' },
149
+ });
150
+ if (result.status !== 0) {
151
+ throw new CaptureError(`Workspace packages (${unbuilt.join(', ')}) need building for the dev server, but \`${rootBuild.command} ${rootBuild.args.join(' ')}\` failed at ${path.relative(cwd, root) || 'the repo root'}. Build them yourself, then re-run \`npx fimo migrate\`.\n${(result.stderr || result.stdout || '').slice(-800)}`);
152
+ }
153
+ }
154
+ export async function startDevServer(opts) {
155
+ preflight(opts.cwd);
156
+ const port = await findFreePort();
157
+ const child = spawn(opts.packageManager, ['run', 'dev'], {
158
+ cwd: opts.cwd,
159
+ detached: process.platform !== 'win32',
160
+ stdio: ['ignore', 'pipe', 'pipe'],
161
+ env: { ...process.env, PORT: String(port), NEXT_TELEMETRY_DISABLED: '1', BROWSER: 'none' },
162
+ });
163
+ let stdoutBuffer = '';
164
+ let stderrBuffer = '';
165
+ child.stdout?.on('data', (chunk) => {
166
+ stdoutBuffer = `${stdoutBuffer}${chunk.toString()}`.slice(-8_000);
167
+ });
168
+ child.stderr?.on('data', (chunk) => {
169
+ stderrBuffer = `${stderrBuffer}${chunk.toString()}`.slice(-8_000);
170
+ });
171
+ let exited = false;
172
+ child.on('exit', () => {
173
+ exited = true;
174
+ });
175
+ const stop = async () => {
176
+ if (exited || child.pid === undefined) {
177
+ return;
178
+ }
179
+ try {
180
+ if (process.platform === 'win32') {
181
+ spawn('taskkill', ['/pid', String(child.pid), '/T', '/F']);
182
+ }
183
+ else {
184
+ process.kill(-child.pid, 'SIGTERM');
185
+ }
186
+ }
187
+ catch {
188
+ child.kill('SIGTERM');
189
+ }
190
+ const deadline = Date.now() + KILL_GRACE_MS;
191
+ while (!exited && Date.now() < deadline) {
192
+ await sleep(100);
193
+ }
194
+ if (!exited && child.pid !== undefined && process.platform !== 'win32') {
195
+ try {
196
+ process.kill(-child.pid, 'SIGKILL');
197
+ }
198
+ catch {
199
+ // already gone
200
+ }
201
+ }
202
+ };
203
+ // Readiness: an HTTP response BELOW 500 counts as listening (a 404 means
204
+ // the router is up). Nuxt's dev server answers 503 immediately while Vite
205
+ // still builds — treating that as ready captured a baseline of 503s.
206
+ // Dev scripts that hardcode their own port are handled by sniffing stdout.
207
+ const probe = async (origin) => {
208
+ try {
209
+ const response = await fetch(origin, { signal: AbortSignal.timeout(2_000) });
210
+ return response.status < 500;
211
+ }
212
+ catch {
213
+ return false;
214
+ }
215
+ };
216
+ // Probe via the `localhost` hostname, never a numeric loopback: Vite-family
217
+ // servers (Astro, Nuxt, SvelteKit) may bind ::1 only, and Node's fetch
218
+ // tries both address families for a hostname (Happy Eyeballs).
219
+ const deadline = Date.now() + opts.bootTimeoutMs;
220
+ let origin = `http://localhost:${port}`;
221
+ while (Date.now() < deadline) {
222
+ if (exited) {
223
+ await stop();
224
+ throw new CaptureError('The dev server exited before becoming ready.', stderrBuffer || stdoutBuffer);
225
+ }
226
+ if (await probe(origin)) {
227
+ return { origin, stderrTail: () => stderrBuffer, stop };
228
+ }
229
+ const sniffed = stdoutBuffer.match(/(?:localhost|127\.0\.0\.1):(\d{2,5})/);
230
+ if (sniffed && Number(sniffed[1]) !== port) {
231
+ const candidate = `http://localhost:${sniffed[1]}`;
232
+ if (await probe(candidate)) {
233
+ origin = candidate;
234
+ return { origin, stderrTail: () => stderrBuffer, stop };
235
+ }
236
+ }
237
+ await sleep(READINESS_POLL_MS);
238
+ }
239
+ await stop();
240
+ throw new CaptureError(`The dev server did not answer within ${Math.round(opts.bootTimeoutMs / 1000)}s.`, stderrBuffer || stdoutBuffer);
241
+ }
242
+ async function fetchRoute(origin, routePath, timeoutMs) {
243
+ const response = await fetch(new URL(routePath, origin), {
244
+ signal: AbortSignal.timeout(timeoutMs),
245
+ headers: { accept: 'text/html' },
246
+ });
247
+ return { status: response.status, html: await response.text() };
248
+ }
249
+ /**
250
+ * Boot the project's own dev server, capture every static route's normalized
251
+ * signature, and tear the server down. Baseline and check both run through
252
+ * this identical pipeline — that symmetry is the gate's correctness property.
253
+ */
254
+ export async function captureParityBaseline(opts) {
255
+ const bootTimeoutMs = opts.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS;
256
+ const routeTimeoutMs = opts.routeTimeoutMs ?? DEFAULT_ROUTE_TIMEOUT_MS;
257
+ const routes = {};
258
+ const paramValues = opts.paramValues ?? {};
259
+ // Capturable = a concrete URL exists (no params, or all params resolved via
260
+ // paramValues). The capture is keyed by the ORIGINAL route.path so baseline
261
+ // and check line up; only the fetched URL is the resolved one.
262
+ const staticRoutes = [];
263
+ for (const route of opts.routes) {
264
+ const resolved = route.path.includes(':') ? resolveRoutePath(route.path, paramValues) : route.path;
265
+ if (resolved === null) {
266
+ routes[route.path] = { skipped: 'dynamic-params' };
267
+ }
268
+ else {
269
+ staticRoutes.push({ path: route.path, url: resolved });
270
+ }
271
+ }
272
+ let server;
273
+ if (opts.serverOrigin) {
274
+ const origin = opts.serverOrigin.replace(/\/+$/, '');
275
+ opts.onProgress?.(`using the running dev server at ${origin}`);
276
+ try {
277
+ await fetch(origin, { signal: AbortSignal.timeout(5_000) });
278
+ }
279
+ catch {
280
+ throw new CaptureError(`No dev server answered at ${origin} — start it (e.g. \`${opts.packageManager} run dev\`) or drop --server to boot one.`);
281
+ }
282
+ server = { origin, stderrTail: () => '', stop: async () => { } };
283
+ }
284
+ else {
285
+ // Monorepo: build the app's workspace deps before the bare dev boot, or
286
+ // `next dev` dies on `Cannot find module '@repo/…'`.
287
+ ensureWorkspaceDepsBuilt(opts.cwd, opts.packageManager, opts.onProgress);
288
+ const bootStartedAt = Date.now();
289
+ opts.onProgress?.('starting your dev server');
290
+ const bootTicker = setInterval(() => {
291
+ opts.onProgress?.(`starting your dev server (${Math.round((Date.now() - bootStartedAt) / 1000)}s)`);
292
+ }, 5_000);
293
+ bootTicker.unref();
294
+ try {
295
+ server = await startDevServer({ cwd: opts.cwd, packageManager: opts.packageManager, bootTimeoutMs });
296
+ }
297
+ finally {
298
+ clearInterval(bootTicker);
299
+ }
300
+ }
301
+ try {
302
+ // Sequential on purpose: dev compilers choke on parallel cold compiles.
303
+ let index = 0;
304
+ for (const route of staticRoutes) {
305
+ index += 1;
306
+ const routeStartedAt = Date.now();
307
+ const progress = () => {
308
+ const elapsed = Math.round((Date.now() - routeStartedAt) / 1000);
309
+ opts.onProgress?.(`capturing ${route.path} (${index}/${staticRoutes.length}${elapsed >= 5 ? `, ${elapsed}s` : ''})`);
310
+ };
311
+ progress();
312
+ const routeTicker = setInterval(progress, 5_000);
313
+ routeTicker.unref();
314
+ try {
315
+ let result = await fetchRoute(server.origin, route.url, routeTimeoutMs).catch(async () => {
316
+ // One retry: first-compile latency and transient dev-server flake.
317
+ await sleep(1_000);
318
+ return fetchRoute(server.origin, route.url, routeTimeoutMs);
319
+ });
320
+ if (result.status >= 500) {
321
+ await sleep(1_000);
322
+ const retried = await fetchRoute(server.origin, route.url, routeTimeoutMs).catch(() => null);
323
+ if (retried) {
324
+ result = retried;
325
+ }
326
+ }
327
+ routes[route.path] = {
328
+ signature: extractRouteSignature(result.html, { origin: server.origin, status: result.status }),
329
+ };
330
+ }
331
+ catch (error) {
332
+ routes[route.path] = { skipped: 'fetch-failed', error: error instanceof Error ? error.message : String(error) };
333
+ }
334
+ finally {
335
+ clearInterval(routeTicker);
336
+ }
337
+ }
338
+ // Second pass for error pages: by now the dev server is fully warm, so a
339
+ // 500 recorded above was very likely a cold-compile flake. An error
340
+ // snapshot poisons the baseline (the parity gate would compare against
341
+ // the error page forever), so retry once more before accepting it.
342
+ for (const route of staticRoutes) {
343
+ const capture = routes[route.path];
344
+ if (capture && 'signature' in capture && capture.signature.status >= 500) {
345
+ opts.onProgress?.(`retrying ${route.path} (captured ${capture.signature.status})`);
346
+ const retried = await fetchRoute(server.origin, route.url, routeTimeoutMs).catch(() => null);
347
+ if (retried && retried.status < 500) {
348
+ routes[route.path] = {
349
+ signature: extractRouteSignature(retried.html, { origin: server.origin, status: retried.status }),
350
+ };
351
+ }
352
+ }
353
+ }
354
+ }
355
+ finally {
356
+ await server.stop();
357
+ }
358
+ return {
359
+ version: PARITY_BASELINE_VERSION,
360
+ capturedAt: new Date().toISOString(),
361
+ engine: 'dev-server',
362
+ framework: opts.framework,
363
+ command: `${opts.packageManager} run dev`,
364
+ routes,
365
+ };
366
+ }
@@ -0,0 +1,59 @@
1
+ import type { InlineMark, ParityBaseline } from './types.js';
2
+ export interface BlockFinding {
3
+ kind: 'text-changed' | 'block-missing' | 'block-added' | 'marks-changed';
4
+ index: number;
5
+ tag?: string;
6
+ before?: string;
7
+ after?: string;
8
+ marksBefore?: InlineMark[];
9
+ marksAfter?: InlineMark[];
10
+ }
11
+ export interface MediaFinding {
12
+ kind: 'changed' | 'missing' | 'added' | 'rehosted';
13
+ index: number;
14
+ before?: string;
15
+ after?: string;
16
+ }
17
+ export interface LinkFinding {
18
+ kind: 'changed' | 'missing' | 'added';
19
+ index: number;
20
+ before?: string;
21
+ after?: string;
22
+ }
23
+ export interface RouteDiff {
24
+ route: string;
25
+ clean: boolean;
26
+ unverified?: boolean;
27
+ statusChange?: {
28
+ before: number;
29
+ after: number;
30
+ };
31
+ blocks: BlockFinding[];
32
+ images: MediaFinding[];
33
+ videos: MediaFinding[];
34
+ links: LinkFinding[];
35
+ notes: string[];
36
+ }
37
+ export interface ParityComparison {
38
+ diffs: RouteDiff[];
39
+ clean: number;
40
+ drifted: number;
41
+ unverified: number;
42
+ }
43
+ export interface CompareOptions {
44
+ /**
45
+ * Full-match regexes for text that is dynamic BY THE SITE'S OWN DESIGN
46
+ * (live clocks, visitor counters). A block whose text fully matches the
47
+ * same pattern in baseline and current compares as equal. Recorded in
48
+ * `.fimo/migration/manifest.json` → `wrap.parityIgnores` with a reason.
49
+ */
50
+ ignoreTextPatterns?: string[];
51
+ /**
52
+ * Routes whose content drifts by DECISION — they render a keep-live
53
+ * external data source (`wrap.dataSources` → live). Counted as
54
+ * unverified, never silently clean.
55
+ */
56
+ ignoreRoutes?: string[];
57
+ }
58
+ export declare function compareBaselines(baseline: ParityBaseline, current: ParityBaseline, options?: CompareOptions): ParityComparison;
59
+ export declare function formatRouteDiff(diff: RouteDiff): string[];
@@ -0,0 +1,13 @@
1
+ import type { RouteSignature } from './types.js';
2
+ /** Strip the capture origin so differing ports between runs compare equal. */
3
+ export declare function normalizeHref(href: string, origin: string): string;
4
+ /**
5
+ * Media src normalization: origin strip + Next image-optimizer decode, so a
6
+ * pre-wrap `next/image` (`/_next/image?url=…&w=…`) and a post-wrap plain
7
+ * `<img src>` of the same asset compare equal.
8
+ */
9
+ export declare function normalizeMediaSrc(src: string, origin: string): string;
10
+ export declare function extractRouteSignature(html: string, opts: {
11
+ origin: string;
12
+ status: number;
13
+ }): RouteSignature;