@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.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-test.log +18 -0
  3. package/LICENSE +21 -0
  4. package/dist/__tests__/client.test.d.ts +2 -0
  5. package/dist/__tests__/client.test.d.ts.map +1 -0
  6. package/dist/__tests__/client.test.js +62 -0
  7. package/dist/__tests__/client.test.js.map +1 -0
  8. package/dist/__tests__/component-registry.test.d.ts +2 -0
  9. package/dist/__tests__/component-registry.test.d.ts.map +1 -0
  10. package/dist/__tests__/component-registry.test.js +38 -0
  11. package/dist/__tests__/component-registry.test.js.map +1 -0
  12. package/dist/__tests__/content-syncer.test.d.ts +2 -0
  13. package/dist/__tests__/content-syncer.test.d.ts.map +1 -0
  14. package/dist/__tests__/content-syncer.test.js +153 -0
  15. package/dist/__tests__/content-syncer.test.js.map +1 -0
  16. package/dist/__tests__/integration.test.d.ts +2 -0
  17. package/dist/__tests__/integration.test.d.ts.map +1 -0
  18. package/dist/__tests__/integration.test.js +141 -0
  19. package/dist/__tests__/integration.test.js.map +1 -0
  20. package/dist/__tests__/schema-generator.test.d.ts +2 -0
  21. package/dist/__tests__/schema-generator.test.d.ts.map +1 -0
  22. package/dist/__tests__/schema-generator.test.js +139 -0
  23. package/dist/__tests__/schema-generator.test.js.map +1 -0
  24. package/dist/client.d.ts +14 -0
  25. package/dist/client.d.ts.map +1 -0
  26. package/dist/client.js +44 -0
  27. package/dist/client.js.map +1 -0
  28. package/dist/component-registry.d.ts +12 -0
  29. package/dist/component-registry.d.ts.map +1 -0
  30. package/dist/component-registry.js +17 -0
  31. package/dist/component-registry.js.map +1 -0
  32. package/dist/content-syncer.d.ts +23 -0
  33. package/dist/content-syncer.d.ts.map +1 -0
  34. package/dist/content-syncer.js +95 -0
  35. package/dist/content-syncer.js.map +1 -0
  36. package/dist/index.d.ts +9 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +7 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/integration.d.ts +4 -0
  41. package/dist/integration.d.ts.map +1 -0
  42. package/dist/integration.js +52 -0
  43. package/dist/integration.js.map +1 -0
  44. package/dist/schema-generator.d.ts +3 -0
  45. package/dist/schema-generator.d.ts.map +1 -0
  46. package/dist/schema-generator.js +77 -0
  47. package/dist/schema-generator.js.map +1 -0
  48. package/dist/types.d.ts +35 -0
  49. package/dist/types.d.ts.map +1 -0
  50. package/dist/types.js +2 -0
  51. package/dist/types.js.map +1 -0
  52. package/package.json +33 -0
  53. package/src/__tests__/client.test.ts +82 -0
  54. package/src/__tests__/component-registry.test.ts +49 -0
  55. package/src/__tests__/content-syncer.test.ts +174 -0
  56. package/src/__tests__/integration.test.ts +169 -0
  57. package/src/__tests__/schema-generator.test.ts +156 -0
  58. package/src/client.ts +54 -0
  59. package/src/component-registry.ts +19 -0
  60. package/src/content-syncer.ts +110 -0
  61. package/src/index.ts +8 -0
  62. package/src/integration.ts +61 -0
  63. package/src/schema-generator.ts +80 -0
  64. package/src/types.ts +40 -0
  65. package/tsconfig.json +8 -0
  66. package/vitest.config.ts +7 -0
package/src/client.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { ComponentDefinition, ContentListResponse, SchemaResponse } from "./types.js";
2
+
3
+ export class EngineClientError extends Error {
4
+ constructor(
5
+ message: string,
6
+ public readonly status: number,
7
+ public readonly body: string,
8
+ ) {
9
+ super(message);
10
+ this.name = "EngineClientError";
11
+ }
12
+ }
13
+
14
+ export class EngineClient {
15
+ constructor(private readonly engineUrl: string) {}
16
+
17
+ async fetchSchema(): Promise<SchemaResponse> {
18
+ const res = await fetch(`${this.engineUrl}/api/schema`);
19
+ if (!res.ok) {
20
+ const body = await res.text();
21
+ throw new EngineClientError(`fetchSchema failed with status ${res.status}`, res.status, body);
22
+ }
23
+ return res.json() as Promise<SchemaResponse>;
24
+ }
25
+
26
+ async fetchContent(collection: string): Promise<ContentListResponse> {
27
+ const res = await fetch(`${this.engineUrl}/api/content/${collection}`);
28
+ if (!res.ok) {
29
+ const body = await res.text();
30
+ throw new EngineClientError(
31
+ `fetchContent(${collection}) failed with status ${res.status}`,
32
+ res.status,
33
+ body,
34
+ );
35
+ }
36
+ return res.json() as Promise<ContentListResponse>;
37
+ }
38
+
39
+ async registerComponents(components: Record<string, ComponentDefinition>): Promise<void> {
40
+ const res = await fetch(`${this.engineUrl}/api/schema/components`, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify(components),
44
+ });
45
+ if (!res.ok) {
46
+ const body = await res.text();
47
+ throw new EngineClientError(
48
+ `registerComponents failed with status ${res.status}`,
49
+ res.status,
50
+ body,
51
+ );
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,19 @@
1
+ import type { EngineClient } from "./client.js";
2
+ import type { ComponentDefinition } from "./types.js";
3
+
4
+ export class ComponentRegistry {
5
+ constructor(
6
+ private client: EngineClient,
7
+ private components: Record<string, ComponentDefinition>,
8
+ ) {}
9
+
10
+ /** POST components to engine so AI clients can query them */
11
+ async register(): Promise<void> {
12
+ await this.client.registerComponents(this.components);
13
+ }
14
+
15
+ /** Return component definitions for local dev endpoint */
16
+ getSchema(): Record<string, ComponentDefinition> {
17
+ return this.components;
18
+ }
19
+ }
@@ -0,0 +1,110 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { EngineClient } from "./client.js";
4
+
5
+ export interface SyncResult {
6
+ synced: number;
7
+ collections: Record<string, number>;
8
+ errors: string[];
9
+ }
10
+
11
+ export interface FsOperations {
12
+ mkdir: (path: string, options?: { recursive?: boolean }) => Promise<void>;
13
+ writeFile: (path: string, content: string, encoding: string) => Promise<void>;
14
+ }
15
+
16
+ function serializeFrontmatter(frontmatter: Record<string, unknown>): string {
17
+ const lines: string[] = [];
18
+ for (const [key, value] of Object.entries(frontmatter)) {
19
+ if (typeof value === "string") {
20
+ // Quote strings that contain special YAML characters
21
+ const needsQuotes = /[:#\[\]{}&*!,|>'"?]/.test(value) || value.includes("\n");
22
+ lines.push(needsQuotes ? `${key}: ${JSON.stringify(value)}` : `${key}: ${value}`);
23
+ } else if (value === null || value === undefined) {
24
+ lines.push(`${key}: null`);
25
+ } else if (typeof value === "boolean" || typeof value === "number") {
26
+ lines.push(`${key}: ${value}`);
27
+ } else {
28
+ lines.push(`${key}: ${JSON.stringify(value)}`);
29
+ }
30
+ }
31
+ return lines.join("\n");
32
+ }
33
+
34
+ export class ContentSyncer {
35
+ private fs: FsOperations;
36
+
37
+ constructor(
38
+ private client: EngineClient,
39
+ private srcDir: string,
40
+ private contentDir: string,
41
+ fs?: FsOperations,
42
+ ) {
43
+ this.fs = fs ?? {
44
+ mkdir: async (path, options) => {
45
+ await mkdir(path, options);
46
+ },
47
+ writeFile: (path, content, encoding) => writeFile(path, content, encoding as BufferEncoding),
48
+ };
49
+ }
50
+
51
+ async sync(collections: string[]): Promise<SyncResult> {
52
+ const result: SyncResult = {
53
+ synced: 0,
54
+ collections: {},
55
+ errors: [],
56
+ };
57
+
58
+ for (const collection of collections) {
59
+ let count = 0;
60
+ try {
61
+ const contentList = await this.client.fetchContent(collection);
62
+ const collectionDir = join(this.srcDir, this.contentDir, collection);
63
+
64
+ await this.fs.mkdir(collectionDir, { recursive: true });
65
+
66
+ for (const item of contentList.items) {
67
+ try {
68
+ const ext = this.resolveExtension(item.path);
69
+ const filePath = join(collectionDir, `${item.slug}.${ext}`);
70
+ const fileContent = this.renderFile(item.frontmatter, item.body, ext);
71
+ await this.fs.writeFile(filePath, fileContent, "utf-8");
72
+ count++;
73
+ } catch (err) {
74
+ const msg = err instanceof Error ? err.message : String(err);
75
+ result.errors.push(`${collection}/${item.slug}: ${msg}`);
76
+ }
77
+ }
78
+ } catch (err) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ result.errors.push(`${collection}: ${msg}`);
81
+ }
82
+
83
+ result.collections[collection] = count;
84
+ result.synced += count;
85
+ }
86
+
87
+ return result;
88
+ }
89
+
90
+ private resolveExtension(path: string): string {
91
+ const match = path.match(/\.([a-z]+)$/i);
92
+ if (match) {
93
+ const ext = match[1].toLowerCase();
94
+ if (["mdx", "md", "yaml", "yml", "json"].includes(ext)) return ext;
95
+ }
96
+ return "mdx";
97
+ }
98
+
99
+ private renderFile(frontmatter: Record<string, unknown>, body: string, ext: string): string {
100
+ if (ext === "json") {
101
+ return `${JSON.stringify({ ...frontmatter, body }, null, 2)}\n`;
102
+ }
103
+ if (ext === "yaml" || ext === "yml") {
104
+ return `${serializeFrontmatter({ ...frontmatter, body })}\n`;
105
+ }
106
+ // mdx / md
107
+ const fm = serializeFrontmatter(frontmatter);
108
+ return `---\n${fm}\n---\n${body}`;
109
+ }
110
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { default as sourcepress } from "./integration.js";
2
+ export { default } from "./integration.js";
3
+ export { EngineClient } from "./client.js";
4
+ export { generateContentConfig } from "./schema-generator.js";
5
+ export { ComponentRegistry } from "./component-registry.js";
6
+ export { ContentSyncer } from "./content-syncer.js";
7
+ export type { SourcePressAstroOptions, ComponentDefinition } from "./types.js";
8
+ export type { SyncResult } from "./content-syncer.js";
@@ -0,0 +1,61 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { AstroIntegration } from "astro";
4
+ import { EngineClient } from "./client.js";
5
+ import { ComponentRegistry } from "./component-registry.js";
6
+ import { ContentSyncer } from "./content-syncer.js";
7
+ import { generateContentConfig } from "./schema-generator.js";
8
+ import type { SourcePressAstroOptions } from "./types.js";
9
+
10
+ export default function sourcepress(options: SourcePressAstroOptions): AstroIntegration {
11
+ return {
12
+ name: "@sourcepress/astro",
13
+ hooks: {
14
+ "astro:config:setup": async ({ config, logger }) => {
15
+ const client = new EngineClient(options.engine);
16
+ const contentDir = options.contentDir ?? "content";
17
+ const configPath = options.configPath ?? "src/content.config.ts";
18
+
19
+ try {
20
+ // 1. Fetch schema from engine
21
+ logger.info("Fetching schema from engine...");
22
+ const schemaResponse = await client.fetchSchema();
23
+
24
+ // 2. Generate content.config.ts and write to configPath
25
+ // Cast to CollectionDefinition shape — fields may be unknown but generateContentConfig handles it
26
+ const collections = schemaResponse.collections as Parameters<
27
+ typeof generateContentConfig
28
+ >[0];
29
+ const configSource = generateContentConfig(collections, contentDir);
30
+ const configFullPath = join(config.root.pathname, configPath);
31
+ await writeFile(configFullPath, configSource, "utf-8");
32
+ logger.info(`Generated ${configPath}`);
33
+
34
+ // 3. Sync content files via ContentSyncer
35
+ const srcDir = config.srcDir.pathname;
36
+ const syncer = new ContentSyncer(client, srcDir, contentDir);
37
+ const collectionNames = Object.keys(schemaResponse.collections);
38
+ const syncResult = await syncer.sync(collectionNames);
39
+ logger.info(
40
+ `Synced ${syncResult.synced} files across ${collectionNames.length} collections`,
41
+ );
42
+ if (syncResult.errors.length > 0) {
43
+ for (const err of syncResult.errors) {
44
+ logger.warn(`Sync warning: ${err}`);
45
+ }
46
+ }
47
+
48
+ // 4. Register components if provided
49
+ if (options.components && Object.keys(options.components).length > 0) {
50
+ const registry = new ComponentRegistry(client, options.components);
51
+ await registry.register();
52
+ logger.info(`Registered ${Object.keys(options.components).length} components`);
53
+ }
54
+ } catch (err) {
55
+ const message = err instanceof Error ? err.message : String(err);
56
+ logger.warn(`SourcePress sync failed: ${message}. Continuing without sync.`);
57
+ }
58
+ },
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,80 @@
1
+ import type { CollectionDefinition, FieldDefinition } from "@sourcepress/core";
2
+
3
+ function fieldToZodCode(fieldDef: FieldDefinition): string {
4
+ switch (fieldDef.type) {
5
+ case "string": {
6
+ if (fieldDef.default !== undefined) {
7
+ return `z.string().default(${JSON.stringify(fieldDef.default)})`;
8
+ }
9
+ if (!fieldDef.required) return "z.string().optional()";
10
+ return "z.string()";
11
+ }
12
+ case "boolean": {
13
+ if (!fieldDef.required) return "z.boolean().optional()";
14
+ return "z.boolean()";
15
+ }
16
+ case "number": {
17
+ if (!fieldDef.required) return "z.number().optional()";
18
+ return "z.number()";
19
+ }
20
+ case "image": {
21
+ if (fieldDef.multiple) {
22
+ if (!fieldDef.required) return "z.array(z.string()).optional()";
23
+ return "z.array(z.string())";
24
+ }
25
+ if (!fieldDef.required) return "z.string().optional()";
26
+ return "z.string()";
27
+ }
28
+ case "relation-one": {
29
+ if (!fieldDef.required) return "z.string().optional()";
30
+ return "z.string()";
31
+ }
32
+ case "relation-many": {
33
+ if (!fieldDef.required) return "z.array(z.string()).optional()";
34
+ return "z.array(z.string())";
35
+ }
36
+ }
37
+ }
38
+
39
+ function collectionToSchemaCode(collection: CollectionDefinition, contentDir: string): string {
40
+ const lines: string[] = [];
41
+ for (const [fieldName, fieldDef] of Object.entries(collection.fields)) {
42
+ lines.push(` ${fieldName}: ${fieldToZodCode(fieldDef)},`);
43
+ }
44
+ const pattern = collection.format === "json" ? "**/*.json" : collection.format === "yaml" ? "**/*.{yaml,yml}" : "**/*.{md,mdx}";
45
+ return `defineCollection({\n loader: glob({ pattern: "${pattern}", base: "src/${contentDir}/${collection.name}" }),\n schema: z.object({\n${lines.join("\n")}\n }),\n})`;
46
+ }
47
+
48
+ export function generateContentConfig(
49
+ collections: Record<string, CollectionDefinition>,
50
+ contentDir = "content",
51
+ ): string {
52
+ const entries = Object.entries(collections);
53
+
54
+ if (entries.length === 0) {
55
+ return [
56
+ `import { defineCollection, z } from 'astro:content';`,
57
+ "",
58
+ "export const collections = {};",
59
+ "",
60
+ ].join("\n");
61
+ }
62
+
63
+ const collectionBlocks = entries.map(([name, def]) => {
64
+ return `const ${name} = ${collectionToSchemaCode(def, contentDir)};`;
65
+ });
66
+
67
+ const exportsMap = entries.map(([name]) => ` ${name},`).join("\n");
68
+
69
+ return [
70
+ `import { defineCollection, z } from 'astro:content';`,
71
+ `import { glob } from 'astro/loaders';`,
72
+ "",
73
+ collectionBlocks.join("\n\n"),
74
+ "",
75
+ "export const collections = {",
76
+ exportsMap,
77
+ "};",
78
+ "",
79
+ ].join("\n");
80
+ }
package/src/types.ts ADDED
@@ -0,0 +1,40 @@
1
+ export interface ComponentDefinition {
2
+ description: string;
3
+ props: Record<string, string>;
4
+ }
5
+
6
+ export interface SourcePressAstroOptions {
7
+ /** URL of the Content Engine, e.g. 'http://localhost:3000' */
8
+ engine: string;
9
+ /** UI components available for AI content generation */
10
+ components?: Record<string, ComponentDefinition>;
11
+ /** Directory to sync content into, relative to Astro srcDir. Default: 'content' */
12
+ contentDir?: string;
13
+ /** Path to generate content.config.ts. Default: 'src/content.config.ts' */
14
+ configPath?: string;
15
+ }
16
+
17
+ /** Schema response from GET /api/schema */
18
+ export interface SchemaResponse {
19
+ collections: Record<
20
+ string,
21
+ {
22
+ name: string;
23
+ path: string;
24
+ format: string;
25
+ fields: Record<string, unknown>;
26
+ }
27
+ >;
28
+ total: number;
29
+ }
30
+
31
+ /** Content list response from GET /api/content/:collection */
32
+ export interface ContentListResponse {
33
+ items: Array<{
34
+ slug: string;
35
+ path: string;
36
+ frontmatter: Record<string, unknown>;
37
+ body: string;
38
+ }>;
39
+ total: number;
40
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["src/__tests__/**/*.test.ts"],
6
+ },
7
+ });