first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
import { z } from "astro/zod";
import type { FieldDefinition, FieldUIHints } from "./types.js";
export interface BooleanOptions {
default?: boolean;
label?: string;
helpText?: string;
}
/**
* Boolean field - checkbox/toggle
*/
export function boolean(options: BooleanOptions = {}): FieldDefinition<boolean> {
const boolSchema = z.boolean();
// Apply default
const schema: z.ZodTypeAny =
options.default !== undefined ? boolSchema.default(options.default) : boolSchema;
const ui: FieldUIHints = {
widget: "boolean",
label: options.label,
helpText: options.helpText,
};
return {
type: "boolean",
columnType: "INTEGER",
schema,
options,
ui,
};
}

View File

@@ -0,0 +1,44 @@
import { z } from "astro/zod";
import type { FieldDefinition, FieldUIHints } from "./types.js";
export interface DatetimeOptions {
required?: boolean;
min?: Date;
max?: Date;
helpText?: string;
}
/**
* Datetime field - date and time picker
*/
export function datetime(options: DatetimeOptions = {}): FieldDefinition<Date> {
let dateSchema = z.date();
// Apply constraints
if (options.min !== undefined) {
dateSchema = dateSchema.min(options.min, "Date is too early");
}
if (options.max !== undefined) {
dateSchema = dateSchema.max(options.max, "Date is too late");
}
// Optional vs required
const schema: z.ZodTypeAny = options.required ? dateSchema : dateSchema.optional();
const ui: FieldUIHints = {
widget: "datetime",
helpText: options.helpText,
min: options.min?.toISOString(),
max: options.max?.toISOString(),
};
return {
type: "datetime",
columnType: "TEXT",
schema,
options,
ui,
};
}

View File

@@ -0,0 +1,41 @@
import { z } from "astro/zod";
import type { FieldDefinition, FieldUIHints, FileValue } from "./types.js";
export interface FileOptions {
required?: boolean;
maxSize?: number; // In bytes
allowedTypes?: string[]; // MIME types
helpText?: string;
}
/**
* File field - file upload
*/
export function file(options: FileOptions = {}): FieldDefinition<FileValue> {
const fileObjSchema = z.object({
id: z.string(),
url: z.string(),
filename: z.string(),
mimeType: z.string(),
size: z.number(),
});
// Optional vs required
const schema: z.ZodTypeAny = options.required ? fileObjSchema : fileObjSchema.optional();
const ui: FieldUIHints = {
widget: "file",
helpText: options.helpText,
maxSize: options.maxSize,
allowedTypes: options.allowedTypes,
};
return {
type: "file",
columnType: "TEXT",
schema,
options,
ui,
};
}

View File

@@ -0,0 +1,34 @@
import { z } from "astro/zod";
import type { FieldDefinition, ImageValue } from "./types.js";
/**
* Image field schema
*/
const imageSchema = z.object({
id: z.string(),
src: z.string(),
alt: z.string().optional(),
width: z.number().optional(),
height: z.number().optional(),
});
/**
* Image field
* References media items from the media library
*/
export function image(options?: {
required?: boolean;
maxSize?: number; // in bytes
allowedTypes?: string[]; // MIME types
}): FieldDefinition<ImageValue | undefined> {
return {
type: "image",
columnType: "TEXT",
schema: options?.required === false ? imageSchema.optional() : imageSchema,
options,
ui: {
widget: "image",
},
};
}

View File

@@ -0,0 +1,42 @@
// Field type exports
export { text } from "./text.js";
export { textarea } from "./textarea.js";
export { number } from "./number.js";
export { integer } from "./integer.js";
export { boolean } from "./boolean.js";
export { select } from "./select.js";
export { multiSelect } from "./multiselect.js";
export { datetime } from "./datetime.js";
export { slug } from "./slug.js";
export { image } from "./image.js";
export { file } from "./file.js";
export { reference } from "./reference.js";
export { json } from "./json.js";
export { richText } from "./richtext.js";
export { portableText } from "./portable-text.js";
// Type exports
export type {
FieldDefinition,
FieldUIHints,
ColumnType,
PortableTextBlock,
ImageValue,
FileValue,
} from "./types.js";
// MediaValue is canonical in media/types.ts but re-exported here for convenience
export type { MediaValue } from "../media/types.js";
export type { TextOptions } from "./text.js";
export type { TextareaOptions } from "./textarea.js";
export type { NumberOptions } from "./number.js";
export type { IntegerOptions } from "./integer.js";
export type { BooleanOptions } from "./boolean.js";
export type { SelectOptions } from "./select.js";
export type { MultiSelectOptions } from "./multiselect.js";
export type { DatetimeOptions } from "./datetime.js";
export type { SlugOptions } from "./slug.js";
export type { FileOptions } from "./file.js";
export type { JsonOptions } from "./json.js";
export type { RichTextOptions } from "./richtext.js";

View File

@@ -0,0 +1,50 @@
import { z } from "astro/zod";
import type { FieldDefinition, FieldUIHints } from "./types.js";
export interface IntegerOptions {
required?: boolean;
min?: number;
max?: number;
placeholder?: string;
helpText?: string;
}
/**
* Integer field - whole number input
*
* Unlike the `number` field which stores as REAL (floating point),
* this field stores as INTEGER for whole numbers.
*/
export function integer(options: IntegerOptions = {}): FieldDefinition<number> {
let intSchema = z.number().int("Must be a whole number");
// Range constraints
if (options.min !== undefined) {
intSchema = intSchema.min(options.min, `Must be at least ${options.min}`);
}
if (options.max !== undefined) {
intSchema = intSchema.max(options.max, `Must be at most ${options.max}`);
}
// Optional vs required
const schema: z.ZodTypeAny = options.required ? intSchema : intSchema.optional();
const ui: FieldUIHints = {
widget: "number",
placeholder: options.placeholder,
helpText: options.helpText,
min: options.min,
max: options.max,
step: 1, // Indicate whole numbers
};
return {
type: "integer",
columnType: "INTEGER",
schema,
options,
ui,
};
}

View File

@@ -0,0 +1,37 @@
import { z } from "astro/zod";
import type { FieldDefinition, FieldUIHints } from "./types.js";
export interface JsonOptions<T = unknown> {
required?: boolean;
schema?: z.ZodType<T>; // Optional custom schema for validation
helpText?: string;
}
/**
* JSON field - arbitrary JSON data
*/
export function json<T = unknown>(options: JsonOptions<T> = {}): FieldDefinition<T> {
// When T = unknown (default), z.unknown() is already z.ZodType<unknown>.
// When a custom schema is provided, it carries the correct generic.
// The generic constraint ensures type safety for callers.
let schema: z.ZodTypeAny = options.schema ?? z.unknown();
// Optional vs required
if (!options.required && !options.schema) {
schema = z.unknown().optional();
}
const ui: FieldUIHints = {
widget: "json",
helpText: options.helpText || "JSON data",
};
return {
type: "json",
columnType: "JSON",
schema,
options,
ui,
};
}

View File

@@ -0,0 +1,48 @@
import { z } from "astro/zod";
import type { FieldDefinition, FieldUIHints } from "./types.js";
export interface MultiSelectOptions<T extends readonly [string, ...string[]]> {
options: T;
required?: boolean;
min?: number;
max?: number;
helpText?: string;
}
/**
* MultiSelect field - multiple choices from predefined options
*/
export function multiSelect<T extends readonly [string, ...string[]]>(
msOptions: MultiSelectOptions<T>,
): FieldDefinition<T[number][]> {
let arraySchema = z.array(z.enum(msOptions.options));
// Apply constraints
if (msOptions.min !== undefined) {
arraySchema = arraySchema.min(msOptions.min, `Must select at least ${msOptions.min}`);
}
if (msOptions.max !== undefined) {
arraySchema = arraySchema.max(msOptions.max, `Must select at most ${msOptions.max}`);
}
// Optional vs required
const schema: z.ZodTypeAny = msOptions.required ? arraySchema : arraySchema.optional();
const ui: FieldUIHints = {
widget: "multiSelect",
helpText: msOptions.helpText,
options: msOptions.options,
min: msOptions.min,
max: msOptions.max,
};
return {
type: "multiSelect",
columnType: "JSON",
schema,
options: msOptions,
ui,
};
}

View File

@@ -0,0 +1,52 @@
import { z } from "astro/zod";
import type { FieldDefinition, FieldUIHints } from "./types.js";
export interface NumberOptions {
required?: boolean;
min?: number;
max?: number;
integer?: boolean;
placeholder?: string;
helpText?: string;
}
/**
* Number field - numeric input
*/
export function number(options: NumberOptions = {}): FieldDefinition<number> {
let numberSchema = z.number();
// Integer constraint
if (options.integer) {
numberSchema = numberSchema.int("Must be an integer");
}
// Range constraints
if (options.min !== undefined) {
numberSchema = numberSchema.min(options.min, `Must be at least ${options.min}`);
}
if (options.max !== undefined) {
numberSchema = numberSchema.max(options.max, `Must be at most ${options.max}`);
}
// Optional vs required
const schema: z.ZodTypeAny = options.required ? numberSchema : numberSchema.optional();
const ui: FieldUIHints = {
widget: "number",
placeholder: options.placeholder,
helpText: options.helpText,
min: options.min,
max: options.max,
};
return {
type: "number",
columnType: "REAL",
schema,
options,
ui,
};
}

View File

@@ -0,0 +1,33 @@
import { z } from "astro/zod";
import type { FieldDefinition, PortableTextBlock } from "./types.js";
/**
* Portable Text block schema
*/
const portableTextBlockSchema: z.ZodType<PortableTextBlock> = z
.object({
_type: z.string(),
_key: z.string(),
})
.passthrough();
/**
* Portable Text field
* Stores structured content in Portable Text format
*/
export function portableText(options?: {
required?: boolean;
}): FieldDefinition<PortableTextBlock[] | undefined> {
const schema = z.array(portableTextBlockSchema);
return {
type: "portableText",
columnType: "JSON",
schema: options?.required === false ? schema.optional() : schema,
options,
ui: {
widget: "portableText",
},
};
}

View File

@@ -0,0 +1,29 @@
import { z } from "astro/zod";
import type { FieldDefinition } from "./types.js";
/**
* Reference field
* References another content item by ID
*/
export function reference(
collection: string,
options?: {
required?: boolean;
},
): FieldDefinition<string | undefined> {
const schema = z.string();
return {
type: "reference",
columnType: "TEXT",
schema: options?.required === false ? schema.optional() : schema,
options: {
...options,
collection,
},
ui: {
widget: "reference",
},
};
}

View File

@@ -0,0 +1,31 @@
import { z } from "astro/zod";
import type { FieldDefinition, FieldUIHints } from "./types.js";
export interface RichTextOptions {
required?: boolean;
helpText?: string;
}
/**
* Rich text field - Markdown content
*/
export function richText(options: RichTextOptions = {}): FieldDefinition<string> {
const stringSchema = z.string();
// Optional vs required
const schema: z.ZodTypeAny = options.required ? stringSchema : stringSchema.optional();
const ui: FieldUIHints = {
widget: "richText",
helpText: options.helpText || "Markdown formatted text",
};
return {
type: "richText",
columnType: "TEXT",
schema,
options,
ui,
};
}

View File

@@ -0,0 +1,46 @@
import { z } from "astro/zod";
import type { FieldDefinition, FieldUIHints } from "./types.js";
export interface SelectOptions<T extends readonly [string, ...string[]]> {
options: T;
required?: boolean;
default?: T[number];
placeholder?: string;
helpText?: string;
}
/**
* Select field - single choice from predefined options
*/
export function select<T extends readonly [string, ...string[]]>(
selectOptions: SelectOptions<T>,
): FieldDefinition<T[number]> {
const enumSchema = z.enum(selectOptions.options);
// Apply default first, then optional
let schema: z.ZodTypeAny;
if (selectOptions.default !== undefined) {
schema = enumSchema.default(selectOptions.default);
} else if (!selectOptions.required) {
// Only make it optional if no default is provided
schema = enumSchema.optional();
} else {
schema = enumSchema;
}
const ui: FieldUIHints = {
widget: "select",
placeholder: selectOptions.placeholder,
helpText: selectOptions.helpText,
options: selectOptions.options,
};
return {
type: "select",
columnType: "TEXT",
schema,
options: selectOptions,
ui,
};
}

View File

@@ -0,0 +1,38 @@
import { z } from "astro/zod";
import type { FieldDefinition, FieldUIHints } from "./types.js";
export interface SlugOptions {
required?: boolean;
from?: string; // Field name to generate slug from
pattern?: RegExp;
helpText?: string;
}
// Default slug pattern: lowercase alphanumeric + hyphens
const DEFAULT_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* Slug field - URL-safe identifier
*/
export function slug(options: SlugOptions = {}): FieldDefinition<string> {
const pattern = options.pattern || DEFAULT_SLUG_PATTERN;
const stringSchema = z.string().regex(pattern, "Invalid slug format");
// Optional vs required
const schema: z.ZodTypeAny = options.required ? stringSchema : stringSchema.optional();
const ui: FieldUIHints = {
widget: "slug",
helpText: options.helpText || "URL-safe identifier (lowercase, hyphens only)",
from: options.from,
};
return {
type: "slug",
columnType: "TEXT",
schema,
options,
ui,
};
}

View File

@@ -0,0 +1,55 @@
import { z } from "astro/zod";
import type { FieldDefinition, FieldUIHints } from "./types.js";
export interface TextOptions {
required?: boolean;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
placeholder?: string;
helpText?: string;
}
/**
* Text field - single line text input
*/
export function text(options: TextOptions = {}): FieldDefinition<string> {
let stringSchema = z.string();
// Apply constraints
if (options.minLength !== undefined) {
stringSchema = stringSchema.min(
options.minLength,
`Must be at least ${options.minLength} characters`,
);
}
if (options.maxLength !== undefined) {
stringSchema = stringSchema.max(
options.maxLength,
`Must be at most ${options.maxLength} characters`,
);
}
if (options.pattern) {
stringSchema = stringSchema.regex(options.pattern, "Invalid format");
}
// Optional vs required
const schema: z.ZodTypeAny = options.required ? stringSchema : stringSchema.optional();
const ui: FieldUIHints = {
widget: "text",
placeholder: options.placeholder,
helpText: options.helpText,
};
return {
type: "text",
columnType: "TEXT",
schema,
options,
ui,
};
}

View File

@@ -0,0 +1,52 @@
import { z } from "astro/zod";
import type { FieldDefinition, FieldUIHints } from "./types.js";
export interface TextareaOptions {
required?: boolean;
minLength?: number;
maxLength?: number;
rows?: number;
placeholder?: string;
helpText?: string;
}
/**
* Textarea field - multi-line text input
*/
export function textarea(options: TextareaOptions = {}): FieldDefinition<string> {
let stringSchema = z.string();
// Apply constraints
if (options.minLength !== undefined) {
stringSchema = stringSchema.min(
options.minLength,
`Must be at least ${options.minLength} characters`,
);
}
if (options.maxLength !== undefined) {
stringSchema = stringSchema.max(
options.maxLength,
`Must be at most ${options.maxLength} characters`,
);
}
// Optional vs required
const schema: z.ZodTypeAny = options.required ? stringSchema : stringSchema.optional();
const ui: FieldUIHints = {
widget: "textarea",
placeholder: options.placeholder,
helpText: options.helpText,
rows: options.rows || 6,
};
return {
type: "textarea",
columnType: "TEXT",
schema,
options,
ui,
};
}

View File

@@ -0,0 +1,64 @@
import type { z } from "astro/zod";
/**
* SQLite column types that map from field types
*/
export type ColumnType = "TEXT" | "REAL" | "INTEGER" | "JSON";
/**
* Base field definition
*
* Note: schema uses z.ZodTypeAny to accommodate optional/default wrappers
*/
export interface FieldDefinition<_T = unknown> {
type: string;
/**
* The SQLite column type to use when storing this field
*/
columnType: ColumnType;
schema: z.ZodTypeAny;
options?: unknown;
ui?: FieldUIHints;
}
/**
* UI hints for admin rendering
*/
export interface FieldUIHints {
widget?: string;
placeholder?: string;
helpText?: string;
rows?: number; // For textarea
min?: number | string;
max?: number | string;
[key: string]: unknown;
}
/**
* Portable Text block structure
*/
export interface PortableTextBlock {
_type: string;
_key: string;
[key: string]: unknown;
}
// Re-export MediaValue from media/types.ts (canonical location)
export type { MediaValue } from "../media/types.js";
import type { MediaValue } from "../media/types.js";
/**
* @deprecated Use MediaValue instead. ImageValue is an alias for backwards compatibility.
*/
export type ImageValue = MediaValue;
/**
* File field value
*/
export interface FileValue {
id: string;
url: string;
filename: string;
mimeType: string;
size: number;
}