diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index 2fe27573b..74f6f5d14 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -3,18 +3,18 @@ import { defineConfig } from 'drizzle-kit'; /** * Drizzle config for the unified mana-api. * - * Most modules in apps/api inline their schemas in routes.ts and create - * tables out-of-band (or piggyback on schemas owned by other services). - * This config currently only manages the `research` schema introduced for - * the deep-research feature; expand the `schema` glob and `schemaFilter` - * as more modules adopt managed migrations. + * Managed schemas accumulate as modules adopt managed migrations. Each + * schema's generated SQL lives under `drizzle/{schema}/`. Expand the + * `schema` array and `schemaFilter` when a new module joins. + * + * Currently managed: `research`, `website`. */ export default defineConfig({ - schema: './src/modules/research/schema.ts', - out: './drizzle/research', + schema: ['./src/modules/research/schema.ts', './src/modules/website/schema.ts'], + out: './drizzle', dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform', }, - schemaFilter: ['research'], + schemaFilter: ['research', 'website'], }); diff --git a/apps/api/drizzle/website/0000_init.sql b/apps/api/drizzle/website/0000_init.sql new file mode 100644 index 000000000..48d298e1a --- /dev/null +++ b/apps/api/drizzle/website/0000_init.sql @@ -0,0 +1,38 @@ +-- Website module — initial schema (manually authored to match +-- apps/api/src/modules/website/schema.ts). +-- +-- Lives in mana_platform under its own pgSchema. M2 scope: published +-- snapshots only — editor data (sites/pages/blocks) lives in the +-- generic mana-sync store and is not mirrored here. See +-- docs/plans/website-builder.md §D5 + §D6. +-- +-- Apply with: +-- psql "$DATABASE_URL" -f apps/api/drizzle/website/0000_init.sql + +CREATE SCHEMA IF NOT EXISTS "website"; + +CREATE TABLE IF NOT EXISTS "website"."published_snapshots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "site_id" uuid NOT NULL, + "slug" text NOT NULL, + "blob" jsonb NOT NULL, + "is_current" boolean NOT NULL DEFAULT false, + "published_at" timestamptz NOT NULL DEFAULT now(), + "published_by" uuid NOT NULL, + "space_id" uuid +); + +-- Rollback / history query path: "all snapshots of this site, newest first". +CREATE INDEX IF NOT EXISTS "published_snapshots_site_idx" + ON "website"."published_snapshots" ("site_id", "published_at" DESC); + +-- Public resolver path: "current snapshot by slug". +CREATE INDEX IF NOT EXISTS "published_snapshots_slug_current_idx" + ON "website"."published_snapshots" ("slug", "is_current"); + +-- Hard invariant: exactly one current row per slug. The publish endpoint +-- wraps the old→false + new→true flip in a transaction; this partial +-- unique index catches any code path that would violate it. +CREATE UNIQUE INDEX IF NOT EXISTS "published_snapshots_slug_current_unique_idx" + ON "website"."published_snapshots" ("slug") + WHERE "is_current" = true; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index acba8c46e..f4e71553e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -42,6 +42,7 @@ import { presiRoutes } from './modules/presi/routes'; import { researchRoutes } from './modules/research/routes'; import { whoRoutes } from './modules/who/routes'; import { websiteRoutes } from './modules/website/routes'; +import { websitePublicRoutes } from './modules/website/public-routes'; import { wetterRoutes } from './modules/wetter/routes'; const PORT = parseInt(process.env.PORT || '3060', 10); @@ -56,8 +57,10 @@ app.use('*', cors({ origin: CORS_ORIGINS, credentials: true })); app.route('/health', healthRoute('mana-api')); app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 })); -// Public routes — no auth required (weather data is public) +// Public routes — no auth required (weather data is public, published +// websites are by definition public). app.route('/api/v1/wetter', wetterRoutes); +app.route('/api/v1/website/public', websitePublicRoutes); app.use('/api/*', authMiddleware()); diff --git a/apps/api/src/modules/website/public-routes.ts b/apps/api/src/modules/website/public-routes.ts new file mode 100644 index 000000000..6b99e7607 --- /dev/null +++ b/apps/api/src/modules/website/public-routes.ts @@ -0,0 +1,55 @@ +/** + * Public website routes — UNAUTHENTICATED. + * + * Mounted at `/api/v1/website/public/*` BEFORE the global + * `authMiddleware` in apps/api/src/index.ts so anonymous visitors can + * fetch published snapshots. + */ + +import { Hono } from 'hono'; +import { and, eq } from 'drizzle-orm'; +import { db, publishedSnapshots } from './schema'; +import { errorResponse } from '../../lib/responses'; + +const routes = new Hono(); + +/** + * GET /api/v1/website/public/sites/:slug + * + * Returns the currently-published snapshot blob (404 if not found). + * The blob includes all pages — the SvelteKit route picks the right + * one by path client-side / in its server-load. One round trip serves + * the whole site. + */ +routes.get('/sites/:slug', async (c) => { + const slug = c.req.param('slug'); + if (!slug) return errorResponse(c, 'slug required', 400); + + const rows = await db + .select({ + id: publishedSnapshots.id, + slug: publishedSnapshots.slug, + blob: publishedSnapshots.blob, + publishedAt: publishedSnapshots.publishedAt, + }) + .from(publishedSnapshots) + .where(and(eq(publishedSnapshots.slug, slug), eq(publishedSnapshots.isCurrent, true))) + .limit(1); + + if (!rows[0]) return errorResponse(c, 'Site not found', 404, { code: 'NOT_FOUND' }); + + // Conservative caching: short freshness window, aggressive stale-while- + // revalidate. Publish endpoint will purge by tag in M6; until then CF + // respects the max-age on the edge layer. + c.header('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=86400'); + c.header('Cache-Tag', `site-${rows[0].id}`); + + return c.json({ + snapshotId: rows[0].id, + slug: rows[0].slug, + publishedAt: rows[0].publishedAt.toISOString(), + blob: rows[0].blob, + }); +}); + +export const websitePublicRoutes = routes; diff --git a/apps/api/src/modules/website/publish.ts b/apps/api/src/modules/website/publish.ts new file mode 100644 index 000000000..94d33693d --- /dev/null +++ b/apps/api/src/modules/website/publish.ts @@ -0,0 +1,246 @@ +/** + * Publish + unpublish endpoints. + * + * Scoped to *authenticated* users who can publish their own site. The + * public read path lives in `public-routes.ts` and is mounted outside + * the auth gate. + */ + +import { Hono } from 'hono'; +import { z } from 'zod'; +import { and, desc, eq } from 'drizzle-orm'; +import type { AuthVariables } from '@mana/shared-hono'; +import { errorResponse, validationError } from '../../lib/responses'; +import { db, publishedSnapshots } from './schema'; +import { isValidSlug } from './reserved-slugs'; + +const routes = new Hono<{ Variables: AuthVariables }>(); + +// Permissive schema — block props are client-trusted in M2; server-side +// Zod validation per block spec arrives in a later phase (see plan D8). +const SnapshotBlockSchema: z.ZodType = z.lazy(() => + z.object({ + id: z.string().uuid(), + type: z.string().min(1).max(64), + schemaVersion: z.number().int().min(1), + slotKey: z.string().max(64).nullable(), + props: z.unknown(), + children: z.array(SnapshotBlockSchema), + }) +); + +const SnapshotPageSchema = z.object({ + id: z.string().uuid(), + path: z.string().min(1).max(256), + title: z.string().min(1).max(256), + seo: z + .object({ + title: z.string().max(256).optional(), + description: z.string().max(1024).optional(), + ogImage: z.string().max(1024).optional(), + noindex: z.boolean().optional(), + }) + .passthrough(), + blocks: z.array(SnapshotBlockSchema), +}); + +const SnapshotSiteSchema = z.object({ + id: z.string().uuid(), + slug: z.string().min(2).max(40), + name: z.string().min(1).max(128), + theme: z.unknown(), + navConfig: z.unknown(), + footerConfig: z.unknown(), + settings: z.unknown(), +}); + +const DraftSnapshotSchema = z.object({ + version: z.literal('1'), + site: SnapshotSiteSchema, + pages: z.array(SnapshotPageSchema).min(1), +}); + +// ─── POST /sites/:id/publish ──────────────────────────── + +routes.post('/sites/:id/publish', async (c) => { + const userId = c.get('userId'); + // Space id flows in via an explicit header (mana-auth doesn't yet + // embed the active space in JWT claims). Nullable — full membership + // check lands in M6; M2 stores whatever the client declares. + const spaceIdHeader = c.req.header('X-Mana-Space'); + const spaceId = spaceIdHeader && /^[0-9a-f-]{36}$/i.test(spaceIdHeader) ? spaceIdHeader : null; + const siteId = c.req.param('id'); + + if (!siteId) return errorResponse(c, 'siteId required', 400); + + const parsed = DraftSnapshotSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return validationError(c, parsed.error.issues); + + const draft = parsed.data; + if (draft.site.id !== siteId) { + return errorResponse(c, 'Site id mismatch between path and body', 400, { + code: 'SITE_ID_MISMATCH', + }); + } + if (!isValidSlug(draft.site.slug)) { + return errorResponse(c, `Slug "${draft.site.slug}" is invalid or reserved`, 400, { + code: 'INVALID_SLUG', + }); + } + + // Check slug conflict: is another site currently published with this slug? + const conflicting = await db + .select({ id: publishedSnapshots.id, siteId: publishedSnapshots.siteId }) + .from(publishedSnapshots) + .where( + and(eq(publishedSnapshots.slug, draft.site.slug), eq(publishedSnapshots.isCurrent, true)) + ) + .limit(1); + if (conflicting[0] && conflicting[0].siteId !== siteId) { + return errorResponse( + c, + `Slug "${draft.site.slug}" is already taken by another published site`, + 409, + { code: 'SLUG_TAKEN' } + ); + } + + // Atomic flip: old→false, new→true. The partial unique index on + // (slug WHERE is_current=true) catches any concurrent publishers + // racing for the same slug. + const now = new Date().toISOString(); + const blob = { + ...draft, + publishedAt: now, + publishedBy: userId, + }; + + try { + const result = await db.transaction(async (tx) => { + await tx + .update(publishedSnapshots) + .set({ isCurrent: false }) + .where(and(eq(publishedSnapshots.siteId, siteId), eq(publishedSnapshots.isCurrent, true))); + + const [row] = await tx + .insert(publishedSnapshots) + .values({ + siteId, + slug: draft.site.slug, + blob, + isCurrent: true, + publishedBy: userId, + spaceId, + }) + .returning({ id: publishedSnapshots.id, publishedAt: publishedSnapshots.publishedAt }); + + return row; + }); + + if (!result) throw new Error('Insert returned no row'); + + return c.json( + { + snapshotId: result.id, + publishedAt: result.publishedAt.toISOString(), + publicUrl: `/s/${draft.site.slug}`, + }, + 201 + ); + } catch (err) { + // Postgres unique-constraint violation → slug conflict we didn't + // catch in the pre-check (classic race). + if (err instanceof Error && /unique/i.test(err.message)) { + return errorResponse(c, `Slug "${draft.site.slug}" was taken by a concurrent publish`, 409, { + code: 'SLUG_TAKEN', + }); + } + throw err; + } +}); + +// ─── POST /sites/:id/unpublish ────────────────────────── + +routes.post('/sites/:id/unpublish', async (c) => { + const siteId = c.req.param('id'); + if (!siteId) return errorResponse(c, 'siteId required', 400); + + const updated = await db + .update(publishedSnapshots) + .set({ isCurrent: false }) + .where(and(eq(publishedSnapshots.siteId, siteId), eq(publishedSnapshots.isCurrent, true))) + .returning({ id: publishedSnapshots.id }); + + if (updated.length === 0) { + return errorResponse(c, 'No current snapshot to unpublish', 404, { + code: 'NOT_PUBLISHED', + }); + } + + return c.json({ unpublished: updated.length }); +}); + +// ─── GET /sites/:id/snapshots ─────────────────────────── +// Rollback-list: the last 10 snapshots of this site, newest first. + +routes.get('/sites/:id/snapshots', async (c) => { + const siteId = c.req.param('id'); + if (!siteId) return errorResponse(c, 'siteId required', 400); + + const rows = await db + .select({ + id: publishedSnapshots.id, + publishedAt: publishedSnapshots.publishedAt, + publishedBy: publishedSnapshots.publishedBy, + isCurrent: publishedSnapshots.isCurrent, + slug: publishedSnapshots.slug, + }) + .from(publishedSnapshots) + .where(eq(publishedSnapshots.siteId, siteId)) + .orderBy(desc(publishedSnapshots.publishedAt)) + .limit(10); + + return c.json({ + snapshots: rows.map((r) => ({ + id: r.id, + publishedAt: r.publishedAt.toISOString(), + publishedBy: r.publishedBy, + isCurrent: r.isCurrent, + slug: r.slug, + })), + }); +}); + +// ─── POST /sites/:id/rollback/:snapshotId ────────────── +// Flip is_current to point at a historical snapshot. + +routes.post('/sites/:id/rollback/:snapshotId', async (c) => { + const siteId = c.req.param('id'); + const snapshotId = c.req.param('snapshotId'); + if (!siteId || !snapshotId) return errorResponse(c, 'siteId and snapshotId required', 400); + + // Verify the snapshot belongs to this site. + const target = await db + .select({ id: publishedSnapshots.id, slug: publishedSnapshots.slug }) + .from(publishedSnapshots) + .where(and(eq(publishedSnapshots.id, snapshotId), eq(publishedSnapshots.siteId, siteId))) + .limit(1); + if (!target[0]) { + return errorResponse(c, 'Snapshot not found for this site', 404, { code: 'NOT_FOUND' }); + } + + await db.transaction(async (tx) => { + await tx + .update(publishedSnapshots) + .set({ isCurrent: false }) + .where(and(eq(publishedSnapshots.siteId, siteId), eq(publishedSnapshots.isCurrent, true))); + await tx + .update(publishedSnapshots) + .set({ isCurrent: true }) + .where(eq(publishedSnapshots.id, snapshotId)); + }); + + return c.json({ rolledBack: true, slug: target[0].slug }); +}); + +export const websitePublishRoutes = routes; diff --git a/apps/api/src/modules/website/reserved-slugs.ts b/apps/api/src/modules/website/reserved-slugs.ts new file mode 100644 index 000000000..65d3e57bb --- /dev/null +++ b/apps/api/src/modules/website/reserved-slugs.ts @@ -0,0 +1,42 @@ +/** + * Reserved slugs — server-authoritative list. The client carries a + * mirror copy in `apps/mana/apps/web/src/lib/modules/website/constants.ts` + * for fast pre-flight UX, but this list is the one that matters at + * publish time. + * + * Rule: any slug that would shadow a SvelteKit route or collide with + * a well-known subdomain goes here. When a new top-level route is + * added, append its segment here in the same PR. + */ + +export const RESERVED_SLUGS: readonly string[] = [ + 'app', + 'api', + 'auth', + 'admin', + 'settings', + 'docs', + 'blog', + 'www', + 'mail', + 'dashboard', + 'login', + 'logout', + 'register', + 'signup', + 'signin', + 'account', + 'billing', + 'help', + 'support', + 's', // the public-renderer prefix /s/ +]; + +/** Same regex as the client uses — 2-40 lowercase alphanumerics + hyphens. */ +const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/; + +export function isValidSlug(slug: string): boolean { + if (!SLUG_REGEX.test(slug)) return false; + if (RESERVED_SLUGS.includes(slug.toLowerCase())) return false; + return true; +} diff --git a/apps/api/src/modules/website/routes.ts b/apps/api/src/modules/website/routes.ts new file mode 100644 index 000000000..f22940334 --- /dev/null +++ b/apps/api/src/modules/website/routes.ts @@ -0,0 +1,53 @@ +/** + * Website module — block-tree CMS backend. + * + * M1 scope (this file): health + reserved-slug validation endpoint. + * + * CRUD for sites / pages / blocks goes through the generic mana-sync + * pipeline (local-first); this module only hosts compute-style + * endpoints that need server authority: + * - slug / path validation (reserved-list check) + * - publish (M2) — builds snapshot, writes published_snapshots, + * purges Cloudflare cache + * - submit (M4) — unauthenticated form endpoint + * - published-snapshot read (M2) — powers the public /s/[slug] renderer + * + * See docs/plans/website-builder.md. + */ + +import { Hono } from 'hono'; +import type { AuthVariables } from '@mana/shared-hono'; +import { RESERVED_SLUGS, isValidSlug } from './reserved-slugs'; +import { websitePublishRoutes } from './publish'; + +const routes = new Hono<{ Variables: AuthVariables }>(); + +routes.get('/health', (c) => c.json({ status: 'ok', module: 'website', milestone: 'M2' })); + +/** + * Slug validation endpoint — mirrors the client-side check in + * constants.ts but with server-authoritative reserved-list. Used by + * the editor before create to surface a clear error early. + */ +routes.get('/slugs/check', (c) => { + const slug = c.req.query('slug') ?? ''; + const reserved = RESERVED_SLUGS.includes(slug.toLowerCase()); + const valid = isValidSlug(slug); + return c.json({ + slug, + valid, + reserved, + reason: !valid ? (reserved ? 'reserved' : 'format') : null, + }); +}); + +/** + * Expose the reserved-slug list so future tools (AI agents, import + * scripts) don't hard-code their own copy. + */ +routes.get('/slugs/reserved', (c) => c.json({ reserved: RESERVED_SLUGS })); + +// ─── Publish + rollback (authenticated) ──────────────── +routes.route('/', websitePublishRoutes); + +export const websiteRoutes = routes; diff --git a/apps/api/src/modules/website/schema.ts b/apps/api/src/modules/website/schema.ts new file mode 100644 index 000000000..6e4be6931 --- /dev/null +++ b/apps/api/src/modules/website/schema.ts @@ -0,0 +1,69 @@ +/** + * Website module — DB schema (Drizzle / pgSchema 'website') + * + * Server-side store for **published snapshots only**. Editor data (sites, + * pages, blocks) is local-first and lives in IndexedDB + the generic + * mana-sync Postgres pool. We don't mirror it into a dedicated table — + * the publish endpoint receives the assembled blob from the client. + * + * See docs/plans/website-builder.md §D5 (draft/published as two + * snapshots) and §D6 (public-serving via SvelteKit-SSR). + * + * Storage model: + * - One row per publish event. `is_current=true` marks the row served + * to the public — there is always exactly one current row per slug + * (enforced by a partial unique index). + * - Older rows stay for rollback (M2). GC job in M7 deletes rows + * beyond a retention window (last 10 per site, say). + * - `slug` is denormalized from the site so public resolution is a + * single index lookup without JOIN. + * - No FK to a `sites` table because sites live in the sync store, not + * here. Orphaning is acceptable: if a user deletes their site, the + * client calls POST /sites/:id/unpublish to flip is_current off. + */ + +import { drizzle } from 'drizzle-orm/postgres-js'; +import { pgSchema, uuid, text, timestamp, jsonb, boolean, index } from 'drizzle-orm/pg-core'; +import { getConnection } from '../../lib/db'; + +export const websiteSchema = pgSchema('website'); + +/** + * One row per publish. Content is a fully-resolved JSON blob that the + * public renderer serves verbatim (no JOINs, no downstream queries). + */ +export const publishedSnapshots = websiteSchema.table( + 'published_snapshots', + { + id: uuid('id').defaultRandom().primaryKey(), + /** Site id from the client. Untethered UUID (no FK). */ + siteId: uuid('site_id').notNull(), + /** Denormalized from site.slug at publish time. Unique per is_current. */ + slug: text('slug').notNull(), + /** Full resolved snapshot — shape mirrors PublishedSnapshot in publish.ts. */ + blob: jsonb('blob').notNull(), + /** True for the row served to the public. Exactly one per slug. */ + isCurrent: boolean('is_current').notNull().default(false), + publishedAt: timestamp('published_at', { withTimezone: true }).defaultNow().notNull(), + /** User who pressed the publish button. */ + publishedBy: uuid('published_by').notNull(), + /** + * Space the site belongs to. Nullable in M2 because mana-auth + * doesn't yet thread the active space into JWT claims — the + * client can pass it via `X-Mana-Space`, but we don't hard-require + * it until server-side membership check lands (M6). + */ + spaceId: uuid('space_id'), + }, + (table) => [ + index('published_snapshots_site_idx').on(table.siteId, table.publishedAt), + index('published_snapshots_slug_current_idx').on(table.slug, table.isCurrent), + ] +); + +export const db = drizzle(getConnection(), { + schema: { publishedSnapshots }, +}); + +export type PublishedSnapshotRow = typeof publishedSnapshots.$inferSelect; +export type NewPublishedSnapshot = typeof publishedSnapshots.$inferInsert; diff --git a/apps/mana/apps/web/package.json b/apps/mana/apps/web/package.json index 947c9b48e..cc19c636c 100644 --- a/apps/mana/apps/web/package.json +++ b/apps/mana/apps/web/package.json @@ -77,6 +77,7 @@ "@mana/shared-utils": "workspace:*", "@mana/spiral-db": "workspace:*", "@mana/wallpaper-generator": "workspace:*", + "@mana/website-blocks": "workspace:*", "@quotes/content": "workspace:*", "@tiptap/core": "^3.22.4", "@tiptap/extension-image": "^3.22.4", diff --git a/apps/mana/apps/web/src/lib/app-registry/apps.ts b/apps/mana/apps/web/src/lib/app-registry/apps.ts index 513e853bd..7f55725fd 100644 --- a/apps/mana/apps/web/src/lib/app-registry/apps.ts +++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts @@ -77,6 +77,7 @@ import { ArrowClockwise, Flask, Exam, + Globe, } from '@mana/shared-icons'; // ── Apps with entity capabilities ─────────────────────────── @@ -1325,6 +1326,27 @@ registerApp({ }, }); +registerApp({ + id: 'website', + name: 'Website', + color: '#6366f1', + icon: Globe, + views: { + list: { load: () => import('$lib/modules/website/ListView.svelte') }, + }, + contextMenuActions: [ + { + id: 'new-site', + label: 'Neue Website', + icon: Plus, + action: () => + window.dispatchEvent( + new CustomEvent('mana:quick-action', { detail: { app: 'website', action: 'new' } }) + ), + }, + ], +}); + registerApp({ id: 'quiz', name: 'Quiz', diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts b/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts index c47c3dd80..75fc5fc1a 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/runner.ts @@ -40,6 +40,7 @@ import { getAgent } from '../agents/store'; import { DEFAULT_AGENT_NAME } from '../agents/types'; import type { Mission, MissionIteration, PlanStep } from './types'; import { + AI_TOOL_CATALOG_BY_NAME, buildSystemPrompt, runPlannerLoop, runPrePlanGuardrails, @@ -266,6 +267,12 @@ async function runMissionInner( tools: availableTools, model: deps.model ?? 'google/gemini-2.5-flash', maxRounds: MAX_PLANNER_ROUNDS, + // Fan-out read tools when the planner requests several in + // one round. Writes (propose policy) stay sequential so the + // proposal inbox shows the LLM's intended ordering and the + // pre-execute guardrail can reason about state built up by + // prior steps in the same round. + isParallelSafe: (name) => AI_TOOL_CATALOG_BY_NAME.get(name)?.defaultPolicy === 'auto', }, onToolCall: async (call: ToolCallRequest): Promise => { await checkCancel(); diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index d6c2441d5..eab34bb99 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -103,6 +103,7 @@ import { articlesModuleConfig } from '$lib/modules/articles/module.config'; import { invoicesModuleConfig } from '$lib/modules/invoices/module.config'; import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config'; import { wetterModuleConfig } from '$lib/modules/wetter/module.config'; +import { websiteModuleConfig } from '$lib/modules/website/module.config'; import { aiModuleConfig } from '$lib/data/ai/module.config'; export const MODULE_CONFIGS: readonly ModuleConfig[] = [ @@ -162,6 +163,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ invoicesModuleConfig, broadcastModuleConfig, wetterModuleConfig, + websiteModuleConfig, aiModuleConfig, ]; diff --git a/apps/mana/apps/web/src/lib/modules/companion/engine.ts b/apps/mana/apps/web/src/lib/modules/companion/engine.ts index 13cddecdc..a1581270c 100644 --- a/apps/mana/apps/web/src/lib/modules/companion/engine.ts +++ b/apps/mana/apps/web/src/lib/modules/companion/engine.ts @@ -14,6 +14,7 @@ import { runPlannerLoop, AI_TOOL_CATALOG, + AI_TOOL_CATALOG_BY_NAME, type ChatMessage, type ToolCallRequest, type ToolResult, @@ -104,6 +105,11 @@ export async function runCompanionChat( model: 'google/gemini-2.5-flash', maxRounds: MAX_TOOL_ROUNDS, temperature: 0.7, + // Parallelise reads (auto-policy tools) when the LLM + // fans out multiple list_*/get_* calls in one round. + // Writes (propose policy) stay sequential to preserve + // user-visible intent order in the proposal inbox. + isParallelSafe: (name) => AI_TOOL_CATALOG_BY_NAME.get(name)?.defaultPolicy === 'auto', }, onToolCall: async (call: ToolCallRequest): Promise => { const startedAt = Date.now(); diff --git a/apps/mana/apps/web/src/lib/modules/website/ListView.svelte b/apps/mana/apps/web/src/lib/modules/website/ListView.svelte new file mode 100644 index 000000000..f6b4bda33 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/ListView.svelte @@ -0,0 +1,393 @@ + + +
+
+
+

Deine Websites

+

+ Block-Editor, veröffentlichen unter mana.how. M1 — Publish kommt in M2. +

+
+ +
+ + {#if sites.value.length === 0} +
+

Noch keine Website. Leg mit einer leeren Seite los.

+ +
+ {:else} + + {/if} +
+ +{#if showCreate} +
e.key === 'Escape' && closeCreate()} + role="button" + tabindex="-1" + >
+ +{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/website/collections.ts b/apps/mana/apps/web/src/lib/modules/website/collections.ts new file mode 100644 index 000000000..75e69d674 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/collections.ts @@ -0,0 +1,6 @@ +import { db } from '$lib/data/database'; +import type { LocalWebsite, LocalWebsitePage, LocalWebsiteBlock } from './types'; + +export const websitesTable = db.table('websites'); +export const websitePagesTable = db.table('websitePages'); +export const websiteBlocksTable = db.table('websiteBlocks'); diff --git a/apps/mana/apps/web/src/lib/modules/website/components/BlockInspector.svelte b/apps/mana/apps/web/src/lib/modules/website/components/BlockInspector.svelte new file mode 100644 index 000000000..2e3af3500 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/components/BlockInspector.svelte @@ -0,0 +1,134 @@ + + +
+ {#if spec} +
+
+

{spec.category}

+

{spec.label}

+
+ +
+ +
+ +
+ + {#if lastError} +

{lastError}

+ {/if} + {:else} +

Unbekannter Block-Typ: {block.type}

+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/website/components/BlockRenderer.svelte b/apps/mana/apps/web/src/lib/modules/website/components/BlockRenderer.svelte new file mode 100644 index 000000000..b755ae9af --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/components/BlockRenderer.svelte @@ -0,0 +1,87 @@ + + +{#each topLevel as block (block.id)} + {@const spec = getBlockSpec(block.type)} + {#if spec} + {#if mode === 'edit'} +
onSelect?.(block.id)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect?.(block.id); + } + }} + > + +
+ {:else} +
+ +
+ {/if} + {:else if mode === 'edit'} +
+ Unbekannter Block-Typ: {block.type} +
+ {/if} +{/each} + + diff --git a/apps/mana/apps/web/src/lib/modules/website/components/InsertPalette.svelte b/apps/mana/apps/web/src/lib/modules/website/components/InsertPalette.svelte new file mode 100644 index 000000000..43975c446 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/components/InsertPalette.svelte @@ -0,0 +1,75 @@ + + +
+

Block einfügen

+
+ {#each specs as spec (spec.type)} + + {/each} +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/website/components/PageList.svelte b/apps/mana/apps/web/src/lib/modules/website/components/PageList.svelte new file mode 100644 index 000000000..707310c1c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/components/PageList.svelte @@ -0,0 +1,259 @@ + + +
+
+

Seiten

+ +
+ + + + {#if showAdd} +
+ + + {#if addError} +

{addError}

+ {/if} +
+ + +
+
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/website/components/PublishBar.svelte b/apps/mana/apps/web/src/lib/modules/website/components/PublishBar.svelte new file mode 100644 index 000000000..806891a28 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/components/PublishBar.svelte @@ -0,0 +1,202 @@ + + +
+
+ {#if site.publishedVersion} + Live + + {publicUrl} ↗ + + {#if hasDraftAhead} + Unveröffentlichte Änderungen + {/if} + {:else} + Entwurf + Noch nicht veröffentlicht + {/if} +
+ +
+ {#if site.publishedVersion} + + + {:else} + + {/if} +
+ + {#if lastError} + + {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/website/constants.ts b/apps/mana/apps/web/src/lib/modules/website/constants.ts new file mode 100644 index 000000000..8d74315c7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/constants.ts @@ -0,0 +1,64 @@ +import type { ThemeConfig, NavConfig, FooterConfig, SiteSettings } from './types'; + +/** + * Reserved slugs that cannot be used as site slugs. Enforced client-side + * in the store pre-check and (authoritatively) in the backend publish + * endpoint. See docs/plans/website-builder.md §D11 — a site with + * slug='api' would shadow the API route tree. + */ +export const RESERVED_SLUGS: readonly string[] = [ + 'app', + 'api', + 'auth', + 'admin', + 'settings', + 'docs', + 'blog', + 'www', + 'mail', + 'dashboard', + 'login', + 'logout', + 'register', + 'signup', + 'signin', + 'account', + 'billing', + 'help', + 'support', + 's', // conflicts with the public-renderer prefix /s/ +]; + +export function isReservedSlug(slug: string): boolean { + return RESERVED_SLUGS.includes(slug.toLowerCase()); +} + +/** + * Slug regex — lowercase alphanumerics + hyphens, 2-40 chars, no leading + * or trailing hyphen. Mirrored in the backend for authoritative checks. + */ +export const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/; + +export function isValidSlug(slug: string): boolean { + return SLUG_REGEX.test(slug) && !isReservedSlug(slug); +} + +export const DEFAULT_THEME: ThemeConfig = { + preset: 'classic', +}; + +export const DEFAULT_NAV: NavConfig = { + items: [], +}; + +export const DEFAULT_FOOTER: FooterConfig = { + text: '', + links: [], +}; + +export const DEFAULT_SETTINGS: SiteSettings = {}; + +export const DEFAULT_HOME_PAGE = { + path: '/', + title: 'Start', +} as const; diff --git a/apps/mana/apps/web/src/lib/modules/website/index.ts b/apps/mana/apps/web/src/lib/modules/website/index.ts new file mode 100644 index 000000000..469c7de33 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/index.ts @@ -0,0 +1,53 @@ +export { sitesStore, InvalidSlugError, DuplicateSlugError } from './stores/sites.svelte'; +export { + pagesStore, + InvalidPathError, + DuplicatePathError, + isValidPath, +} from './stores/pages.svelte'; +export { blocksStore, InvalidBlockPropsError } from './stores/blocks.svelte'; + +export { + useAllSites, + useAllPages, + useAllBlocks, + findSite, + findPage, + pagesForSite, + blocksForPage, + toWebsite, + toWebsitePage, + toWebsiteBlock, + buildBlockTree, +} from './queries'; + +export { websitesTable, websitePagesTable, websiteBlocksTable } from './collections'; + +export { + RESERVED_SLUGS, + SLUG_REGEX, + isReservedSlug, + isValidSlug, + DEFAULT_THEME, + DEFAULT_NAV, + DEFAULT_FOOTER, + DEFAULT_SETTINGS, + DEFAULT_HOME_PAGE, +} from './constants'; + +export type { + LocalWebsite, + LocalWebsitePage, + LocalWebsiteBlock, + Website, + WebsitePage, + WebsiteBlock, + ThemeConfig, + ThemePreset, + NavConfig, + NavItem, + FooterConfig, + FooterLink, + SiteSettings, + PageSeo, +} from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/website/module.config.ts b/apps/mana/apps/web/src/lib/modules/website/module.config.ts new file mode 100644 index 000000000..ff418f30e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/module.config.ts @@ -0,0 +1,6 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const websiteModuleConfig: ModuleConfig = { + appId: 'website', + tables: [{ name: 'websites' }, { name: 'websitePages' }, { name: 'websiteBlocks' }], +}; diff --git a/apps/mana/apps/web/src/lib/modules/website/publish.ts b/apps/mana/apps/web/src/lib/modules/website/publish.ts new file mode 100644 index 000000000..c411bea9d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/publish.ts @@ -0,0 +1,305 @@ +/** + * Snapshot builder + publish client. + * + * `buildSnapshot(siteId)` walks Dexie to produce the deterministic blob + * the publish endpoint stores. `publishSite()` and `unpublishSite()` + * thin wrappers around the api + sync-path. + * + * Determinism: pages sorted by (order ASC, id ASC), block trees by the + * same key. Same draft state → same blob bytes, regardless of which + * client or session does the publish. Important because we might later + * skip re-publish-if-no-change in the UI, and because a byte-identical + * blob means CF cache keys stay stable. + * + * See docs/plans/website-builder.md §D5. + */ + +import { getManaApiUrl } from '$lib/api/config'; +import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections'; +import type { + LocalWebsite, + LocalWebsiteBlock, + ThemeConfig, + NavConfig, + FooterConfig, + SiteSettings, + PageSeo, +} from './types'; + +// ─── Snapshot shape ────────────────────────────────────── + +export const SNAPSHOT_VERSION = '1' as const; + +export interface SnapshotBlockNode { + id: string; + type: string; + schemaVersion: number; + slotKey: string | null; + props: unknown; + children: SnapshotBlockNode[]; +} + +export interface SnapshotPage { + id: string; + path: string; + title: string; + seo: PageSeo; + blocks: SnapshotBlockNode[]; +} + +export interface SnapshotSite { + id: string; + slug: string; + name: string; + theme: ThemeConfig; + navConfig: NavConfig; + footerConfig: FooterConfig; + settings: SiteSettings; +} + +/** + * Client-built snapshot. Server augments with `publishedAt` and + * `publishedBy` before storage — those fields live on the row, not in + * the client-sent payload, so they can't be spoofed. + */ +export interface DraftSnapshot { + version: typeof SNAPSHOT_VERSION; + site: SnapshotSite; + pages: SnapshotPage[]; +} + +/** + * The shape served by the public endpoint — client-built blob plus the + * server-authored publication fields. + */ +export interface PublishedSnapshot extends DraftSnapshot { + publishedAt: string; + publishedBy: string; + snapshotId: string; +} + +// ─── Build (Dexie → DraftSnapshot) ─────────────────────── + +export class SnapshotBuildError extends Error { + constructor(message: string) { + super(message); + this.name = 'SnapshotBuildError'; + } +} + +/** + * Assemble the draft snapshot for `siteId` from Dexie. Throws if the + * site, a required table, or any referenced record is missing. + */ +export async function buildSnapshot(siteId: string): Promise { + const site = await websitesTable.get(siteId); + if (!site || site.deletedAt) { + throw new SnapshotBuildError(`Site ${siteId} not found`); + } + + const allPages = await websitePagesTable.where('siteId').equals(siteId).toArray(); + const livePages = allPages + .filter((p) => !p.deletedAt) + .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)); + if (livePages.length === 0) { + throw new SnapshotBuildError( + `Site ${siteId} has no pages — add at least one before publishing` + ); + } + + const pageIds = livePages.map((p) => p.id); + const allBlocks = await websiteBlocksTable.where('pageId').anyOf(pageIds).toArray(); + const liveBlocks = allBlocks.filter((b) => !b.deletedAt); + + // Group blocks by pageId for fast tree building. + const blocksByPage = new Map(); + for (const block of liveBlocks) { + const existing = blocksByPage.get(block.pageId); + if (existing) existing.push(block); + else blocksByPage.set(block.pageId, [block]); + } + + const pages: SnapshotPage[] = livePages.map((p) => ({ + id: p.id, + path: p.path, + title: p.title, + seo: p.seo ?? {}, + blocks: buildBlockTree(blocksByPage.get(p.id) ?? []), + })); + + return { + version: SNAPSHOT_VERSION, + site: toSnapshotSite(site), + pages, + }; +} + +function toSnapshotSite(site: LocalWebsite): SnapshotSite { + return { + id: site.id, + slug: site.slug, + name: site.name, + theme: site.theme, + navConfig: site.navConfig, + footerConfig: site.footerConfig, + settings: site.settings, + }; +} + +/** + * Build the block tree deterministically. Top-level blocks (parentBlockId + * null) come first, ordered by (order ASC, id ASC); each child list uses + * the same ordering. Unreachable blocks (parentBlockId points at a row + * that isn't in `blocks`) are dropped — they'd be orphans that the + * renderer couldn't show anyway. + */ +export function buildBlockTree(blocks: LocalWebsiteBlock[]): SnapshotBlockNode[] { + const byId = new Map(); + for (const b of blocks) byId.set(b.id, b); + + const childrenByParent = new Map(); + for (const b of blocks) { + const parent = b.parentBlockId ?? null; + // Drop orphans: a non-null parentBlockId that doesn't resolve. + if (parent !== null && !byId.has(parent)) continue; + const list = childrenByParent.get(parent); + if (list) list.push(b); + else childrenByParent.set(parent, [b]); + } + // Stable sort every bucket. + for (const list of childrenByParent.values()) { + list.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)); + } + + function walk(parentId: string | null): SnapshotBlockNode[] { + const children = childrenByParent.get(parentId) ?? []; + return children.map((b) => ({ + id: b.id, + type: b.type, + schemaVersion: b.schemaVersion, + slotKey: b.slotKey ?? null, + props: b.props, + children: walk(b.id), + })); + } + + return walk(null); +} + +// ─── Publish client ────────────────────────────────────── + +/** + * Returned by the API on successful publish. + */ +export interface PublishResult { + snapshotId: string; + publishedAt: string; + publicUrl: string; +} + +export class PublishError extends Error { + readonly code: string; + readonly status: number; + constructor(message: string, code: string, status: number) { + super(message); + this.name = 'PublishError'; + this.code = code; + this.status = status; + } +} + +/** + * Call the publish endpoint. Caller must pass a mana-auth JWT and the + * active space id (mana-auth doesn't embed the active space in claims + * yet — see §M6 of the plan). + */ +export async function publishSnapshot( + siteId: string, + jwt: string, + spaceId: string | null +): Promise { + const draft = await buildSnapshot(siteId); + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }; + if (spaceId) headers['X-Mana-Space'] = spaceId; + + const res = await fetch(`${getManaApiUrl()}/api/v1/website/sites/${siteId}/publish`, { + method: 'POST', + headers, + body: JSON.stringify(draft), + }); + + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { code?: string; error?: string }; + throw new PublishError( + body.error ?? `Publish failed (${res.status})`, + body.code ?? 'UNKNOWN', + res.status + ); + } + + return (await res.json()) as PublishResult; +} + +/** + * Mark the site's public snapshot as no-longer-current — the public + * renderer will serve 404 on the next request. + */ +export async function unpublishSnapshot(siteId: string, jwt: string): Promise { + const res = await fetch(`${getManaApiUrl()}/api/v1/website/sites/${siteId}/unpublish`, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + }); + if (!res.ok && res.status !== 404) { + throw new PublishError(`Unpublish failed (${res.status})`, 'UNKNOWN', res.status); + } +} + +/** + * Fetch the site's snapshot history (for rollback UI). + */ +export interface SnapshotHistoryEntry { + id: string; + publishedAt: string; + publishedBy: string; + isCurrent: boolean; + slug: string; +} + +export async function fetchSnapshotHistory( + siteId: string, + jwt: string +): Promise { + const res = await fetch(`${getManaApiUrl()}/api/v1/website/sites/${siteId}/snapshots`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + if (!res.ok) { + throw new PublishError(`History fetch failed (${res.status})`, 'UNKNOWN', res.status); + } + const body = (await res.json()) as { snapshots: SnapshotHistoryEntry[] }; + return body.snapshots; +} + +export async function rollbackSnapshot( + siteId: string, + snapshotId: string, + jwt: string +): Promise { + const res = await fetch( + `${getManaApiUrl()}/api/v1/website/sites/${siteId}/rollback/${snapshotId}`, + { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + } + ); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { code?: string; error?: string }; + throw new PublishError( + body.error ?? `Rollback failed (${res.status})`, + body.code ?? 'UNKNOWN', + res.status + ); + } +} diff --git a/apps/mana/apps/web/src/lib/modules/website/queries.ts b/apps/mana/apps/web/src/lib/modules/website/queries.ts new file mode 100644 index 000000000..571789c50 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/queries.ts @@ -0,0 +1,140 @@ +/** + * Reactive queries and pure helpers for the Website module. + * + * All queries return the full scoped collection — consumers filter by + * id/siteId/pageId with `$derived`. Dexie's liveQuery re-runs on every + * write to the backing table; it does NOT re-run when caller-side + * parameters change, which is why we don't accept id params here. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { db } from '$lib/data/database'; +import { scopedForModule } from '$lib/data/scope'; +import type { + LocalWebsite, + LocalWebsitePage, + LocalWebsiteBlock, + Website, + WebsitePage, + WebsiteBlock, +} from './types'; +import { DEFAULT_THEME, DEFAULT_NAV, DEFAULT_FOOTER, DEFAULT_SETTINGS } from './constants'; + +// ─── Type Converters ───────────────────────────────────── + +export function toWebsite(local: LocalWebsite): Website { + const now = new Date().toISOString(); + return { + id: local.id, + slug: local.slug, + name: local.name, + theme: local.theme ?? DEFAULT_THEME, + navConfig: local.navConfig ?? DEFAULT_NAV, + footerConfig: local.footerConfig ?? DEFAULT_FOOTER, + settings: local.settings ?? DEFAULT_SETTINGS, + publishedVersion: local.publishedVersion ?? null, + draftUpdatedAt: local.draftUpdatedAt ?? null, + createdAt: local.createdAt ?? now, + updatedAt: local.updatedAt ?? now, + }; +} + +export function toWebsitePage(local: LocalWebsitePage): WebsitePage { + const now = new Date().toISOString(); + return { + id: local.id, + siteId: local.siteId, + path: local.path, + title: local.title, + seo: local.seo ?? {}, + order: local.order, + createdAt: local.createdAt ?? now, + updatedAt: local.updatedAt ?? now, + }; +} + +export function toWebsiteBlock(local: LocalWebsiteBlock): WebsiteBlock { + const now = new Date().toISOString(); + return { + id: local.id, + pageId: local.pageId, + parentBlockId: local.parentBlockId ?? null, + slotKey: local.slotKey ?? null, + type: local.type, + props: local.props, + schemaVersion: local.schemaVersion, + order: local.order, + createdAt: local.createdAt ?? now, + updatedAt: local.updatedAt ?? now, + }; +} + +// ─── Live Queries ───────────────────────────────────────── + +export function useAllSites() { + return useLiveQueryWithDefault(async () => { + const locals = await scopedForModule('website', 'websites').toArray(); + const visible = locals.filter((s) => !s.deletedAt); + return visible.map(toWebsite).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + }, [] as Website[]); +} + +export function useAllPages() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('websitePages').toArray(); + const visible = locals.filter((p) => !p.deletedAt); + return visible.map(toWebsitePage).sort((a, b) => a.order - b.order); + }, [] as WebsitePage[]); +} + +export function useAllBlocks() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('websiteBlocks').toArray(); + const visible = locals.filter((b) => !b.deletedAt); + return visible.map(toWebsiteBlock).sort((a, b) => a.order - b.order); + }, [] as WebsiteBlock[]); +} + +// ─── Pure Helpers (filter inside views) ────────────────── + +export function findSite(sites: Website[], id: string | null | undefined): Website | null { + if (!id) return null; + return sites.find((s) => s.id === id) ?? null; +} + +export function findPage(pages: WebsitePage[], id: string | null | undefined): WebsitePage | null { + if (!id) return null; + return pages.find((p) => p.id === id) ?? null; +} + +export function pagesForSite( + pages: WebsitePage[], + siteId: string | null | undefined +): WebsitePage[] { + if (!siteId) return []; + return pages.filter((p) => p.siteId === siteId).sort((a, b) => a.order - b.order); +} + +export function blocksForPage( + blocks: WebsiteBlock[], + pageId: string | null | undefined +): WebsiteBlock[] { + if (!pageId) return []; + return blocks.filter((b) => b.pageId === pageId).sort((a, b) => a.order - b.order); +} + +/** + * Arrange flat block list into a tree keyed by parentBlockId. Top-level + * blocks live under the `null` key. Children are sorted by `order`. + */ +export function buildBlockTree(blocks: WebsiteBlock[]): Map { + const tree = new Map(); + const sorted = [...blocks].sort((a, b) => a.order - b.order); + for (const block of sorted) { + const parent = block.parentBlockId; + const existing = tree.get(parent); + if (existing) existing.push(block); + else tree.set(parent, [block]); + } + return tree; +} diff --git a/apps/mana/apps/web/src/lib/modules/website/stores/blocks.svelte.ts b/apps/mana/apps/web/src/lib/modules/website/stores/blocks.svelte.ts new file mode 100644 index 000000000..4902607ae --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/stores/blocks.svelte.ts @@ -0,0 +1,163 @@ +import { emitDomainEvent } from '$lib/data/events'; +import { requireBlockSpec, safeValidateBlockProps } from '@mana/website-blocks'; +import { websitesTable, websitePagesTable, websiteBlocksTable } from '../collections'; +import type { LocalWebsiteBlock } from '../types'; + +export interface AddBlockInput { + pageId: string; + type: string; + parentBlockId?: string | null; + slotKey?: string | null; + /** + * Override defaults from the block spec. Must satisfy the block type's + * Zod schema — throws `InvalidBlockPropsError` otherwise. + */ + props?: unknown; + /** + * Optional explicit order. If omitted, the block is appended to the + * end of the page / parent's children. + */ + order?: number; +} + +export class InvalidBlockPropsError extends Error { + readonly zodError: unknown; + constructor(type: string, zodError: unknown) { + super(`Invalid props for block type "${type}".`); + this.name = 'InvalidBlockPropsError'; + this.zodError = zodError; + } +} + +async function touchSiteForPage(pageId: string): Promise { + const page = await websitePagesTable.get(pageId); + if (!page) return; + const now = new Date().toISOString(); + await websitesTable.update(page.siteId, { draftUpdatedAt: now, updatedAt: now }); +} + +async function nextOrder(pageId: string, parentBlockId: string | null): Promise { + const siblings = await websiteBlocksTable.where('pageId').equals(pageId).toArray(); + const live = siblings.filter((b) => !b.deletedAt && (b.parentBlockId ?? null) === parentBlockId); + const maxOrder = live.reduce((m, b) => Math.max(m, b.order), 0); + return maxOrder + 1024; +} + +export const blocksStore = { + /** + * Insert a block. Props default to the block type's `defaults` from + * the registry; if `input.props` is given, it must validate against + * the block's Zod schema. + */ + async addBlock(input: AddBlockInput) { + const spec = requireBlockSpec(input.type); + const parentId = input.parentBlockId ?? null; + const slotKey = input.slotKey ?? null; + + const props = input.props ?? spec.defaults; + const validated = safeValidateBlockProps(input.type, props); + if (!validated.success) { + throw new InvalidBlockPropsError(input.type, validated.error); + } + + const order = input.order ?? (await nextOrder(input.pageId, parentId)); + const now = new Date().toISOString(); + const id = crypto.randomUUID(); + + const newBlock: LocalWebsiteBlock = { + id, + pageId: input.pageId, + parentBlockId: parentId, + slotKey, + type: input.type, + props: validated.data, + schemaVersion: spec.schemaVersion, + order, + createdAt: now, + updatedAt: now, + }; + + await websiteBlocksTable.add(newBlock); + await touchSiteForPage(input.pageId); + + emitDomainEvent('WebsiteBlockAdded', 'website', 'websiteBlocks', id, { + blockId: id, + pageId: input.pageId, + type: input.type, + }); + + return newBlock; + }, + + /** + * Patch a block's props. The resulting props (existing ∪ patch) are + * re-validated against the block's schema. + */ + async updateBlockProps(id: string, patch: Record) { + const existing = await websiteBlocksTable.get(id); + if (!existing) throw new Error(`Block ${id} not found`); + + const nextProps = { ...(existing.props as Record), ...patch }; + const validated = safeValidateBlockProps(existing.type, nextProps); + if (!validated.success) { + throw new InvalidBlockPropsError(existing.type, validated.error); + } + + const now = new Date().toISOString(); + await websiteBlocksTable.update(id, { + props: validated.data, + updatedAt: now, + }); + await touchSiteForPage(existing.pageId); + + emitDomainEvent('WebsiteBlockUpdated', 'website', 'websiteBlocks', id, { + blockId: id, + pageId: existing.pageId, + fields: Object.keys(patch), + }); + }, + + async deleteBlock(id: string) { + const existing = await websiteBlocksTable.get(id); + if (!existing) return; + + const now = new Date().toISOString(); + await websiteBlocksTable.update(id, { + deletedAt: now, + updatedAt: now, + }); + await touchSiteForPage(existing.pageId); + + emitDomainEvent('WebsiteBlockDeleted', 'website', 'websiteBlocks', id, { + blockId: id, + pageId: existing.pageId, + }); + }, + + /** + * Move a block to a new position within its current parent. Same + * fractional-index logic as pages. + */ + async reorderBlock(id: string, beforeOrder: number | null, afterOrder: number | null) { + let newOrder: number; + if (beforeOrder === null && afterOrder === null) { + newOrder = 1024; + } else if (beforeOrder === null) { + newOrder = (afterOrder as number) / 2; + } else if (afterOrder === null) { + newOrder = beforeOrder + 1024; + } else { + newOrder = (beforeOrder + afterOrder) / 2; + } + + const existing = await websiteBlocksTable.get(id); + if (!existing) return; + + const now = new Date().toISOString(); + await websiteBlocksTable.update(id, { + order: newOrder, + updatedAt: now, + }); + await touchSiteForPage(existing.pageId); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/website/stores/pages.svelte.ts b/apps/mana/apps/web/src/lib/modules/website/stores/pages.svelte.ts new file mode 100644 index 000000000..88ec9ba23 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/stores/pages.svelte.ts @@ -0,0 +1,149 @@ +import { emitDomainEvent } from '$lib/data/events'; +import { websitesTable, websitePagesTable } from '../collections'; +import type { LocalWebsitePage, PageSeo } from '../types'; + +export interface CreatePageInput { + siteId: string; + path: string; + title: string; + seo?: PageSeo; +} + +export class InvalidPathError extends Error { + constructor(path: string) { + super( + `"${path}" is not a valid page path — must start with "/" and contain only lowercase alphanumerics, hyphens, and additional segments.` + ); + this.name = 'InvalidPathError'; + } +} + +export class DuplicatePathError extends Error { + constructor(path: string) { + super(`A page with path "${path}" already exists on this site.`); + this.name = 'DuplicatePathError'; + } +} + +const PATH_REGEX = /^\/(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\/[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*)?$/; + +export function isValidPath(path: string): boolean { + return PATH_REGEX.test(path); +} + +function touchSite(siteId: string): Promise { + const now = new Date().toISOString(); + return websitesTable + .update(siteId, { draftUpdatedAt: now, updatedAt: now }) + .then(() => undefined); +} + +export const pagesStore = { + async createPage(input: CreatePageInput) { + if (!isValidPath(input.path)) { + throw new InvalidPathError(input.path); + } + + const siblings = await websitePagesTable.where('siteId').equals(input.siteId).toArray(); + const livePaths = siblings.filter((p) => !p.deletedAt); + if (livePaths.some((p) => p.path === input.path)) { + throw new DuplicatePathError(input.path); + } + + const maxOrder = livePaths.reduce((m, p) => Math.max(m, p.order), 0); + const now = new Date().toISOString(); + const id = crypto.randomUUID(); + + const newPage: LocalWebsitePage = { + id, + siteId: input.siteId, + path: input.path, + title: input.title, + seo: input.seo ?? {}, + order: maxOrder + 1024, + createdAt: now, + updatedAt: now, + }; + + await websitePagesTable.add(newPage); + await touchSite(input.siteId); + + emitDomainEvent('WebsitePageCreated', 'website', 'websitePages', id, { + pageId: id, + siteId: input.siteId, + path: input.path, + }); + + return newPage; + }, + + async updatePage(id: string, patch: Partial>) { + if (patch.path !== undefined && !isValidPath(patch.path)) { + throw new InvalidPathError(patch.path); + } + + const now = new Date().toISOString(); + const existing = await websitePagesTable.get(id); + if (!existing) throw new Error(`Page ${id} not found`); + + await websitePagesTable.update(id, { + ...patch, + updatedAt: now, + }); + await touchSite(existing.siteId); + + emitDomainEvent('WebsitePageUpdated', 'website', 'websitePages', id, { + pageId: id, + siteId: existing.siteId, + fields: Object.keys(patch), + }); + }, + + async deletePage(id: string) { + const existing = await websitePagesTable.get(id); + if (!existing) return; + + const now = new Date().toISOString(); + await websitePagesTable.update(id, { + deletedAt: now, + updatedAt: now, + }); + await touchSite(existing.siteId); + + emitDomainEvent('WebsitePageDeleted', 'website', 'websitePages', id, { + pageId: id, + siteId: existing.siteId, + }); + }, + + /** + * Move a page to a new position by recomputing its `order` field. + * `beforeOrder` and `afterOrder` are the orders of the pages that + * should sit immediately before and after the moved page — pass + * `null` on either side if the page becomes first or last. + */ + async reorderPage(id: string, beforeOrder: number | null, afterOrder: number | null) { + let newOrder: number; + if (beforeOrder === null && afterOrder === null) { + newOrder = 1024; + } else if (beforeOrder === null) { + // moving to first + newOrder = (afterOrder as number) / 2; + } else if (afterOrder === null) { + // moving to last + newOrder = beforeOrder + 1024; + } else { + newOrder = (beforeOrder + afterOrder) / 2; + } + + const now = new Date().toISOString(); + const existing = await websitePagesTable.get(id); + if (!existing) return; + + await websitePagesTable.update(id, { + order: newOrder, + updatedAt: now, + }); + await touchSite(existing.siteId); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/website/stores/sites.svelte.ts b/apps/mana/apps/web/src/lib/modules/website/stores/sites.svelte.ts new file mode 100644 index 000000000..251111bd8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/stores/sites.svelte.ts @@ -0,0 +1,209 @@ +import { emitDomainEvent } from '$lib/data/events'; +import { authStore } from '$lib/stores/auth.svelte'; +import { getActiveSpaceId } from '$lib/data/scope'; +import { websitesTable, websitePagesTable } from '../collections'; +import { toWebsite } from '../queries'; +import { + publishSnapshot, + unpublishSnapshot, + rollbackSnapshot, + PublishError, + type PublishResult, +} from '../publish'; +import type { LocalWebsite, LocalWebsitePage, ThemeConfig } from '../types'; +import { + DEFAULT_THEME, + DEFAULT_NAV, + DEFAULT_FOOTER, + DEFAULT_SETTINGS, + DEFAULT_HOME_PAGE, + isValidSlug, +} from '../constants'; + +export interface CreateSiteInput { + slug: string; + name: string; + theme?: ThemeConfig; +} + +export class InvalidSlugError extends Error { + constructor(slug: string) { + super( + `"${slug}" is not a valid slug — use 2-40 lowercase alphanumerics or hyphens, avoid reserved names.` + ); + this.name = 'InvalidSlugError'; + } +} + +export class DuplicateSlugError extends Error { + constructor(slug: string) { + super(`A site with slug "${slug}" already exists in this space.`); + this.name = 'DuplicateSlugError'; + } +} + +export const sitesStore = { + /** + * Create a new site + a default home page in one transaction. Throws + * `InvalidSlugError` on bad slugs and `DuplicateSlugError` when the + * slug collides within the active space. + */ + async createSite(input: CreateSiteInput) { + if (!isValidSlug(input.slug)) { + throw new InvalidSlugError(input.slug); + } + + // Slug dedupe within the current active space. We query via scope + // so the check is automatically limited to records the user can + // see; without scope, users in different spaces could trigger + // false positives. + const existing = await websitesTable.where('slug').equals(input.slug).toArray(); + const liveDuplicate = existing.find((s) => !s.deletedAt); + if (liveDuplicate) { + throw new DuplicateSlugError(input.slug); + } + + const now = new Date().toISOString(); + const siteId = crypto.randomUUID(); + const pageId = crypto.randomUUID(); + + const newSite: LocalWebsite = { + id: siteId, + slug: input.slug, + name: input.name, + theme: input.theme ?? DEFAULT_THEME, + navConfig: DEFAULT_NAV, + footerConfig: DEFAULT_FOOTER, + settings: DEFAULT_SETTINGS, + publishedVersion: null, + draftUpdatedAt: now, + createdAt: now, + updatedAt: now, + }; + + const homePage: LocalWebsitePage = { + id: pageId, + siteId, + path: DEFAULT_HOME_PAGE.path, + title: DEFAULT_HOME_PAGE.title, + seo: {}, + order: 1024, + createdAt: now, + updatedAt: now, + }; + + await websitesTable.add(newSite); + await websitePagesTable.add(homePage); + + emitDomainEvent('WebsiteCreated', 'website', 'websites', siteId, { + siteId, + slug: input.slug, + name: input.name, + }); + + return { site: toWebsite(newSite), homePageId: pageId }; + }, + + async updateSite( + id: string, + patch: Partial> + ) { + const now = new Date().toISOString(); + await websitesTable.update(id, { + ...patch, + updatedAt: now, + draftUpdatedAt: now, + }); + emitDomainEvent('WebsiteUpdated', 'website', 'websites', id, { + siteId: id, + fields: Object.keys(patch), + }); + }, + + async deleteSite(id: string) { + const now = new Date().toISOString(); + await websitesTable.update(id, { + deletedAt: now, + updatedAt: now, + }); + // Best-effort: unpublish so the public URL stops serving. Failures + // here don't block the soft-delete — the GC job in M7 will clean + // orphan snapshots. + try { + const token = await authStore.getValidToken(); + if (token) await unpublishSnapshot(id, token); + } catch { + // swallow — soft-delete succeeded locally + } + emitDomainEvent('WebsiteDeleted', 'website', 'websites', id, { siteId: id }); + }, + + /** + * Publish the current draft to the public `/s/{slug}` URL. Returns + * the snapshot id + public URL on success. Updates + * `site.publishedVersion` locally so the editor reflects the new + * state immediately. + */ + async publishSite(id: string): Promise { + const token = await authStore.getValidToken(); + if (!token) throw new PublishError('Not signed in', 'NO_TOKEN', 401); + + const result = await publishSnapshot(id, token, getActiveSpaceId()); + + const now = new Date().toISOString(); + await websitesTable.update(id, { + publishedVersion: result.snapshotId, + updatedAt: now, + }); + + emitDomainEvent('WebsitePublished', 'website', 'websites', id, { + siteId: id, + snapshotId: result.snapshotId, + publishedAt: result.publishedAt, + publicUrl: result.publicUrl, + }); + + return result; + }, + + /** + * Take the site offline. Leaves the local draft untouched. + */ + async unpublishSite(id: string): Promise { + const token = await authStore.getValidToken(); + if (!token) throw new PublishError('Not signed in', 'NO_TOKEN', 401); + + await unpublishSnapshot(id, token); + + const now = new Date().toISOString(); + await websitesTable.update(id, { + publishedVersion: null, + updatedAt: now, + }); + + emitDomainEvent('WebsiteUnpublished', 'website', 'websites', id, { siteId: id }); + }, + + /** + * Roll back to a historical snapshot. The server flips `is_current`; + * we also update `publishedVersion` locally to reflect the new live + * version. + */ + async rollback(siteId: string, snapshotId: string): Promise { + const token = await authStore.getValidToken(); + if (!token) throw new PublishError('Not signed in', 'NO_TOKEN', 401); + + await rollbackSnapshot(siteId, snapshotId, token); + + const now = new Date().toISOString(); + await websitesTable.update(siteId, { + publishedVersion: snapshotId, + updatedAt: now, + }); + + emitDomainEvent('WebsiteRolledBack', 'website', 'websites', siteId, { + siteId, + snapshotId, + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/website/types.ts b/apps/mana/apps/web/src/lib/modules/website/types.ts new file mode 100644 index 000000000..b469481a9 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/types.ts @@ -0,0 +1,145 @@ +/** + * Website module types — block-tree CMS for the Mana website builder. + * + * Three tables, all space-scoped, all plaintext (public content by + * design — see docs/plans/website-builder.md §D4): + * - websites: root entity per space (slug, theme, nav, footer) + * - websitePages: multi-page support (path + seo + order) + * - websiteBlocks: block-tree via parentBlockId + fractional order + * + * Block.props is untyped (`unknown`) at the TS level because the shape + * depends on block.type. Runtime validation goes through + * `@mana/website-blocks`'s registry (Zod schemas per type). + */ + +import type { BaseRecord } from '@mana/local-store'; + +// ─── Theme / Nav / Footer config shapes ────────────────── + +export type ThemePreset = 'classic' | 'modern' | 'warm'; + +export interface ThemeConfig { + preset: ThemePreset; + overrides?: { + primary?: string; + background?: string; + foreground?: string; + }; +} + +export interface NavItem { + label: string; + pagePath: string; +} + +export interface NavConfig { + items: NavItem[]; +} + +export interface FooterLink { + label: string; + href: string; +} + +export interface FooterConfig { + text: string; + links: FooterLink[]; +} + +export interface SiteSettings { + favicon?: string | null; + defaultSeo?: { + title?: string; + description?: string; + }; + analytics?: { + plausibleDomain?: string; + }; +} + +// ─── SEO (per page) ────────────────────────────────────── + +export interface PageSeo { + title?: string; + description?: string; + ogImage?: string; + noindex?: boolean; +} + +// ─── Local records (Dexie) ─────────────────────────────── + +export interface LocalWebsite extends BaseRecord { + slug: string; + name: string; + theme: ThemeConfig; + navConfig: NavConfig; + footerConfig: FooterConfig; + settings: SiteSettings; + /** UUID pointing at the currently-live published_snapshots row. */ + publishedVersion: string | null; + /** Bumped on every draft mutation — surfaces "unveröffentlichte Änderungen". */ + draftUpdatedAt: string | null; +} + +export interface LocalWebsitePage extends BaseRecord { + siteId: string; + /** URL path — '/' for home, '/about' for subpage. */ + path: string; + title: string; + seo: PageSeo; + /** Fractional index for reorder-without-reindex. */ + order: number; +} + +export interface LocalWebsiteBlock extends BaseRecord { + pageId: string; + parentBlockId: string | null; + /** Slot name within a container block (future — unused in M1). */ + slotKey: string | null; + /** Block type id — must match a registered spec in @mana/website-blocks. */ + type: string; + /** Block-type-specific props. Runtime-validated against the registry. */ + props: unknown; + schemaVersion: number; + order: number; +} + +// ─── Domain types (UI-facing, nulls coalesced) ─────────── + +export interface Website { + id: string; + slug: string; + name: string; + theme: ThemeConfig; + navConfig: NavConfig; + footerConfig: FooterConfig; + settings: SiteSettings; + publishedVersion: string | null; + draftUpdatedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface WebsitePage { + id: string; + siteId: string; + path: string; + title: string; + seo: PageSeo; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface WebsiteBlock { + id: string; + pageId: string; + parentBlockId: string | null; + slotKey: string | null; + type: string; + props: unknown; + schemaVersion: number; + order: number; + createdAt: string; + updatedAt: string; +} diff --git a/apps/mana/apps/web/src/lib/modules/website/views/EditorView.svelte b/apps/mana/apps/web/src/lib/modules/website/views/EditorView.svelte new file mode 100644 index 000000000..3eeca26e5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/views/EditorView.svelte @@ -0,0 +1,187 @@ + + +
+ {#if site} + + {/if} + +
+ + +
+ {#if pageBlocks.length === 0} +
+

Leere Seite

+

Füge links einen Block ein, um loszulegen.

+
+ {:else} +
+ (selectedBlockId = id)} + /> +
+ {/if} +
+ + +
+
+ + diff --git a/apps/mana/apps/web/src/routes/(app)/website/+page.svelte b/apps/mana/apps/web/src/routes/(app)/website/+page.svelte new file mode 100644 index 000000000..344e52ae1 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/website/+page.svelte @@ -0,0 +1,12 @@ + + + + Website - Mana + + + + + diff --git a/apps/mana/apps/web/src/routes/(app)/website/[siteId]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/website/[siteId]/+page.svelte new file mode 100644 index 000000000..d1c4d46fb --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/website/[siteId]/+page.svelte @@ -0,0 +1,33 @@ + + + + Website - Mana + + +
+

Öffne Editor…

+
+ + diff --git a/apps/mana/apps/web/src/routes/(app)/website/[siteId]/edit/[pageId]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/website/[siteId]/edit/[pageId]/+page.svelte new file mode 100644 index 000000000..64910c0bf --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/website/[siteId]/edit/[pageId]/+page.svelte @@ -0,0 +1,23 @@ + + + + {title} - Mana + + + + {#key pageId} + + {/key} + diff --git a/apps/mana/apps/web/src/routes/s/[siteSlug]/+layout.server.ts b/apps/mana/apps/web/src/routes/s/[siteSlug]/+layout.server.ts new file mode 100644 index 000000000..918fd2b60 --- /dev/null +++ b/apps/mana/apps/web/src/routes/s/[siteSlug]/+layout.server.ts @@ -0,0 +1,53 @@ +/** + * Public website renderer — loads the current published snapshot and + * hands it to every child route. + * + * This route is OUTSIDE the (app) group — no auth, no AuthGate, no + * Dexie. Everything is SSR from Postgres via the mana-api public + * endpoint. + */ + +import { error } from '@sveltejs/kit'; +import { getManaApiUrl } from '$lib/api/config'; +import type { LayoutServerLoad } from './$types'; +import type { SnapshotSite, SnapshotPage } from '$lib/modules/website/publish'; + +interface PublicSnapshotResponse { + snapshotId: string; + slug: string; + publishedAt: string; + blob: { + version: string; + site: SnapshotSite; + pages: SnapshotPage[]; + publishedAt: string; + publishedBy: string; + }; +} + +export const load: LayoutServerLoad = async ({ params, fetch, setHeaders }) => { + const slug = params.siteSlug; + if (!slug) error(404, 'Not found'); + + const res = await fetch(`${getManaApiUrl()}/api/v1/website/public/sites/${slug}`, { + headers: { Accept: 'application/json' }, + }); + + if (res.status === 404) error(404, 'Website not found'); + if (!res.ok) error(502, 'Upstream error fetching published site'); + + const payload = (await res.json()) as PublicSnapshotResponse; + + // Mirror the edge-cache hint from the API so that SvelteKit's own + // adapter respects it. `page` data isn't personalized for public + // sites, so the short freshness window is safe. + setHeaders({ + 'cache-control': 'public, max-age=60, s-maxage=300, stale-while-revalidate=86400', + }); + + return { + snapshot: payload.blob, + snapshotId: payload.snapshotId, + publishedAt: payload.publishedAt, + }; +}; diff --git a/apps/mana/apps/web/src/routes/s/[siteSlug]/+layout.svelte b/apps/mana/apps/web/src/routes/s/[siteSlug]/+layout.svelte new file mode 100644 index 000000000..dcaa6964a --- /dev/null +++ b/apps/mana/apps/web/src/routes/s/[siteSlug]/+layout.svelte @@ -0,0 +1,156 @@ + + + + {site.name} + {#if site.settings?.favicon} + + {/if} + {#if site.settings?.defaultSeo?.description} + + {/if} + + +
+ {#if navItems.length > 0} + + {:else} + + {/if} + +
+ {@render children()} +
+ + {#if footer && (footer.text || (footer.links && footer.links.length > 0))} +
+ {#if footer.text} +

{footer.text}

+ {/if} + {#if footer.links && footer.links.length > 0} +
    + {#each footer.links as link (link.href)} +
  • {link.label}
  • + {/each} +
+ {/if} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/+page.server.ts b/apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/+page.server.ts new file mode 100644 index 000000000..9e1ca1b22 --- /dev/null +++ b/apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/+page.server.ts @@ -0,0 +1,23 @@ +/** + * Public page resolver — picks the right page from the snapshot blob + * by path. No extra fetch: the parent +layout.server.ts already loaded + * the full snapshot. + */ + +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, parent }) => { + const { snapshot } = await parent(); + const rest = params.path ?? ''; + // `/` for the home route, `/about` / `/docs/foo` otherwise. Strip a + // trailing slash (except the root itself). + const targetPath = '/' + rest.replace(/\/$/, ''); + + const page = snapshot.pages.find((p) => p.path === targetPath); + if (!page) error(404, `Page "${targetPath}" not found`); + + return { + page, + }; +}; diff --git a/apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/+page.svelte b/apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/+page.svelte new file mode 100644 index 000000000..ef215e3c3 --- /dev/null +++ b/apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/+page.svelte @@ -0,0 +1,51 @@ + + + + {page.seo?.title ?? page.title} + {#if page.seo?.description} + + {/if} + {#if page.seo?.noindex} + + {/if} + {#if page.seo?.ogImage} + + {/if} + + +{#each blocks as block, i (block.id)} + {@const spec = getBlockSpec(block.type)} + {#if spec} + + {/if} +{/each} diff --git a/docs/plans/website-builder.md b/docs/plans/website-builder.md new file mode 100644 index 000000000..1c7c841e6 --- /dev/null +++ b/docs/plans/website-builder.md @@ -0,0 +1,776 @@ +# Website Builder — Block-Tree CMS für Privat + Firma + +_Started 2026-04-23._ + +Ein Modul `website`, mit dem Nutzer (privat) und Firmen (Space mit mehreren Mitgliedern) mehrseitige Websites bauen, live bearbeiten und unter `mana.how`-Domains veröffentlichen. Kein Drag-Drop-Canvas wie Framer/Webflow, sondern ein **Block-Baum-Editor** mit Zod-validierten Block-Typen — dieselben Svelte-Komponenten rendern im Editor, in der Live-Preview und im öffentlichen Seitenaufruf. Content aus anderen Mana-Modulen (picture, library, news, …) wird per `moduleEmbed`-Block direkt eingebettet. + +Voraussetzung: **nicht live, unbegrenzte Ressourcen, keine Migrations-Kompromisse.** Zielzustand direkt, keine Legacy-Reste. + +## Ziel in einem Satz + +Jeder Mana-Nutzer (oder jede Firma via Space) kann eine vollständige Website mit beliebig vielen Seiten über einen Block-Baum-Editor bauen, Daten aus seinen Mana-Modulen einbetten, und unter `/s/{slug}`, später `{slug}.mana.how` oder einer Custom-Domain veröffentlichen — mit SSR-Rendering aus denselben Svelte-Komponenten, ohne separaten Astro-Build-Pfad. + +## Nicht-Ziele + +- **Kein Free-Form Canvas.** Keine absolute Positionierung, keine Pixel-CSS. Layout über Block-Typen, Theme-Variablen und wenige Container (`columns`, `rows`, `spacer`). +- **Kein dualer Renderer.** Keine Svelte-Komponenten **und** Astro-Komponenten für dieselben Blöcke. Der öffentliche Renderer ist SvelteKit-SSR, der Editor rendert dieselben Components. +- **Keine Admin-UI-Nutzung des bestehenden `mana-landing-builder` Services** für User-Sites. Der Service bleibt für Org-Landing-Pages (andere Code-Pfade, andere Zielgruppe). Wir beschreiben in M6 optional eine Konsolidierung. +- **Kein Plugin-System.** Block-Typen sind intern und in `packages/website-blocks` versioniert. Dritt-Blöcke erst wenn Bedarf real wird. +- **Kein Markdown-Editor-Ersatz.** RichText-Blöcke nutzen einen kuratierten Satz Tiptap-Extensions, nicht Markdown. Ein Export-zu-Markdown ist möglich, aber nicht Teil des Write-Pfades. +- **Keine E-Commerce-Primitive.** Shop, Warenkorb, Checkout: nicht Scope. Pricing-Blöcke sind Display-only. +- **Keine Versionierung auf Block-Ebene.** Sites haben `draft` und `published` als zwei konsistente Snapshots; kein per-Block-History-Browser. + +## Architektur + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Editor (auth-gated, local-first) │ +│ apps/mana/apps/web/src/routes/(app)/website/… │ +│ │ +│ ┌───────────────┐ ┌───────────────┐ ┌──────────────────┐ │ +│ │ Seitenbaum + │ │ Block-Baum + │ │ Inspector │ │ +│ │ Seite-Settings│ │ Live Preview │ │ (Zod → Form) │ │ +│ └───────┬───────┘ └───────┬───────┘ └────────┬─────────┘ │ +│ └──── Dexie (websites/pages/blocks) ───┘ │ +│ │ │ +│ ▼ │ +│ encryptRecord(plaintext) → table.add() │ +│ │ │ +│ ▼ │ +│ _pendingChanges (appId='website') │ +└──────────────────────────┬───────────────────────────────────┘ + │ (sync engine, same pipe as every module) + ▼ + mana-sync → Postgres (website.* schema) + │ + │ read path for public visitors + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Public renderer (no auth, SSR) │ +│ apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/… │ +│ │ +│ +page.server.ts │ +│ └─ resolveSite(siteSlug, path) │ +│ └─ reads published snapshot from Postgres (no Dexie) │ +│ │ │ +│ ▼ │ +│ +page.svelte renders │ +└──────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────┐ +│ Form submissions (no auth) │ +│ POST /api/v1/website/sites/:id/submit/:blockId │ +│ → validate against stored block schema (Zod) │ +│ → write to target module via mana-tool-registry handler │ +│ → record in websiteSubmissions for audit │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Entscheidungen + +Explizit, begründet, und als Ankerpunkt für spätere Zweifel. + +### D1 — Ein Block-Typ, eine Svelte-Komponente, drei Render-Modi + +Jeder Block-Typ (`hero`, `richText`, `image`, `gallery`, `form`, …) lebt als **genau eine** Svelte-Komponente in `packages/website-blocks/src/{type}/{Type}.svelte`. Die Komponente bekommt `{ block, mode }`, wobei `mode ∈ 'edit' | 'preview' | 'public'`. Im `edit`-Mode werden Inline-Editing-Controls sichtbar; im `public`-Mode reine Anzeige. + +**Warum:** Ein Codepfad bedeutet: wenn der Editor eine Change rendert, sieht der Besucher später exakt dasselbe. Dual-Rendering (Svelte-Editor + Astro-Public) wie in `shared-landing-ui` heute erzeugt garantiert Drift. + +**Konsequenz:** Der SvelteKit-Public-Renderer ist SSR, nicht statisch. Für Performance siehe D9 (Caching). + +### D2 — Block-Schema ist SSOT für Rendering, Validierung, UI, AI-Tools + +Pro Block-Typ ein Zod-Schema in `packages/website-blocks/src/{type}/schema.ts`. Das Schema ist gleichzeitig: + +1. **Datenbank-Validierung** (Store schreibt, Server validiert) +2. **Inspector-Formular** (Auto-Generierung via `zod-to-form`-Utility) +3. **AI-Tool-Input** (über `mana-tool-registry`, siehe D7) +4. **Persistenz-Schema-Migrationen** (jedes Block-Schema hat `version`, Upgrader) + +**Warum:** Schema und Renderer werden immer zusammen geändert. Wenn sie getrennt leben, bekommen wir stille UI-Abweichungen vom Datenmodell. Zod als eine Quelle schließt das aus. + +**Block-Paket-Skizze:** +``` +packages/website-blocks/src/ +├── hero/ +│ ├── schema.ts # HeroBlockSchema (Zod, v1) +│ ├── Hero.svelte # Renderer (mode-aware) +│ ├── Hero.inspector.ts # optional: custom inspector (sonst auto) +│ └── index.ts +├── richText/… +├── image/… +├── gallery/… +├── form/… +├── moduleEmbed/… +├── columns/… +├── spacer/… +├── cta/… +├── faq/… +├── registry.ts # { type → { schema, Component, icon, category } } +└── index.ts +``` + +### D3 — Block-Baum über `parentBlockId`, Reihenfolge über `order` + +Blöcke speichern `parentBlockId` (nullable — Top-Level auf einer Seite) und `order` (double-linked via fractional indexing, kein Reindex bei Insert). Container-Blöcke (`columns`, `rows`) haben mehrere Slots; `slotKey` ist optional auf dem Child. + +**Warum:** Flache Tabelle mit `parentBlockId` ist die konventionelle, gut-getestete Repräsentation eines Baums in einem CRDT-fähigen System (wir haben field-level LWW via mana-sync). Alternativen: + +- *JSON-Blob für den ganzen Baum*: einfach, aber jedes Move eines Blocks schreibt den gesamten Baum. Konfliktverlust garantiert bei Co-Editing. +- *Nested Set / Path-Enumeration*: schnell für Lese-Queries, aber Writes sind teuer und Konflikte weh tun. + +Flach + `parentBlockId` + fractional index = feld-weise LWW ist pro Block funktionabel, Co-Editing zweier Member am selben Block ist sicher (Timestamp entscheidet pro Feld). + +### D4 — Drei Tabellen plus optional Submissions, alle space-scoped, alle plaintext + +``` +websites { id, spaceId, slug, name, theme, navConfig, footerConfig, + publishedVersion, draftUpdatedAt, settings } +websitePages { id, siteId, path, title, seo, order } +websiteBlocks { id, pageId, parentBlockId, type, slotKey, props, order, schemaVersion } +websiteSubmissions { id, siteId, blockId, payload, targetModule, targetRecordId, + status, createdAt, ip, userAgent } +``` + +Alle Felder plaintext. Begründung: Site-Content ist öffentlich — es für den Autor zu verschlüsseln wäre sinnfrei, und macht SSR im Public-Path unmöglich (der Server hat keinen MK). Form-Submissions können sensible Daten enthalten; die landen nach Validierung in den Zielmodulen (z.B. `contacts`), dort sind die existenten Encryption-Regeln gültig. Der Submission-Audit-Row (`payload`) wird nach erfolgreicher Weitergabe geleert (siehe M2). + +### D5 — Publish-Modell: `draft` + `published` als zwei separate Snapshots + +Jedes `website` hat einen `publishedVersion` (UUID). Editor schreibt immer gegen den Draft (= die Live-Tabellenzeilen). Auf "Publish" wird ein Snapshot erzeugt: `websitePublishedSnapshots { siteId, version, blob }` — das `blob` ist ein vollständig aufgelöster, deterministisch serialisierter Baum (JSON). Der Public-Renderer liest nur dieses Blob. + +**Warum:** +- Editor kann beliebig herumspielen, ohne dass Besucher halbfertige Seiten sehen. +- Rollback ist trivial: `publishedVersion` zeigt auf älteren Snapshot. +- Public-Read ist **ein** Query (`SELECT blob WHERE siteId AND version`), kein JOIN über drei Tabellen. +- Snapshots sind unveränderlich — gut cachebar (D9). + +**Alternative verworfen:** "Live-Edit = sofort live". Katastrophal für Firmen-Nutzung, wo mehrere Member über Stunden editieren. Draft/Publish ist der nicht-verhandelbare Standard. + +### D6 — Public-Serving über SvelteKit-Route, nicht via `mana-landing-builder` + +`apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/+page.server.ts` lädt Site + Page + BlockTree aus Postgres und rendert SSR. `mana-landing-builder` wird **nicht** erweitert — der Service bleibt für den separaten Org-Landing-Pages-Use-Case (admin-only), in M6 wird entschieden, ob er fusioniert oder abgelöst wird. + +**Warum:** +- Astro-Static-Export in `mana-landing-builder` zwingt zu dualem Rendering (D1 verletzt). +- SvelteKit-SSR mit Caching (D9) ist für Hunderttausende User-Sites schnell genug. +- Statischer Export lohnt erst bei hohem Traffic pro Site — dann pro Site opt-in, nicht default. + +**Subdomain-Handling (Phase 3):** SvelteKit-Host-Handler im Hook `hooks.server.ts` erkennt `{slug}.mana.how` und rewritet intern auf `/s/{slug}/…`. Wildcard-Cert existiert bereits. + +### D7 — AI-Tools via `mana-tool-registry`, nicht separat + +Sobald `mana-tool-registry` (siehe `docs/plans/mana-mcp-and-personas.md` M1) steht, registriert `website` seine Tools dort: `website.create_page`, `website.add_block`, `website.update_block`, `website.reorder_blocks`, `website.publish`, `website.apply_template`. Policy-Hint pro Tool: `write` für CRUD, `destructive` für `delete_page`/`delete_site` (nicht MCP-exponiert). + +**Warum:** Alle AI-Writes laufen zwingend durch denselben Tool-Layer. Kein paralleles "AI-kann-Websites-bauen"-Subsystem. + +**Reihenfolge:** `mana-tool-registry` M1 muss stehen, bevor Website-AI-Tools registriert werden. Bis dahin: Editor ohne AI. Website-AI-Tools landen als Teil von M5. + +### D8 — Form-Submissions schreiben über Tool-Registry-Handler, nicht direkt in Ziel-Tabellen + +Ein `form`-Block hat `targetModule` (z.B. `'contacts'`) und `targetAction` (z.B. `'create_contact'`). Der Submit-Endpoint: +1. Validiert Payload gegen das im Block gespeicherte Zod-Schema +2. Speichert Audit-Row in `websiteSubmissions` (Status: `'received'`) +3. Ruft den entsprechenden Tool-Handler aus `mana-tool-registry` auf (`ctx`: site-owner user/space) +4. Updated Audit-Row mit `targetRecordId` und `'delivered'` + +**Warum:** Der Tool-Registry-Handler kennt bereits Encryption, RLS, Validation des Zielmoduls. Duplizieren wäre erzwungener Legacy-Einstiegspunkt. + +**Abgrenzung:** Unauthentifizierter Submit-Endpoint → Rate-Limiting via Edge (Cloudflare) und Captcha-Block-Typ (in M6, nicht M1). + +### D9 — Caching: Published-Snapshot mit Cache-Tag, Invalidation bei Publish + +Published-Blob wird mit `Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=86400` geliefert, plus `Cache-Tag: site-{siteId}`. Bei `website.publish` → Cloudflare Purge der Tag-Gruppe. + +**Warum:** Keine statische Build-Stufe nötig; Edge-Cache bei Cloudflare liefert millisekunden-Responses für populäre Sites. Bei Edits ist die neue Version nach Publish binnen weniger Sekunden live. + +**Alternative verworfen:** Redis-Cache in der App. Doppelter Infrastruktur-Aufwand, CF macht es gratis. + +### D10 — Multi-Tenant über Spaces, Editing-Permission über Membership + +Ein `website` gehört zu einer `spaceId`. Jedes Mitglied des Spaces kann editieren + publishen. Rollen (editor-only, viewer-only) kommen später, wenn `space_members.role` nicht-trivial wird. + +**Warum:** Wir verwenden, was es gibt. Spaces-RLS ist getestet. Ein eigenes `website_members` wäre parallele Permission-Ebene → Drift garantiert. + +**Privat vs. Firma-Distinction:** Kein eigenes "ist eine Firma"-Flag. Ein Space mit einem Member = Privat, ein Space mit 2+ Membern = Firma. Die UI kann in Phase 2 auf `spaceMemberCount > 1` reagieren, um Team-Workflows zu zeigen. + +### D11 — Slugs: space-scoped unique, reserved-Liste hart + +`websites.slug` ist unique pro `spaceId`. Öffentliche URL in Phase 1 ist `/s/{siteSlug}` global unique (nicht space-scoped) — und deswegen gibt es *auch* eine globale Unique-Constraint auf `slug` wenn `isPublished=true`. + +**Reserved slugs:** `app`, `api`, `auth`, `admin`, `settings`, `docs`, `blog`, `www`, `mail`, `dashboard`, plus alle existierenden Modulnamen. Liste in `apps/api/src/modules/website/reserved-slugs.ts`, erzwungen bei Write und in Migration gecheckt. + +**Warum:** Eine Site mit `slug=api` würde die API-Route verschatten. Lieber strikt + reserviert Namen. + +### D12 — Media-Assets über bestehendes `shared-uload`, kein eigenes `websiteAssets` + +Bilder, Dateien, Cover: Upload über `shared-uload` → MinIO, Rückgabe der URL. Der `image`-Block speichert `{ url, altText, focalPoint }`. Cleanup: wir führen keine Reference-Counting-Tabelle in Phase 1. Bei `delete site` bleiben Assets liegen (ok, sie sind im Space-Bucket, Storage ist billig). GC-Job in M7. + +**Warum:** Ein eigenes `websiteAssets` mit Reference-Counting wäre sauberer, aber ein GC-Job reicht als Aufräumer und verzögert erst mal Komplexität. + +### D13 — Kein Legacy-Fork der `shared-landing-ui` Astro-Sections + +Die 13 existierenden Astro-Sections (`HeroSection`, `FeatureSection`, …) werden **nicht** nach Svelte portiert oder geteilt. Wir schreiben die Block-Renderer neu. Die visuellen Patterns darf man inspirieren, aber Code teilen = duales Rendering (D1 verletzt). + +**Warum:** Die Astro-Sections haben andere Constraints (Astro-Islands, build-time-data). Teilen würde beide Seiten einschränken. + +**Konsequenz:** `shared-landing-ui` bleibt für Org-Landing-Pages. In M6 diskutieren wir die Konsolidierung ehrlich. + +## Komponenten + +### Komponente 1 — `packages/website-blocks` + +Neues Workspace-Paket. Reine Svelte-Components + Zod-Schemata, keine Dexie/Netzwerk-Abhängigkeiten. Nutzbar vom Editor (Dexie-Kontext) **und** vom Public-Renderer (Postgres-Snapshot-Kontext) — beide Seiten geben `{ block, mode, children }`, der Renderer kümmert sich nicht um Datenquelle. + +**Public API (Skizze):** + +```ts +// packages/website-blocks/src/registry.ts +export interface BlockSpec { + type: string; // 'hero', 'richText', … + schema: ZodSchema; + schemaVersion: number; + Component: SvelteComponent<{ + block: Block; + mode: 'edit' | 'preview' | 'public'; + children?: Block[]; // nur bei Containern + onEdit?: (patch: Partial) => void; // im edit-Mode + }>; + icon: string; // Lucide-Name + category: 'content' | 'media' | 'layout' | 'form' | 'embed'; + defaults: Props; // Initialwerte beim Einfügen + upgraders?: Record Props>; // v1→v2 migrations +} + +export const blockRegistry: Record; +``` + +**Block-Coverage M1:** `hero`, `richText`, `image`, `spacer`, `cta`, `columns` (2/3-spalt), `gallery`. Sieben Typen reichen für brauchbare One-Pager. + +**Block-Coverage M4 expand:** `form`, `moduleEmbed`, `pricing`, `faq`, `testimonials`, `team`, `contact`, `footer`. Fünfzehn Typen decken alle 13 `shared-landing-ui`-Sections plus neuen Bedarf. + +### Komponente 2 — `apps/mana/apps/web/src/lib/modules/website` + +Standard-Modul-Struktur, wie jedes andere Modul im Repo: + +``` +apps/mana/apps/web/src/lib/modules/website/ +├── types.ts # LocalWebsite, LocalWebsitePage, LocalWebsiteBlock +├── collections.ts # websitesTable, websitePagesTable, websiteBlocksTable +├── queries.ts # useSite(id), usePage(id), useBlocks(pageId), useBlockTree(pageId) +├── stores/ +│ ├── sites.svelte.ts # createSite, updateSite, deleteSite, publishSite +│ ├── pages.svelte.ts # createPage, updatePage, deletePage, reorderPages +│ └── blocks.svelte.ts # addBlock, updateBlock, deleteBlock, moveBlock +├── components/ +│ ├── BlockRenderer.svelte # rekursiv, nutzt blockRegistry +│ ├── BlockTreeEditor.svelte # Seitenleiste: Baum + Insert-Palette +│ ├── BlockInspector.svelte # rechts: Zod-schema → Formular +│ ├── InsertPalette.svelte # "+" zwischen Blöcken +│ ├── PagePicker.svelte +│ ├── SiteSettingsDialog.svelte # Theme, Nav, Footer, SEO-Defaults +│ ├── PublishBar.svelte # "Unveröffentlichte Änderungen" + Publish-Button +│ └── TemplatePicker.svelte # Starter-Templates +├── views/ +│ ├── SitesListView.svelte # alle Sites des Spaces +│ ├── SiteEditorView.svelte # drei-Pane Editor +│ └── SiteSettingsView.svelte +├── tools.ts # AI-Tool-Registrierungen (aktiviert erst in M5) +├── constants.ts # THEME_PRESETS, RESERVED_SLUGS (client-copy) +├── module.config.ts # { appId: 'website', tables: [...] } +└── index.ts +``` + +**Routes:** +``` +apps/mana/apps/web/src/routes/(app)/website/ +├── +page.svelte # SitesListView +├── new/+page.svelte # Template-Picker oder Blank +└── [siteId]/ + ├── +layout.svelte # lädt site, stellt Context + ├── +page.svelte # redirect auf /edit + ├── edit/ + │ └── [pageId]/+page.svelte # SiteEditorView + ├── settings/+page.svelte # SiteSettingsView + └── submissions/+page.svelte # Eingegangene Form-Submissions +``` + +### Komponente 3 — Public-Renderer-Routes + +``` +apps/mana/apps/web/src/routes/s/ +└── [siteSlug]/ + ├── +layout.server.ts # resolve site, throw 404 if unpublished + ├── +layout.svelte # theme vars, nav, footer + └── [[...path]]/ + ├── +page.server.ts # resolve page by path, 404 if missing + └── +page.svelte # +``` + +**Resolver-Logik (+layout.server.ts):** +```ts +export const load = async ({ params, setHeaders }) => { + const snapshot = await db + .select() + .from(publishedSnapshotsTable) + .where(and( + eq(publishedSnapshotsTable.slug, params.siteSlug), + eq(publishedSnapshotsTable.isCurrent, true) + )) + .limit(1); + if (!snapshot[0]) error(404); + setHeaders({ + 'cache-control': 'public, max-age=60, s-maxage=3600, stale-while-revalidate=86400', + 'cache-tag': `site-${snapshot[0].siteId}`, + }); + return { site: snapshot[0].blob }; +}; +``` + +Snapshot-Blob-Format: +```ts +interface PublishedSnapshot { + version: string; + site: { id, slug, name, theme, navConfig, footerConfig, settings }; + pages: Array<{ + id, path, title, seo, + blocks: BlockTreeNode[]; // rekursiver Baum, bereits auflösend + }>; + publishedAt: string; + publishedBy: string; +} +``` + +### Komponente 4 — `apps/api/src/modules/website` + +Backend-Routes im unified `@mana/api`: + +``` +apps/api/src/modules/website/ +├── routes.ts # Hono router +├── publish.ts # POST /sites/:id/publish +├── submit.ts # POST /sites/:id/submit/:blockId (unauth) +├── snapshots.ts # query helpers for published snapshots +├── reserved-slugs.ts # SSOT +└── tools.ts # Tool-Registry registrations (M5) +``` + +**Endpoints:** + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| `POST` | `/api/v1/website/sites/:id/publish` | JWT (space-member) | Snapshot erzeugen, `publishedVersion` setzen, CF-Cache purgen | +| `POST` | `/api/v1/website/sites/:id/submit/:blockId` | None | Form-Submission annehmen, validieren, weitergeben | +| `GET` | `/api/v1/website/sites/:id/submissions` | JWT (space-member) | Submissions listen | +| `DELETE` | `/api/v1/website/sites/:id/submissions/:subId` | JWT (space-member) | Submission löschen | + +Keine CRUD-Endpoints für Pages/Blocks — das läuft über den normalen Sync-Pfad (Dexie → mana-sync → Postgres) wie bei allen anderen Modulen. + +### Komponente 5 — Starter-Templates + +Sechs handkuratierte Templates in `apps/mana/apps/web/src/lib/modules/website/templates/`: + +| Template | Zielgruppe | Seiten | Blöcke | +|----------|-----------|--------|--------| +| `portfolio` | Kreative, Freelancer | Start, Über mich, Arbeiten, Kontakt | hero + gallery + richText + form | +| `personal-linktree` | Privatnutzer, Creator | Start (Single-Page) | hero + 8× cta | +| `event` | Hochzeit, Geburtstag, Konferenz | Start, Programm, Anreise, RSVP | hero + richText + form | +| `smb-corporate` | Kleinbetrieb | Start, Leistungen, Team, Kontakt | hero + 3×columns + team + contact | +| `product-landing` | Firmen-Produktseite | Start (Single-Page, lang) | hero + features + testimonials + pricing + faq + cta | +| `blank` | Fortgeschritten | 1 leere Seite | — | + +Templates sind JSON in `templates/{name}.json`: `{ site, pages[], blocks[] }`. Apply-Funktion klont mit neuen UUIDs in den Ziel-Space. Templates sind statisch im Build, nicht in DB — kein Admin-Flow zum Editieren im MVP (M6 evtl.). + +### Komponente 6 — Inspector-Autoform + +`components/BlockInspector.svelte` rendert Formulare aus Zod-Schemas via kleiner Utility `zodToForm(schema)` in `packages/website-blocks/src/inspector/`. Mapping: + +| Zod | UI | +|-----|----| +| `z.string()` | `` | +| `z.string().long()` (custom brand) | ` + + +
+ + +
+ +
+ + + +
+ + + diff --git a/packages/website-blocks/src/hero/index.ts b/packages/website-blocks/src/hero/index.ts new file mode 100644 index 000000000..9082cf1bd --- /dev/null +++ b/packages/website-blocks/src/hero/index.ts @@ -0,0 +1,19 @@ +import type { BlockSpec } from '../types'; +import Hero from './Hero.svelte'; +import HeroInspector from './HeroInspector.svelte'; +import { HeroSchema, HERO_DEFAULTS, type HeroProps } from './schema'; + +export const heroBlockSpec: BlockSpec = { + type: 'hero', + label: 'Hero', + icon: 'heading', + category: 'content', + schema: HeroSchema, + schemaVersion: 1, + defaults: HERO_DEFAULTS, + Component: Hero, + Inspector: HeroInspector, +}; + +export type { HeroProps }; +export { HeroSchema, HERO_DEFAULTS }; diff --git a/packages/website-blocks/src/hero/schema.ts b/packages/website-blocks/src/hero/schema.ts new file mode 100644 index 000000000..00642decd --- /dev/null +++ b/packages/website-blocks/src/hero/schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export const HeroSchema = z.object({ + eyebrow: z.string().max(120).default(''), + title: z.string().min(1).max(240), + subtitle: z.string().max(480).default(''), + ctaLabel: z.string().max(60).default(''), + ctaHref: z.string().max(512).default(''), + align: z.enum(['left', 'center']).default('center'), + background: z.enum(['none', 'subtle', 'gradient']).default('subtle'), +}); + +export type HeroProps = z.infer; + +export const HERO_DEFAULTS: HeroProps = { + eyebrow: '', + title: 'Dein Titel', + subtitle: 'Eine kurze Beschreibung — was macht diese Seite relevant?', + ctaLabel: '', + ctaHref: '', + align: 'center', + background: 'subtle', +}; diff --git a/packages/website-blocks/src/index.ts b/packages/website-blocks/src/index.ts new file mode 100644 index 000000000..98b3be561 --- /dev/null +++ b/packages/website-blocks/src/index.ts @@ -0,0 +1,28 @@ +export type { + Block, + BlockMode, + BlockCategory, + BlockRenderProps, + BlockInspectorProps, + BlockSpec, + PropsOf, + InferProps, +} from './types'; + +export { + BLOCK_SPECS, + getBlockSpec, + requireBlockSpec, + getAllBlockSpecs, + validateBlockProps, + safeValidateBlockProps, +} from './registry'; + +export { heroBlockSpec, HeroSchema, HERO_DEFAULTS, type HeroProps } from './hero'; +export { + richTextBlockSpec, + RichTextSchema, + RICH_TEXT_DEFAULTS, + type RichTextProps, +} from './richText'; +export { spacerBlockSpec, SpacerSchema, SPACER_DEFAULTS, type SpacerProps } from './spacer'; diff --git a/packages/website-blocks/src/registry.ts b/packages/website-blocks/src/registry.ts new file mode 100644 index 000000000..e72011f00 --- /dev/null +++ b/packages/website-blocks/src/registry.ts @@ -0,0 +1,68 @@ +import type { BlockSpec } from './types'; +import { heroBlockSpec } from './hero'; +import { richTextBlockSpec } from './richText'; +import { spacerBlockSpec } from './spacer'; + +/** + * The block registry — single source of truth for every block type the + * website builder knows about. Editor insert palette, renderer, inspector, + * schema validation, and future AI tools all consume this map. + * + * Adding a new block = create a folder under `src/{type}/`, export a + * `BlockSpec` from its index, and list it here. + */ +export const BLOCK_SPECS: readonly BlockSpec[] = [ + heroBlockSpec, + richTextBlockSpec, + spacerBlockSpec, +] as unknown as readonly BlockSpec[]; + +const BY_TYPE: Record> = (() => { + const map: Record> = {}; + for (const spec of BLOCK_SPECS) { + if (map[spec.type]) { + throw new Error(`[website-blocks] duplicate block type "${spec.type}"`); + } + map[spec.type] = spec as BlockSpec; + } + return map; +})(); + +export function getBlockSpec(type: string): BlockSpec | undefined { + return BY_TYPE[type]; +} + +export function requireBlockSpec(type: string): BlockSpec { + const spec = BY_TYPE[type]; + if (!spec) throw new Error(`[website-blocks] unknown block type "${type}"`); + return spec; +} + +export function getAllBlockSpecs(): readonly BlockSpec[] { + return BLOCK_SPECS; +} + +/** + * Validate props against a block type's schema. Returns the parsed props + * (with defaults applied) on success, or throws with the Zod error. + */ +export function validateBlockProps(type: string, props: unknown): unknown { + const spec = requireBlockSpec(type); + return spec.schema.parse(props); +} + +/** + * Safe-validate: returns `{ success, data, error }` without throwing. + * Used at boundaries (submit endpoint, snapshot builder) where we want + * to collect all errors rather than fail on the first one. + */ +export function safeValidateBlockProps( + type: string, + props: unknown +): { success: true; data: unknown } | { success: false; error: unknown } { + const spec = getBlockSpec(type); + if (!spec) return { success: false, error: new Error(`Unknown block type "${type}"`) }; + const parsed = spec.schema.safeParse(props); + if (parsed.success) return { success: true, data: parsed.data }; + return { success: false, error: parsed.error }; +} diff --git a/packages/website-blocks/src/richText/RichText.svelte b/packages/website-blocks/src/richText/RichText.svelte new file mode 100644 index 000000000..0d09176c8 --- /dev/null +++ b/packages/website-blocks/src/richText/RichText.svelte @@ -0,0 +1,73 @@ + + +
+
+ {#if paragraphs.length === 0 && isEdit} +

Leerer Text — öffne den Inspector und fang an zu schreiben.

+ {:else} + {#each paragraphs as paragraph, i (i)} +

{paragraph}

+ {/each} + {/if} +
+
+ + diff --git a/packages/website-blocks/src/richText/RichTextInspector.svelte b/packages/website-blocks/src/richText/RichTextInspector.svelte new file mode 100644 index 000000000..8d76797b8 --- /dev/null +++ b/packages/website-blocks/src/richText/RichTextInspector.svelte @@ -0,0 +1,82 @@ + + +
+ + +
+ + + +
+
+ + diff --git a/packages/website-blocks/src/richText/index.ts b/packages/website-blocks/src/richText/index.ts new file mode 100644 index 000000000..f35c43afb --- /dev/null +++ b/packages/website-blocks/src/richText/index.ts @@ -0,0 +1,19 @@ +import type { BlockSpec } from '../types'; +import RichText from './RichText.svelte'; +import RichTextInspector from './RichTextInspector.svelte'; +import { RichTextSchema, RICH_TEXT_DEFAULTS, type RichTextProps } from './schema'; + +export const richTextBlockSpec: BlockSpec = { + type: 'richText', + label: 'Text', + icon: 'text', + category: 'content', + schema: RichTextSchema, + schemaVersion: 1, + defaults: RICH_TEXT_DEFAULTS, + Component: RichText, + Inspector: RichTextInspector, +}; + +export type { RichTextProps }; +export { RichTextSchema, RICH_TEXT_DEFAULTS }; diff --git a/packages/website-blocks/src/richText/schema.ts b/packages/website-blocks/src/richText/schema.ts new file mode 100644 index 000000000..2c09e42e6 --- /dev/null +++ b/packages/website-blocks/src/richText/schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const RichTextSchema = z.object({ + content: z.string().max(10_000).default(''), + align: z.enum(['left', 'center']).default('left'), + size: z.enum(['sm', 'md', 'lg']).default('md'), +}); + +export type RichTextProps = z.infer; + +export const RICH_TEXT_DEFAULTS: RichTextProps = { + content: '', + align: 'left', + size: 'md', +}; diff --git a/packages/website-blocks/src/spacer/Spacer.svelte b/packages/website-blocks/src/spacer/Spacer.svelte new file mode 100644 index 000000000..3d80d40eb --- /dev/null +++ b/packages/website-blocks/src/spacer/Spacer.svelte @@ -0,0 +1,50 @@ + + +
+ {#if isEdit} + Spacer ({block.props.size}) + {/if} +
+ + diff --git a/packages/website-blocks/src/spacer/SpacerInspector.svelte b/packages/website-blocks/src/spacer/SpacerInspector.svelte new file mode 100644 index 000000000..fd6bd7382 --- /dev/null +++ b/packages/website-blocks/src/spacer/SpacerInspector.svelte @@ -0,0 +1,50 @@ + + +
+ +
+ + diff --git a/packages/website-blocks/src/spacer/index.ts b/packages/website-blocks/src/spacer/index.ts new file mode 100644 index 000000000..c56157216 --- /dev/null +++ b/packages/website-blocks/src/spacer/index.ts @@ -0,0 +1,19 @@ +import type { BlockSpec } from '../types'; +import Spacer from './Spacer.svelte'; +import SpacerInspector from './SpacerInspector.svelte'; +import { SpacerSchema, SPACER_DEFAULTS, type SpacerProps } from './schema'; + +export const spacerBlockSpec: BlockSpec = { + type: 'spacer', + label: 'Abstand', + icon: 'separator', + category: 'layout', + schema: SpacerSchema, + schemaVersion: 1, + defaults: SPACER_DEFAULTS, + Component: Spacer, + Inspector: SpacerInspector, +}; + +export type { SpacerProps }; +export { SpacerSchema, SPACER_DEFAULTS }; diff --git a/packages/website-blocks/src/spacer/schema.ts b/packages/website-blocks/src/spacer/schema.ts new file mode 100644 index 000000000..9a243ec71 --- /dev/null +++ b/packages/website-blocks/src/spacer/schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const SpacerSchema = z.object({ + size: z.enum(['sm', 'md', 'lg', 'xl']).default('md'), +}); + +export type SpacerProps = z.infer; + +export const SPACER_DEFAULTS: SpacerProps = { + size: 'md', +}; diff --git a/packages/website-blocks/src/types.ts b/packages/website-blocks/src/types.ts new file mode 100644 index 000000000..27526cd56 --- /dev/null +++ b/packages/website-blocks/src/types.ts @@ -0,0 +1,92 @@ +import type { Component } from 'svelte'; +import type { ZodTypeAny, z } from 'zod'; + +/** + * Render modes for every block component. + * + * - `edit` — Inside the editor. Shows inline-edit affordances (e.g. click + * a Hero title to edit it), may render placeholder copy for + * empty required fields. + * - `preview` — Editor preview pane. Same rendering as `public` but inside + * the editor chrome (responsive preview, breakpoint switcher). + * - `public` — Served to real visitors via SvelteKit SSR. No edit chrome, + * no placeholders — only real data. This is the mode the + * published_snapshots blob is serialized for. + */ +export type BlockMode = 'edit' | 'preview' | 'public'; + +/** + * A single block in the tree. Props are block-type-specific and validated + * against the registered Zod schema at write time (in stores) and at + * publish time (in the snapshot builder). + */ +export interface Block { + id: string; + type: string; + props: Props; + schemaVersion: number; + order: number; + parentBlockId: string | null; + slotKey: string | null; +} + +/** + * Category for grouping blocks in the insert palette. + */ +export type BlockCategory = 'content' | 'media' | 'layout' | 'form' | 'embed'; + +/** + * Props passed to every block renderer. `onEdit` is only present in + * `edit` mode — consumers must guard with `if (mode === 'edit' && onEdit)`. + */ +export interface BlockRenderProps { + block: Block; + mode: BlockMode; + children?: Block[]; + onEdit?: (patch: Partial) => void; +} + +/** + * Props passed to every block inspector (right pane of the editor). + */ +export interface BlockInspectorProps { + block: Block; + onChange: (patch: Partial) => void; +} + +/** + * Registered spec for one block type. The schema, renderer, inspector, + * and metadata are bundled — the editor and public renderer consume the + * same spec, so drift is structurally impossible. + */ +export interface BlockSpec { + /** Stable type id, used in DB (`blocks.type`) and in code. */ + type: string; + /** Human label shown in the insert palette. */ + label: string; + /** Lucide icon name (or any icon id the editor knows how to render). */ + icon: string; + /** Category for palette grouping. */ + category: BlockCategory; + /** Zod schema defining valid props. Enforced at write + publish time. */ + schema: ZodTypeAny; + /** Current schema version. Bump when the schema shape changes. */ + schemaVersion: number; + /** Default prop values when a new block of this type is inserted. */ + defaults: Props; + /** Svelte 5 component rendering the block in all three modes. */ + Component: Component>; + /** Svelte 5 component rendering the inspector form for this block. */ + Inspector: Component>; + /** + * Optional upgraders: version N → version N+1 prop transformer. + * Keyed by the SOURCE version (v1 → v2 upgrader lives under key `1`). + */ + upgraders?: Record Props>; +} + +/** Helper to infer props type from a spec's schema. */ +export type PropsOf> = Spec extends BlockSpec ? P : never; + +/** Helper to infer props type from a Zod schema. */ +export type InferProps = z.infer; diff --git a/packages/website-blocks/tsconfig.json b/packages/website-blocks/tsconfig.json new file mode 100644 index 000000000..c05c7cc81 --- /dev/null +++ b/packages/website-blocks/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "types": ["svelte"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5649bfcec..e3a06b91f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,14 +138,14 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.9.2 version: 5.9.3 devDependencies: '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) @@ -154,13 +154,13 @@ importers: version: 20.19.39 eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@1.21.7) + version: 9.39.4(jiti@2.6.1) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.4(jiti@1.21.7)) + version: 9.1.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-astro: specifier: ^1.0.0 - version: 1.6.0(eslint@9.39.4(jiti@1.21.7)) + version: 1.6.0(eslint@9.39.4(jiti@2.6.1)) prettier: specifier: ^3.6.2 version: 3.8.1 @@ -253,10 +253,10 @@ importers: version: 3.7.2 '@astrojs/tailwind': specifier: ^6.0.0 - version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) astro: specifier: ^5.16.11 - version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tailwindcss: specifier: ^3.4.17 version: 3.4.19(tsx@4.21.0)(yaml@2.8.3) @@ -576,6 +576,9 @@ importers: '@mana/wallpaper-generator': specifier: workspace:* version: link:../../../../packages/wallpaper-generator + '@mana/website-blocks': + specifier: workspace:* + version: link:../../../../packages/website-blocks '@quotes/content': specifier: workspace:* version: link:../../../quotes/packages/content @@ -1939,6 +1942,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^4.1.3 + version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) packages/notify-client: devDependencies: @@ -2552,6 +2558,22 @@ importers: specifier: ^4.1.2 version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/website-blocks: + dependencies: + zod: + specifier: ^3.23.8 + version: 3.25.76 + devDependencies: + svelte: + specifier: ^5.16.0 + version: 5.55.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@24.12.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.1) + services/mana-ai: dependencies: '@mana/shared-ai': @@ -2563,6 +2585,9 @@ importers: '@mana/shared-research': specifier: workspace:* version: link:../../packages/shared-research + '@mana/tool-registry': + specifier: workspace:* + version: link:../../packages/mana-tool-registry '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.1 @@ -2921,6 +2946,31 @@ importers: services/mana-notify: {} + services/mana-persona-runner: + dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.2.118 + version: 0.2.118(zod@3.25.76) + '@mana/shared-hono': + specifier: workspace:* + version: link:../../packages/shared-hono + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@3.25.76) + hono: + specifier: ^4.7.0 + version: 4.12.12 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/bun': + specifier: ^1.1.16 + version: 1.3.13 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + services/mana-research: dependencies: '@mana/shared-hono': @@ -3103,6 +3153,52 @@ packages: '@antfu/utils@8.1.1': resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.118': + resolution: {integrity: sha512-RudnoBekv0c9CPL0EeMc4RqDe4Pb7tdz/2oxa5EYqaajXNRlYtTvru9q7wq7Zvp40JQ24hz38swOTJ7PkW7G/g==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.118': + resolution: {integrity: sha512-Hf/H46uElpfygALlb4KZR2EuyyJRe7jBuWa+TDA4jmAHVblNfwkVyaCp8s61hZINB3kAmXdLdM81VI+xwruWzA==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.118': + resolution: {integrity: sha512-gSuZS8GM8MZuklzAJS8VCCjqK2UJJeerV+JpVYzXNMelotq4sXUg2dp17VbjCJ1jhUC9u1gpzlQDWkmYrXCbOg==} + cpu: [arm64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.118': + resolution: {integrity: sha512-lwMXnweJKpzESezJFM8mngRxJfaq/N0gqyFXBm5bOYaPIZnlGlP3h1JMKsJeqC4neLVGbe5a3Hq4T22Rr7OoAA==} + cpu: [arm64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.118': + resolution: {integrity: sha512-36lG1F9IsuNBV7AzJY98z8KwryoWZCeEtMzgZL7614zPBhZGBsziQUZEBm2Eu7FVWbRQmYv6BL52+gffpkM4Gw==} + cpu: [x64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.2.118': + resolution: {integrity: sha512-m0KBbwN9s0+hQwAPzeUFvegrEqoT9EOC+Vz3vr4dd9FcZyvKZE0yiv9S7YbFp1ZKWDQmppmvpcB+9eME7WQ0yA==} + cpu: [x64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.118': + resolution: {integrity: sha512-o30/SL084+a8wJ+5cgKM1BflxiBUEy+xEcEpZPW+zCFtiqY0b1Pr+K35ECsbKBrv+w5/0Byp4/CvCkP15Otsgw==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.2.118': + resolution: {integrity: sha512-TSqsVBUaZGgYMkjCZckXhPvmJDTS7C6VAl4IOeMVNB/oPINVFaobtVagjYvY0BFnlDCOzz6sb8puafHwcm7qQA==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.2.118': + resolution: {integrity: sha512-OfxCTzmfqvctpTLd3CP+UrpC0JdhYcJp12rD+SK29k+9+hrbblCrLobvhdWpTuYFejTPJuiLVsbHxq0BkEuELQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^4.0.0 + '@anthropic-ai/sdk@0.65.0': resolution: {integrity: sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw==} hasBin: true @@ -3112,6 +3208,15 @@ packages: zod: optional: true + '@anthropic-ai/sdk@0.81.0': + resolution: {integrity: sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@apideck/better-ajv-errors@0.3.7': resolution: {integrity: sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==} engines: {node: '>=10'} @@ -8405,9 +8510,23 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/expect@4.1.3': resolution: {integrity: sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==} + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.1.3': resolution: {integrity: sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==} peerDependencies: @@ -8419,15 +8538,27 @@ packages: vite: optional: true + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@4.1.3': resolution: {integrity: sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==} + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/runner@4.1.3': resolution: {integrity: sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==} + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@4.1.3': resolution: {integrity: sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==} + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@4.1.3': resolution: {integrity: sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==} @@ -8436,6 +8567,9 @@ packages: peerDependencies: vitest: 4.1.3 + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@4.1.3': resolution: {integrity: sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==} @@ -9196,6 +9330,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -9231,6 +9369,10 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -9737,6 +9879,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -12788,6 +12934,9 @@ packages: react-native-windows: optional: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -13762,6 +13911,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + pdf-lib@1.17.1: resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} @@ -15117,6 +15270,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} @@ -15502,10 +15658,22 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} @@ -16023,6 +16191,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-plugin-pwa@1.2.0: resolution: {integrity: sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==} engines: {node: '>=16.0.0'} @@ -16114,6 +16287,31 @@ packages: vite: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.1.3: resolution: {integrity: sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -16775,12 +16973,60 @@ snapshots: '@antfu/utils@8.1.1': {} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.118': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.118': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.118': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.118': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.118': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.2.118': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.118': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.2.118': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.2.118(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.81.0(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + zod: 3.25.76 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.2.118 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.2.118 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.2.118 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.2.118 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.2.118 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.2.118 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.2.118 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.2.118 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + '@anthropic-ai/sdk@0.65.0(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: zod: 3.25.76 + '@anthropic-ai/sdk@0.81.0(zod@3.25.76)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.76 + '@apideck/better-ajv-errors@0.3.7(ajv@8.18.0)': dependencies: ajv: 8.18.0 @@ -17001,16 +17247,6 @@ snapshots: transitivePeerDependencies: - ts-node - '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - autoprefixer: 10.4.27(postcss@8.5.8) - postcss: 8.5.8 - postcss-load-config: 4.0.2(postcss@8.5.8) - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - ts-node - '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': dependencies: astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -17031,6 +17267,16 @@ snapshots: transitivePeerDependencies: - ts-node + '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + autoprefixer: 10.4.27(postcss@8.5.8) + postcss: 8.5.8 + postcss-load-config: 4.0.2(postcss@8.5.8) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - ts-node + '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': dependencies: astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -19190,11 +19436,6 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': - dependencies: - eslint: 9.39.4(jiti@1.21.7) - eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -23626,6 +23867,13 @@ snapshots: tinyrainbow: 3.1.0 vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + '@vitest/expect@4.1.3': dependencies: '@standard-schema/spec': 1.1.0 @@ -23635,6 +23883,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(terser@5.46.1))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(terser@5.46.1) + '@vitest/mocker@4.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.3 @@ -23659,15 +23915,30 @@ snapshots: optionalDependencies: vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + '@vitest/pretty-format@4.1.3': dependencies: tinyrainbow: 3.1.0 + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + '@vitest/runner@4.1.3': dependencies: '@vitest/utils': 4.1.3 pathe: 2.0.3 + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + '@vitest/snapshot@4.1.3': dependencies: '@vitest/pretty-format': 4.1.3 @@ -23675,6 +23946,10 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + '@vitest/spy@4.1.3': {} '@vitest/ui@4.1.3(vitest@4.1.3)': @@ -23688,6 +23963,12 @@ snapshots: tinyrainbow: 3.1.0 vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + '@vitest/utils@4.1.3': dependencies: '@vitest/pretty-format': 4.1.3 @@ -24117,108 +24398,6 @@ snapshots: transitivePeerDependencies: - supports-color - astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): - dependencies: - '@astrojs/compiler': 2.13.1 - '@astrojs/internal-helpers': 0.7.6 - '@astrojs/markdown-remark': 6.3.11 - '@astrojs/telemetry': 3.3.0 - '@capsizecss/unpack': 4.0.0 - '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.60.1) - acorn: 8.16.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - boxen: 8.0.1 - ci-info: 4.4.0 - clsx: 2.1.1 - common-ancestor-path: 1.0.1 - cookie: 1.1.1 - cssesc: 3.0.0 - debug: 4.4.3 - deterministic-object-hash: 2.0.2 - devalue: 5.7.0 - diff: 8.0.4 - dlv: 1.1.3 - dset: 3.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.27.7 - estree-walker: 3.0.3 - flattie: 1.1.1 - fontace: 0.4.1 - github-slugger: 2.0.0 - html-escaper: 3.0.3 - http-cache-semantics: 4.2.0 - import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 - magic-string: 0.30.21 - magicast: 0.5.2 - mrmime: 2.0.1 - neotraverse: 0.6.18 - p-limit: 6.2.0 - p-queue: 8.1.1 - package-manager-detector: 1.6.0 - piccolore: 0.1.3 - picomatch: 4.0.4 - prompts: 2.4.2 - rehype: 13.0.2 - semver: 7.7.4 - shiki: 3.23.0 - smol-toml: 1.6.1 - svgo: 4.0.1 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - tsconfck: 3.1.6(typescript@5.9.3) - ultrahtml: 1.6.0 - unifont: 0.7.4 - unist-util-visit: 5.1.0 - unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1) - vfile: 6.0.3 - vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - xxhash-wasm: 1.1.0 - yargs-parser: 21.1.1 - yocto-spinner: 0.2.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) - optionalDependencies: - sharp: 0.34.5 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - db0 - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - rollup - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - yaml - astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: '@astrojs/compiler': 2.13.1 @@ -24423,6 +24602,108 @@ snapshots: - uploadthing - yaml + astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/internal-helpers': 0.7.6 + '@astrojs/markdown-remark': 6.3.11 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 4.0.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + acorn: 8.16.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.1 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.7.0 + diff: 8.0.4 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.27.7 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.4.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.2 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.4 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.4 + shiki: 3.23.0 + smol-toml: 1.6.1 + svgo: 4.0.1 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1) + vfile: 6.0.3 + vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: '@astrojs/compiler': 2.13.1 @@ -25013,6 +25294,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} chalk@2.4.2: @@ -25040,6 +25329,8 @@ snapshots: chardet@0.7.0: {} + check-error@2.1.3: {} + cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -25582,6 +25873,8 @@ snapshots: dedent@1.7.2: {} + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -26242,11 +26535,6 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) semver: 7.7.4 - eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)): - dependencies: - eslint: 9.39.4(jiti@1.21.7) - semver: 7.7.4 - eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -26256,10 +26544,6 @@ snapshots: dependencies: eslint: 9.39.4(jiti@2.6.1) - eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)): - dependencies: - eslint: 9.39.4(jiti@1.21.7) - eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -26304,20 +26588,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) - '@jridgewell/sourcemap-codec': 1.5.5 - '@typescript-eslint/types': 8.58.0 - astro-eslint-parser: 1.4.0 - eslint: 9.39.4(jiti@1.21.7) - eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7)) - globals: 16.5.0 - postcss: 8.5.8 - postcss-selector-parser: 7.1.1 - transitivePeerDependencies: - - supports-color - eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -26491,47 +26761,6 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@1.21.7): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 1.21.7 - transitivePeerDependencies: - - supports-color - eslint@9.39.4(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -29335,6 +29564,8 @@ snapshots: react: 19.2.0 react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) + loupe@3.2.1: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -30864,6 +31095,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + pdf-lib@1.17.1: dependencies: '@pdf-lib/standard-fonts': 1.0.0 @@ -32618,6 +32851,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + std-env@4.0.0: {} stop-iteration-iterator@1.1.0: @@ -33064,8 +33299,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + tinyrainbow@3.1.0: {} + tinyspy@3.0.2: {} + tldts-core@6.1.86: {} tldts-core@7.0.28: {} @@ -33565,6 +33806,24 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@2.1.9(@types/node@24.12.2)(lightningcss@1.32.0)(terser@5.46.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(terser@5.46.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-plugin-pwa@1.2.0(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): dependencies: debug: 4.4.3 @@ -33609,23 +33868,6 @@ snapshots: lightningcss: 1.32.0 terser: 5.46.1 - vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.8 - rollup: 4.60.1 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 20.19.39 - fsevents: 2.3.3 - jiti: 1.21.7 - lightningcss: 1.32.0 - terser: 5.46.1 - tsx: 4.21.0 - yaml: 2.8.3 - vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -33660,6 +33902,23 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 + vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.12.2 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.32.0 + terser: 5.46.1 + tsx: 4.21.0 + yaml: 2.8.3 + vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -33677,10 +33936,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - optionalDependencies: - vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -33689,10 +33944,50 @@ snapshots: optionalDependencies: vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + optionalDependencies: + vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitest@2.1.9(@types/node@24.12.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(terser@5.46.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(terser@5.46.1) + vite-node: 2.1.9(@types/node@24.12.2)(lightningcss@1.32.0)(terser@5.46.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.2 + jsdom: 29.0.2(@noble/hashes@2.0.1) + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@4.1.3(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.3