@decocms/blocks-cli 8.0.0 → 8.1.0-next.0
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/package.json +5 -3
- package/scripts/analyze-traces.mjs +1 -0
- package/scripts/cdn-rules.test.ts +105 -0
- package/scripts/cdn-rules.ts +192 -0
- package/scripts/deco-migrate-cli.ts +6 -7
- package/scripts/fast-deploy-kv.test.ts +172 -0
- package/scripts/generate-blocks.test.ts +11 -0
- package/scripts/generate-blocks.ts +0 -10
- package/scripts/generate-invoke.test.ts +2 -2
- package/scripts/generate-invoke.ts +9 -21
- package/scripts/generate-loaders.test.ts +2 -2
- package/scripts/generate-loaders.ts +0 -10
- package/scripts/generate-schema-loader-aliases.test.ts +186 -0
- package/scripts/generate-schema-workspaces.test.ts +180 -0
- package/scripts/generate-schema.test.ts +68 -29
- package/scripts/generate-schema.ts +101 -89
- package/scripts/generate-sections.test.ts +104 -35
- package/scripts/generate-sections.ts +57 -11
- package/scripts/generate-workspaces.test.ts +179 -0
- package/scripts/generate.test.ts +1 -2
- package/scripts/generate.ts +43 -45
- package/scripts/lib/blocks-dedupe.test.ts +1 -1
- package/scripts/lib/cf-kv-rest.ts +49 -1
- package/scripts/lib/installedPackages.ts +42 -0
- package/scripts/lib/invokeSource.ts +18 -0
- package/scripts/lib/kv-snapshot.ts +44 -0
- package/scripts/lib/read-decofile.ts +13 -2
- package/scripts/lib/upgrade-6-to-7.ts +1 -1
- package/scripts/lib/wrangler-config.test.ts +49 -0
- package/scripts/lib/wrangler-config.ts +31 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
- package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
- package/scripts/migrate/config.ts +2 -2
- package/scripts/migrate/delete-sets.ts +75 -0
- package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
- package/scripts/migrate/icon-casing.test.ts +63 -0
- package/scripts/migrate/phase-analyze.test.ts +66 -4
- package/scripts/migrate/phase-analyze.ts +79 -60
- package/scripts/migrate/phase-cleanup.test.ts +3 -1
- package/scripts/migrate/phase-cleanup.ts +1 -1
- package/scripts/migrate/phase-compile.test.ts +3 -1
- package/scripts/migrate/phase-report.test.ts +2 -0
- package/scripts/migrate/phase-report.ts +3 -4
- package/scripts/migrate/phase-scaffold.ts +150 -45
- package/scripts/migrate/phase-transform.ts +107 -2
- package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
- package/scripts/migrate/phase-verify.test.ts +3 -1
- package/scripts/migrate/phase-verify.ts +87 -33
- package/scripts/migrate/post-cleanup/rules.ts +29 -11
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
- package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
- package/scripts/migrate/source-layout.test.ts +43 -0
- package/scripts/migrate/source-layout.ts +32 -5
- package/scripts/migrate/templates/app-css.test.ts +24 -0
- package/scripts/migrate/templates/app-css.ts +27 -0
- package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
- package/scripts/migrate/templates/ci-yml.ts +182 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
- package/scripts/migrate/templates/hooks.test.ts +3 -1
- package/scripts/migrate/templates/lib-utils.ts +3 -3
- package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
- package/scripts/migrate/templates/package-json.ts +10 -0
- package/scripts/migrate/templates/parity-yml.ts +136 -0
- package/scripts/migrate/templates/perf-yml.ts +449 -0
- package/scripts/migrate/templates/playwright-yml.ts +127 -0
- package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
- package/scripts/migrate/templates/routes.test.ts +31 -0
- package/scripts/migrate/templates/routes.ts +19 -9
- package/scripts/migrate/templates/section-loaders.ts +3 -3
- package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
- package/scripts/migrate/templates/server-entry.ts +19 -14
- package/scripts/migrate/templates/setup.ts +11 -3
- package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
- package/scripts/migrate/templates/ui-components.ts +3 -0
- package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
- package/scripts/migrate/transforms/ctx-compat.ts +93 -1
- package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
- package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
- package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
- package/scripts/migrate/transforms/jsx.ts +10 -0
- package/scripts/migrate/transforms/picture.test.ts +56 -0
- package/scripts/migrate/transforms/picture.ts +95 -0
- package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
- package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
- package/scripts/migrate/transforms/timer-types.test.ts +30 -0
- package/scripts/migrate/transforms/timer-types.ts +48 -0
- package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
- package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
- package/scripts/migrate/types.ts +13 -1
- package/scripts/migrate-blocks-to-kv.ts +16 -2
- package/scripts/migrate-to-cf-observability.test.ts +5 -5
- package/scripts/migrate-to-cf-observability.ts +1 -1
- package/scripts/migrate.ts +244 -6
- package/scripts/reconcile.test.ts +162 -0
- package/scripts/reconcile.ts +303 -0
- package/scripts/sync-blocks-bot.test.ts +276 -0
- package/scripts/sync-blocks-bot.ts +567 -0
- package/scripts/sync-blocks-to-kv.ts +20 -2
- package/scripts/upgrade-6-to-7.ts +0 -0
- package/scripts/lib/legacyArtifact.ts +0 -20
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
import fs from "node:fs";
|
|
47
47
|
import path from "node:path";
|
|
48
48
|
import { Project, type PropertyAssignment, SyntaxKind } from "ts-morph";
|
|
49
|
+
import { resolveInvokeSource } from "./lib/invokeSource";
|
|
49
50
|
|
|
50
51
|
const args = process.argv.slice(2);
|
|
51
52
|
function arg(name: string, fallback: string): string {
|
|
@@ -77,28 +78,15 @@ const PRIVILEGED_ACTIONS = new Set([
|
|
|
77
78
|
|
|
78
79
|
function resolveAppsDir(): string {
|
|
79
80
|
const explicit = arg("apps-dir", "");
|
|
81
|
+
const source = resolveInvokeSource(cwd, explicit || null);
|
|
82
|
+
if (source) return path.dirname(source);
|
|
83
|
+
// Preserve explicit-path errors and the legacy raw-checkout fallback used
|
|
84
|
+
// only by direct invocation. Published packages can be local or hoisted,
|
|
85
|
+
// with invoke.ts at the package root or under src/.
|
|
80
86
|
if (explicit) return path.resolve(cwd, explicit);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// for anyone still developing against the pre-split monorepo.
|
|
85
|
-
//
|
|
86
|
-
// For each root, invoke.ts may sit directly at the root (legacy dev
|
|
87
|
-
// checkouts) or under src/ — the published @decocms/apps-vtex tarball
|
|
88
|
-
// ships its sources under src/ (`"files": ["src"]`, `"main":
|
|
89
|
-
// "./src/index.ts"`), so on any site with npm-installed 7.x packages the
|
|
90
|
-
// file lives at node_modules/@decocms/apps-vtex/src/invoke.ts. The src/
|
|
91
|
-
// nesting doesn't affect the emitted imports: relative `./actions/*`
|
|
92
|
-
// specifiers are rewritten to `@decocms/apps-vtex/actions/*`, which the
|
|
93
|
-
// package's exports map points back into src/.
|
|
94
|
-
const roots = [
|
|
95
|
-
path.resolve(cwd, "node_modules/@decocms/apps-vtex"),
|
|
96
|
-
path.resolve(cwd, "../apps-start/vtex"),
|
|
97
|
-
];
|
|
98
|
-
for (const root of roots) {
|
|
99
|
-
for (const c of [root, path.join(root, "src")]) {
|
|
100
|
-
if (fs.existsSync(path.join(c, "invoke.ts"))) return c;
|
|
101
|
-
}
|
|
87
|
+
const legacy = resolveInvokeSource(cwd, "../apps-start/vtex");
|
|
88
|
+
if (legacy) {
|
|
89
|
+
return path.dirname(legacy);
|
|
102
90
|
}
|
|
103
91
|
throw new Error("Could not find @decocms/apps-vtex. Use --apps-dir to specify its location.");
|
|
104
92
|
}
|
|
@@ -14,8 +14,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
14
14
|
|
|
15
15
|
const SCRIPT = path.resolve(__dirname, "generate-loaders.ts");
|
|
16
16
|
|
|
17
|
-
function run(cwd: string): { stdout: string; stderr: string; code: number } {
|
|
18
|
-
const r = cp.spawnSync("npx", ["tsx", SCRIPT], { encoding: "utf8", cwd });
|
|
17
|
+
function run(cwd: string, args: string[] = []): { stdout: string; stderr: string; code: number } {
|
|
18
|
+
const r = cp.spawnSync("npx", ["tsx", SCRIPT, ...args], { encoding: "utf8", cwd });
|
|
19
19
|
return { stdout: r.stdout || "", stderr: r.stderr || "", code: r.status ?? -1 };
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -43,14 +43,9 @@
|
|
|
43
43
|
* --exclude comma-separated list of loader keys to skip (they have custom wiring)
|
|
44
44
|
* --prune-by-decofile only emit entries whose key appears as `__resolveType` in any JSON
|
|
45
45
|
* --decofile-dir @deprecated alias for --prune-by-decofile
|
|
46
|
-
*
|
|
47
|
-
* If no `--out-file` is passed and the OLD default (src/server/cms/loaders.gen.ts)
|
|
48
|
-
* still exists on disk, a one-line legacy warning is printed to stderr and the
|
|
49
|
-
* NEW default is written anyway — see lib/legacyArtifact.ts.
|
|
50
46
|
*/
|
|
51
47
|
import fs from "node:fs";
|
|
52
48
|
import path from "node:path";
|
|
53
|
-
import { warnLegacyArtifact } from "./lib/legacyArtifact";
|
|
54
49
|
|
|
55
50
|
const args = process.argv.slice(2);
|
|
56
51
|
function arg(name: string, fallback: string): string {
|
|
@@ -60,13 +55,8 @@ function arg(name: string, fallback: string): string {
|
|
|
60
55
|
|
|
61
56
|
const loadersDir = path.resolve(process.cwd(), arg("loaders-dir", "src/loaders"));
|
|
62
57
|
const actionsDir = path.resolve(process.cwd(), arg("actions-dir", "src/actions"));
|
|
63
|
-
const OUT_FILE_EXPLICIT = args.includes("--out-file");
|
|
64
58
|
const NEW_DEFAULT_OUT_FILE = ".deco/loaders.gen.ts";
|
|
65
|
-
const OLD_DEFAULT_OUT_FILE = "src/server/cms/loaders.gen.ts";
|
|
66
59
|
const outFile = path.resolve(process.cwd(), arg("out-file", NEW_DEFAULT_OUT_FILE));
|
|
67
|
-
if (!OUT_FILE_EXPLICIT && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_FILE))) {
|
|
68
|
-
warnLegacyArtifact(OLD_DEFAULT_OUT_FILE, NEW_DEFAULT_OUT_FILE);
|
|
69
|
-
}
|
|
70
60
|
const excludeRaw = arg("exclude", "");
|
|
71
61
|
const excludeSet = new Set(excludeRaw.split(",").map((s) => s.trim()).filter(Boolean));
|
|
72
62
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
interface Schema {
|
|
8
|
+
type?: string;
|
|
9
|
+
$ref?: string;
|
|
10
|
+
allOf?: Schema[];
|
|
11
|
+
anyOf?: Schema[];
|
|
12
|
+
properties?: Record<string, Schema>;
|
|
13
|
+
items?: Schema;
|
|
14
|
+
required?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Exercise loader discovery AND prop matching through the real CLI. A context
|
|
18
|
+
// pre-seeded with "Settings" would miss the bug in output-type registration.
|
|
19
|
+
describe("generate-schema named loader aliases", () => {
|
|
20
|
+
let fixture: string;
|
|
21
|
+
let props: Schema;
|
|
22
|
+
|
|
23
|
+
beforeAll(() => {
|
|
24
|
+
fixture = mkdtempSync(path.join(tmpdir(), "schema-loader-aliases-"));
|
|
25
|
+
const write = (relative: string, contents: string) => {
|
|
26
|
+
const file = path.join(fixture, relative);
|
|
27
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
28
|
+
writeFileSync(file, contents);
|
|
29
|
+
};
|
|
30
|
+
write(
|
|
31
|
+
"tsconfig.json",
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
compilerOptions: {
|
|
34
|
+
target: "ES2022",
|
|
35
|
+
module: "ESNext",
|
|
36
|
+
moduleResolution: "Bundler",
|
|
37
|
+
strict: true,
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
write(
|
|
42
|
+
"src/types.ts",
|
|
43
|
+
`export interface Config { title: string; modules: unknown; }
|
|
44
|
+
export type Settings = Omit<Config, "modules">;
|
|
45
|
+
export type Picked = Pick<Config, "title">;
|
|
46
|
+
export type Plain = { label: string };
|
|
47
|
+
export type Tree = { id: string; children?: Tree[] };
|
|
48
|
+
export type Generic<T> = { value: T };
|
|
49
|
+
export interface Product { productID: string; }
|
|
50
|
+
export interface Collection { label: string; settings: Settings; }
|
|
51
|
+
export type Unrelated = { title: string };`,
|
|
52
|
+
);
|
|
53
|
+
const loaders: Record<string, string> = {
|
|
54
|
+
settings: "Settings",
|
|
55
|
+
nullable: "Promise<Settings | null>",
|
|
56
|
+
picked: "Picked",
|
|
57
|
+
plain: "Plain",
|
|
58
|
+
list: "Promise<Settings[] | null>",
|
|
59
|
+
tree: "Tree",
|
|
60
|
+
products: "Product[]",
|
|
61
|
+
anonymous: "{ title: string }",
|
|
62
|
+
anonymousList: "{ label: string }[]",
|
|
63
|
+
strings: "string[]",
|
|
64
|
+
directOmit: 'Omit<Config, "title">',
|
|
65
|
+
generic: "Generic<string>",
|
|
66
|
+
};
|
|
67
|
+
for (const [name, output] of Object.entries(loaders)) {
|
|
68
|
+
write(
|
|
69
|
+
`src/loaders/${name}.ts`,
|
|
70
|
+
`import type { Config, Settings, Picked, Plain, Tree, Generic, Product } from "../types";
|
|
71
|
+
export default function load(): ${output} { throw new Error("fixture only"); }`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
write(
|
|
75
|
+
"src/sections/Example.tsx",
|
|
76
|
+
`import type { Config, Settings as SiteSettings, Picked, Plain, Tree,
|
|
77
|
+
Generic, Product, Collection, Unrelated } from "../types";
|
|
78
|
+
export interface Props {
|
|
79
|
+
settings: SiteSettings;
|
|
80
|
+
picked?: Picked | null;
|
|
81
|
+
plain: Plain;
|
|
82
|
+
list: SiteSettings[];
|
|
83
|
+
nullableList?: Array<SiteSettings> | null;
|
|
84
|
+
tree: Tree;
|
|
85
|
+
products: Product[];
|
|
86
|
+
collections: Collection[];
|
|
87
|
+
anonymous: { title: string };
|
|
88
|
+
anonymousList: { other: number }[];
|
|
89
|
+
strings: string[];
|
|
90
|
+
unrelated: Unrelated;
|
|
91
|
+
directOmit: Omit<Config, "modules">;
|
|
92
|
+
generic: Generic<number>;
|
|
93
|
+
}
|
|
94
|
+
export default function Example(_props: Props) { return null; }`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const run = spawnSync(
|
|
98
|
+
process.execPath,
|
|
99
|
+
[
|
|
100
|
+
"--import",
|
|
101
|
+
import.meta.resolve("tsx"),
|
|
102
|
+
path.resolve(__dirname, "generate-schema.ts"),
|
|
103
|
+
"--skip-apps",
|
|
104
|
+
],
|
|
105
|
+
{ cwd: fixture, encoding: "utf8", timeout: 30_000 },
|
|
106
|
+
);
|
|
107
|
+
expect(run.status, run.error?.message ?? run.stderr).toBe(0);
|
|
108
|
+
const meta = JSON.parse(readFileSync(path.join(fixture, ".deco/meta.gen.json"), "utf8"));
|
|
109
|
+
const section = meta.schema.definitions[btoa("site/sections/Example.tsx")];
|
|
110
|
+
const ref = section.allOf.find((part: Schema) => part.$ref).$ref;
|
|
111
|
+
props = meta.schema.definitions[ref.replace("#/definitions/", "")];
|
|
112
|
+
}, 30_000);
|
|
113
|
+
|
|
114
|
+
afterAll(() => {
|
|
115
|
+
if (fixture) rmSync(fixture, { recursive: true, force: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function field(name: string): Schema {
|
|
119
|
+
const schema = props.properties?.[name];
|
|
120
|
+
expect(schema, name).toBeDefined();
|
|
121
|
+
return schema!;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function inline(schema: Schema): Schema {
|
|
125
|
+
return schema.anyOf?.find((branch) => branch.type) ?? schema;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function expectLoader(schema: Schema, name: string) {
|
|
129
|
+
expect(schema.anyOf).toEqual(expect.arrayContaining([{ $ref: "#/definitions/Resolvable" }]));
|
|
130
|
+
expect(schema.anyOf).toContainEqual({
|
|
131
|
+
$ref: `#/definitions/${btoa(`site/loaders/${name}.ts`)}`,
|
|
132
|
+
});
|
|
133
|
+
expect(schema.properties).toBeUndefined();
|
|
134
|
+
expect(schema.anyOf).not.toContainEqual({
|
|
135
|
+
$ref: `#/definitions/${btoa("site/loaders/anonymousList.ts")}`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it("matches an imported Omit alias, including a nullable async loader", () => {
|
|
140
|
+
expectLoader(field("settings"), "settings");
|
|
141
|
+
expectLoader(field("settings"), "nullable");
|
|
142
|
+
expect(props.required).toContain("settings");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("matches Pick and plain object aliases without changing optionality", () => {
|
|
146
|
+
expectLoader(field("picked"), "picked");
|
|
147
|
+
expectLoader(field("plain"), "plain");
|
|
148
|
+
expect(props.required).not.toContain("picked");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("matches aliases through arrays, Promise and nullable wrappers", () => {
|
|
152
|
+
expectLoader(field("list"), "list");
|
|
153
|
+
expectLoader(field("nullableList"), "list");
|
|
154
|
+
expect(props.required).not.toContain("nullableList");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("matches a recursive alias without expanding its fields", () => {
|
|
158
|
+
expectLoader(field("tree"), "tree");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("keeps named-interface array loaders working", () => {
|
|
162
|
+
expectLoader(field("products"), "products");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("keeps unrelated object arrays editable and resolves their nested settings", () => {
|
|
166
|
+
const collections = inline(field("collections"));
|
|
167
|
+
expect(collections.type).toBe("array");
|
|
168
|
+
expect(collections.items?.properties?.label.type).toBe("string");
|
|
169
|
+
expectLoader(collections.items!.properties!.settings, "settings");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("does not collapse unrelated anonymous arrays into an __type[] bucket", () => {
|
|
173
|
+
expect(inline(field("anonymousList")).items?.properties?.other.type).toBe("number");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("does not register generic utility types or match different instantiations", () => {
|
|
177
|
+
expect(inline(field("directOmit")).properties?.title.type).toBe("string");
|
|
178
|
+
expect(inline(field("generic")).properties?.value.type).toBe("number");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("does not match primitives, anonymous objects or unrelated named aliases", () => {
|
|
182
|
+
expect(inline(field("strings")).items?.type).toBe("string");
|
|
183
|
+
expect(inline(field("anonymous")).properties?.title.type).toBe("string");
|
|
184
|
+
expect(inline(field("unrelated")).properties?.title.type).toBe("string");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
interface Schema {
|
|
8
|
+
type?: string;
|
|
9
|
+
$ref?: string;
|
|
10
|
+
allOf?: Schema[];
|
|
11
|
+
properties?: Record<string, Schema>;
|
|
12
|
+
required?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Meta {
|
|
16
|
+
manifest: { blocks: { loaders: Record<string, Schema>; apps: Record<string, Schema> } };
|
|
17
|
+
schema: { definitions: Record<string, Schema> };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("generate-schema workspace app packages", () => {
|
|
21
|
+
let workspace: string;
|
|
22
|
+
let site: string;
|
|
23
|
+
|
|
24
|
+
function write(relative: string, contents: string) {
|
|
25
|
+
const file = path.join(workspace, relative);
|
|
26
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
27
|
+
writeFileSync(file, contents);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function installApp(base: string, field: string) {
|
|
31
|
+
const directory = `${base}/node_modules/@decocms/apps-catalog`;
|
|
32
|
+
write(
|
|
33
|
+
`${directory}/package.json`,
|
|
34
|
+
JSON.stringify({
|
|
35
|
+
name: "@decocms/apps-catalog",
|
|
36
|
+
version: "1.0.0",
|
|
37
|
+
type: "module",
|
|
38
|
+
exports: { "./mod": "./src/mod.ts" },
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
write(
|
|
42
|
+
`${directory}/src/mod.ts`,
|
|
43
|
+
`export interface Props { account: string; }
|
|
44
|
+
export default function app(_props: Props) { return {}; }`,
|
|
45
|
+
);
|
|
46
|
+
write(
|
|
47
|
+
`${directory}/src/loaders/product.ts`,
|
|
48
|
+
`export interface Props {
|
|
49
|
+
${field}: string;
|
|
50
|
+
limit?: number;
|
|
51
|
+
}
|
|
52
|
+
export interface Product { sku: string; }
|
|
53
|
+
export default function loader(_props: Props): Product {
|
|
54
|
+
throw new Error("Fixture loaders must never execute during generation");
|
|
55
|
+
}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function runGenerator(script: string, args: string[] = []): string {
|
|
60
|
+
const result = spawnSync(
|
|
61
|
+
process.execPath,
|
|
62
|
+
["--import", import.meta.resolve("tsx"), path.resolve(__dirname, script), ...args],
|
|
63
|
+
{ cwd: site, encoding: "utf8", timeout: 30_000 },
|
|
64
|
+
);
|
|
65
|
+
expect(result.status, result.error?.message ?? result.stderr).toBe(0);
|
|
66
|
+
return result.stdout;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readMeta(): Meta {
|
|
70
|
+
return JSON.parse(readFileSync(path.join(site, ".deco/meta.gen.json"), "utf8"));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function generate(): Meta {
|
|
74
|
+
runGenerator("generate-schema.ts");
|
|
75
|
+
return readMeta();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function definition(meta: Meta, reference: Schema): Schema {
|
|
79
|
+
expect(reference?.$ref).toBeDefined();
|
|
80
|
+
return meta.schema.definitions[reference.$ref!.replace("#/definitions/", "")];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
workspace = mkdtempSync(path.join(tmpdir(), "schema-workspaces-"));
|
|
85
|
+
site = path.join(workspace, "apps/website");
|
|
86
|
+
write("package.json", JSON.stringify({ private: true, workspaces: ["apps/*"] }));
|
|
87
|
+
write(
|
|
88
|
+
"apps/website/package.json",
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
name: "@example/website",
|
|
91
|
+
private: true,
|
|
92
|
+
dependencies: { "@decocms/apps-catalog": "1.0.0" },
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
write(
|
|
96
|
+
"apps/website/tsconfig.json",
|
|
97
|
+
JSON.stringify({
|
|
98
|
+
compilerOptions: {
|
|
99
|
+
target: "ES2022",
|
|
100
|
+
module: "ESNext",
|
|
101
|
+
moduleResolution: "Bundler",
|
|
102
|
+
strict: true,
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
write(
|
|
107
|
+
"apps/website/src/apps/catalog.ts",
|
|
108
|
+
'export { default } from "@decocms/apps-catalog/mod";',
|
|
109
|
+
);
|
|
110
|
+
mkdirSync(path.join(site, "src/sections"), { recursive: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
if (workspace) rmSync(workspace, { recursive: true, force: true });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("emits loaders and their Props from an app hoisted above the website", () => {
|
|
118
|
+
installApp(".", "productId");
|
|
119
|
+
|
|
120
|
+
const meta = generate();
|
|
121
|
+
const loader = definition(meta, meta.manifest.blocks.loaders["catalog/loaders/product.ts"]);
|
|
122
|
+
expect(loader.properties).toMatchObject({
|
|
123
|
+
productId: { type: "string" },
|
|
124
|
+
limit: { type: "number" },
|
|
125
|
+
});
|
|
126
|
+
expect(loader.required).toEqual(["__resolveType", "productId"]);
|
|
127
|
+
|
|
128
|
+
const app = definition(meta, meta.manifest.blocks.apps["site/apps/catalog.ts"]);
|
|
129
|
+
const props = definition(meta, app.allOf!.find((part) => part.$ref)!);
|
|
130
|
+
expect(props.properties?.account).toMatchObject({ type: "string" });
|
|
131
|
+
expect(props.required).toEqual(["account"]);
|
|
132
|
+
}, 30_000);
|
|
133
|
+
|
|
134
|
+
it("uses the nearest app package without merging loaders from the hoisted copy", () => {
|
|
135
|
+
installApp(".", "parentProductId");
|
|
136
|
+
write(
|
|
137
|
+
"node_modules/@decocms/apps-catalog/src/loaders/parentOnly.ts",
|
|
138
|
+
"export default function loader(): string { return 'parent'; }",
|
|
139
|
+
);
|
|
140
|
+
installApp("apps/website", "localProductId");
|
|
141
|
+
|
|
142
|
+
const meta = generate();
|
|
143
|
+
const loader = definition(meta, meta.manifest.blocks.loaders["catalog/loaders/product.ts"]);
|
|
144
|
+
expect(loader.properties?.localProductId).toMatchObject({ type: "string" });
|
|
145
|
+
expect(loader.properties?.parentProductId).toBeUndefined();
|
|
146
|
+
expect(loader.required).toEqual(["__resolveType", "localProductId"]);
|
|
147
|
+
expect(meta.manifest.blocks.loaders["catalog/loaders/parentOnly.ts"]).toBeUndefined();
|
|
148
|
+
}, 30_000);
|
|
149
|
+
|
|
150
|
+
it("invalidates a warm schema cache when hoisted loader Props change at the same version", () => {
|
|
151
|
+
installApp(".", "productId");
|
|
152
|
+
const args = ["--only", "schema"];
|
|
153
|
+
const key = "catalog/loaders/product.ts";
|
|
154
|
+
const first = runGenerator("generate.ts", args);
|
|
155
|
+
expect(first).toMatch(/\[generate\] schema \d+ms \(fresh\)/);
|
|
156
|
+
const before = readMeta();
|
|
157
|
+
expect(
|
|
158
|
+
definition(before, before.manifest.blocks.loaders[key]).properties?.productId,
|
|
159
|
+
).toMatchObject({ type: "string" });
|
|
160
|
+
|
|
161
|
+
const warm = runGenerator("generate.ts", args);
|
|
162
|
+
expect(warm).toMatch(/\[generate\] schema \d+ms \(cached(?:, content-verified)?\)/);
|
|
163
|
+
|
|
164
|
+
// A linked/workspace app can change its source without a version bump.
|
|
165
|
+
// Only the hoisted loader changes; the site and package manifest stay put.
|
|
166
|
+
const relative = "node_modules/@decocms/apps-catalog/src/loaders/product.ts";
|
|
167
|
+
const source = readFileSync(path.join(workspace, relative), "utf8");
|
|
168
|
+
write(relative, source.replace("productId: string;", "productId: number; category?: string;"));
|
|
169
|
+
|
|
170
|
+
const updated = runGenerator("generate.ts", args);
|
|
171
|
+
expect(updated).toMatch(/\[generate\] schema \d+ms \(fresh\)/);
|
|
172
|
+
const after = readMeta();
|
|
173
|
+
const loader = definition(after, after.manifest.blocks.loaders[key]);
|
|
174
|
+
expect(loader.properties).toMatchObject({
|
|
175
|
+
productId: { type: "number" },
|
|
176
|
+
category: { type: "string" },
|
|
177
|
+
});
|
|
178
|
+
expect(loader.required).toEqual(["__resolveType", "productId"]);
|
|
179
|
+
}, 30_000);
|
|
180
|
+
});
|
|
@@ -156,7 +156,7 @@ describe("typeToJsonSchema with intersection types", () => {
|
|
|
156
156
|
useInMemoryFileSystem: true,
|
|
157
157
|
compilerOptions: { skipLibCheck: true },
|
|
158
158
|
});
|
|
159
|
-
// Mirrors
|
|
159
|
+
// Mirrors a production header `SiteNavigationElement` recursive workaround:
|
|
160
160
|
// nested children written as `Leaf & { children?: Array<…> }`. Before the
|
|
161
161
|
// intersection branch these collapsed to `children: { items: { type: "string" } }`.
|
|
162
162
|
const sf = project.createSourceFile(
|
|
@@ -191,6 +191,68 @@ describe("typeToJsonSchema with intersection types", () => {
|
|
|
191
191
|
}, 30_000);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
describe("typeToJsonSchema Section-typed props", () => {
|
|
195
|
+
// The framework's `Section` is opaque (`export type Section = any`), so a
|
|
196
|
+
// Section-typed prop is a "pick any section" reference emitted as a
|
|
197
|
+
// __SECTION_REF__ picker. This must keep working.
|
|
198
|
+
it("emits a __SECTION_REF__ picker for the framework's opaque Section[]", () => {
|
|
199
|
+
const project = new Project({
|
|
200
|
+
useInMemoryFileSystem: true,
|
|
201
|
+
compilerOptions: { skipLibCheck: true },
|
|
202
|
+
});
|
|
203
|
+
const sf = project.createSourceFile(
|
|
204
|
+
"props.ts",
|
|
205
|
+
`
|
|
206
|
+
type Section = any;
|
|
207
|
+
export interface Props {
|
|
208
|
+
children?: Section[] | null;
|
|
209
|
+
}
|
|
210
|
+
`,
|
|
211
|
+
);
|
|
212
|
+
const schema = typeToJsonSchema(sf.getInterfaceOrThrow("Props").getType());
|
|
213
|
+
|
|
214
|
+
expect(schema.properties.children).toMatchObject({
|
|
215
|
+
type: "array",
|
|
216
|
+
items: { $ref: "#/definitions/__SECTION_REF__" },
|
|
217
|
+
});
|
|
218
|
+
}, 30_000);
|
|
219
|
+
|
|
220
|
+
// Regression (a production footer): a component that declares its own local
|
|
221
|
+
// `type Section = { label; items }` used to collide with the magic name and
|
|
222
|
+
// collapse into a non-editable __SECTION_REF__ picker — the footer columns
|
|
223
|
+
// showed only a drag handle, no expand arrow, no form. A concretely-shaped
|
|
224
|
+
// local type is user data and must render as an inline editable object.
|
|
225
|
+
it("keeps a user-defined local `type Section` as an inline editable object", () => {
|
|
226
|
+
const project = new Project({
|
|
227
|
+
useInMemoryFileSystem: true,
|
|
228
|
+
compilerOptions: { skipLibCheck: true },
|
|
229
|
+
});
|
|
230
|
+
const sf = project.createSourceFile(
|
|
231
|
+
"props.ts",
|
|
232
|
+
`
|
|
233
|
+
interface Item { label: string; href: string; }
|
|
234
|
+
type Section = { label: string; items: Item[] };
|
|
235
|
+
export interface Props {
|
|
236
|
+
sections?: Section[];
|
|
237
|
+
}
|
|
238
|
+
`,
|
|
239
|
+
);
|
|
240
|
+
const schema = typeToJsonSchema(sf.getInterfaceOrThrow("Props").getType());
|
|
241
|
+
|
|
242
|
+
const sections = schema.properties.sections;
|
|
243
|
+
// Editable array of objects — NOT a section-picker reference.
|
|
244
|
+
expect(sections.type).toBe("array");
|
|
245
|
+
expect(JSON.stringify(sections)).not.toContain("__SECTION_REF__");
|
|
246
|
+
|
|
247
|
+
const item = sections.items;
|
|
248
|
+
expect(item.type).toBe("object");
|
|
249
|
+
expect(item.properties.label).toMatchObject({ type: "string" });
|
|
250
|
+
expect(item.properties.items.type).toBe("array");
|
|
251
|
+
expect(item.properties.items.items.type).toBe("object");
|
|
252
|
+
expect(item.properties.items.items.properties.href).toMatchObject({ type: "string" });
|
|
253
|
+
}, 30_000);
|
|
254
|
+
});
|
|
255
|
+
|
|
194
256
|
describe("typeToJsonSchema loader block-ref matching", () => {
|
|
195
257
|
// Regression: a loader that returns an array whose element type has no
|
|
196
258
|
// resolvable name (`VNode[]`, `string[]`, …) used to be bucketed under the
|
|
@@ -252,10 +314,10 @@ describe("typeToJsonSchema loader block-ref matching", () => {
|
|
|
252
314
|
});
|
|
253
315
|
|
|
254
316
|
// ---------------------------------------------------------------------------
|
|
255
|
-
// Default output path (.deco/)
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
317
|
+
// Default output path (.deco/) — subprocess, mirrors the pattern in
|
|
318
|
+
// generate-sections.test.ts. generate-schema.ts IS guarded by isMainModule(),
|
|
319
|
+
// but it's still driven as a subprocess here so the CLI's argv-parsed OUT_REL
|
|
320
|
+
// top-level code runs against a real cwd.
|
|
259
321
|
// ---------------------------------------------------------------------------
|
|
260
322
|
|
|
261
323
|
const SCRIPT = path.resolve(__dirname, "generate-schema.ts");
|
|
@@ -313,32 +375,9 @@ describe("generate-schema default output path (.deco/)", () => {
|
|
|
313
375
|
expect(meta.manifest.blocks.sections).toHaveProperty("site/sections/Hero.tsx");
|
|
314
376
|
}, 30_000);
|
|
315
377
|
|
|
316
|
-
it("
|
|
317
|
-
const oldDefaultDir = path.join(tmpDir, "src", "server", "admin");
|
|
318
|
-
fs.mkdirSync(oldDefaultDir, { recursive: true });
|
|
319
|
-
fs.writeFileSync(path.join(oldDefaultDir, "meta.gen.json"), "{}");
|
|
320
|
-
|
|
378
|
+
it("does not warn about a legacy default path", () => {
|
|
321
379
|
const { code, stderr } = runGenerator(["--skip-apps"], { cwd: tmpDir });
|
|
322
380
|
expect(code).toBe(0);
|
|
323
|
-
|
|
324
|
-
expect(stderr).toContain("src/server/admin/meta.gen.json");
|
|
325
|
-
expect(stderr).toContain(".deco/meta.gen.json");
|
|
326
|
-
expect(stderr).toContain("Move the file and update its importers");
|
|
327
|
-
|
|
328
|
-
const newDefault = path.join(tmpDir, ".deco", "meta.gen.json");
|
|
329
|
-
expect(fs.existsSync(newDefault)).toBe(true);
|
|
330
|
-
}, 30_000);
|
|
331
|
-
|
|
332
|
-
it("does not warn when an explicit --out is passed, even if the OLD default file exists", () => {
|
|
333
|
-
const oldDefaultDir = path.join(tmpDir, "src", "server", "admin");
|
|
334
|
-
fs.mkdirSync(oldDefaultDir, { recursive: true });
|
|
335
|
-
fs.writeFileSync(path.join(oldDefaultDir, "meta.gen.json"), "{}");
|
|
336
|
-
|
|
337
|
-
const explicitOut = path.join(tmpDir, "custom", "meta.gen.json");
|
|
338
|
-
const { code, stderr } = runGenerator(["--skip-apps", "--out", explicitOut], { cwd: tmpDir });
|
|
339
|
-
expect(code).toBe(0);
|
|
340
|
-
|
|
341
381
|
expect(stderr).not.toContain("Generator default output moved");
|
|
342
|
-
expect(fs.existsSync(explicitOut)).toBe(true);
|
|
343
382
|
}, 30_000);
|
|
344
383
|
});
|