import { z } from 'zod'; import { CAPABILITIES } from './capabilities'; /** * Plugin manifest schema (ADR 0008, plugin-architecture.md §"manifest.json", * which is normative). A `manifest.json` is validated at install time; invalid * packages are rejected with the precise messages Zod produces here. */ /** Plugin classes by trust needs (ADR 0008). */ export const PLUGIN_KINDS = ['code', 'section_style'] as const; export type PluginKind = (typeof PLUGIN_KINDS)[number]; /** Extension point types a manifest may declare. */ export const EXTENSION_POINT_TYPES = ['sectionStyle', 'block', 'pageTool'] as const; export type ExtensionPointType = (typeof EXTENSION_POINT_TYPES)[number]; /** Extension point types that belong to each plugin kind. A `section_style` * plugin exposes only `sectionStyle` points; a `code` plugin only `block` / * `pageTool`. */ export const EXTENSION_POINTS_BY_KIND = { section_style: ['sectionStyle'], code: ['block', 'pageTool'], } as const satisfies Record; /** A plugin `id`: a stable slug used in URLs, CSS scopes, and node attributes. * Lowercase to keep CSS class scoping (`.dt-style--`) predictable. */ const pluginIdSchema = z .string() .min(1) .max(64) .regex( /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, 'must be a lowercase slug (letters, digits, single hyphens), e.g. "page-index"', ); /** Semantic version, e.g. `1.2.0`. Kept strict so update comparisons (#71) are * well-defined. */ const semverSchema = z.string().regex(/^\d+\.\d+\.\d+$/, 'must be a semantic version like "1.2.0"'); /** The host checks this against its supported range at install time * (see api-version.ts). A single major, e.g. `"1"`. */ const apiVersionSchema = z .string() .regex(/^\d+$/, 'must be a whole-number major version string like "1"'); /** A human-readable label available in at least German and English (ADR 0012). * Extra locales are allowed. */ const localizedTextSchema = z .object({ de: z.string().min(1), en: z.string().min(1), }) .catchall(z.string().min(1)); const extensionPointSchema = z.object({ type: z.enum(EXTENSION_POINT_TYPES), /** Unique within the manifest; used as the surface id (e.g. the `pageTool` * key, or the section-style key in the CSS scope). */ id: pluginIdSchema, title: localizedTextSchema, }); export type ExtensionPointManifest = z.infer; /** Static representation rendered in Word/PDF exports and when a plugin is * disabled but its blocks still exist in documents (plugin-architecture.md). */ const fallbackSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('text'), value: z.string().min(1) }), z.object({ type: z.literal('image'), /** Path to a bundled asset, relative to the package root. */ value: z.string().min(1), }), ]); export type ManifestFallback = z.infer; /** Optional references to bundled UI-string files, keyed by locale. */ const i18nRefsSchema = z.record( z.string().regex(/^[a-z]{2}(-[A-Z]{2})?$/, 'must be a locale code like "de" or "en-GB"'), z.string().min(1), ); export const manifestSchema = z .object({ id: pluginIdSchema, name: z.string().min(1).max(120), version: semverSchema, apiVersion: apiVersionSchema, kind: z.enum(PLUGIN_KINDS), extensionPoints: z.array(extensionPointSchema).min(1), permissions: z.array(z.enum(CAPABILITIES)).default([]), fallback: fallbackSchema.optional(), /** SPDX identifier or free-form license string. */ license: z.string().min(1), homepage: z.string().url().optional(), i18n: i18nRefsSchema.optional(), }) .strict() .superRefine((manifest, ctx) => { const allowed: readonly string[] = EXTENSION_POINTS_BY_KIND[manifest.kind]; // Extension point types must match the plugin kind, and ids must be unique. const seen = new Set(); manifest.extensionPoints.forEach((point, index) => { if (!allowed.includes(point.type)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['extensionPoints', index, 'type'], message: `kind "${manifest.kind}" allows only ${allowed .map((t) => `"${t}"`) .join(', ')} extension points, not "${point.type}"`, }); } if (seen.has(point.id)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['extensionPoints', index, 'id'], message: `duplicate extension point id "${point.id}"`, }); } seen.add(point.id); }); // Declarative section-style plugins run no JavaScript, so they cannot use // capabilities — those only exist for the sandboxed code path. if (manifest.kind === 'section_style' && manifest.permissions.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['permissions'], message: 'section_style plugins run no code and must not declare permissions', }); } }); export type PluginManifest = z.infer; /** A single actionable validation problem: a dotted path and a message. */ export interface ManifestIssue { path: string; message: string; } export interface ManifestValidationResult { success: boolean; manifest?: PluginManifest; issues: ManifestIssue[]; } function toIssues(error: z.ZodError): ManifestIssue[] { return error.issues.map((issue) => ({ path: issue.path.length > 0 ? issue.path.join('.') : '(root)', message: issue.message, })); } /** * Validates an unknown value against the manifest schema, returning a flat list * of actionable issues instead of throwing — suited to the install path where * every problem is reported to the Site Admin. */ export function validateManifest(input: unknown): ManifestValidationResult { const result = manifestSchema.safeParse(input); if (result.success) { return { success: true, manifest: result.data, issues: [] }; } return { success: false, issues: toIssues(result.error) }; } /** Parses a manifest, throwing a `ZodError` on failure. Convenience for call * sites that treat an invalid manifest as an exceptional condition. */ export function parseManifest(input: unknown): PluginManifest { return manifestSchema.parse(input); }