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,231 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { parseSync } from 'oxc-parser';
|
|
4
|
+
// Elements whose subtree counts as already wrapped.
|
|
5
|
+
const WRAPPING_COMPONENTS = new Set([
|
|
6
|
+
'FimoText',
|
|
7
|
+
'FimoRichTextBlock',
|
|
8
|
+
'FimoMediaImage',
|
|
9
|
+
'FimoMediaVideo',
|
|
10
|
+
'FimoFileLink',
|
|
11
|
+
'Text',
|
|
12
|
+
'RichText',
|
|
13
|
+
]);
|
|
14
|
+
const IMAGE_ELEMENTS = new Set(['img', 'Image']);
|
|
15
|
+
const VIDEO_ELEMENTS = new Set(['video', 'source', 'Video']);
|
|
16
|
+
const WRAPPABLE_ATTRIBUTES = new Set(['placeholder', 'title', 'aria-label']);
|
|
17
|
+
const FILE_LINK_PATTERN = /\.(pdf|zip|docx?|xlsx?|pptx?|csv|mp4|mov|webm)$/i;
|
|
18
|
+
const SKIP_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', '.fimo']);
|
|
19
|
+
const MIN_TEXT_LENGTH = 3;
|
|
20
|
+
const PREVIEW_LENGTH = 60;
|
|
21
|
+
function toPreview(value) {
|
|
22
|
+
const collapsed = value.replace(/\s+/g, ' ').trim();
|
|
23
|
+
return collapsed.length > PREVIEW_LENGTH ? `${collapsed.slice(0, PREVIEW_LENGTH)}…` : collapsed;
|
|
24
|
+
}
|
|
25
|
+
function lineOf(code, offset) {
|
|
26
|
+
return code.slice(0, offset).split('\n').length;
|
|
27
|
+
}
|
|
28
|
+
function jsxElementName(node) {
|
|
29
|
+
const name = node?.openingElement?.name ?? node?.name;
|
|
30
|
+
if (!name) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (name.type === 'JSXIdentifier') {
|
|
34
|
+
return name.name;
|
|
35
|
+
}
|
|
36
|
+
if (name.type === 'JSXMemberExpression') {
|
|
37
|
+
return name.property?.name ?? null;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function attributeValue(node, attribute) {
|
|
42
|
+
const attrs = node?.openingElement?.attributes ?? [];
|
|
43
|
+
for (const attr of attrs) {
|
|
44
|
+
if (attr.type !== 'JSXAttribute' || attr.name?.name !== attribute) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const value = attr.value;
|
|
48
|
+
if (value?.type === 'Literal' && typeof value.value === 'string' && value.value.length > 0) {
|
|
49
|
+
return { value: value.value, start: attr.start ?? node.start ?? 0 };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const WRAPPED_ID_KIND = {
|
|
55
|
+
FimoText: 'labels',
|
|
56
|
+
Text: 'labels',
|
|
57
|
+
FimoRichTextBlock: 'richText',
|
|
58
|
+
RichText: 'richText',
|
|
59
|
+
FimoMediaImage: 'media',
|
|
60
|
+
FimoMediaVideo: 'media',
|
|
61
|
+
FimoFileLink: 'files',
|
|
62
|
+
};
|
|
63
|
+
export function scanWrappableFile(filePath, code, relPath) {
|
|
64
|
+
const items = [];
|
|
65
|
+
let wrapped = 0;
|
|
66
|
+
const wrappedIds = { labels: [], media: [], richText: [], files: [] };
|
|
67
|
+
let ast;
|
|
68
|
+
try {
|
|
69
|
+
ast = parseSync(filePath, code).program;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { items, wrapped, wrappedIds };
|
|
73
|
+
}
|
|
74
|
+
function visit(node, insideWrapped) {
|
|
75
|
+
if (!node || typeof node !== 'object') {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
let childInsideWrapped = insideWrapped;
|
|
79
|
+
if (node.type === 'JSXElement') {
|
|
80
|
+
const name = jsxElementName(node);
|
|
81
|
+
if (name && WRAPPING_COMPONENTS.has(name)) {
|
|
82
|
+
wrapped += 1;
|
|
83
|
+
childInsideWrapped = true;
|
|
84
|
+
const kind = WRAPPED_ID_KIND[name];
|
|
85
|
+
const id = attributeValue(node, 'id');
|
|
86
|
+
if (kind && id) {
|
|
87
|
+
wrappedIds[kind].push(id.value);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!insideWrapped && name) {
|
|
91
|
+
if (IMAGE_ELEMENTS.has(name)) {
|
|
92
|
+
const src = attributeValue(node, 'src');
|
|
93
|
+
if (src) {
|
|
94
|
+
items.push({ kind: 'image', file: relPath, line: lineOf(code, src.start), preview: toPreview(src.value) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (VIDEO_ELEMENTS.has(name)) {
|
|
98
|
+
const src = attributeValue(node, 'src');
|
|
99
|
+
if (src) {
|
|
100
|
+
items.push({ kind: 'video', file: relPath, line: lineOf(code, src.start), preview: toPreview(src.value) });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (name === 'a') {
|
|
104
|
+
const href = attributeValue(node, 'href');
|
|
105
|
+
if (href && FILE_LINK_PATTERN.test(href.value)) {
|
|
106
|
+
items.push({
|
|
107
|
+
kind: 'file-link',
|
|
108
|
+
file: relPath,
|
|
109
|
+
line: lineOf(code, href.start),
|
|
110
|
+
preview: toPreview(href.value),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const attribute of WRAPPABLE_ATTRIBUTES) {
|
|
115
|
+
const value = attributeValue(node, attribute);
|
|
116
|
+
if (value) {
|
|
117
|
+
items.push({
|
|
118
|
+
kind: 'attribute',
|
|
119
|
+
file: relPath,
|
|
120
|
+
line: lineOf(code, value.start),
|
|
121
|
+
preview: `${attribute}: ${toPreview(value.value)}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (node.type === 'JSXText' && !insideWrapped) {
|
|
128
|
+
const text = String(node.value ?? '');
|
|
129
|
+
const meaningful = text.replace(/[^\p{L}\p{N}]/gu, '');
|
|
130
|
+
if (meaningful.length >= MIN_TEXT_LENGTH) {
|
|
131
|
+
items.push({ kind: 'text', file: relPath, line: lineOf(code, node.start ?? 0), preview: toPreview(text) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// t('key') calls count as wrapped content.
|
|
135
|
+
if (node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 't') {
|
|
136
|
+
wrapped += 1;
|
|
137
|
+
const firstArg = node.arguments?.[0];
|
|
138
|
+
if (firstArg?.type === 'Literal' && typeof firstArg.value === 'string') {
|
|
139
|
+
wrappedIds.labels.push(firstArg.value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const key of Object.keys(node)) {
|
|
143
|
+
const child = node[key];
|
|
144
|
+
if (Array.isArray(child)) {
|
|
145
|
+
for (const item of child) {
|
|
146
|
+
visit(item, childInsideWrapped);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if (typeof child === 'object' && child !== null) {
|
|
150
|
+
visit(child, childInsideWrapped);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
visit(ast, false);
|
|
155
|
+
return { items, wrapped, wrappedIds };
|
|
156
|
+
}
|
|
157
|
+
function collectSourceFiles(dir, out = []) {
|
|
158
|
+
if (!existsSync(dir)) {
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
for (const entry of readdirSync(dir)) {
|
|
162
|
+
if (SKIP_DIRS.has(entry)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const full = join(dir, entry);
|
|
166
|
+
let stats;
|
|
167
|
+
try {
|
|
168
|
+
stats = statSync(full);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (stats.isDirectory()) {
|
|
174
|
+
collectSourceFiles(full, out);
|
|
175
|
+
}
|
|
176
|
+
else if (/\.(tsx|jsx)$/.test(entry)) {
|
|
177
|
+
out.push(full);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Scan a project's source directories for unwrapped content. `dirs` defaults
|
|
184
|
+
* to the common Next/React layouts that exist under `rootDir`.
|
|
185
|
+
*/
|
|
186
|
+
export function scanWrappable(rootDir, opts = {}) {
|
|
187
|
+
const dirs = (opts.dirs ?? ['app', 'src', 'pages', 'components']).filter((dir) => existsSync(join(rootDir, dir)));
|
|
188
|
+
const files = new Set();
|
|
189
|
+
for (const dir of dirs) {
|
|
190
|
+
for (const file of collectSourceFiles(join(rootDir, dir))) {
|
|
191
|
+
files.add(file);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const items = [];
|
|
195
|
+
let wrappedCount = 0;
|
|
196
|
+
const idSets = {
|
|
197
|
+
labels: new Set(),
|
|
198
|
+
media: new Set(),
|
|
199
|
+
richText: new Set(),
|
|
200
|
+
files: new Set(),
|
|
201
|
+
};
|
|
202
|
+
for (const file of files) {
|
|
203
|
+
let code;
|
|
204
|
+
try {
|
|
205
|
+
code = readFileSync(file, 'utf8');
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const result = scanWrappableFile(file, code, relative(rootDir, file));
|
|
211
|
+
items.push(...result.items);
|
|
212
|
+
wrappedCount += result.wrapped;
|
|
213
|
+
for (const kind of ['labels', 'media', 'richText', 'files']) {
|
|
214
|
+
for (const id of result.wrappedIds[kind]) {
|
|
215
|
+
idSets[kind].add(id);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
items.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
220
|
+
return {
|
|
221
|
+
items,
|
|
222
|
+
wrappedCount,
|
|
223
|
+
filesScanned: files.size,
|
|
224
|
+
wrappedIds: {
|
|
225
|
+
labels: [...idSets.labels].sort(),
|
|
226
|
+
media: [...idSets.media].sort(),
|
|
227
|
+
richText: [...idSets.richText].sort(),
|
|
228
|
+
files: [...idSets.files].sort(),
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { scanWrappableFile } from './scan-wrappable.js';
|
|
3
|
+
const scan = (code) => scanWrappableFile('/test/page.tsx', code, 'app/page.tsx');
|
|
4
|
+
describe('scanWrappableFile', () => {
|
|
5
|
+
it('finds hardcoded JSX text with its line', () => {
|
|
6
|
+
const { items } = scan(`export default function Page() {
|
|
7
|
+
return (
|
|
8
|
+
<div>
|
|
9
|
+
<h1>Ship your site in hours</h1>
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}`);
|
|
13
|
+
expect(items).toEqual([{ kind: 'text', file: 'app/page.tsx', line: 4, preview: 'Ship your site in hours' }]);
|
|
14
|
+
});
|
|
15
|
+
it('skips whitespace and short fragments', () => {
|
|
16
|
+
const { items } = scan(`const x = <div> {'a'} <span>ok</span> </div>;`);
|
|
17
|
+
expect(items.filter((i) => i.kind === 'text')).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
it('finds images, videos, file links, and attribute strings', () => {
|
|
20
|
+
const { items } = scan(`const x = (
|
|
21
|
+
<div>
|
|
22
|
+
<img src="/images/hero.png" alt="Hero" />
|
|
23
|
+
<video src="/demo.mp4" />
|
|
24
|
+
<a href="/whitepaper.pdf">Download</a>
|
|
25
|
+
<input placeholder="Your work email" />
|
|
26
|
+
</div>
|
|
27
|
+
);`);
|
|
28
|
+
expect(items.map((i) => i.kind).sort()).toEqual(['attribute', 'file-link', 'image', 'text', 'video']);
|
|
29
|
+
expect(items.find((i) => i.kind === 'attribute')?.preview).toBe('placeholder: Your work email');
|
|
30
|
+
});
|
|
31
|
+
it('treats content inside Fimo wrappers as wrapped', () => {
|
|
32
|
+
const { items, wrapped } = scan(`const x = (
|
|
33
|
+
<div>
|
|
34
|
+
<FimoText id="home.hero.title">Ship your site in hours</FimoText>
|
|
35
|
+
<FimoMediaImage id="home.hero.image" src="/images/hero.png" alt="Hero" />
|
|
36
|
+
</div>
|
|
37
|
+
);`);
|
|
38
|
+
expect(items).toEqual([]);
|
|
39
|
+
expect(wrapped).toBe(2);
|
|
40
|
+
});
|
|
41
|
+
it('collects wrapped ids grouped by kind', () => {
|
|
42
|
+
const { wrappedIds } = scan(`const x = (
|
|
43
|
+
<div>
|
|
44
|
+
<FimoText id="home.hero.title">Title</FimoText>
|
|
45
|
+
<FimoRichTextBlock id="home.hero.body">Body</FimoRichTextBlock>
|
|
46
|
+
<FimoMediaImage id="home.hero.image" src="/a.png" />
|
|
47
|
+
<FimoFileLink id="home.hero.pdf" href="/a.pdf">PDF</FimoFileLink>
|
|
48
|
+
<input placeholder={String(t('home.hero.email', 'Email'))} />
|
|
49
|
+
</div>
|
|
50
|
+
);`);
|
|
51
|
+
expect(wrappedIds).toEqual({
|
|
52
|
+
labels: ['home.hero.title', 'home.hero.email'],
|
|
53
|
+
media: ['home.hero.image'],
|
|
54
|
+
richText: ['home.hero.body'],
|
|
55
|
+
files: ['home.hero.pdf'],
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
it('counts t() calls as wrapped and skips regular links', () => {
|
|
59
|
+
const { items, wrapped } = scan(`const x = <input placeholder={String(t('home.email', 'Your email'))} />;
|
|
60
|
+
const y = <a href="/about">About</a>;`);
|
|
61
|
+
expect(items.filter((i) => i.kind === 'attribute')).toEqual([]);
|
|
62
|
+
expect(items.filter((i) => i.kind === 'file-link')).toEqual([]);
|
|
63
|
+
expect(wrapped).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -31,6 +31,17 @@ export function validateRouteMetadata(options = {}) {
|
|
|
31
31
|
const srcDir = path.resolve(cwd, 'src');
|
|
32
32
|
const routesFilePath = path.resolve(srcDir, 'routes.ts');
|
|
33
33
|
const errors = [];
|
|
34
|
+
// Everything below the i18n checks cross-validates React Router's
|
|
35
|
+
// src/routes.ts. Adopted (wrapped) projects on another framework declare it
|
|
36
|
+
// in fimo-config.json — their routes come from framework scans, so only the
|
|
37
|
+
// framework-neutral config checks apply.
|
|
38
|
+
const earlyConfig = readJson(configFilePath);
|
|
39
|
+
const framework = typeof earlyConfig.framework === 'string' ? earlyConfig.framework : 'react-router';
|
|
40
|
+
if (framework !== 'react-router') {
|
|
41
|
+
const i18n = validateI18nConfig(earlyConfig);
|
|
42
|
+
const configRoutes = loadConfigRoutes(earlyConfig);
|
|
43
|
+
return { errors: i18n.errors, routesChecked: configRoutes.length };
|
|
44
|
+
}
|
|
34
45
|
// Parse routes from src/routes.ts (file → path mapping)
|
|
35
46
|
let fileRoutes = [];
|
|
36
47
|
if (!fs.existsSync(routesFilePath)) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface SchemaFileError {
|
|
2
|
+
rule: string;
|
|
3
|
+
message: string;
|
|
4
|
+
file: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ValidateSchemasResult {
|
|
7
|
+
errors: SchemaFileError[];
|
|
8
|
+
schemasChecked: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function validateSchemas(options?: {
|
|
11
|
+
cwd?: string;
|
|
12
|
+
}): ValidateSchemasResult;
|
|
13
|
+
export declare function validateSchemaFile(absolutePath: string, file: string): SchemaFileError[];
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, join, resolve } from 'node:path';
|
|
3
|
+
import { FieldTypeSchema, RESERVED_CONTENT_FIELD_NAMES } from '@fimo/api-contracts';
|
|
4
|
+
/**
|
|
5
|
+
* Validate `src/schemas/*.json` locally against the same rules the API's
|
|
6
|
+
* schema validator enforces (field types, reserved field names, required
|
|
7
|
+
* uid/name). Catching these in `fimo validate` surfaces the real problem
|
|
8
|
+
* before a `schemas push` can fail with a less direct server error.
|
|
9
|
+
*/
|
|
10
|
+
const SCHEMAS_REL = 'src/schemas';
|
|
11
|
+
const FIELD_TYPES = new Set(FieldTypeSchema.options);
|
|
12
|
+
const RESERVED_FIELD_NAMES = new Set(RESERVED_CONTENT_FIELD_NAMES);
|
|
13
|
+
export function validateSchemas(options = {}) {
|
|
14
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
15
|
+
const schemasDir = join(cwd, SCHEMAS_REL);
|
|
16
|
+
if (!existsSync(schemasDir)) {
|
|
17
|
+
return { errors: [], schemasChecked: 0 };
|
|
18
|
+
}
|
|
19
|
+
const files = readdirSync(schemasDir)
|
|
20
|
+
.filter((f) => f.endsWith('.json'))
|
|
21
|
+
.sort();
|
|
22
|
+
const errors = [];
|
|
23
|
+
for (const fileName of files) {
|
|
24
|
+
const file = join(SCHEMAS_REL, fileName);
|
|
25
|
+
errors.push(...validateSchemaFile(join(schemasDir, fileName), file));
|
|
26
|
+
}
|
|
27
|
+
return { errors, schemasChecked: files.length };
|
|
28
|
+
}
|
|
29
|
+
export function validateSchemaFile(absolutePath, file) {
|
|
30
|
+
let schema;
|
|
31
|
+
try {
|
|
32
|
+
schema = JSON.parse(readFileSync(absolutePath, 'utf-8'));
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
rule: 'schemas/parse',
|
|
38
|
+
message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
39
|
+
file,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
|
|
44
|
+
return [{ rule: 'schemas/shape', message: 'Schema must be a JSON object.', file }];
|
|
45
|
+
}
|
|
46
|
+
const errors = [];
|
|
47
|
+
const { uid, name, fields } = schema;
|
|
48
|
+
const expectedUid = basename(file, '.json');
|
|
49
|
+
if (typeof uid !== 'string' || uid.length === 0) {
|
|
50
|
+
errors.push({
|
|
51
|
+
rule: 'schemas/uid',
|
|
52
|
+
message: `Missing "uid". Set it to the file name: "${expectedUid}".`,
|
|
53
|
+
file,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
else if (uid !== expectedUid) {
|
|
57
|
+
errors.push({
|
|
58
|
+
rule: 'schemas/uid',
|
|
59
|
+
message: `"uid" is "${uid}" but the file is ${expectedUid}.json — they must match.`,
|
|
60
|
+
file,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
64
|
+
errors.push({
|
|
65
|
+
rule: 'schemas/name',
|
|
66
|
+
message: 'Missing "name" (the human-readable label).',
|
|
67
|
+
file,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
|
|
71
|
+
errors.push({
|
|
72
|
+
rule: 'schemas/fields',
|
|
73
|
+
message: 'Missing "fields" object.',
|
|
74
|
+
file,
|
|
75
|
+
});
|
|
76
|
+
return errors;
|
|
77
|
+
}
|
|
78
|
+
for (const [fieldName, definition] of Object.entries(fields)) {
|
|
79
|
+
if (RESERVED_FIELD_NAMES.has(fieldName)) {
|
|
80
|
+
errors.push({
|
|
81
|
+
rule: 'schemas/reserved-field',
|
|
82
|
+
message: `Field "${fieldName}" is reserved entry metadata — the platform manages it automatically. ` +
|
|
83
|
+
`Remove it from the schema${fieldName === 'slug' ? ' (set slug inside entry data on create instead)' : ''}.`,
|
|
84
|
+
file,
|
|
85
|
+
});
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (typeof definition !== 'object' || definition === null || Array.isArray(definition)) {
|
|
89
|
+
errors.push({
|
|
90
|
+
rule: 'schemas/field-shape',
|
|
91
|
+
message: `Field "${fieldName}" must be an object like { "type": "string" }.`,
|
|
92
|
+
file,
|
|
93
|
+
});
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const { type } = definition;
|
|
97
|
+
if (typeof type !== 'string' || !FIELD_TYPES.has(type)) {
|
|
98
|
+
errors.push({
|
|
99
|
+
rule: 'schemas/field-type',
|
|
100
|
+
message: `Field "${fieldName}" has unsupported type "${String(type)}". Allowed: ${[...FIELD_TYPES].join(', ')}.`,
|
|
101
|
+
file,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if ('i18n' in definition) {
|
|
105
|
+
errors.push({
|
|
106
|
+
rule: 'schemas/field-i18n',
|
|
107
|
+
message: `Field "${fieldName}": field-level locale settings ("i18n") are not supported.`,
|
|
108
|
+
file,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return errors;
|
|
113
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
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 { validateSchemas } from './validate-schemas';
|
|
6
|
+
describe('validateSchemas', () => {
|
|
7
|
+
let scratch = null;
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
if (scratch) {
|
|
10
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
11
|
+
scratch = null;
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
function writeProject(schemas) {
|
|
15
|
+
scratch = mkdtempSync(join(tmpdir(), 'validate-schemas-'));
|
|
16
|
+
mkdirSync(join(scratch, 'src/schemas'), { recursive: true });
|
|
17
|
+
for (const [fileName, schema] of Object.entries(schemas)) {
|
|
18
|
+
writeFileSync(join(scratch, 'src/schemas', fileName), typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2));
|
|
19
|
+
}
|
|
20
|
+
return scratch;
|
|
21
|
+
}
|
|
22
|
+
it('accepts a valid schema', () => {
|
|
23
|
+
const cwd = writeProject({
|
|
24
|
+
'BlogPost.json': {
|
|
25
|
+
uid: 'BlogPost',
|
|
26
|
+
name: 'Blog Posts',
|
|
27
|
+
fields: {
|
|
28
|
+
title: { type: 'string' },
|
|
29
|
+
body: { type: 'richtext' },
|
|
30
|
+
coverImage: { type: 'media' },
|
|
31
|
+
featured: { type: 'boolean' },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
expect(validateSchemas({ cwd })).toEqual({ errors: [], schemasChecked: 1 });
|
|
36
|
+
});
|
|
37
|
+
it('returns no errors when the schemas directory does not exist', () => {
|
|
38
|
+
scratch = mkdtempSync(join(tmpdir(), 'validate-schemas-'));
|
|
39
|
+
expect(validateSchemas({ cwd: scratch })).toEqual({ errors: [], schemasChecked: 0 });
|
|
40
|
+
});
|
|
41
|
+
it('rejects reserved field names like slug', () => {
|
|
42
|
+
const cwd = writeProject({
|
|
43
|
+
'TeamMember.json': {
|
|
44
|
+
uid: 'TeamMember',
|
|
45
|
+
name: 'Team Members',
|
|
46
|
+
fields: {
|
|
47
|
+
name: { type: 'string' },
|
|
48
|
+
slug: { type: 'string' },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const { errors } = validateSchemas({ cwd });
|
|
53
|
+
expect(errors).toHaveLength(1);
|
|
54
|
+
expect(errors[0]).toMatchObject({ rule: 'schemas/reserved-field', file: 'src/schemas/TeamMember.json' });
|
|
55
|
+
expect(errors[0].message).toContain('"slug" is reserved');
|
|
56
|
+
expect(errors[0].message).toContain('entry data');
|
|
57
|
+
});
|
|
58
|
+
it('rejects every reserved metadata name', () => {
|
|
59
|
+
const cwd = writeProject({
|
|
60
|
+
'Bad.json': {
|
|
61
|
+
uid: 'Bad',
|
|
62
|
+
name: 'Bad',
|
|
63
|
+
fields: Object.fromEntries(['id', 'documentId', 'slug', 'locale', 'createdAt', 'updatedAt', '__fimo'].map((name) => [
|
|
64
|
+
name,
|
|
65
|
+
{ type: 'string' },
|
|
66
|
+
])),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const { errors } = validateSchemas({ cwd });
|
|
70
|
+
expect(errors).toHaveLength(7);
|
|
71
|
+
expect(errors.every((err) => err.rule === 'schemas/reserved-field')).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
it('rejects unsupported field types', () => {
|
|
74
|
+
const cwd = writeProject({
|
|
75
|
+
'Product.json': {
|
|
76
|
+
uid: 'Product',
|
|
77
|
+
name: 'Products',
|
|
78
|
+
fields: { price: { type: 'decimal' } },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const { errors } = validateSchemas({ cwd });
|
|
82
|
+
expect(errors).toHaveLength(1);
|
|
83
|
+
expect(errors[0]).toMatchObject({ rule: 'schemas/field-type' });
|
|
84
|
+
expect(errors[0].message).toContain('"decimal"');
|
|
85
|
+
expect(errors[0].message).toContain('string, richtext, media');
|
|
86
|
+
});
|
|
87
|
+
it('rejects field-level i18n settings', () => {
|
|
88
|
+
const cwd = writeProject({
|
|
89
|
+
'Page.json': {
|
|
90
|
+
uid: 'Page',
|
|
91
|
+
name: 'Pages',
|
|
92
|
+
fields: { title: { type: 'string', i18n: true } },
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
const { errors } = validateSchemas({ cwd });
|
|
96
|
+
expect(errors).toHaveLength(1);
|
|
97
|
+
expect(errors[0]).toMatchObject({ rule: 'schemas/field-i18n' });
|
|
98
|
+
});
|
|
99
|
+
it('rejects a uid that does not match the file name', () => {
|
|
100
|
+
const cwd = writeProject({
|
|
101
|
+
'TeamMember.json': {
|
|
102
|
+
uid: 'Member',
|
|
103
|
+
name: 'Team Members',
|
|
104
|
+
fields: { name: { type: 'string' } },
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const { errors } = validateSchemas({ cwd });
|
|
108
|
+
expect(errors).toHaveLength(1);
|
|
109
|
+
expect(errors[0]).toMatchObject({ rule: 'schemas/uid' });
|
|
110
|
+
});
|
|
111
|
+
it('rejects missing uid, name, and fields', () => {
|
|
112
|
+
const cwd = writeProject({ 'Empty.json': {} });
|
|
113
|
+
const { errors } = validateSchemas({ cwd });
|
|
114
|
+
expect(errors.map((err) => err.rule)).toEqual(['schemas/uid', 'schemas/name', 'schemas/fields']);
|
|
115
|
+
});
|
|
116
|
+
it('reports invalid JSON', () => {
|
|
117
|
+
const cwd = writeProject({ 'Broken.json': '{ not json' });
|
|
118
|
+
const { errors } = validateSchemas({ cwd });
|
|
119
|
+
expect(errors).toHaveLength(1);
|
|
120
|
+
expect(errors[0]).toMatchObject({ rule: 'schemas/parse' });
|
|
121
|
+
});
|
|
122
|
+
it('validates multiple files and counts them', () => {
|
|
123
|
+
const cwd = writeProject({
|
|
124
|
+
'Good.json': { uid: 'Good', name: 'Good', fields: { title: { type: 'string' } } },
|
|
125
|
+
'Bad.json': { uid: 'Bad', name: 'Bad', fields: { slug: { type: 'string' } } },
|
|
126
|
+
});
|
|
127
|
+
const result = validateSchemas({ cwd });
|
|
128
|
+
expect(result.schemasChecked).toBe(2);
|
|
129
|
+
expect(result.errors).toHaveLength(1);
|
|
130
|
+
expect(result.errors[0].file).toBe('src/schemas/Bad.json');
|
|
131
|
+
});
|
|
132
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fimo",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2-experimental.1783354419249",
|
|
4
4
|
"description": "Fimo CLI - create, deploy, and manage Fimo projects",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fimo": "dist/cli/index.js"
|
|
@@ -15,10 +15,23 @@
|
|
|
15
15
|
],
|
|
16
16
|
"type": "module",
|
|
17
17
|
"exports": {
|
|
18
|
+
"./package.json": "./package.json",
|
|
18
19
|
"./ui": {
|
|
19
20
|
"types": "./dist/runtime/index.d.ts",
|
|
20
21
|
"import": "./dist/runtime/index.js"
|
|
21
22
|
},
|
|
23
|
+
"./react": {
|
|
24
|
+
"types": "./dist/runtime/react/index.d.ts",
|
|
25
|
+
"import": "./dist/runtime/react/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./next": {
|
|
28
|
+
"types": "./dist/runtime/next/index.d.ts",
|
|
29
|
+
"import": "./dist/runtime/next/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./next/server": {
|
|
32
|
+
"types": "./dist/runtime/next/server.d.ts",
|
|
33
|
+
"import": "./dist/runtime/next/server.js"
|
|
34
|
+
},
|
|
22
35
|
"./react-router": {
|
|
23
36
|
"types": "./dist/runtime/react-router/index.d.ts",
|
|
24
37
|
"import": "./dist/runtime/react-router/index.js"
|
|
@@ -96,11 +109,26 @@
|
|
|
96
109
|
},
|
|
97
110
|
"peerDependencies": {
|
|
98
111
|
"@tanstack/react-query": "^5.80.6",
|
|
112
|
+
"next": ">=14.0.0",
|
|
99
113
|
"react": "^19.0.0",
|
|
100
114
|
"react-dom": "^19.0.0",
|
|
101
115
|
"react-router": "^7.0.0",
|
|
102
116
|
"vite": "^7.0.0 || ^8.0.0"
|
|
103
117
|
},
|
|
118
|
+
"peerDependenciesMeta": {
|
|
119
|
+
"@tanstack/react-query": {
|
|
120
|
+
"optional": true
|
|
121
|
+
},
|
|
122
|
+
"next": {
|
|
123
|
+
"optional": true
|
|
124
|
+
},
|
|
125
|
+
"react-router": {
|
|
126
|
+
"optional": true
|
|
127
|
+
},
|
|
128
|
+
"vite": {
|
|
129
|
+
"optional": true
|
|
130
|
+
}
|
|
131
|
+
},
|
|
104
132
|
"engines": {
|
|
105
133
|
"node": ">=20.12.0"
|
|
106
134
|
}
|
package/release.json
CHANGED