fimo 0.4.0-experimental.4 → 0.4.0-staging.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.
- package/assets/content-templates/hooks-template.eta +66 -27
- package/assets/content-templates/types-template.eta +34 -15
- package/assets/skills/fimo/references/content.md +52 -35
- package/assets/skills/fimo-cli/references/content.md +12 -8
- package/assets/skills/fimo-migration/SKILL.md +1 -1
- package/assets/skills/fimo-migration/references/content.md +4 -4
- package/assets/skills/fimo-migration/references/import-contract.md +1 -1
- package/assets/skills/fimo-migration/references/manifest.md +1 -1
- package/assets/skills/fimo-migration/references/platforms.md +1 -1
- package/assets/skills/fimo-migration/references/workflow.md +1 -1
- package/assets/skills/fimo-migration/scripts/build-canonical-manifest.mjs +2 -2
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +121 -23
- package/dist/runtime/primitives/index.d.ts +2 -2
- package/dist/runtime/primitives/index.d.ts.map +1 -1
- package/dist/runtime/primitives/lib/index.d.ts +2 -2
- package/dist/runtime/primitives/lib/index.d.ts.map +1 -1
- package/dist/runtime/primitives/lib/primitives.d.ts +5 -0
- package/dist/runtime/primitives/lib/primitives.d.ts.map +1 -1
- package/dist/runtime/primitives/lib/query.d.ts +12 -12
- package/dist/runtime/primitives/lib/query.d.ts.map +1 -1
- package/dist/runtime/primitives/public-surface.test.js +4 -4
- package/dist/scripts/generated-types.test.d.ts +1 -0
- package/dist/scripts/generated-types.test.js +172 -0
- package/dist/scripts/sync-schemas.js +25 -6
- package/dist/scripts/sync-schemas.test.js +10 -10
- package/dist/scripts/validate-schemas.js +48 -13
- package/dist/scripts/validate-schemas.test.js +13 -13
- package/package.json +1 -1
- package/scripts/lib/release-core.mjs +24 -0
- package/scripts/lib/release-core.test.ts +37 -2
- package/scripts/publish-npm.mjs +4 -9
- package/templates/react-router/package.json +1 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { afterAll, describe, expect, it } from 'vitest';
|
|
8
|
+
import { syncSchemas } from './sync-schemas.js';
|
|
9
|
+
/**
|
|
10
|
+
* The generated-types gate: generate hooks + types from fixture schemas that
|
|
11
|
+
* cover every field type and reference shape, add usage code written the way
|
|
12
|
+
* an agent writes site code, and run the real TypeScript compiler over the
|
|
13
|
+
* output. A template regression fails this test — never a user project.
|
|
14
|
+
*/
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
17
|
+
const runtimeBarrel = resolve(here, '../runtime/index.ts');
|
|
18
|
+
const tscBin = requireFromHere.resolve('typescript/bin/tsc');
|
|
19
|
+
const fixtureRoot = mkdtempSync(join(tmpdir(), 'fimo-generated-types-'));
|
|
20
|
+
const schemasDir = join(fixtureRoot, 'src', 'schemas');
|
|
21
|
+
const SCHEMAS = {
|
|
22
|
+
Author: {
|
|
23
|
+
uid: 'Author',
|
|
24
|
+
name: 'Author',
|
|
25
|
+
i18n: { enabled: true },
|
|
26
|
+
fields: {
|
|
27
|
+
name: { type: 'string', required: true },
|
|
28
|
+
bio: { type: 'richtext' },
|
|
29
|
+
avatar: { type: 'media' },
|
|
30
|
+
active: { type: 'boolean' },
|
|
31
|
+
bornAt: { type: 'date' },
|
|
32
|
+
meta: { type: 'json' },
|
|
33
|
+
rating: { type: 'number' },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
Video: {
|
|
37
|
+
uid: 'Video',
|
|
38
|
+
name: 'Video',
|
|
39
|
+
i18n: { enabled: false },
|
|
40
|
+
fields: {
|
|
41
|
+
title: { type: 'string', required: true },
|
|
42
|
+
clips: { type: 'media', multiple: true },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
Post: {
|
|
46
|
+
uid: 'Post',
|
|
47
|
+
name: 'Post',
|
|
48
|
+
i18n: { enabled: true },
|
|
49
|
+
fields: {
|
|
50
|
+
title: { type: 'string', required: true },
|
|
51
|
+
author: { type: 'reference', target: 'Author' },
|
|
52
|
+
contributors: { type: 'reference', target: 'Author', many: true },
|
|
53
|
+
related: { type: 'reference', target: 'Post', many: true },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
Page: {
|
|
57
|
+
uid: 'Page',
|
|
58
|
+
name: 'Page',
|
|
59
|
+
i18n: { enabled: true },
|
|
60
|
+
fields: {
|
|
61
|
+
title: { type: 'string' },
|
|
62
|
+
featured: { type: 'reference', targets: ['Post', 'Video'], many: true },
|
|
63
|
+
hero: { type: 'reference', targets: ['Author', 'Video'] },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
SiteSettings: {
|
|
67
|
+
uid: 'SiteSettings',
|
|
68
|
+
name: 'SiteSettings',
|
|
69
|
+
isSingleton: true,
|
|
70
|
+
i18n: { enabled: false },
|
|
71
|
+
fields: {
|
|
72
|
+
siteName: { type: 'string', required: true },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const USAGE = `// Agent-style usage of the generated types — compile-time contract.
|
|
77
|
+
import type { PageResult, PageWhere } from "./Page";
|
|
78
|
+
import type { PostResult } from "./Post";
|
|
79
|
+
|
|
80
|
+
export function renderFeatured(page: PageResult<undefined, readonly ["featured", "hero"]>): string[] {
|
|
81
|
+
const out: string[] = [];
|
|
82
|
+
for (const item of page.featured) {
|
|
83
|
+
switch (item.$type) {
|
|
84
|
+
case "Post":
|
|
85
|
+
out.push(\`post \${String(item.title)} by \${item.author?.documentId ?? "nobody"}\`);
|
|
86
|
+
break;
|
|
87
|
+
case "Video":
|
|
88
|
+
out.push(\`video \${String(item.title)} with \${item.clips.length} clip(s)\`);
|
|
89
|
+
break;
|
|
90
|
+
default: {
|
|
91
|
+
const exhaustive: never = item;
|
|
92
|
+
return exhaustive;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const hero = page.hero;
|
|
97
|
+
if (hero && hero.$type === "Author") {
|
|
98
|
+
out.push(\`hero \${String(hero.name)}\`);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function renderPost(post: PostResult<undefined, readonly ["author", "contributors"]>): string {
|
|
104
|
+
const contributors = post.contributors.map((author) => String(author.name)).join(", ");
|
|
105
|
+
return \`\${String(post.title)} — \${post.author ? String(post.author.name) : "anonymous"} (\${contributors})\`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function whereContract(): PageWhere {
|
|
109
|
+
return {
|
|
110
|
+
title: "Home",
|
|
111
|
+
// @ts-expect-error filtering on polymorphic reference fields is not supported
|
|
112
|
+
featured: "some-document-id",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
`;
|
|
116
|
+
const SHIMS = `declare module "virtual:fimo-config" {
|
|
117
|
+
const config: Record<string, unknown>;
|
|
118
|
+
export default config;
|
|
119
|
+
}
|
|
120
|
+
declare module "virtual:translations" {
|
|
121
|
+
export const dictionaries: Record<string, Record<string, string>>;
|
|
122
|
+
export const defaultLocale: string;
|
|
123
|
+
export const signature: string;
|
|
124
|
+
export const updatedAt: string;
|
|
125
|
+
}
|
|
126
|
+
declare module "*.css" {}
|
|
127
|
+
`;
|
|
128
|
+
afterAll(() => {
|
|
129
|
+
rmSync(fixtureRoot, { recursive: true, force: true });
|
|
130
|
+
});
|
|
131
|
+
describe('generated types gate', () => {
|
|
132
|
+
it('generates hooks + types for every field shape and the output typechecks with agent-style usage', () => {
|
|
133
|
+
mkdirSync(schemasDir, { recursive: true });
|
|
134
|
+
// Real react-query types: the fixture resolves them like a real project.
|
|
135
|
+
const reactQueryDir = dirname(requireFromHere.resolve('@tanstack/react-query/package.json'));
|
|
136
|
+
mkdirSync(join(fixtureRoot, 'node_modules', '@tanstack'), { recursive: true });
|
|
137
|
+
symlinkSync(reactQueryDir, join(fixtureRoot, 'node_modules', '@tanstack', 'react-query'));
|
|
138
|
+
for (const [uid, schema] of Object.entries(SCHEMAS)) {
|
|
139
|
+
writeFileSync(join(schemasDir, `${uid}.json`), JSON.stringify(schema, null, 2));
|
|
140
|
+
}
|
|
141
|
+
const result = syncSchemas({ cwd: fixtureRoot, verbose: false });
|
|
142
|
+
expect(result.created).toBeGreaterThanOrEqual(Object.keys(SCHEMAS).length * 2);
|
|
143
|
+
writeFileSync(join(schemasDir, 'usage.ts'), USAGE);
|
|
144
|
+
writeFileSync(join(fixtureRoot, 'shims.d.ts'), SHIMS);
|
|
145
|
+
writeFileSync(join(fixtureRoot, 'tsconfig.json'), JSON.stringify({
|
|
146
|
+
compilerOptions: {
|
|
147
|
+
strict: true,
|
|
148
|
+
noEmit: true,
|
|
149
|
+
skipLibCheck: true,
|
|
150
|
+
target: 'ES2022',
|
|
151
|
+
module: 'ESNext',
|
|
152
|
+
moduleResolution: 'bundler',
|
|
153
|
+
jsx: 'react-jsx',
|
|
154
|
+
allowImportingTsExtensions: true,
|
|
155
|
+
baseUrl: '.',
|
|
156
|
+
paths: {
|
|
157
|
+
'fimo/ui': [runtimeBarrel],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
include: ['src/**/*.ts', 'shims.d.ts'],
|
|
161
|
+
}, null, 2));
|
|
162
|
+
let output = '';
|
|
163
|
+
try {
|
|
164
|
+
output = execFileSync(process.execPath, [tscBin, '-p', fixtureRoot], { encoding: 'utf8' });
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const failure = error;
|
|
168
|
+
throw new Error(`generated types failed to typecheck:\n${failure.stdout ?? ''}${failure.stderr ?? ''}`);
|
|
169
|
+
}
|
|
170
|
+
expect(output.trim()).toBe('');
|
|
171
|
+
}, 120_000);
|
|
172
|
+
});
|
|
@@ -35,6 +35,9 @@ function normalizeFields(fields) {
|
|
|
35
35
|
}
|
|
36
36
|
const field = value;
|
|
37
37
|
const target = typeof field.target === 'string' ? field.target : undefined;
|
|
38
|
+
const targets = Array.isArray(field.targets)
|
|
39
|
+
? field.targets.filter((candidate) => typeof candidate === 'string')
|
|
40
|
+
: undefined;
|
|
38
41
|
return [
|
|
39
42
|
key,
|
|
40
43
|
{
|
|
@@ -42,19 +45,29 @@ function normalizeFields(fields) {
|
|
|
42
45
|
type: typeof field.type === 'string' ? field.type : undefined,
|
|
43
46
|
target,
|
|
44
47
|
targetPascal: target ? pascalCase(target) : undefined,
|
|
48
|
+
targets: targets && targets.length > 0 ? targets : undefined,
|
|
49
|
+
targetRefs: targets && targets.length > 0
|
|
50
|
+
? targets.map((candidate) => ({ uid: candidate, pascal: pascalCase(candidate) }))
|
|
51
|
+
: undefined,
|
|
45
52
|
many: typeof field.many === 'boolean' ? field.many : undefined,
|
|
46
53
|
multiple: typeof field.multiple === 'boolean' ? field.multiple : undefined,
|
|
47
54
|
},
|
|
48
55
|
];
|
|
49
56
|
}));
|
|
50
57
|
}
|
|
51
|
-
function
|
|
58
|
+
function referenceImportsFor(schema, fields) {
|
|
52
59
|
const imports = new Map();
|
|
53
60
|
for (const field of Object.values(fields)) {
|
|
54
|
-
if (field.type !== '
|
|
61
|
+
if (field.type !== 'reference') {
|
|
55
62
|
continue;
|
|
56
63
|
}
|
|
57
|
-
|
|
64
|
+
const targets = field.targets ?? (field.target ? [field.target] : []);
|
|
65
|
+
for (const target of targets) {
|
|
66
|
+
if (target === schema.uid) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
imports.set(target, { uid: target, pascal: pascalCase(target) });
|
|
70
|
+
}
|
|
58
71
|
}
|
|
59
72
|
return [...imports.values()].sort((a, b) => a.pascal.localeCompare(b.pascal));
|
|
60
73
|
}
|
|
@@ -65,11 +78,17 @@ function schemaToModel(schema, localizedByUid) {
|
|
|
65
78
|
pascal: pascalCase(schema.uid),
|
|
66
79
|
isSingleton: schema.isSingleton ?? false,
|
|
67
80
|
localized: schema.i18n?.enabled !== false,
|
|
68
|
-
|
|
69
|
-
.filter(([, field]) =>
|
|
81
|
+
localizedReferenceFields: Object.entries(fields)
|
|
82
|
+
.filter(([, field]) => {
|
|
83
|
+
if (field.type !== 'reference') {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const targets = field.targets ?? (field.target ? [field.target] : []);
|
|
87
|
+
return targets.some((target) => localizedByUid.get(target) !== false);
|
|
88
|
+
})
|
|
70
89
|
.map(([fieldName]) => fieldName),
|
|
71
90
|
fields,
|
|
72
|
-
|
|
91
|
+
referenceImports: referenceImportsFor(schema, fields),
|
|
73
92
|
};
|
|
74
93
|
}
|
|
75
94
|
export function syncSchemas(options = {}) {
|
|
@@ -15,7 +15,7 @@ function writeSchema(name, schema) {
|
|
|
15
15
|
writeFileSync(join(scratch, 'src/schemas', `${name}.json`), JSON.stringify(schema, null, 2));
|
|
16
16
|
}
|
|
17
17
|
describe('syncSchemas', () => {
|
|
18
|
-
it('generates raw
|
|
18
|
+
it('generates raw references plus typed populated helpers', () => {
|
|
19
19
|
writeSchema('Author', {
|
|
20
20
|
uid: 'Author',
|
|
21
21
|
name: 'Authors',
|
|
@@ -28,9 +28,9 @@ describe('syncSchemas', () => {
|
|
|
28
28
|
name: 'Posts',
|
|
29
29
|
fields: {
|
|
30
30
|
title: { type: 'string' },
|
|
31
|
-
author: { type: '
|
|
32
|
-
contributors: { type: '
|
|
33
|
-
relatedPosts: { type: '
|
|
31
|
+
author: { type: 'reference', target: 'Author' },
|
|
32
|
+
contributors: { type: 'reference', target: 'Author', many: true },
|
|
33
|
+
relatedPosts: { type: 'reference', target: 'Post', many: true },
|
|
34
34
|
},
|
|
35
35
|
});
|
|
36
36
|
expect(syncSchemas({ cwd: scratch, verbose: false })).toEqual({ created: 4, updated: 0, deleted: 0 });
|
|
@@ -40,7 +40,7 @@ describe('syncSchemas', () => {
|
|
|
40
40
|
expect(post).toContain('import type { Post, PostInput, PostPopulate, PostResult, PostFields, PostQuery } from "./Post.types";');
|
|
41
41
|
expect(post).toContain('from "./Post.types";');
|
|
42
42
|
expect(post).not.toContain('import type { Author } from "./Author";');
|
|
43
|
-
expect(post).not.toContain('export interface Post extends
|
|
43
|
+
expect(post).not.toContain('export interface Post extends WithReferences<PostReferences>');
|
|
44
44
|
expect(post).not.toContain('type FimoReference');
|
|
45
45
|
expect(post).toContain('Promise<PostResult<undefined, TPopulate>>');
|
|
46
46
|
expect(post).toContain('TPopulate extends PostPopulate | undefined');
|
|
@@ -49,12 +49,12 @@ describe('syncSchemas', () => {
|
|
|
49
49
|
expect(post).toContain('export async function create(payload: PostInput): Promise<Post>');
|
|
50
50
|
expect(post).toContain('return wrapWithSource(data.data);');
|
|
51
51
|
expect(postTypes).toContain('import type { Author } from "./Author.types";');
|
|
52
|
-
expect(postTypes).toContain('export interface Post extends
|
|
52
|
+
expect(postTypes).toContain('export interface Post extends WithReferences<PostReferences>');
|
|
53
53
|
expect(postTypes).toContain('type FimoReference');
|
|
54
54
|
expect(postTypes).toContain('author: FimoReference | null;');
|
|
55
55
|
expect(postTypes).toContain('contributors: FimoReference[];');
|
|
56
56
|
expect(postTypes).toContain('relatedPosts: FimoReference[];');
|
|
57
|
-
expect(postTypes).toContain('export interface
|
|
57
|
+
expect(postTypes).toContain('export interface PostReferences');
|
|
58
58
|
expect(postTypes).toContain('export interface PostInput');
|
|
59
59
|
expect(postTypes).toContain('author?: FimoReference | null;');
|
|
60
60
|
expect(postTypes).toContain('author: Author | null;');
|
|
@@ -80,8 +80,8 @@ describe('syncSchemas', () => {
|
|
|
80
80
|
name: 'Settings',
|
|
81
81
|
i18n: { enabled: false },
|
|
82
82
|
fields: {
|
|
83
|
-
homepage: { type: '
|
|
84
|
-
category: { type: '
|
|
83
|
+
homepage: { type: 'reference', target: 'Homepage' },
|
|
84
|
+
category: { type: 'reference', target: 'Category' },
|
|
85
85
|
},
|
|
86
86
|
});
|
|
87
87
|
syncSchemas({ cwd: scratch, verbose: false });
|
|
@@ -92,7 +92,7 @@ describe('syncSchemas', () => {
|
|
|
92
92
|
expect(settingsTypes).not.toContain('locale?: string;');
|
|
93
93
|
expect(homepageTypes).toContain('locale: string;');
|
|
94
94
|
expect(settings).toContain('const LOCALIZED_CONTENT = false;');
|
|
95
|
-
expect(settings).toContain('const
|
|
95
|
+
expect(settings).toContain('const LOCALIZED_REFERENCE_FIELDS = new Set<string>(["homepage"]);');
|
|
96
96
|
expect(settings).not.toContain(' locale: entry.locale,');
|
|
97
97
|
expect(settings).toContain('readLocale(params, LOCALIZED_CONTENT, useQueryLocale(params.locale))');
|
|
98
98
|
expect(settings.match(/readLocale\(params, LOCALIZED_CONTENT, useQueryLocale\(params\.locale\)\)/g)?.length).toBe(2);
|
|
@@ -169,36 +169,71 @@ export function validateSchemaFile(absolutePath, file, schemaUids = new Set()) {
|
|
|
169
169
|
file,
|
|
170
170
|
});
|
|
171
171
|
}
|
|
172
|
-
if (type === '
|
|
173
|
-
const { target, many } = definition;
|
|
174
|
-
if (
|
|
172
|
+
if (type === 'reference') {
|
|
173
|
+
const { target, targets, many } = definition;
|
|
174
|
+
if (targets !== undefined && target !== undefined) {
|
|
175
175
|
errors.push({
|
|
176
|
-
rule: 'schemas/
|
|
177
|
-
message: `
|
|
176
|
+
rule: 'schemas/reference-target',
|
|
177
|
+
message: `Reference field "${fieldName}" declares both "target" and "targets" — a field is fixed or polymorphic, never both.`,
|
|
178
|
+
file,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
else if (targets !== undefined) {
|
|
182
|
+
const targetList = Array.isArray(targets) ? targets : null;
|
|
183
|
+
if (!targetList || targetList.length === 0 || new Set(targetList).size !== targetList.length) {
|
|
184
|
+
errors.push({
|
|
185
|
+
rule: 'schemas/reference-target',
|
|
186
|
+
message: `Reference field "${fieldName}" must declare "targets" as a non-empty array of unique content types.`,
|
|
187
|
+
file,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
for (const candidate of targetList) {
|
|
192
|
+
if (typeof candidate !== 'string' || !CONTENT_TYPE_NAME_PATTERN.test(candidate)) {
|
|
193
|
+
errors.push({
|
|
194
|
+
rule: 'schemas/reference-target',
|
|
195
|
+
message: `Reference field "${fieldName}" has an invalid entry in "targets".`,
|
|
196
|
+
file,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
else if (schemaUids.size > 0 && !schemaUids.has(candidate)) {
|
|
200
|
+
errors.push({
|
|
201
|
+
rule: 'schemas/reference-target',
|
|
202
|
+
message: `Reference field "${fieldName}" targets unknown schema "${candidate}".`,
|
|
203
|
+
file,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else if (typeof target !== 'string' || !CONTENT_TYPE_NAME_PATTERN.test(target)) {
|
|
210
|
+
errors.push({
|
|
211
|
+
rule: 'schemas/reference-target',
|
|
212
|
+
message: `Reference field "${fieldName}" must declare a valid "target" content type (or "targets" for a polymorphic field).`,
|
|
178
213
|
file,
|
|
179
214
|
});
|
|
180
215
|
}
|
|
181
216
|
else if (schemaUids.size > 0 && !schemaUids.has(target)) {
|
|
182
217
|
errors.push({
|
|
183
|
-
rule: 'schemas/
|
|
184
|
-
message: `
|
|
218
|
+
rule: 'schemas/reference-target',
|
|
219
|
+
message: `Reference field "${fieldName}" targets unknown schema "${target}".`,
|
|
185
220
|
file,
|
|
186
221
|
});
|
|
187
222
|
}
|
|
188
223
|
if (many !== undefined && typeof many !== 'boolean') {
|
|
189
224
|
errors.push({
|
|
190
|
-
rule: 'schemas/
|
|
191
|
-
message: `
|
|
225
|
+
rule: 'schemas/reference-many',
|
|
226
|
+
message: `Reference field "${fieldName}" must use a boolean "many" value.`,
|
|
192
227
|
file,
|
|
193
228
|
});
|
|
194
229
|
}
|
|
195
230
|
}
|
|
196
231
|
else {
|
|
197
|
-
for (const
|
|
198
|
-
if (
|
|
232
|
+
for (const referenceOnlyKey of ['target', 'targets', 'many']) {
|
|
233
|
+
if (referenceOnlyKey in definition) {
|
|
199
234
|
errors.push({
|
|
200
|
-
rule: 'schemas/
|
|
201
|
-
message: `Field "${fieldName}": "${
|
|
235
|
+
rule: 'schemas/reference-shape',
|
|
236
|
+
message: `Field "${fieldName}": "${referenceOnlyKey}" is only supported on reference fields.`,
|
|
202
237
|
file,
|
|
203
238
|
});
|
|
204
239
|
}
|
|
@@ -36,20 +36,20 @@ describe('validateSchemas', () => {
|
|
|
36
36
|
body: { type: 'richtext' },
|
|
37
37
|
coverImage: { type: 'media' },
|
|
38
38
|
featured: { type: 'boolean' },
|
|
39
|
-
author: { type: '
|
|
39
|
+
author: { type: 'reference', target: 'Author' },
|
|
40
40
|
},
|
|
41
41
|
},
|
|
42
42
|
});
|
|
43
43
|
expect(validateSchemas({ cwd })).toEqual({ errors: [], schemasChecked: 2 });
|
|
44
44
|
});
|
|
45
|
-
it('accepts self-referential
|
|
45
|
+
it('accepts self-referential reference fields', () => {
|
|
46
46
|
const cwd = writeProject({
|
|
47
47
|
'Item.json': {
|
|
48
48
|
uid: 'Item',
|
|
49
49
|
name: 'Items',
|
|
50
50
|
fields: {
|
|
51
51
|
title: { type: 'string' },
|
|
52
|
-
parent: { type: '
|
|
52
|
+
parent: { type: 'reference', target: 'Item' },
|
|
53
53
|
},
|
|
54
54
|
},
|
|
55
55
|
});
|
|
@@ -141,32 +141,32 @@ describe('validateSchemas', () => {
|
|
|
141
141
|
expect(errors[0].message).toContain('"decimal"');
|
|
142
142
|
expect(errors[0].message).toContain('string, richtext, media');
|
|
143
143
|
});
|
|
144
|
-
it('rejects
|
|
144
|
+
it('rejects reference fields without a valid target', () => {
|
|
145
145
|
const cwd = writeProject({
|
|
146
146
|
'Post.json': {
|
|
147
147
|
uid: 'Post',
|
|
148
148
|
name: 'Posts',
|
|
149
|
-
fields: { author: { type: '
|
|
149
|
+
fields: { author: { type: 'reference' } },
|
|
150
150
|
},
|
|
151
151
|
});
|
|
152
152
|
const { errors } = validateSchemas({ cwd });
|
|
153
153
|
expect(errors).toHaveLength(1);
|
|
154
|
-
expect(errors[0]).toMatchObject({ rule: 'schemas/
|
|
154
|
+
expect(errors[0]).toMatchObject({ rule: 'schemas/reference-target' });
|
|
155
155
|
});
|
|
156
|
-
it('rejects
|
|
156
|
+
it('rejects reference fields targeting a missing local schema', () => {
|
|
157
157
|
const cwd = writeProject({
|
|
158
158
|
'Post.json': {
|
|
159
159
|
uid: 'Post',
|
|
160
160
|
name: 'Posts',
|
|
161
|
-
fields: { author: { type: '
|
|
161
|
+
fields: { author: { type: 'reference', target: 'Author' } },
|
|
162
162
|
},
|
|
163
163
|
});
|
|
164
164
|
const { errors } = validateSchemas({ cwd });
|
|
165
165
|
expect(errors).toHaveLength(1);
|
|
166
|
-
expect(errors[0]).toMatchObject({ rule: 'schemas/
|
|
166
|
+
expect(errors[0]).toMatchObject({ rule: 'schemas/reference-target' });
|
|
167
167
|
expect(errors[0].message).toContain('unknown schema "Author"');
|
|
168
168
|
});
|
|
169
|
-
it('rejects
|
|
169
|
+
it('rejects reference-only properties on scalar fields', () => {
|
|
170
170
|
const cwd = writeProject({
|
|
171
171
|
'Post.json': {
|
|
172
172
|
uid: 'Post',
|
|
@@ -176,7 +176,7 @@ describe('validateSchemas', () => {
|
|
|
176
176
|
});
|
|
177
177
|
const { errors } = validateSchemas({ cwd });
|
|
178
178
|
expect(errors).toHaveLength(1);
|
|
179
|
-
expect(errors[0]).toMatchObject({ rule: 'schemas/
|
|
179
|
+
expect(errors[0]).toMatchObject({ rule: 'schemas/reference-shape' });
|
|
180
180
|
});
|
|
181
181
|
it('rejects schema uid names that cannot be used as content type identifiers', () => {
|
|
182
182
|
const cwd = writeProject({
|
|
@@ -214,12 +214,12 @@ describe('validateSchemas', () => {
|
|
|
214
214
|
expect(errors).toHaveLength(1);
|
|
215
215
|
expect(errors[0]).toMatchObject({ rule: 'schemas/field-i18n' });
|
|
216
216
|
});
|
|
217
|
-
it('rejects
|
|
217
|
+
it('rejects reference-level locale settings', () => {
|
|
218
218
|
const cwd = writeProject({
|
|
219
219
|
'Page.json': {
|
|
220
220
|
uid: 'Page',
|
|
221
221
|
name: 'Pages',
|
|
222
|
-
fields: { parent: { type: '
|
|
222
|
+
fields: { parent: { type: 'reference', target: 'Page', localized: true } },
|
|
223
223
|
},
|
|
224
224
|
});
|
|
225
225
|
const { errors } = validateSchemas({ cwd });
|
package/package.json
CHANGED
|
@@ -100,6 +100,30 @@ export function stripTemplateLockfile(cliRoot) {
|
|
|
100
100
|
return () => writeFileSync(file, original);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Parse `npm view <name> versions --json` output into a flat list of version
|
|
105
|
+
* strings, tolerating every shape npm has shipped: an array of strings
|
|
106
|
+
* (npm <= 11), a bare string when exactly one version is published
|
|
107
|
+
* (npm <= 11), and the npm 12 wrapper — "npm view --json now always returns
|
|
108
|
+
* an array", one entry per matched version, i.e. `[[...versions]]` for a bare
|
|
109
|
+
* package name. Returns null when nothing version-shaped survives (error
|
|
110
|
+
* envelopes, empty output), so callers fall back to the run-number/timestamp
|
|
111
|
+
* counter instead of trusting garbage.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} stdout - raw stdout of `npm view <name> versions --json`
|
|
114
|
+
* @returns {string[] | null}
|
|
115
|
+
*/
|
|
116
|
+
export function parsePublishedVersions(stdout) {
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(stdout);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const versions = (Array.isArray(parsed) ? parsed : [parsed]).flat().filter((version) => typeof version === 'string');
|
|
124
|
+
return versions.length > 0 ? versions : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
103
127
|
/**
|
|
104
128
|
* Next per-version prerelease counter: one past the highest already-published
|
|
105
129
|
* `<base>-<distTag>.<n>` on npm. Counting from what's actually published
|
|
@@ -4,8 +4,13 @@ import { join } from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
computePublishVersion,
|
|
9
|
+
nextPrereleaseCounter,
|
|
10
|
+
nextVersionFromChangesets,
|
|
11
|
+
parsePublishedVersions,
|
|
12
|
+
// @ts-expect-error plain mjs module without types
|
|
13
|
+
} from './release-core.mjs';
|
|
9
14
|
|
|
10
15
|
const stagingChannel = { prerelease: true, distTag: 'staging' };
|
|
11
16
|
const latestChannel = { prerelease: false, distTag: 'latest' };
|
|
@@ -34,6 +39,36 @@ describe('computePublishVersion', () => {
|
|
|
34
39
|
});
|
|
35
40
|
});
|
|
36
41
|
|
|
42
|
+
describe('parsePublishedVersions', () => {
|
|
43
|
+
it('passes through the npm <= 11 array-of-strings shape', () => {
|
|
44
|
+
expect(parsePublishedVersions('["0.3.4", "0.3.5-staging.1"]')).toEqual(['0.3.4', '0.3.5-staging.1']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('wraps the npm <= 11 bare string returned for a single published version', () => {
|
|
48
|
+
expect(parsePublishedVersions('"0.0.0"')).toEqual(['0.0.0']);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('unwraps the npm 12 shape (--json always returns an array)', () => {
|
|
52
|
+
expect(parsePublishedVersions('[["0.3.4", "0.3.5-staging.1", "0.3.5"]]')).toEqual([
|
|
53
|
+
'0.3.4',
|
|
54
|
+
'0.3.5-staging.1',
|
|
55
|
+
'0.3.5',
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns null for unparseable output', () => {
|
|
60
|
+
expect(parsePublishedVersions('')).toBeNull();
|
|
61
|
+
expect(parsePublishedVersions('npm ERR! something')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns null when nothing version-shaped survives (error envelopes, empty lists)', () => {
|
|
65
|
+
expect(parsePublishedVersions('{"error":{"code":"E401"}}')).toBeNull();
|
|
66
|
+
expect(parsePublishedVersions('[]')).toBeNull();
|
|
67
|
+
expect(parsePublishedVersions('[{"error":{"code":"E404"}}]')).toBeNull();
|
|
68
|
+
expect(parsePublishedVersions('null')).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
37
72
|
describe('nextPrereleaseCounter', () => {
|
|
38
73
|
it('returns 1 when nothing matches the base + tag', () => {
|
|
39
74
|
expect(nextPrereleaseCounter([], '0.3.5', 'staging')).toBe(1);
|
package/scripts/publish-npm.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { writeDevReleaseState } from './lib/dev-release-state.mjs';
|
|
|
36
36
|
import {
|
|
37
37
|
computePublishVersion,
|
|
38
38
|
nextVersionFromChangesets,
|
|
39
|
+
parsePublishedVersions,
|
|
39
40
|
pinTemplateFimo,
|
|
40
41
|
stripTemplateLockfile,
|
|
41
42
|
writeReleaseJson,
|
|
@@ -72,20 +73,14 @@ function run(cmd, args, cwd) {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
// All published versions of the package, or null when the registry can't be
|
|
75
|
-
// reached (offline, first-ever publish)
|
|
76
|
-
// run-number/timestamp counter.
|
|
76
|
+
// reached (offline, first-ever publish) or the output shape is unrecognized —
|
|
77
|
+
// callers fall back to the run-number/timestamp counter.
|
|
77
78
|
function fetchPublishedVersions(name) {
|
|
78
79
|
const result = spawnSync('npm', ['view', name, 'versions', '--json'], { encoding: 'utf8' });
|
|
79
80
|
if (result.status !== 0) {
|
|
80
81
|
return null;
|
|
81
82
|
}
|
|
82
|
-
|
|
83
|
-
const parsed = JSON.parse(result.stdout);
|
|
84
|
-
// npm view returns a bare string when exactly one version is published.
|
|
85
|
-
return Array.isArray(parsed) ? parsed : [parsed];
|
|
86
|
-
} catch {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
83
|
+
return parsePublishedVersions(result.stdout);
|
|
89
84
|
}
|
|
90
85
|
|
|
91
86
|
const { channel: channelName, dryRun, bootstrap, plan } = parseArgs(process.argv.slice(2));
|