First cut
This commit is contained in:
14
shared/package.json
Normal file
14
shared/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@dynamic-sites/shared",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.0"
|
||||
}
|
||||
}
|
||||
20
shared/src/canonical-json.ts
Normal file
20
shared/src/canonical-json.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Canonical JSON serialization: sorted keys at every level, 2-space indent, trailing newline.
|
||||
*/
|
||||
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
function sortKeys(value: unknown): unknown {
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(sortKeys);
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||||
sorted[key] = sortKeys((value as Record<string, unknown>)[key]);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function stringifyCanonical(value: unknown): string {
|
||||
return JSON.stringify(sortKeys(value), null, 2) + '\n';
|
||||
}
|
||||
7
shared/src/index.ts
Normal file
7
shared/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { stringifyCanonical, type JsonValue, type JsonPrimitive } from './canonical-json.js';
|
||||
export * from './schemas/index.js';
|
||||
export {
|
||||
schemaForRepoRelativePath,
|
||||
isEditableContentRepoPath,
|
||||
parseAndValidateRepoJson,
|
||||
} from './repo-validation.js';
|
||||
42
shared/src/repo-validation.ts
Normal file
42
shared/src/repo-validation.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
siteContextSchema,
|
||||
sectionFileSchema,
|
||||
eventsFileSchema,
|
||||
smsSitesConfigSchema,
|
||||
} from './schemas/index.js';
|
||||
|
||||
const EDITABLE_PATTERNS: Array<{ pattern: RegExp; schema: z.ZodTypeAny }> = [
|
||||
{ pattern: /^content\/sections\/[^/]+\.json$/, schema: sectionFileSchema },
|
||||
{ pattern: /^content\/events\.json$/, schema: eventsFileSchema },
|
||||
{ pattern: /^site-context\.json$/, schema: siteContextSchema },
|
||||
{ pattern: /^config\/sms-sites\.json$/, schema: smsSitesConfigSchema },
|
||||
];
|
||||
|
||||
export function schemaForRepoRelativePath(relativePath: string): z.ZodTypeAny | null {
|
||||
for (const { pattern, schema } of EDITABLE_PATTERNS) {
|
||||
if (pattern.test(relativePath)) return schema;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isEditableContentRepoPath(relativePath: string): boolean {
|
||||
return EDITABLE_PATTERNS.some(({ pattern }) => pattern.test(relativePath));
|
||||
}
|
||||
|
||||
export function parseAndValidateRepoJson(
|
||||
repoRelativePath: string,
|
||||
rawText: string
|
||||
): { success: true; data: unknown } | { success: false; error: string } {
|
||||
const schema = schemaForRepoRelativePath(repoRelativePath);
|
||||
if (!schema) return { success: false, error: `No schema found for path: ${repoRelativePath}` };
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawText);
|
||||
const result = schema.safeParse(parsed);
|
||||
if (result.success) return { success: true, data: result.data };
|
||||
return { success: false, error: result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ') };
|
||||
} catch (e) {
|
||||
return { success: false, error: `Invalid JSON: ${(e as Error).message}` };
|
||||
}
|
||||
}
|
||||
152
shared/src/schemas/index.ts
Normal file
152
shared/src/schemas/index.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Site Context ──
|
||||
export const siteContextSchema = z.object({
|
||||
businessName: z.string(),
|
||||
tagline: z.string().optional(),
|
||||
tone: z.string().default('professional and friendly'),
|
||||
style: z.string().optional(),
|
||||
promptContext: z.string().optional(),
|
||||
primaryColor: z.string().optional(),
|
||||
contactEmail: z.string().email().optional(),
|
||||
contactPhone: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
});
|
||||
export type SiteContext = z.infer<typeof siteContextSchema>;
|
||||
|
||||
// ── Section File (union of section types) ──
|
||||
const baseSectionFields = {
|
||||
id: z.string(),
|
||||
order: z.number().int().default(0),
|
||||
visible: z.boolean().default(true),
|
||||
image: z.string().optional(),
|
||||
};
|
||||
|
||||
export const heroSectionSchema = z.object({
|
||||
...baseSectionFields,
|
||||
type: z.literal('hero'),
|
||||
headline: z.string(),
|
||||
subheading: z.string().optional(),
|
||||
ctaText: z.string().optional(),
|
||||
ctaLink: z.string().optional(),
|
||||
});
|
||||
|
||||
export const aboutSectionSchema = z.object({
|
||||
...baseSectionFields,
|
||||
type: z.literal('about'),
|
||||
title: z.string().default('About Us'),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const featureItemSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
icon: z.string().optional(),
|
||||
});
|
||||
|
||||
export const featuresSectionSchema = z.object({
|
||||
...baseSectionFields,
|
||||
type: z.literal('features'),
|
||||
title: z.string().default('What We Offer'),
|
||||
items: z.array(featureItemSchema).min(1),
|
||||
});
|
||||
|
||||
export const testimonialItemSchema = z.object({
|
||||
quote: z.string(),
|
||||
author: z.string(),
|
||||
role: z.string().optional(),
|
||||
});
|
||||
|
||||
export const testimonialsSectionSchema = z.object({
|
||||
...baseSectionFields,
|
||||
type: z.literal('testimonials'),
|
||||
title: z.string().default('What Our Clients Say'),
|
||||
items: z.array(testimonialItemSchema).min(1),
|
||||
});
|
||||
|
||||
export const textSectionSchema = z.object({
|
||||
...baseSectionFields,
|
||||
type: z.literal('text'),
|
||||
heading: z.string().optional(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const sectionFileSchema = z.discriminatedUnion('type', [
|
||||
heroSectionSchema,
|
||||
aboutSectionSchema,
|
||||
featuresSectionSchema,
|
||||
testimonialsSectionSchema,
|
||||
textSectionSchema,
|
||||
]);
|
||||
export type SectionFile = z.infer<typeof sectionFileSchema>;
|
||||
|
||||
// ── Events ──
|
||||
export const eventItemSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
date: z.string(),
|
||||
time: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
});
|
||||
|
||||
export const eventsFileSchema = z.object({
|
||||
events: z.array(eventItemSchema),
|
||||
});
|
||||
export type EventsFile = z.infer<typeof eventsFileSchema>;
|
||||
|
||||
// ── SMS Sites Config ──
|
||||
export const smsSiteEntrySchema = z.object({
|
||||
siteId: z.string(),
|
||||
phoneNumber: z.string(),
|
||||
allowedSenders: z.array(z.string()),
|
||||
repoRoot: z.string().optional(),
|
||||
});
|
||||
|
||||
export const smsSitesConfigSchema = z.object({
|
||||
sites: z.array(smsSiteEntrySchema),
|
||||
});
|
||||
export type SmsSitesConfig = z.infer<typeof smsSitesConfigSchema>;
|
||||
|
||||
// ── Edit Request ──
|
||||
export const editRequestSchema = z.object({
|
||||
message: z.string().min(1),
|
||||
repo_relative_path: z.string().optional(),
|
||||
proposal_id: z.string().optional(),
|
||||
confirm: z.enum(['yes', 'no']).optional(),
|
||||
});
|
||||
export type EditRequest = z.infer<typeof editRequestSchema>;
|
||||
|
||||
export const editJobPayloadSchema = z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('propose'),
|
||||
id: z.string(),
|
||||
message: z.string(),
|
||||
repo_relative_path: z.string().optional(),
|
||||
source: z.enum(['sms', 'http', 'editor']).default('http'),
|
||||
smsReplyMeta: z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
}).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('apply'),
|
||||
id: z.string(),
|
||||
proposal_id: z.string(),
|
||||
source: z.enum(['sms', 'http', 'editor']).default('http'),
|
||||
smsReplyMeta: z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
}).optional(),
|
||||
}),
|
||||
]);
|
||||
export type EditJobPayload = z.infer<typeof editJobPayloadSchema>;
|
||||
|
||||
// ── Routing output (LLM structured output) ──
|
||||
export const routingOutputSchema = z.object({
|
||||
repo_relative_path: z.string(),
|
||||
needs_clarification: z.boolean().default(false),
|
||||
reason: z.string(),
|
||||
clarification_message: z.string().optional(),
|
||||
});
|
||||
export type RoutingOutput = z.infer<typeof routingOutputSchema>;
|
||||
Reference in New Issue
Block a user