First cut

This commit is contained in:
kadil
2026-04-17 16:08:31 -05:00
parent d10105ac00
commit 4ee4cb8e7c
58 changed files with 3243 additions and 1 deletions

View 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
View 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';

View 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
View 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>;