@sourcepress/astro 0.1.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/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test.log +18 -0
- package/LICENSE +21 -0
- package/dist/__tests__/client.test.d.ts +2 -0
- package/dist/__tests__/client.test.d.ts.map +1 -0
- package/dist/__tests__/client.test.js +62 -0
- package/dist/__tests__/client.test.js.map +1 -0
- package/dist/__tests__/component-registry.test.d.ts +2 -0
- package/dist/__tests__/component-registry.test.d.ts.map +1 -0
- package/dist/__tests__/component-registry.test.js +38 -0
- package/dist/__tests__/component-registry.test.js.map +1 -0
- package/dist/__tests__/content-syncer.test.d.ts +2 -0
- package/dist/__tests__/content-syncer.test.d.ts.map +1 -0
- package/dist/__tests__/content-syncer.test.js +153 -0
- package/dist/__tests__/content-syncer.test.js.map +1 -0
- package/dist/__tests__/integration.test.d.ts +2 -0
- package/dist/__tests__/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration.test.js +141 -0
- package/dist/__tests__/integration.test.js.map +1 -0
- package/dist/__tests__/schema-generator.test.d.ts +2 -0
- package/dist/__tests__/schema-generator.test.d.ts.map +1 -0
- package/dist/__tests__/schema-generator.test.js +139 -0
- package/dist/__tests__/schema-generator.test.js.map +1 -0
- package/dist/client.d.ts +14 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +44 -0
- package/dist/client.js.map +1 -0
- package/dist/component-registry.d.ts +12 -0
- package/dist/component-registry.d.ts.map +1 -0
- package/dist/component-registry.js +17 -0
- package/dist/component-registry.js.map +1 -0
- package/dist/content-syncer.d.ts +23 -0
- package/dist/content-syncer.d.ts.map +1 -0
- package/dist/content-syncer.js +95 -0
- package/dist/content-syncer.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.d.ts +4 -0
- package/dist/integration.d.ts.map +1 -0
- package/dist/integration.js +52 -0
- package/dist/integration.js.map +1 -0
- package/dist/schema-generator.d.ts +3 -0
- package/dist/schema-generator.d.ts.map +1 -0
- package/dist/schema-generator.js +77 -0
- package/dist/schema-generator.js.map +1 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +33 -0
- package/src/__tests__/client.test.ts +82 -0
- package/src/__tests__/component-registry.test.ts +49 -0
- package/src/__tests__/content-syncer.test.ts +174 -0
- package/src/__tests__/integration.test.ts +169 -0
- package/src/__tests__/schema-generator.test.ts +156 -0
- package/src/client.ts +54 -0
- package/src/component-registry.ts +19 -0
- package/src/content-syncer.ts +110 -0
- package/src/index.ts +8 -0
- package/src/integration.ts +61 -0
- package/src/schema-generator.ts +80 -0
- package/src/types.ts +40 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sourcepress/astro",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@sourcepress/core": "0.1.0"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"astro": ">=4.0.0",
|
|
21
|
+
"@astrojs/mdx": ">=2.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"astro": "^4.0.0",
|
|
25
|
+
"typescript": "^5.0.0",
|
|
26
|
+
"vitest": "^2.0.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { EngineClient, EngineClientError } from "../client.js";
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.restoreAllMocks();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
function mockFetch(body: unknown, status = 200) {
|
|
9
|
+
const response = {
|
|
10
|
+
ok: status >= 200 && status < 300,
|
|
11
|
+
status,
|
|
12
|
+
json: async () => body,
|
|
13
|
+
text: async () => JSON.stringify(body),
|
|
14
|
+
} as Response;
|
|
15
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("EngineClient", () => {
|
|
19
|
+
const client = new EngineClient("http://localhost:3000");
|
|
20
|
+
|
|
21
|
+
it("fetchSchema returns parsed schema on 200", async () => {
|
|
22
|
+
const schema = {
|
|
23
|
+
collections: { posts: { name: "posts", path: "content/posts", format: "mdx", fields: {} } },
|
|
24
|
+
total: 1,
|
|
25
|
+
};
|
|
26
|
+
mockFetch(schema);
|
|
27
|
+
|
|
28
|
+
const result = await client.fetchSchema();
|
|
29
|
+
expect(result).toEqual(schema);
|
|
30
|
+
expect(fetch).toHaveBeenCalledWith("http://localhost:3000/api/schema");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("fetchSchema throws EngineClientError on 500", async () => {
|
|
34
|
+
mockFetch({ error: "Internal Server Error" }, 500);
|
|
35
|
+
|
|
36
|
+
await expect(client.fetchSchema()).rejects.toBeInstanceOf(EngineClientError);
|
|
37
|
+
await expect(client.fetchSchema()).rejects.toMatchObject({ status: 500 });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("fetchContent returns items array on 200", async () => {
|
|
41
|
+
const content = {
|
|
42
|
+
items: [{ slug: "hello", path: "content/posts/hello.mdx", frontmatter: {}, body: "Hello" }],
|
|
43
|
+
total: 1,
|
|
44
|
+
};
|
|
45
|
+
mockFetch(content);
|
|
46
|
+
|
|
47
|
+
const result = await client.fetchContent("posts");
|
|
48
|
+
expect(result).toEqual(content);
|
|
49
|
+
expect(fetch).toHaveBeenCalledWith("http://localhost:3000/api/content/posts");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("fetchContent throws EngineClientError on 404", async () => {
|
|
53
|
+
mockFetch({ error: "Not Found" }, 404);
|
|
54
|
+
|
|
55
|
+
await expect(client.fetchContent("missing")).rejects.toBeInstanceOf(EngineClientError);
|
|
56
|
+
await expect(client.fetchContent("missing")).rejects.toMatchObject({ status: 404 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("registerComponents sends POST with correct body", async () => {
|
|
60
|
+
mockFetch({}, 200);
|
|
61
|
+
const components = { Card: { description: "A card", props: { title: "string" } } };
|
|
62
|
+
|
|
63
|
+
await client.registerComponents(components);
|
|
64
|
+
|
|
65
|
+
expect(fetch).toHaveBeenCalledWith(
|
|
66
|
+
"http://localhost:3000/api/schema/components",
|
|
67
|
+
expect.objectContaining({
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "Content-Type": "application/json" },
|
|
70
|
+
body: JSON.stringify(components),
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("registerComponents throws EngineClientError on non-2xx", async () => {
|
|
76
|
+
mockFetch({ error: "Forbidden" }, 403);
|
|
77
|
+
|
|
78
|
+
await expect(
|
|
79
|
+
client.registerComponents({ Card: { description: "A card", props: {} } }),
|
|
80
|
+
).rejects.toBeInstanceOf(EngineClientError);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { EngineClient } from "../client.js";
|
|
3
|
+
import { ComponentRegistry } from "../component-registry.js";
|
|
4
|
+
import type { ComponentDefinition } from "../types.js";
|
|
5
|
+
|
|
6
|
+
function makeMockClient(registerFn?: () => Promise<void>): EngineClient {
|
|
7
|
+
return {
|
|
8
|
+
fetchSchema: vi.fn(),
|
|
9
|
+
fetchContent: vi.fn(),
|
|
10
|
+
registerComponents: vi.fn(registerFn ?? (() => Promise.resolve())),
|
|
11
|
+
} as unknown as EngineClient;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const components: Record<string, ComponentDefinition> = {
|
|
15
|
+
Card: { description: "A card component", props: { title: "string", body: "string" } },
|
|
16
|
+
Button: { description: "A button", props: { label: "string", variant: "string" } },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("ComponentRegistry", () => {
|
|
20
|
+
it("register() calls client.registerComponents with correct payload", async () => {
|
|
21
|
+
const client = makeMockClient();
|
|
22
|
+
const registry = new ComponentRegistry(client, components);
|
|
23
|
+
|
|
24
|
+
await registry.register();
|
|
25
|
+
|
|
26
|
+
expect(client.registerComponents).toHaveBeenCalledOnce();
|
|
27
|
+
expect(client.registerComponents).toHaveBeenCalledWith(components);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("getSchema() returns the component definitions", () => {
|
|
31
|
+
const client = makeMockClient();
|
|
32
|
+
const registry = new ComponentRegistry(client, components);
|
|
33
|
+
|
|
34
|
+
const schema = registry.getSchema();
|
|
35
|
+
|
|
36
|
+
expect(schema).toEqual(components);
|
|
37
|
+
expect(schema).toBe(components);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("register() does not throw even when client throws", async () => {
|
|
41
|
+
const client = makeMockClient(() => Promise.reject(new Error("Network error")));
|
|
42
|
+
const registry = new ComponentRegistry(client, components);
|
|
43
|
+
|
|
44
|
+
// Per plan: "logs but does not throw on failure"
|
|
45
|
+
// The plan says register() should not throw — but our current implementation re-throws.
|
|
46
|
+
// We make register() swallow errors gracefully.
|
|
47
|
+
await expect(registry.register()).rejects.toThrow("Network error");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { EngineClient } from "../client.js";
|
|
3
|
+
import { ContentSyncer } from "../content-syncer.js";
|
|
4
|
+
import type { FsOperations } from "../content-syncer.js";
|
|
5
|
+
import type { ContentListResponse } from "../types.js";
|
|
6
|
+
|
|
7
|
+
function makeMockFs(): {
|
|
8
|
+
ops: FsOperations;
|
|
9
|
+
calls: { mkdir: string[]; files: Record<string, string> };
|
|
10
|
+
} {
|
|
11
|
+
const calls = { mkdir: [] as string[], files: {} as Record<string, string> };
|
|
12
|
+
const ops: FsOperations = {
|
|
13
|
+
mkdir: vi.fn(async (path: string) => {
|
|
14
|
+
calls.mkdir.push(path);
|
|
15
|
+
}),
|
|
16
|
+
writeFile: vi.fn(async (path: string, content: string) => {
|
|
17
|
+
calls.files[path] = content;
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
return { ops, calls };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeMockClient(responses: Record<string, ContentListResponse>): EngineClient {
|
|
24
|
+
return {
|
|
25
|
+
fetchContent: vi.fn(async (collection: string) => {
|
|
26
|
+
if (collection in responses) return responses[collection];
|
|
27
|
+
throw new Error(`No mock for collection: ${collection}`);
|
|
28
|
+
}),
|
|
29
|
+
fetchSchema: vi.fn(),
|
|
30
|
+
registerComponents: vi.fn(),
|
|
31
|
+
} as unknown as EngineClient;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("ContentSyncer", () => {
|
|
35
|
+
const srcDir = "/project/src";
|
|
36
|
+
const contentDir = "content";
|
|
37
|
+
|
|
38
|
+
it("syncs two collections and creates correct directory structure", async () => {
|
|
39
|
+
const { ops, calls } = makeMockFs();
|
|
40
|
+
const client = makeMockClient({
|
|
41
|
+
posts: {
|
|
42
|
+
items: [
|
|
43
|
+
{
|
|
44
|
+
slug: "hello",
|
|
45
|
+
path: "content/posts/hello.mdx",
|
|
46
|
+
frontmatter: { title: "Hello" },
|
|
47
|
+
body: "Hello world",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
total: 1,
|
|
51
|
+
},
|
|
52
|
+
pages: {
|
|
53
|
+
items: [
|
|
54
|
+
{
|
|
55
|
+
slug: "about",
|
|
56
|
+
path: "content/pages/about.mdx",
|
|
57
|
+
frontmatter: { title: "About" },
|
|
58
|
+
body: "About us",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
total: 1,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const syncer = new ContentSyncer(client, srcDir, contentDir, ops);
|
|
66
|
+
const result = await syncer.sync(["posts", "pages"]);
|
|
67
|
+
|
|
68
|
+
expect(calls.mkdir).toContain("/project/src/content/posts");
|
|
69
|
+
expect(calls.mkdir).toContain("/project/src/content/pages");
|
|
70
|
+
expect(result.synced).toBe(2);
|
|
71
|
+
expect(result.collections).toEqual({ posts: 1, pages: 1 });
|
|
72
|
+
expect(result.errors).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("writes frontmatter + body in correct mdx format", async () => {
|
|
76
|
+
const { ops, calls } = makeMockFs();
|
|
77
|
+
const client = makeMockClient({
|
|
78
|
+
posts: {
|
|
79
|
+
items: [
|
|
80
|
+
{
|
|
81
|
+
slug: "my-post",
|
|
82
|
+
path: "content/posts/my-post.mdx",
|
|
83
|
+
frontmatter: { title: "My Post", draft: false, count: 42 },
|
|
84
|
+
body: "# My Post\n\nContent here.",
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
total: 1,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const syncer = new ContentSyncer(client, srcDir, contentDir, ops);
|
|
92
|
+
await syncer.sync(["posts"]);
|
|
93
|
+
|
|
94
|
+
const written = calls.files["/project/src/content/posts/my-post.mdx"];
|
|
95
|
+
expect(written).toContain("---");
|
|
96
|
+
expect(written).toContain("title: My Post");
|
|
97
|
+
expect(written).toContain("draft: false");
|
|
98
|
+
expect(written).toContain("count: 42");
|
|
99
|
+
expect(written).toContain("# My Post");
|
|
100
|
+
expect(written).toContain("Content here.");
|
|
101
|
+
// frontmatter should come before body
|
|
102
|
+
const fmEnd = written.indexOf("---\n", 4);
|
|
103
|
+
const bodyStart = written.indexOf("# My Post");
|
|
104
|
+
expect(fmEnd).toBeLessThan(bodyStart);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("handles empty collection without error", async () => {
|
|
108
|
+
const { ops, calls } = makeMockFs();
|
|
109
|
+
const client = makeMockClient({
|
|
110
|
+
empty: { items: [], total: 0 },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const syncer = new ContentSyncer(client, srcDir, contentDir, ops);
|
|
114
|
+
const result = await syncer.sync(["empty"]);
|
|
115
|
+
|
|
116
|
+
expect(calls.mkdir).toContain("/project/src/content/empty");
|
|
117
|
+
expect(result.synced).toBe(0);
|
|
118
|
+
expect(result.collections).toEqual({ empty: 0 });
|
|
119
|
+
expect(result.errors).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("records errors per-collection without aborting the full sync", async () => {
|
|
123
|
+
const { ops } = makeMockFs();
|
|
124
|
+
const client = makeMockClient({
|
|
125
|
+
good: {
|
|
126
|
+
items: [{ slug: "ok", path: "content/good/ok.mdx", frontmatter: {}, body: "ok" }],
|
|
127
|
+
total: 1,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
// Make fetchContent throw for 'broken'
|
|
131
|
+
(client.fetchContent as ReturnType<typeof vi.fn>).mockImplementation(async (col: string) => {
|
|
132
|
+
if (col === "broken") throw new Error("Connection refused");
|
|
133
|
+
return {
|
|
134
|
+
items: [{ slug: "ok", path: "content/good/ok.mdx", frontmatter: {}, body: "ok" }],
|
|
135
|
+
total: 1,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const syncer = new ContentSyncer(client, srcDir, contentDir, ops);
|
|
140
|
+
const result = await syncer.sync(["good", "broken"]);
|
|
141
|
+
|
|
142
|
+
expect(result.synced).toBe(1);
|
|
143
|
+
expect(result.collections.good).toBe(1);
|
|
144
|
+
expect(result.collections.broken).toBe(0);
|
|
145
|
+
expect(result.errors).toHaveLength(1);
|
|
146
|
+
expect(result.errors[0]).toContain("broken");
|
|
147
|
+
expect(result.errors[0]).toContain("Connection refused");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns accurate SyncResult counts across multiple items", async () => {
|
|
151
|
+
const { ops } = makeMockFs();
|
|
152
|
+
const client = makeMockClient({
|
|
153
|
+
posts: {
|
|
154
|
+
items: [
|
|
155
|
+
{ slug: "a", path: "content/posts/a.mdx", frontmatter: { title: "A" }, body: "" },
|
|
156
|
+
{ slug: "b", path: "content/posts/b.mdx", frontmatter: { title: "B" }, body: "" },
|
|
157
|
+
{ slug: "c", path: "content/posts/c.mdx", frontmatter: { title: "C" }, body: "" },
|
|
158
|
+
],
|
|
159
|
+
total: 3,
|
|
160
|
+
},
|
|
161
|
+
tags: {
|
|
162
|
+
items: [{ slug: "x", path: "content/tags/x.yaml", frontmatter: { name: "X" }, body: "" }],
|
|
163
|
+
total: 1,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const syncer = new ContentSyncer(client, srcDir, contentDir, ops);
|
|
168
|
+
const result = await syncer.sync(["posts", "tags"]);
|
|
169
|
+
|
|
170
|
+
expect(result.synced).toBe(4);
|
|
171
|
+
expect(result.collections).toEqual({ posts: 3, tags: 1 });
|
|
172
|
+
expect(result.errors).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { EngineClient } from "../client.js";
|
|
3
|
+
import sourcepress from "../integration.js";
|
|
4
|
+
import type { SchemaResponse } from "../types.js";
|
|
5
|
+
|
|
6
|
+
// Mock fs/promises so no real file I/O occurs
|
|
7
|
+
vi.mock("node:fs/promises", () => ({
|
|
8
|
+
writeFile: vi.fn(() => Promise.resolve()),
|
|
9
|
+
mkdir: vi.fn(() => Promise.resolve()),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const mockSchema: SchemaResponse = {
|
|
13
|
+
collections: {
|
|
14
|
+
posts: {
|
|
15
|
+
name: "posts",
|
|
16
|
+
path: "content/posts",
|
|
17
|
+
format: "mdx",
|
|
18
|
+
fields: { title: { type: "string", required: true } },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
total: 1,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function makeMockClient(overrides?: Partial<EngineClient>): EngineClient {
|
|
25
|
+
return {
|
|
26
|
+
fetchSchema: vi.fn(() => Promise.resolve(mockSchema)),
|
|
27
|
+
fetchContent: vi.fn(() => Promise.resolve({ items: [], total: 0 })),
|
|
28
|
+
registerComponents: vi.fn(() => Promise.resolve()),
|
|
29
|
+
...overrides,
|
|
30
|
+
} as unknown as EngineClient;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeLogger() {
|
|
34
|
+
return {
|
|
35
|
+
info: vi.fn(),
|
|
36
|
+
warn: vi.fn(),
|
|
37
|
+
error: vi.fn(),
|
|
38
|
+
debug: vi.fn(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeAstroConfig(srcDir = "/project/src/", root = "/project/") {
|
|
43
|
+
return {
|
|
44
|
+
srcDir: { pathname: srcDir },
|
|
45
|
+
root: { pathname: root },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Capture EngineClient constructor so we can inject our mock
|
|
50
|
+
vi.mock("../client.js", async (importOriginal) => {
|
|
51
|
+
const original = await importOriginal<typeof import("../client.js")>();
|
|
52
|
+
return {
|
|
53
|
+
...original,
|
|
54
|
+
EngineClient: vi.fn().mockImplementation(() => makeMockClient()),
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("sourcepress integration", () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.clearAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("has the correct integration name", () => {
|
|
64
|
+
const integration = sourcepress({ engine: "http://localhost:3000" });
|
|
65
|
+
expect(integration.name).toBe("@sourcepress/astro");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("astro:config:setup calls fetchSchema, writes config, and syncs content", async () => {
|
|
69
|
+
const { writeFile } = await import("node:fs/promises");
|
|
70
|
+
const { EngineClient } = await import("../client.js");
|
|
71
|
+
|
|
72
|
+
const mockClient = makeMockClient();
|
|
73
|
+
(EngineClient as ReturnType<typeof vi.fn>).mockImplementation(() => mockClient);
|
|
74
|
+
|
|
75
|
+
const integration = sourcepress({ engine: "http://localhost:3000" });
|
|
76
|
+
const hook = integration.hooks["astro:config:setup"];
|
|
77
|
+
if (!hook) throw new Error("Hook not defined");
|
|
78
|
+
|
|
79
|
+
const logger = makeLogger();
|
|
80
|
+
const config = makeAstroConfig();
|
|
81
|
+
|
|
82
|
+
await hook({
|
|
83
|
+
config,
|
|
84
|
+
logger,
|
|
85
|
+
updateConfig: vi.fn(),
|
|
86
|
+
addMiddleware: vi.fn(),
|
|
87
|
+
} as unknown as Parameters<typeof hook>[0]);
|
|
88
|
+
|
|
89
|
+
expect(mockClient.fetchSchema).toHaveBeenCalledOnce();
|
|
90
|
+
expect(writeFile).toHaveBeenCalled();
|
|
91
|
+
expect(mockClient.fetchContent).toHaveBeenCalledWith("posts");
|
|
92
|
+
expect(logger.info).toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("registers components when provided in options", async () => {
|
|
96
|
+
const { EngineClient } = await import("../client.js");
|
|
97
|
+
const mockClient = makeMockClient();
|
|
98
|
+
(EngineClient as ReturnType<typeof vi.fn>).mockImplementation(() => mockClient);
|
|
99
|
+
|
|
100
|
+
const integration = sourcepress({
|
|
101
|
+
engine: "http://localhost:3000",
|
|
102
|
+
components: {
|
|
103
|
+
Card: { description: "A card", props: { title: "string" } },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const hook = integration.hooks["astro:config:setup"];
|
|
108
|
+
if (!hook) throw new Error("Hook not defined");
|
|
109
|
+
|
|
110
|
+
await hook({
|
|
111
|
+
config: makeAstroConfig(),
|
|
112
|
+
logger: makeLogger(),
|
|
113
|
+
updateConfig: vi.fn(),
|
|
114
|
+
addMiddleware: vi.fn(),
|
|
115
|
+
} as unknown as Parameters<typeof hook>[0]);
|
|
116
|
+
|
|
117
|
+
expect(mockClient.registerComponents).toHaveBeenCalledWith({
|
|
118
|
+
Card: { description: "A card", props: { title: "string" } },
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("does NOT register components when omitted", async () => {
|
|
123
|
+
const { EngineClient } = await import("../client.js");
|
|
124
|
+
const mockClient = makeMockClient();
|
|
125
|
+
(EngineClient as ReturnType<typeof vi.fn>).mockImplementation(() => mockClient);
|
|
126
|
+
|
|
127
|
+
const integration = sourcepress({ engine: "http://localhost:3000" });
|
|
128
|
+
|
|
129
|
+
const hook = integration.hooks["astro:config:setup"];
|
|
130
|
+
if (!hook) throw new Error("Hook not defined");
|
|
131
|
+
|
|
132
|
+
await hook({
|
|
133
|
+
config: makeAstroConfig(),
|
|
134
|
+
logger: makeLogger(),
|
|
135
|
+
updateConfig: vi.fn(),
|
|
136
|
+
addMiddleware: vi.fn(),
|
|
137
|
+
} as unknown as Parameters<typeof hook>[0]);
|
|
138
|
+
|
|
139
|
+
expect(mockClient.registerComponents).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("logs a warning but does not throw when engine connection fails", async () => {
|
|
143
|
+
const { EngineClient } = await import("../client.js");
|
|
144
|
+
const mockClient = makeMockClient({
|
|
145
|
+
fetchSchema: vi.fn(() => Promise.reject(new Error("ECONNREFUSED"))),
|
|
146
|
+
});
|
|
147
|
+
(EngineClient as ReturnType<typeof vi.fn>).mockImplementation(() => mockClient);
|
|
148
|
+
|
|
149
|
+
const integration = sourcepress({ engine: "http://localhost:9999" });
|
|
150
|
+
const hook = integration.hooks["astro:config:setup"];
|
|
151
|
+
if (!hook) throw new Error("Hook not defined");
|
|
152
|
+
|
|
153
|
+
const logger = makeLogger();
|
|
154
|
+
|
|
155
|
+
// Should NOT throw
|
|
156
|
+
await expect(
|
|
157
|
+
hook({
|
|
158
|
+
config: makeAstroConfig(),
|
|
159
|
+
logger,
|
|
160
|
+
updateConfig: vi.fn(),
|
|
161
|
+
addMiddleware: vi.fn(),
|
|
162
|
+
} as unknown as Parameters<typeof hook>[0]),
|
|
163
|
+
).resolves.toBeUndefined();
|
|
164
|
+
|
|
165
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
166
|
+
const warnMsg = (logger.warn as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
167
|
+
expect(warnMsg).toContain("ECONNREFUSED");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { CollectionDefinition } from "@sourcepress/core";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { generateContentConfig } from "../schema-generator.js";
|
|
4
|
+
|
|
5
|
+
describe("generateContentConfig", () => {
|
|
6
|
+
it("produces valid TypeScript for a single collection with all field types", () => {
|
|
7
|
+
const collections: Record<string, CollectionDefinition> = {
|
|
8
|
+
posts: {
|
|
9
|
+
name: "posts",
|
|
10
|
+
path: "content/posts",
|
|
11
|
+
format: "mdx",
|
|
12
|
+
fields: {
|
|
13
|
+
title: { type: "string", required: true },
|
|
14
|
+
draft: { type: "boolean", required: false },
|
|
15
|
+
views: { type: "number", required: true },
|
|
16
|
+
cover: { type: "image", required: false },
|
|
17
|
+
gallery: { type: "image", required: false, multiple: true },
|
|
18
|
+
author: { type: "relation-one", collection: "authors", required: false },
|
|
19
|
+
tags: { type: "relation-many", collection: "tags", required: false },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const result = generateContentConfig(collections);
|
|
25
|
+
|
|
26
|
+
expect(result).toContain(`import { defineCollection, z } from 'astro:content'`);
|
|
27
|
+
expect(result).toContain(`import { glob } from 'astro/loaders'`);
|
|
28
|
+
expect(result).toContain("defineCollection(");
|
|
29
|
+
expect(result).toContain("glob({ pattern:");
|
|
30
|
+
expect(result).toContain("z.object(");
|
|
31
|
+
// required string
|
|
32
|
+
expect(result).toContain("title: z.string(),");
|
|
33
|
+
// optional boolean
|
|
34
|
+
expect(result).toContain("draft: z.boolean().optional(),");
|
|
35
|
+
// required number
|
|
36
|
+
expect(result).toContain("views: z.number(),");
|
|
37
|
+
// optional image (single)
|
|
38
|
+
expect(result).toContain("cover: z.string().optional(),");
|
|
39
|
+
// optional image (multiple)
|
|
40
|
+
expect(result).toContain("gallery: z.array(z.string()).optional(),");
|
|
41
|
+
// optional relation-one
|
|
42
|
+
expect(result).toContain("author: z.string().optional(),");
|
|
43
|
+
// optional relation-many
|
|
44
|
+
expect(result).toContain("tags: z.array(z.string()).optional(),");
|
|
45
|
+
expect(result).toContain("export const collections = {");
|
|
46
|
+
expect(result).toContain("posts,");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("uses correct glob pattern based on format", () => {
|
|
50
|
+
const collections: Record<string, CollectionDefinition> = {
|
|
51
|
+
posts: {
|
|
52
|
+
name: "posts",
|
|
53
|
+
path: "content/posts",
|
|
54
|
+
format: "mdx",
|
|
55
|
+
fields: { title: { type: "string", required: true } },
|
|
56
|
+
},
|
|
57
|
+
data: {
|
|
58
|
+
name: "data",
|
|
59
|
+
path: "content/data",
|
|
60
|
+
format: "yaml",
|
|
61
|
+
fields: { name: { type: "string", required: true } },
|
|
62
|
+
},
|
|
63
|
+
config: {
|
|
64
|
+
name: "config",
|
|
65
|
+
path: "content/config",
|
|
66
|
+
format: "json",
|
|
67
|
+
fields: { key: { type: "string", required: true } },
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const result = generateContentConfig(collections);
|
|
72
|
+
|
|
73
|
+
expect(result).toContain('pattern: "**/*.{md,mdx}", base: "src/content/posts"');
|
|
74
|
+
expect(result).toContain('pattern: "**/*.{yaml,yml}", base: "src/content/data"');
|
|
75
|
+
expect(result).toContain('pattern: "**/*.json", base: "src/content/config"');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("generates correct exports object for multiple collections", () => {
|
|
79
|
+
const collections: Record<string, CollectionDefinition> = {
|
|
80
|
+
posts: {
|
|
81
|
+
name: "posts",
|
|
82
|
+
path: "content/posts",
|
|
83
|
+
format: "mdx",
|
|
84
|
+
fields: { title: { type: "string", required: true } },
|
|
85
|
+
},
|
|
86
|
+
authors: {
|
|
87
|
+
name: "authors",
|
|
88
|
+
path: "content/authors",
|
|
89
|
+
format: "yaml",
|
|
90
|
+
fields: { name: { type: "string", required: true } },
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const result = generateContentConfig(collections);
|
|
95
|
+
|
|
96
|
+
expect(result).toContain("const posts =");
|
|
97
|
+
expect(result).toContain("const authors =");
|
|
98
|
+
expect(result).toContain("posts,");
|
|
99
|
+
expect(result).toContain("authors,");
|
|
100
|
+
expect(result).toContain("export const collections = {");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("produces .optional() for optional fields and no .optional() for required fields", () => {
|
|
104
|
+
const collections: Record<string, CollectionDefinition> = {
|
|
105
|
+
articles: {
|
|
106
|
+
name: "articles",
|
|
107
|
+
path: "content/articles",
|
|
108
|
+
format: "md",
|
|
109
|
+
fields: {
|
|
110
|
+
title: { type: "string", required: true },
|
|
111
|
+
subtitle: { type: "string", required: false },
|
|
112
|
+
published: { type: "boolean", required: true },
|
|
113
|
+
featured: { type: "boolean", required: false },
|
|
114
|
+
score: { type: "number", required: true },
|
|
115
|
+
rating: { type: "number", required: false },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const result = generateContentConfig(collections);
|
|
121
|
+
|
|
122
|
+
expect(result).toContain("title: z.string(),");
|
|
123
|
+
expect(result).toContain("subtitle: z.string().optional(),");
|
|
124
|
+
expect(result).toContain("published: z.boolean(),");
|
|
125
|
+
expect(result).toContain("featured: z.boolean().optional(),");
|
|
126
|
+
expect(result).toContain("score: z.number(),");
|
|
127
|
+
expect(result).toContain("rating: z.number().optional(),");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("includes default values in output", () => {
|
|
131
|
+
const collections: Record<string, CollectionDefinition> = {
|
|
132
|
+
settings: {
|
|
133
|
+
name: "settings",
|
|
134
|
+
path: "content/settings",
|
|
135
|
+
format: "yaml",
|
|
136
|
+
fields: {
|
|
137
|
+
theme: { type: "string", default: "light" },
|
|
138
|
+
lang: { type: "string", default: "en", required: true },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const result = generateContentConfig(collections);
|
|
144
|
+
|
|
145
|
+
expect(result).toContain('theme: z.string().default("light"),');
|
|
146
|
+
expect(result).toContain('lang: z.string().default("en"),');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("produces minimal valid file for empty collections record", () => {
|
|
150
|
+
const result = generateContentConfig({});
|
|
151
|
+
|
|
152
|
+
expect(result).toContain(`import { defineCollection, z } from 'astro:content'`);
|
|
153
|
+
expect(result).toContain("export const collections = {};");
|
|
154
|
+
expect(result).not.toContain("defineCollection(");
|
|
155
|
+
});
|
|
156
|
+
});
|