All checks were successful
CD / Build and push images (push) Successful in 3m14s
CI / Lint, typecheck, test (push) Successful in 3m18s
CI / Auth e2e pack (push) Successful in 4m3s
CI / Import/export fidelity gate (push) Successful in 54s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Has been skipped
The SDK is the contract every other M7 story builds on (ADR 0008,
plugin-architecture.md). New package `@dorfteich/plugin-sdk`, standalone
(only depends on zod) so a plugin author needs nothing else.
- Zod manifest schema (`validateManifest`/`parseManifest`) with actionable
`{ path, message }` issues and cross-field rules (extension-point/kind
match, unique ids, section_style declares no permissions). Fixtures:
3 valid + 14 invalid variants, asserted individually.
- `checkApiVersion` compatibility helper against the host's supported range.
- Capability names + method→capability map as the single source of truth
for the permission gate.
- Transport-agnostic postMessage RPC engine (`createRpcEndpoint`) with
request/response ids, per-request timeouts, unknown-method and
endpoint-disposed handling, plus a `windowTransport` adapter.
- Host side (`createHostBridge`): routes plugin capability calls through
the manifest permission gate; drives plugin lifecycle (render/edit/destroy).
- Plugin side (`createPlugin`): answers lifecycle calls, exposes a typed
`host` proxy. RPC roundtrip verified in a jsdom MessageChannel test
(roundtrip, args, timeout, unknown method, undeclared capability, dispose).
- README documents the protocol with a mermaid sequence diagram.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
175 lines
6.2 KiB
TypeScript
175 lines
6.2 KiB
TypeScript
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<PluginKind, readonly ExtensionPointType[]>;
|
|
|
|
/** A plugin `id`: a stable slug used in URLs, CSS scopes, and node attributes.
|
|
* Lowercase to keep CSS class scoping (`.dt-style-<id>-<styleId>`) 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<typeof extensionPointSchema>;
|
|
|
|
/** 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<typeof fallbackSchema>;
|
|
|
|
/** 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<string>();
|
|
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<typeof manifestSchema>;
|
|
|
|
/** 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);
|
|
}
|