fimo 0.3.1 → 0.3.2-experimental.1783354419249
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.
- package/README.md +6 -12
- package/assets/agents-starter/AGENTS.md +14 -2
- package/assets/skills/fimo/SKILL.md +1 -1
- package/assets/skills/fimo/references/content.md +14 -2
- package/assets/skills/fimo/references/setup-plain-vite.md +8 -3
- package/assets/skills/fimo/references/setup-react-router.md +13 -6
- package/assets/skills/fimo/references/ui.md +1 -1
- package/assets/skills/fimo-cli/SKILL.md +30 -18
- package/assets/skills/fimo-cli/references/account.md +4 -2
- package/assets/skills/fimo-cli/references/agent-setup.md +14 -16
- package/assets/skills/fimo-cli/references/agents.md +2 -0
- package/assets/skills/fimo-cli/references/analytics.md +2 -0
- package/assets/skills/fimo-cli/references/assets.md +2 -0
- package/assets/skills/fimo-cli/references/branches.md +2 -0
- package/assets/skills/fimo-cli/references/content.md +3 -1
- package/assets/skills/fimo-cli/references/credits.md +2 -0
- package/assets/skills/fimo-cli/references/deploy.md +10 -4
- package/assets/skills/fimo-cli/references/describe.md +2 -0
- package/assets/skills/fimo-cli/references/domains.md +2 -0
- package/assets/skills/fimo-cli/references/forms.md +2 -0
- package/assets/skills/fimo-cli/references/init.md +114 -0
- package/assets/skills/fimo-cli/references/integrations/firecrawl.md +2 -0
- package/assets/skills/fimo-cli/references/integrations.md +2 -0
- package/assets/skills/fimo-cli/references/invite.md +2 -0
- package/assets/skills/fimo-cli/references/labels.md +2 -0
- package/assets/skills/fimo-cli/references/locales.md +2 -0
- package/assets/skills/fimo-cli/references/media.md +2 -0
- package/assets/skills/fimo-cli/references/preview.md +2 -0
- package/assets/skills/fimo-cli/references/project-vars-and-secrets.md +3 -1
- package/assets/skills/fimo-cli/references/projects.md +5 -3
- package/assets/skills/fimo-cli/references/schedules.md +2 -0
- package/assets/skills/fimo-migration/SKILL.md +19 -1
- package/assets/skills/fimo-migration/references/manifest.md +40 -1
- package/assets/skills/fimo-migration/references/qa.md +24 -2
- package/assets/skills/fimo-migration/references/workflow.md +12 -1
- package/assets/skills/fimo-migration/references/wrap.md +174 -0
- package/assets/skills/fimo-studio/references/visual-editing.md +5 -3
- package/dist/build/vite/index.d.ts.map +1 -1
- package/dist/build/vite/index.js +8 -6
- package/dist/build/vite/plugins/translations.d.ts +2 -8
- package/dist/build/vite/plugins/translations.d.ts.map +1 -1
- package/dist/build/vite/plugins/translations.js +3 -121
- package/dist/cli/bundle.json +2 -2
- package/dist/cli/index.js +5802 -3285
- package/dist/runtime/app/FimoProviders.d.ts +1 -11
- package/dist/runtime/app/FimoProviders.d.ts.map +1 -1
- package/dist/runtime/app/FimoProviders.js +2 -23
- package/dist/runtime/i18n/FimoLocaleContext.d.ts.map +1 -1
- package/dist/runtime/i18n/FimoLocaleContext.js +1 -0
- package/dist/runtime/next/FimoFileLink.d.ts +17 -0
- package/dist/runtime/next/FimoFileLink.d.ts.map +1 -0
- package/dist/runtime/next/FimoFileLink.js +19 -0
- package/dist/runtime/next/FimoMediaImage.d.ts +14 -0
- package/dist/runtime/next/FimoMediaImage.d.ts.map +1 -0
- package/dist/runtime/next/FimoMediaImage.js +20 -0
- package/dist/runtime/next/FimoMediaVideo.d.ts +14 -0
- package/dist/runtime/next/FimoMediaVideo.d.ts.map +1 -0
- package/dist/runtime/next/FimoMediaVideo.js +20 -0
- package/dist/runtime/next/FimoNextProviders.d.ts +30 -0
- package/dist/runtime/next/FimoNextProviders.d.ts.map +1 -0
- package/dist/runtime/next/FimoNextProviders.js +106 -0
- package/dist/runtime/next/FimoNextScripts.d.ts +10 -0
- package/dist/runtime/next/FimoNextScripts.d.ts.map +1 -0
- package/dist/runtime/next/FimoNextScripts.js +19 -0
- package/dist/runtime/next/FimoRichTextBlock.d.ts +23 -0
- package/dist/runtime/next/FimoRichTextBlock.d.ts.map +1 -0
- package/dist/runtime/next/FimoRichTextBlock.js +22 -0
- package/dist/runtime/next/FimoText.d.ts +22 -0
- package/dist/runtime/next/FimoText.d.ts.map +1 -0
- package/dist/runtime/next/FimoText.js +20 -0
- package/dist/runtime/next/WrappedContentContext.d.ts +28 -0
- package/dist/runtime/next/WrappedContentContext.d.ts.map +1 -0
- package/dist/runtime/next/WrappedContentContext.js +30 -0
- package/dist/runtime/next/index.d.ts +14 -0
- package/dist/runtime/next/index.d.ts.map +1 -0
- package/dist/runtime/next/index.js +14 -0
- package/dist/runtime/next/server.d.ts +33 -0
- package/dist/runtime/next/server.d.ts.map +1 -0
- package/dist/runtime/next/server.js +107 -0
- package/dist/runtime/primitives/labels-hook.d.ts +33 -0
- package/dist/runtime/primitives/labels-hook.d.ts.map +1 -0
- package/dist/runtime/primitives/labels-hook.js +41 -0
- package/dist/runtime/primitives/labels-store.d.ts +12 -0
- package/dist/runtime/primitives/labels-store.d.ts.map +1 -0
- package/dist/runtime/primitives/labels-store.js +60 -0
- package/dist/runtime/primitives/labels-store.test.d.ts +2 -0
- package/dist/runtime/primitives/labels-store.test.d.ts.map +1 -0
- package/dist/runtime/primitives/labels-store.test.js +66 -0
- package/dist/runtime/primitives/lib/useFimoLiveValue.d.ts +5 -3
- package/dist/runtime/primitives/lib/useFimoLiveValue.d.ts.map +1 -1
- package/dist/runtime/primitives/lib/useFimoLiveValue.js +8 -4
- package/dist/runtime/primitives/translations.d.ts +1 -27
- package/dist/runtime/primitives/translations.d.ts.map +1 -1
- package/dist/runtime/primitives/translations.js +7 -85
- package/dist/runtime/react/index.d.ts +24 -0
- package/dist/runtime/react/index.d.ts.map +1 -0
- package/dist/runtime/react/index.js +28 -0
- package/dist/runtime/shared/display-routes.d.ts +14 -0
- package/dist/runtime/shared/display-routes.d.ts.map +1 -0
- package/dist/runtime/shared/display-routes.js +25 -0
- package/dist/runtime/shared/fimo-config.server.d.ts.map +1 -1
- package/dist/runtime/shared/fimo-config.server.js +1 -0
- package/dist/runtime/shared/fimo-config.types.d.ts +2 -0
- package/dist/runtime/shared/fimo-config.types.d.ts.map +1 -1
- package/dist/runtime/shared/labels-fetch.server.d.ts +14 -0
- package/dist/runtime/shared/labels-fetch.server.d.ts.map +1 -0
- package/dist/runtime/shared/labels-fetch.server.js +145 -0
- package/dist/scripts/detect-framework.d.ts +12 -0
- package/dist/scripts/detect-framework.js +32 -0
- package/dist/scripts/detect-framework.test.d.ts +1 -0
- package/dist/scripts/detect-framework.test.js +39 -0
- package/dist/scripts/lib/discover-next-routes.d.ts +11 -0
- package/dist/scripts/lib/discover-next-routes.js +76 -0
- package/dist/scripts/lib/discover-next-routes.test.d.ts +1 -0
- package/dist/scripts/lib/discover-next-routes.test.js +40 -0
- package/dist/scripts/render-parity/capture.d.ts +34 -0
- package/dist/scripts/render-parity/capture.js +192 -0
- package/dist/scripts/render-parity/capture.test.d.ts +1 -0
- package/dist/scripts/render-parity/capture.test.js +102 -0
- package/dist/scripts/render-parity/compare.d.ts +44 -0
- package/dist/scripts/render-parity/compare.js +0 -0
- package/dist/scripts/render-parity/compare.test.d.ts +1 -0
- package/dist/scripts/render-parity/compare.test.js +75 -0
- package/dist/scripts/render-parity/normalize-html.d.ts +13 -0
- package/dist/scripts/render-parity/normalize-html.js +208 -0
- package/dist/scripts/render-parity/normalize-html.test.d.ts +1 -0
- package/dist/scripts/render-parity/normalize-html.test.js +58 -0
- package/dist/scripts/render-parity/types.d.ts +41 -0
- package/dist/scripts/render-parity/types.js +1 -0
- package/dist/scripts/scan-wrappable.d.ts +38 -0
- package/dist/scripts/scan-wrappable.js +231 -0
- package/dist/scripts/scan-wrappable.test.d.ts +1 -0
- package/dist/scripts/scan-wrappable.test.js +65 -0
- package/dist/scripts/validate-route-metadata.js +11 -0
- package/dist/scripts/validate-schemas.d.ts +13 -0
- package/dist/scripts/validate-schemas.js +113 -0
- package/dist/scripts/validate-schemas.test.d.ts +1 -0
- package/dist/scripts/validate-schemas.test.js +132 -0
- package/package.json +29 -1
- package/release.json +2 -2
- package/scripts/postinstall.mjs +10 -54
- package/templates/react-router/package.json +1 -1
- package/assets/agents-starter/claude/settings.json +0 -19
- package/assets/skills/fimo-cli/references/create.md +0 -90
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { captureParityBaseline, CaptureError, findFreePort } from './capture.js';
|
|
6
|
+
let dir;
|
|
7
|
+
/**
|
|
8
|
+
* Hermetic fixture: a project whose `dev` script is a tiny node server
|
|
9
|
+
* honoring PORT — exercises the whole engine without Next.
|
|
10
|
+
*/
|
|
11
|
+
const SERVER_JS = `
|
|
12
|
+
const http = require('http');
|
|
13
|
+
http
|
|
14
|
+
.createServer((req, res) => {
|
|
15
|
+
if (req.url === '/about') {
|
|
16
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
17
|
+
res.end('<html><body><h1>About us</h1><p>We are <strong>honest</strong>.</p></body></html>');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (req.url === '/broken') {
|
|
21
|
+
res.writeHead(500, { 'content-type': 'text/html' });
|
|
22
|
+
res.end('<html><body><p>boom</p></body></html>');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
26
|
+
res.end('<html><body><h1>Home</h1><img src="/hero.png"/><a href="/whitepaper.pdf">PDF</a></body></html>');
|
|
27
|
+
})
|
|
28
|
+
.listen(process.env.PORT, '127.0.0.1');
|
|
29
|
+
`;
|
|
30
|
+
const project = (opts = {}) => {
|
|
31
|
+
dir = mkdtempSync(join(tmpdir(), 'parity-capture-'));
|
|
32
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 't', scripts: { dev: opts.devScript ?? 'node server.js' } }));
|
|
33
|
+
writeFileSync(join(dir, 'server.js'), SERVER_JS);
|
|
34
|
+
if (opts.withNodeModules !== false) {
|
|
35
|
+
mkdirSync(join(dir, 'node_modules'), { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
return dir;
|
|
38
|
+
};
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
if (dir) {
|
|
41
|
+
rmSync(dir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
describe('findFreePort', () => {
|
|
45
|
+
it('returns a usable port', async () => {
|
|
46
|
+
const port = await findFreePort();
|
|
47
|
+
expect(port).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe('captureParityBaseline', () => {
|
|
51
|
+
it('boots the dev server, captures routes, and tears down', async () => {
|
|
52
|
+
const cwd = project();
|
|
53
|
+
const baseline = await captureParityBaseline({
|
|
54
|
+
cwd,
|
|
55
|
+
packageManager: 'npm',
|
|
56
|
+
framework: 'next',
|
|
57
|
+
routes: [{ path: '/' }, { path: '/about' }, { path: '/blog/:slug' }],
|
|
58
|
+
bootTimeoutMs: 30_000,
|
|
59
|
+
routeTimeoutMs: 10_000,
|
|
60
|
+
});
|
|
61
|
+
expect(baseline.version).toBe(1);
|
|
62
|
+
const home = baseline.routes['/'];
|
|
63
|
+
expect('signature' in home && home.signature.blocks[0]).toEqual({ tag: 'h1', text: 'Home', marks: [] });
|
|
64
|
+
expect('signature' in home && home.signature.images).toEqual(['/hero.png']);
|
|
65
|
+
const about = baseline.routes['/about'];
|
|
66
|
+
expect('signature' in about && about.signature.blocks[1].marks[0]).toEqual({ kind: 'strong', text: 'honest' });
|
|
67
|
+
expect(baseline.routes['/blog/:slug']).toEqual({ skipped: 'dynamic-params' });
|
|
68
|
+
}, 60_000);
|
|
69
|
+
it('records 5xx status as part of the signature', async () => {
|
|
70
|
+
const cwd = project();
|
|
71
|
+
const baseline = await captureParityBaseline({
|
|
72
|
+
cwd,
|
|
73
|
+
packageManager: 'npm',
|
|
74
|
+
framework: 'next',
|
|
75
|
+
routes: [{ path: '/broken' }],
|
|
76
|
+
bootTimeoutMs: 30_000,
|
|
77
|
+
routeTimeoutMs: 10_000,
|
|
78
|
+
});
|
|
79
|
+
const broken = baseline.routes['/broken'];
|
|
80
|
+
expect('signature' in broken && broken.signature.status).toBe(500);
|
|
81
|
+
}, 60_000);
|
|
82
|
+
it('fails fast without node_modules', async () => {
|
|
83
|
+
const cwd = project({ withNodeModules: false });
|
|
84
|
+
await expect(captureParityBaseline({
|
|
85
|
+
cwd,
|
|
86
|
+
packageManager: 'npm',
|
|
87
|
+
framework: null,
|
|
88
|
+
routes: [{ path: '/' }],
|
|
89
|
+
bootTimeoutMs: 5_000,
|
|
90
|
+
})).rejects.toThrow(CaptureError);
|
|
91
|
+
});
|
|
92
|
+
it('surfaces stderr when the dev server dies at boot', async () => {
|
|
93
|
+
const cwd = project({ devScript: 'node -e "console.error(\'FATAL: nope\'); process.exit(1)"' });
|
|
94
|
+
await expect(captureParityBaseline({
|
|
95
|
+
cwd,
|
|
96
|
+
packageManager: 'npm',
|
|
97
|
+
framework: null,
|
|
98
|
+
routes: [{ path: '/' }],
|
|
99
|
+
bootTimeoutMs: 15_000,
|
|
100
|
+
})).rejects.toMatchObject({ stderrTail: expect.stringContaining('FATAL: nope') });
|
|
101
|
+
}, 30_000);
|
|
102
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
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 declare function compareBaselines(baseline: ParityBaseline, current: ParityBaseline): ParityComparison;
|
|
44
|
+
export declare function formatRouteDiff(diff: RouteDiff): string[];
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { compareBaselines, formatRouteDiff } from './compare.js';
|
|
3
|
+
const signature = (over = {}) => ({
|
|
4
|
+
status: 200,
|
|
5
|
+
blocks: [
|
|
6
|
+
{ tag: 'h1', text: 'Ship faster', marks: [] },
|
|
7
|
+
{ tag: 'p', text: 'Read honest numbers today', marks: [{ kind: 'strong', text: 'honest numbers' }] },
|
|
8
|
+
{ tag: 'p', text: 'Get early access', marks: [] },
|
|
9
|
+
],
|
|
10
|
+
images: ['/images/hero.png'],
|
|
11
|
+
videos: [],
|
|
12
|
+
links: [{ href: '/whitepaper.pdf', text: 'PDF' }],
|
|
13
|
+
...over,
|
|
14
|
+
});
|
|
15
|
+
const baseline = (routes) => ({
|
|
16
|
+
version: 1,
|
|
17
|
+
capturedAt: '2026-07-06T00:00:00Z',
|
|
18
|
+
engine: 'dev-server',
|
|
19
|
+
framework: 'next',
|
|
20
|
+
command: 'npm run dev',
|
|
21
|
+
routes: Object.fromEntries(Object.entries(routes).map(([route, sig]) => [route, { signature: sig }])),
|
|
22
|
+
});
|
|
23
|
+
describe('compareBaselines', () => {
|
|
24
|
+
it('reports clean when identical', () => {
|
|
25
|
+
const result = compareBaselines(baseline({ '/': signature() }), baseline({ '/': signature() }));
|
|
26
|
+
expect(result).toMatchObject({ clean: 1, drifted: 0, unverified: 0 });
|
|
27
|
+
});
|
|
28
|
+
it('reports a single text change with index and before/after', () => {
|
|
29
|
+
const after = signature();
|
|
30
|
+
after.blocks = after.blocks.map((block, i) => (i === 2 ? { ...block, text: 'Get access' } : block));
|
|
31
|
+
const { diffs, drifted } = compareBaselines(baseline({ '/': signature() }), baseline({ '/': after }));
|
|
32
|
+
expect(drifted).toBe(1);
|
|
33
|
+
expect(diffs[0].blocks).toEqual([
|
|
34
|
+
{ kind: 'text-changed', index: 2, tag: 'p', before: 'Get early access', after: 'Get access' },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
it('reports one block-added without cascading through LCS', () => {
|
|
38
|
+
const after = signature();
|
|
39
|
+
after.blocks = [after.blocks[0], { tag: 'p', text: 'Injected', marks: [] }, ...after.blocks.slice(1)];
|
|
40
|
+
const { diffs } = compareBaselines(baseline({ '/': signature() }), baseline({ '/': after }));
|
|
41
|
+
expect(diffs[0].blocks).toEqual([{ kind: 'block-added', index: 1, tag: 'p', after: 'Injected' }]);
|
|
42
|
+
});
|
|
43
|
+
it('reports lost marks even when text is unchanged', () => {
|
|
44
|
+
const after = signature();
|
|
45
|
+
after.blocks = after.blocks.map((block, i) => (i === 1 ? { ...block, marks: [] } : block));
|
|
46
|
+
const { diffs } = compareBaselines(baseline({ '/': signature() }), baseline({ '/': after }));
|
|
47
|
+
expect(diffs[0].blocks[0]).toMatchObject({ kind: 'marks-changed', index: 1 });
|
|
48
|
+
expect(formatRouteDiff(diffs[0])[0]).toContain('marks changed at block 1');
|
|
49
|
+
});
|
|
50
|
+
it('reports status change and media change', () => {
|
|
51
|
+
const after = signature({ status: 500, images: ['/images/other.png'] });
|
|
52
|
+
const { diffs } = compareBaselines(baseline({ '/': signature() }), baseline({ '/': after }));
|
|
53
|
+
expect(diffs[0].statusChange).toEqual({ before: 200, after: 500 });
|
|
54
|
+
expect(diffs[0].images[0]).toMatchObject({ kind: 'changed', before: '/images/hero.png' });
|
|
55
|
+
});
|
|
56
|
+
it('treats rehosted media as an informational note, not drift', () => {
|
|
57
|
+
const after = signature({ images: ['https://assets.fimo.ai/uploads/hero-abc123.png'] });
|
|
58
|
+
const { diffs, drifted } = compareBaselines(baseline({ '/': signature() }), baseline({ '/': after }));
|
|
59
|
+
expect(drifted).toBe(0);
|
|
60
|
+
expect(diffs[0].notes[0]).toContain('media rehosted');
|
|
61
|
+
});
|
|
62
|
+
it('counts skipped routes as unverified', () => {
|
|
63
|
+
const base = baseline({ '/': signature() });
|
|
64
|
+
base.routes['/blog/:slug'] = { skipped: 'dynamic-params' };
|
|
65
|
+
const current = baseline({ '/': signature() });
|
|
66
|
+
current.routes['/blog/:slug'] = { skipped: 'dynamic-params' };
|
|
67
|
+
const result = compareBaselines(base, current);
|
|
68
|
+
expect(result).toMatchObject({ clean: 1, unverified: 1 });
|
|
69
|
+
});
|
|
70
|
+
it('reports link href changes', () => {
|
|
71
|
+
const after = signature({ links: [{ href: '/wp.pdf', text: 'PDF' }] });
|
|
72
|
+
const { diffs } = compareBaselines(baseline({ '/': signature() }), baseline({ '/': after }));
|
|
73
|
+
expect(diffs[0].links[0]).toMatchObject({ kind: 'changed', before: '/whitepaper.pdf', after: '/wp.pdf' });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -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;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio';
|
|
2
|
+
const REMOVED_TAGS = new Set(['script', 'style', 'noscript', 'template', 'link', 'meta', 'svg', 'iframe', 'head']);
|
|
3
|
+
const BLOCK_TAGS = new Set([
|
|
4
|
+
'p',
|
|
5
|
+
'h1',
|
|
6
|
+
'h2',
|
|
7
|
+
'h3',
|
|
8
|
+
'h4',
|
|
9
|
+
'h5',
|
|
10
|
+
'h6',
|
|
11
|
+
'li',
|
|
12
|
+
'td',
|
|
13
|
+
'th',
|
|
14
|
+
'dt',
|
|
15
|
+
'dd',
|
|
16
|
+
'blockquote',
|
|
17
|
+
'figcaption',
|
|
18
|
+
'pre',
|
|
19
|
+
'button',
|
|
20
|
+
'label',
|
|
21
|
+
'summary',
|
|
22
|
+
'caption',
|
|
23
|
+
'option',
|
|
24
|
+
]);
|
|
25
|
+
const FALLBACK_BLOCK_TAGS = new Set(['div', 'section', 'article', 'main', 'aside', 'header', 'footer', 'nav', 'body']);
|
|
26
|
+
const MARK_KIND = {
|
|
27
|
+
strong: 'strong',
|
|
28
|
+
b: 'strong',
|
|
29
|
+
em: 'em',
|
|
30
|
+
i: 'em',
|
|
31
|
+
u: 'u',
|
|
32
|
+
s: 's',
|
|
33
|
+
del: 's',
|
|
34
|
+
strike: 's',
|
|
35
|
+
code: 'code',
|
|
36
|
+
a: 'a',
|
|
37
|
+
};
|
|
38
|
+
function collapse(text) {
|
|
39
|
+
return text.replace(/ /g, ' ').replace(/\s+/g, ' ').trim();
|
|
40
|
+
}
|
|
41
|
+
/** Strip the capture origin so differing ports between runs compare equal. */
|
|
42
|
+
export function normalizeHref(href, origin) {
|
|
43
|
+
if (href.startsWith(origin)) {
|
|
44
|
+
const stripped = href.slice(origin.length);
|
|
45
|
+
return stripped.startsWith('/') ? stripped : `/${stripped}`;
|
|
46
|
+
}
|
|
47
|
+
return href;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Media src normalization: origin strip + Next image-optimizer decode, so a
|
|
51
|
+
* pre-wrap `next/image` (`/_next/image?url=…&w=…`) and a post-wrap plain
|
|
52
|
+
* `<img src>` of the same asset compare equal.
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeMediaSrc(src, origin) {
|
|
55
|
+
let value = normalizeHref(src, origin);
|
|
56
|
+
const optimizerMatch = value.match(/^\/_next\/image\?(.+)$/);
|
|
57
|
+
if (optimizerMatch) {
|
|
58
|
+
const params = new URLSearchParams(optimizerMatch[1]);
|
|
59
|
+
const url = params.get('url');
|
|
60
|
+
if (url) {
|
|
61
|
+
value = url;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
function isExcluded(node) {
|
|
67
|
+
const tag = node.name?.toLowerCase() ?? '';
|
|
68
|
+
if (REMOVED_TAGS.has(tag)) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
// Hidden elements are not visible content — except Next streaming
|
|
72
|
+
// containers (div[hidden][id^="S:"]), whose content replaces Suspense
|
|
73
|
+
// holes; kept in document order so streamed content is captured
|
|
74
|
+
// symmetrically between baseline and check.
|
|
75
|
+
if (node.attribs && 'hidden' in node.attribs) {
|
|
76
|
+
const isStreamingContainer = tag === 'div' && (node.attribs.id ?? '').startsWith('S:');
|
|
77
|
+
if (!isStreamingContainer) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
function nearestBlock(node) {
|
|
84
|
+
let current = node.parent;
|
|
85
|
+
let fallback = null;
|
|
86
|
+
while (current) {
|
|
87
|
+
const tag = current.name?.toLowerCase();
|
|
88
|
+
if (tag && BLOCK_TAGS.has(tag)) {
|
|
89
|
+
return current;
|
|
90
|
+
}
|
|
91
|
+
if (!fallback && tag && FALLBACK_BLOCK_TAGS.has(tag)) {
|
|
92
|
+
fallback = current;
|
|
93
|
+
}
|
|
94
|
+
current = current.parent;
|
|
95
|
+
}
|
|
96
|
+
return fallback;
|
|
97
|
+
}
|
|
98
|
+
function collectText(node) {
|
|
99
|
+
if (node.type === 'text') {
|
|
100
|
+
return String(node.data ?? '');
|
|
101
|
+
}
|
|
102
|
+
if (node.type !== 'tag' || isExcluded(node)) {
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
return (node.children ?? []).map(collectText).join('');
|
|
106
|
+
}
|
|
107
|
+
export function extractRouteSignature(html, opts) {
|
|
108
|
+
const $ = cheerio.load(html);
|
|
109
|
+
const bodyNode = $('body').get(0);
|
|
110
|
+
const blocks = [];
|
|
111
|
+
const blockIndex = new Map();
|
|
112
|
+
const images = [];
|
|
113
|
+
const videos = [];
|
|
114
|
+
const links = [];
|
|
115
|
+
const visit = (node) => {
|
|
116
|
+
if (node.type === 'text') {
|
|
117
|
+
const text = collapse(String(node.data ?? ''));
|
|
118
|
+
if (!text) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const block = nearestBlock(node);
|
|
122
|
+
if (!block) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const existing = blockIndex.get(block);
|
|
126
|
+
if (existing === undefined) {
|
|
127
|
+
blockIndex.set(block, blocks.length);
|
|
128
|
+
blocks.push({ tag: block.name?.toLowerCase() ?? 'div', text, marks: [] });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
blocks[existing].text = collapse(`${blocks[existing].text} ${text}`);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (node.type !== 'tag' && node.type !== 'root') {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (node.type === 'tag' && isExcluded(node)) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const tag = node.name?.toLowerCase() ?? '';
|
|
142
|
+
const attribs = node.attribs ?? {};
|
|
143
|
+
// Inline-marks signature: the formatting that must survive wrapping and
|
|
144
|
+
// the TipTap seed round-trip. Attached to the nearest block.
|
|
145
|
+
const markKind = MARK_KIND[tag];
|
|
146
|
+
if (markKind) {
|
|
147
|
+
const text = collapse(collectText(node));
|
|
148
|
+
if (text) {
|
|
149
|
+
const block = nearestBlock(node);
|
|
150
|
+
if (block) {
|
|
151
|
+
if (!blockIndex.has(block)) {
|
|
152
|
+
blockIndex.set(block, blocks.length);
|
|
153
|
+
blocks.push({ tag: block.name?.toLowerCase() ?? 'div', text: '', marks: [] });
|
|
154
|
+
}
|
|
155
|
+
const mark = { kind: markKind, text };
|
|
156
|
+
if (markKind === 'a') {
|
|
157
|
+
if (attribs.href) {
|
|
158
|
+
mark.href = normalizeHref(attribs.href, opts.origin);
|
|
159
|
+
blocks[blockIndex.get(block)].marks.push(mark);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
blocks[blockIndex.get(block)].marks.push(mark);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (tag === 'img' && attribs.src) {
|
|
169
|
+
images.push(normalizeMediaSrc(attribs.src, opts.origin));
|
|
170
|
+
}
|
|
171
|
+
if (tag === 'source' && attribs.src) {
|
|
172
|
+
const parentTag = node.parent?.name?.toLowerCase();
|
|
173
|
+
if (parentTag === 'picture') {
|
|
174
|
+
images.push(normalizeMediaSrc(attribs.src, opts.origin));
|
|
175
|
+
}
|
|
176
|
+
else if (parentTag === 'video') {
|
|
177
|
+
videos.push(normalizeMediaSrc(attribs.src, opts.origin));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (tag === 'video') {
|
|
181
|
+
if (attribs.src) {
|
|
182
|
+
videos.push(normalizeMediaSrc(attribs.src, opts.origin));
|
|
183
|
+
}
|
|
184
|
+
if (attribs.poster) {
|
|
185
|
+
videos.push(normalizeMediaSrc(attribs.poster, opts.origin));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (tag === 'a' && attribs.href) {
|
|
189
|
+
links.push({ href: normalizeHref(attribs.href, opts.origin), text: collapse(collectText(node)) });
|
|
190
|
+
}
|
|
191
|
+
for (const child of node.children ?? []) {
|
|
192
|
+
visit(child);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
if (bodyNode) {
|
|
196
|
+
for (const child of bodyNode.children ?? []) {
|
|
197
|
+
visit(child);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Blocks created empty by a mark preceding any direct text (e.g. a <p>
|
|
201
|
+
// starting with <strong>) get their text from the block's full content.
|
|
202
|
+
for (const [block, index] of blockIndex) {
|
|
203
|
+
if (!blocks[index].text) {
|
|
204
|
+
blocks[index].text = collapse(collectText(block));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { status: opts.status, blocks: blocks.filter((block) => block.text.length > 0), images, videos, links };
|
|
208
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { extractRouteSignature, normalizeMediaSrc } from './normalize-html.js';
|
|
3
|
+
const ORIGIN = 'http://localhost:3999';
|
|
4
|
+
const extract = (body, status = 200) => extractRouteSignature(`<!doctype html><html><head><title>x</title></head><body>${body}</body></html>`, {
|
|
5
|
+
origin: ORIGIN,
|
|
6
|
+
status,
|
|
7
|
+
});
|
|
8
|
+
describe('extractRouteSignature', () => {
|
|
9
|
+
it('is invariant to Fimo wrapper spans and attribute churn', () => {
|
|
10
|
+
const before = extract('<main><h1>Ship faster</h1><p>Analytics for humans</p></main>');
|
|
11
|
+
const after = extract('<main class="x"><h1 data-y="1"><span data-fimo-source="__labels.en.home.hero.title.value">Ship faster</span></h1><p><span data-fimo-source="z">Analytics for humans</span></p></main>');
|
|
12
|
+
expect(after.blocks).toEqual(before.blocks);
|
|
13
|
+
});
|
|
14
|
+
it('captures inline marks with hrefs and fails on their loss', () => {
|
|
15
|
+
const withMarks = extract('<p>Read <strong>honest numbers</strong> and <a href="/docs">the docs</a>.</p>');
|
|
16
|
+
expect(withMarks.blocks[0].marks).toEqual([
|
|
17
|
+
{ kind: 'strong', text: 'honest numbers' },
|
|
18
|
+
{ kind: 'a', text: 'the docs', href: '/docs' },
|
|
19
|
+
]);
|
|
20
|
+
const lostBold = extract('<p>Read honest numbers and <a href="/docs">the docs</a>.</p>');
|
|
21
|
+
expect(lostBold.blocks[0].marks).toHaveLength(1);
|
|
22
|
+
expect(withMarks.blocks[0].text).toBe(lostBold.blocks[0].text);
|
|
23
|
+
});
|
|
24
|
+
it('drops scripts (RSC flight payload), styles, and hidden elements but keeps streaming containers', () => {
|
|
25
|
+
const sig = extract('<p>Visible</p><script>self.__next_f.push([1,"Hidden payload text"])</script><style>.x{}</style><div hidden>gone</div><div hidden id="S:0"><p>Streamed</p></div>');
|
|
26
|
+
expect(sig.blocks.map((b) => b.text)).toEqual(['Visible', 'Streamed']);
|
|
27
|
+
});
|
|
28
|
+
it('collapses whitespace, decodes entities, merges runs per block', () => {
|
|
29
|
+
const sig = extract('<p> Hello &\n <span>world</span> </p>');
|
|
30
|
+
expect(sig.blocks).toEqual([{ tag: 'p', text: 'Hello & world', marks: [] }]);
|
|
31
|
+
});
|
|
32
|
+
it('extracts media with origin stripping and optimizer decoding', () => {
|
|
33
|
+
const sig = extract(`<img src="${ORIGIN}/_next/image?url=%2Fimages%2Fhero.png&w=640&q=75" /><video src="/demo.mp4" poster="/poster.jpg"></video>`);
|
|
34
|
+
expect(sig.images).toEqual(['/images/hero.png']);
|
|
35
|
+
expect(sig.videos).toEqual(['/demo.mp4', '/poster.jpg']);
|
|
36
|
+
});
|
|
37
|
+
it('extracts links with normalized hrefs', () => {
|
|
38
|
+
const sig = extract(`<p><a href="${ORIGIN}/pricing">Pricing</a> <a href="/whitepaper.pdf">PDF</a></p>`);
|
|
39
|
+
expect(sig.links).toEqual([
|
|
40
|
+
{ href: '/pricing', text: 'Pricing' },
|
|
41
|
+
{ href: '/whitepaper.pdf', text: 'PDF' },
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
it('attaches text to the nearest block through wrapper divs', () => {
|
|
45
|
+
const wrapped = extract('<div><p>Original</p></div>');
|
|
46
|
+
const withWrapper = extract('<div><div data-wrapper><p>Original</p></div></div>');
|
|
47
|
+
expect(withWrapper.blocks).toEqual(wrapped.blocks);
|
|
48
|
+
});
|
|
49
|
+
it('records status', () => {
|
|
50
|
+
expect(extract('<p>x</p>', 500).status).toBe(500);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('normalizeMediaSrc', () => {
|
|
54
|
+
it('keeps plain srcs and decodes optimizer urls', () => {
|
|
55
|
+
expect(normalizeMediaSrc('/a.png', ORIGIN)).toBe('/a.png');
|
|
56
|
+
expect(normalizeMediaSrc('/_next/image?url=%2Fb.png&w=64', ORIGIN)).toBe('/b.png');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export declare const PARITY_BASELINE_VERSION = 1;
|
|
2
|
+
export interface InlineMark {
|
|
3
|
+
kind: 'strong' | 'em' | 'u' | 's' | 'code' | 'a';
|
|
4
|
+
text: string;
|
|
5
|
+
href?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface TextBlock {
|
|
8
|
+
tag: string;
|
|
9
|
+
text: string;
|
|
10
|
+
marks: InlineMark[];
|
|
11
|
+
}
|
|
12
|
+
export interface RouteSignature {
|
|
13
|
+
status: number;
|
|
14
|
+
blocks: TextBlock[];
|
|
15
|
+
images: string[];
|
|
16
|
+
videos: string[];
|
|
17
|
+
links: Array<{
|
|
18
|
+
href: string;
|
|
19
|
+
text: string;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
export type RouteCapture = {
|
|
23
|
+
signature: RouteSignature;
|
|
24
|
+
} | {
|
|
25
|
+
skipped: 'dynamic-params' | 'fetch-failed';
|
|
26
|
+
error?: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Normalized pre-wrap snapshot of every route's rendered content. Written to
|
|
30
|
+
* `.fimo/migration/parity-baseline.json`; `fimo migrate wrap --check`
|
|
31
|
+
* recaptures with the identical pipeline and compares.
|
|
32
|
+
*/
|
|
33
|
+
export interface ParityBaseline {
|
|
34
|
+
version: typeof PARITY_BASELINE_VERSION;
|
|
35
|
+
capturedAt: string;
|
|
36
|
+
engine: 'dev-server';
|
|
37
|
+
framework: string | null;
|
|
38
|
+
command: string;
|
|
39
|
+
unwrappedAtCapture?: number;
|
|
40
|
+
routes: Record<string, RouteCapture>;
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PARITY_BASELINE_VERSION = 1;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inverse of extract-translations: finds content that is NOT yet wrapped with
|
|
3
|
+
* Fimo primitives — hardcoded JSX text, image/video sources, file links, and
|
|
4
|
+
* user-visible attribute strings. Drives the wrap migration's coverage gate:
|
|
5
|
+
* every item must end wrapped, ignored-with-a-reason, or unresolved.
|
|
6
|
+
*/
|
|
7
|
+
export type WrappableKind = 'text' | 'image' | 'video' | 'file-link' | 'attribute';
|
|
8
|
+
export interface WrappableItem {
|
|
9
|
+
kind: WrappableKind;
|
|
10
|
+
file: string;
|
|
11
|
+
line: number;
|
|
12
|
+
preview: string;
|
|
13
|
+
}
|
|
14
|
+
/** Identities of already-wrapped content, for the seeding gate. */
|
|
15
|
+
export interface WrappedIds {
|
|
16
|
+
labels: string[];
|
|
17
|
+
media: string[];
|
|
18
|
+
richText: string[];
|
|
19
|
+
files: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface WrapScanResult {
|
|
22
|
+
items: WrappableItem[];
|
|
23
|
+
wrappedCount: number;
|
|
24
|
+
filesScanned: number;
|
|
25
|
+
wrappedIds: WrappedIds;
|
|
26
|
+
}
|
|
27
|
+
export declare function scanWrappableFile(filePath: string, code: string, relPath: string): {
|
|
28
|
+
items: WrappableItem[];
|
|
29
|
+
wrapped: number;
|
|
30
|
+
wrappedIds: WrappedIds;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Scan a project's source directories for unwrapped content. `dirs` defaults
|
|
34
|
+
* to the common Next/React layouts that exist under `rootDir`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function scanWrappable(rootDir: string, opts?: {
|
|
37
|
+
dirs?: string[];
|
|
38
|
+
}): WrapScanResult;
|