diff --git a/.changeset/free-dodos-type.md b/.changeset/free-dodos-type.md new file mode 100644 index 0000000..a845151 --- /dev/null +++ b/.changeset/free-dodos-type.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/docs/connecting-agents.md b/docs/connecting-agents.md index df54715..214bbe9 100644 --- a/docs/connecting-agents.md +++ b/docs/connecting-agents.md @@ -94,6 +94,6 @@ shows the same steps. The plugin lives in [`../plugin/`](../plugin/). `/agent-howto` is the current operational playbook for agents: publishing, feedback, CLI/MCP/curl choices, and gotchas. The contract at `/guide` is the lower-level design reference: fragment-only HTML, theme CSS variables, dark mode -rules, and when to reach for each part kind. Agents should fetch the instructions +rules, and when to reach for each surface kind. Agents should fetch the instructions first, then fetch the guide once before their first publish (`sideshow guide`, `get_design_guide`, or `curl -s …/guide`). diff --git a/server/app.ts b/server/app.ts index 6e15931..ed83caf 100644 --- a/server/app.ts +++ b/server/app.ts @@ -31,6 +31,7 @@ import { type CommentAnchor, type DiffSurface, htmlSurface, + isSandboxedSurfaceKind, type MarkdownSurface, MAX_ASSET_BYTES, surfacesByteLength, @@ -38,6 +39,7 @@ import { type Store, type Post, type Surface, + SURFACE_CONTENT_FIELDS, type TerminalSurface, type TraceStep, } from "./types.ts"; @@ -310,7 +312,7 @@ export function createApp({ // Rendered-document cache for /s/:id rich surfaces. Rendering a markdown/code/ // diff surface runs shiki / @pierre-diffs SSR, which is non-trivial (a big diff // is tens of ms + tens of KB), so memoize the finished document string. The - // key pins everything the output depends on — post id, part index, the + // key pins everything the output depends on — post id, surface index, the // RESOLVED version number, theme, mode — and a version's content is immutable, // so a hit is always correct (a post edit bumps the version → a new key). // Bounded + FIFO-evicted: a dropped entry costs a re-render, never @@ -382,19 +384,6 @@ export function createApp({ return feedback.length > 0 ? feedback.map(feedbackView) : undefined; } - // Maps a surface kind to its primary content field — used by content-only - // updates (PATCH with `content`) to slot a raw string into the right field - // while preserving extra fields (language, cols, layout, etc.). - const CONTENT_FIELD: Record = { - html: "html", - markdown: "markdown", - mermaid: "mermaid", - diff: "patch", - terminal: "text", - code: "code", - json: "data", - }; - // Find a surface's index by id (first match) or 0-based numeric index. function findSurfaceIndex(surfaces: Surface[], target: string): number { const byId = surfaces.findIndex((s) => s.id === target); @@ -408,7 +397,7 @@ export function createApp({ // extra fields. Returns null if the kind has no content field or JSON parse // fails. The caller handles error reporting. function applyContent(surface: Surface, content: string, kits?: unknown): Surface | null { - const field = CONTENT_FIELD[surface.kind]; + const field = SURFACE_CONTENT_FIELDS[surface.kind]; if (!field) return null; let value: unknown = content; if (surface.kind === "json") { @@ -601,7 +590,7 @@ export function createApp({ // The validator strips the id field (zod schemas don't declare it), so // re-apply the target's id after validation to preserve surface identity. const surfaces = [...existing.surfaces]; - surfaces[idx] = { ...parsed.parts[0], id: existing.surfaces[idx].id }; + surfaces[idx] = { ...parsed.surfaces[0], id: existing.surfaces[idx].id }; return revisePost(id, { surfaces }); } @@ -1002,7 +991,7 @@ export function createApp({ return c.json(sessions.map((s) => sessionRowView(s, counts.get(s.id) ?? 0))); }); - // --- recent surfaces (post-grained feed source) --- + // --- recent posts (post-grained feed source) --- // // The N most-recently-updated posts across ALL sessions, newest first — one // row per post (post-grained), distinct from the session-grained GET @@ -1128,7 +1117,7 @@ export function createApp({ } const parsed = await validateSurfaces(blocks); if (!parsed.ok) return c.json({ error: parsed.error }, 400); - return publish(c, body, parsed.parts); + return publish(c, body, parsed.surfaces); }; app.post("/api/posts", publishPost); // canonical app.post("/api/surfaces", publishPost); @@ -1143,7 +1132,7 @@ export function createApp({ } const parsed = await validateSurfaces([htmlSurface(body.html, body.kits)]); if (!parsed.ok) return c.json({ error: parsed.error }, 400); - return publish(c, body, parsed.parts); + return publish(c, body, parsed.surfaces); }); async function publish(c: any, body: any, surfaces: Surface[]) { @@ -1180,11 +1169,11 @@ export function createApp({ } const parsed = await validateSurfaces(blocks); if (!parsed.ok) return c.json({ error: parsed.error }, 400); - surfaces = parsed.parts; + surfaces = parsed.surfaces; } else if (typeof body.html === "string") { const parsed = await validateSurfaces([htmlSurface(body.html, body.kits)]); if (!parsed.ok) return c.json({ error: parsed.error }, 400); - surfaces = parsed.parts; + surfaces = parsed.surfaces; } const result = await revisePost(c.req.param("id"), { surfaces, @@ -1249,7 +1238,7 @@ export function createApp({ const parsed = await validateSurfaces([updated]); if (!parsed.ok) return c.json({ error: parsed.error }, 400); surfaces = [...existing.surfaces]; - surfaces[targetIdx] = { ...parsed.parts[0], id: existing.surfaces[targetIdx].id }; + surfaces[targetIdx] = { ...parsed.surfaces[0], id: existing.surfaces[targetIdx].id }; } const result = await revisePost(c.req.param("id"), { surfaces, @@ -1273,7 +1262,7 @@ export function createApp({ } const parsed = await validateSurfaces([body.surface]); if (!parsed.ok) return c.json({ error: parsed.error }, 400); - const result = await appendPostSurface(c.req.param("id"), parsed.parts[0], { + const result = await appendPostSurface(c.req.param("id"), parsed.surfaces[0], { before: body.before, after: body.after, }); @@ -1296,7 +1285,7 @@ export function createApp({ if (body.surface !== undefined) { const parsed = await validateSurfaces([body.surface]); if (!parsed.ok) return c.json({ error: parsed.error }, 400); - surface = parsed.parts[0]; + surface = parsed.surfaces[0]; } const result = await replacePostSurface(c.req.param("id"), c.req.param("target"), { surface, @@ -1450,8 +1439,9 @@ export function createApp({ const renderPostPage = async (c: any) => { const post = await store.getPost(c.req.param("id")); if (!post) return c.text("Post not found", 404); - const partParam = c.req.query("surface") ?? c.req.query("part"); - if (partParam == null) return c.html(configuredViewerHtml(c, { post })); + // `part` is the legacy query key; `surface` is canonical. + const surfaceParam = c.req.query("surface") ?? c.req.query("part"); + if (surfaceParam == null) return c.html(configuredViewerHtml(c, { post })); const ver = c.req.query("ver"); let title = post.title; @@ -1464,12 +1454,11 @@ export function createApp({ surfaces = old.surfaces; version = old.version; } - const idx = Number(partParam ?? 0); - const part = surfaces[idx]; + const idx = Number(surfaceParam ?? 0); + const surface = surfaces[idx]; // Only the kinds that become HTML are served here. Image/trace/json render // natively in the viewer and must not be reachable as a document. - const SANDBOXED = ["html", "markdown", "code", "diff", "terminal", "mermaid"]; - if (!part || !SANDBOXED.includes(part.kind)) { + if (!surface || !isSandboxedSurfaceKind(surface.kind)) { return c.text("No renderable surface at that index", 404); } c.header("X-Content-Type-Options", "nosniff"); @@ -1505,20 +1494,27 @@ export function createApp({ else c.header("Cache-Control", "private, no-cache"); const doc = await cachedRender(cacheKey, async () => { - if (part.kind === "html") { - return renderHtmlPage({ title, html: part.html, origin, theme, mode, kits: part.kits }); + if (surface.kind === "html") { + return renderHtmlPage({ + title, + html: surface.html, + origin, + theme, + mode, + kits: surface.kits, + }); } - if (part.kind === "mermaid") { - return renderMermaidPage({ mermaid: part.mermaid, origin, theme, mode }); + if (surface.kind === "mermaid") { + return renderMermaidPage({ mermaid: surface.mermaid, origin, theme, mode }); } const rendered = - part.kind === "markdown" - ? await renderMarkdown(part as MarkdownSurface, { theme: themeId, mode }) - : part.kind === "code" - ? await renderCode(part as CodeSurface, { theme: themeId, mode }) - : part.kind === "terminal" - ? renderTerminal(part as TerminalSurface) - : await renderDiff(part as DiffSurface, { theme: themeId, mode }).catch((e) => ({ + surface.kind === "markdown" + ? await renderMarkdown(surface as MarkdownSurface, { theme: themeId, mode }) + : surface.kind === "code" + ? await renderCode(surface as CodeSurface, { theme: themeId, mode }) + : surface.kind === "terminal" + ? renderTerminal(surface as TerminalSurface) + : await renderDiff(surface as DiffSurface, { theme: themeId, mode }).catch((e) => ({ body: `
Couldn’t render diff — ${escapeHtml( e instanceof Error ? e.message : "render error", )}
`, diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts index dca777a..0ce70f5 100644 --- a/server/mcpHttp.ts +++ b/server/mcpHttp.ts @@ -64,6 +64,7 @@ export interface McpDeps { // Coerce loosely-typed tool args into a validated Surface[]. Unknown kinds // and empty surfaces are dropped rather than rejected, so a slightly-off call // still publishes what it can. +// Deprecated alias retained for older deep imports that used the legacy name. export const coerceParts = coerceSurfaces; export function registerMcp(app: Hono, deps: McpDeps) { @@ -93,8 +94,8 @@ export function registerMcp(app: Hono, deps: McpDeps) { const blocks = name === "publish_post" ? (args.surfaces ?? args.parts) : args.parts; const surfaces = name === "publish_snippet" - ? await coerceParts([htmlSurface(String(args.html ?? ""), args.kits)]) - : await coerceParts(blocks); + ? await coerceSurfaces([htmlSurface(String(args.html ?? ""), args.kits)]) + : await coerceSurfaces(blocks); if (surfaces.length === 0) { throw new Error("a post needs at least one surface"); } @@ -116,10 +117,10 @@ export function registerMcp(app: Hono, deps: McpDeps) { }; if (name === "update_snippet") { if (typeof args.html === "string") - patch.surfaces = await coerceParts([htmlSurface(args.html, args.kits)]); + patch.surfaces = await coerceSurfaces([htmlSurface(args.html, args.kits)]); } else { const blocks = name === "update_post" ? (args.surfaces ?? args.parts) : args.parts; - if (blocks !== undefined) patch.surfaces = await coerceParts(blocks); + if (blocks !== undefined) patch.surfaces = await coerceSurfaces(blocks); } const result = await deps.revisePost(String(args.id ?? ""), patch); if ("error" in result) throw new Error(result.error); @@ -210,7 +211,7 @@ export function registerMcp(app: Hono, deps: McpDeps) { case "get_design_guide": return deps.guide; case "add_surface": { - const surfaces = await coerceParts([args.surface]); + const surfaces = await coerceSurfaces([args.surface]); if (surfaces.length === 0) throw new Error("invalid surface"); const result = await deps.appendPostSurface(String(args.postId ?? ""), surfaces[0], { before: typeof args.before === "string" ? args.before : undefined, @@ -222,7 +223,7 @@ export function registerMcp(app: Hono, deps: McpDeps) { case "edit_surface": { let surface: Surface | undefined; if (args.surface !== undefined) { - const surfaces = await coerceParts([args.surface]); + const surfaces = await coerceSurfaces([args.surface]); if (surfaces.length === 0) throw new Error("invalid surface"); surface = surfaces[0]; } diff --git a/server/mcpSpec.ts b/server/mcpSpec.ts index cd756e0..963c5bd 100644 --- a/server/mcpSpec.ts +++ b/server/mcpSpec.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { KIT_IDS } from "./kits.ts"; -import { SURFACE_KINDS, type SurfaceKind } from "./types.ts"; +import { SURFACE_KIND_LIST, SURFACE_KINDS, type SurfaceKind } from "./types.ts"; export const MCP_SERVER_INFO = { name: "sideshow", version: "0.1.0" }; @@ -155,10 +155,8 @@ const MCP_SURFACES_JSON_SCHEMA = { } as const; export const MCP_TOOL_DESCRIPTIONS = { - publishPostHttp: - "Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id, view URL, sessionId, and the new surface ids (use them to target a surface for later edits without a get_post round-trip) — pass sessionId as `session` on later calls. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", - publishPostStdio: - "Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id, view URL, and the new surface ids (use them to target a surface for later edits without a get_post round-trip). On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", + publishPostHttp: `Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (${SURFACE_KIND_LIST}). Returns the post id, view URL, sessionId, and the new surface ids (use them to target a surface for later edits without a get_post round-trip) — pass sessionId as \`session\` on later calls. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.`, + publishPostStdio: `Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (${SURFACE_KIND_LIST}). Returns the post id, view URL, and the new surface ids (use them to target a surface for later edits without a get_post round-trip). On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.`, updatePost: "Revise a post in place (same card, new version). Prefer this over publishing a near-duplicate. Pass the full replacement surfaces array. Returns the new surface ids (use them to target a surface for later edits without a get_post round-trip). If the result includes userFeedback, read it.", listPostsHttp: @@ -167,10 +165,8 @@ export const MCP_TOOL_DESCRIPTIONS = { "List posts in this conversation's session. Returns lean post rows with surfaces as `{id, kind, index}` metadata (no surface bodies).", getPost: "Fetch a single post by id — returns the full post object including surfaces (with their ids and 0-based indexes), version, and history. Use this to recover surface ids (or indexes) for per-surface operations (edit_surface, remove_surface, reorder_surfaces) after a context compaction, or to inspect a post's current state before editing.", - publishSurfaceHttp: - "Deprecated alias of publish_post — Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id, view URL, and sessionId — pass sessionId as `session` on later calls. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", - publishSurfaceStdio: - "Deprecated alias of publish_post — Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (html, markdown, mermaid, diff, image, trace, terminal, json, code). Returns the post id and view URL. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.", + publishSurfaceHttp: `Deprecated alias of publish_post — Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (${SURFACE_KIND_LIST}). Returns the post id, view URL, and sessionId — pass sessionId as \`session\` on later calls. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.`, + publishSurfaceStdio: `Deprecated alias of publish_post — Publish a post to the user's sideshow workspace. A post is an ordered list of surfaces (${SURFACE_KIND_LIST}). Returns the post id and view URL. On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those are new comments from the user. Call get_design_guide first if you have not this session.`, updateSurface: "Deprecated alias of update_post — Revise a post in place (same card, new version). Prefer this over publishing a near-duplicate. Pass the full replacement surfaces array. If the result includes userFeedback, read it.", publishSnippet: @@ -445,7 +441,7 @@ const traceStepSchema = z.object({ ts: z.string().optional().describe(d.traceTs), }); -const mcpPartSchema = z +const mcpSurfaceSchema = z .object({ kind: z.enum(PART_KIND_ENUM), html: z.string().optional().describe(d.surfaceHtml), @@ -478,12 +474,12 @@ const mcpPartSchema = z export const STDIO_MCP_INPUT_SCHEMAS = { publishPost: { title: z.string().describe(d.title), - surfaces: z.array(mcpPartSchema).describe(MCP_SURFACES_DESCRIPTION), + surfaces: z.array(mcpSurfaceSchema).describe(MCP_SURFACES_DESCRIPTION), sessionTitle: z.string().optional().describe(d.stdioSessionTitle), }, updatePost: { id: z.string().describe(d.surfaceId), - surfaces: z.array(mcpPartSchema).optional().describe(d.replacementParts), + surfaces: z.array(mcpSurfaceSchema).optional().describe(d.replacementParts), title: z.string().optional().describe(d.replacementTitle), }, getPost: { @@ -491,12 +487,12 @@ export const STDIO_MCP_INPUT_SCHEMAS = { }, publishSurface: { title: z.string().describe(d.title), - parts: z.array(mcpPartSchema).describe(MCP_SURFACES_DESCRIPTION), + parts: z.array(mcpSurfaceSchema).describe(MCP_SURFACES_DESCRIPTION), sessionTitle: z.string().optional().describe(d.stdioSessionTitle), }, updateSurface: { id: z.string().describe(d.surfaceId), - parts: z.array(mcpPartSchema).optional().describe(d.replacementParts), + parts: z.array(mcpSurfaceSchema).optional().describe(d.replacementParts), title: z.string().optional().describe(d.replacementTitle), }, publishSnippet: { @@ -535,14 +531,14 @@ export const STDIO_MCP_INPUT_SCHEMAS = { }, addSurface: { postId: z.string().describe(d.surfaceId), - surface: mcpPartSchema.describe("Surface to append"), + surface: mcpSurfaceSchema.describe("Surface to append"), before: z.string().optional().describe(d.surfaceTarget), after: z.string().optional().describe(d.surfaceTarget), }, editSurface: { postId: z.string().describe(d.surfaceId), target: z.string().describe(d.surfaceTarget), - surface: mcpPartSchema.optional().describe("Full replacement surface"), + surface: mcpSurfaceSchema.optional().describe("Full replacement surface"), content: z .string() .optional() diff --git a/server/postSurfaces.ts b/server/postSurfaces.ts index 02cf5af..3a051a3 100644 --- a/server/postSurfaces.ts +++ b/server/postSurfaces.ts @@ -2,9 +2,11 @@ import { z } from "zod"; import { processFile, parsePatchFiles } from "@pierre/diffs"; import { parse as parseMermaid } from "@mermaid-js/parser"; import { isKnownKit, KIT_IDS } from "./kits.ts"; -import type { Surface } from "./types.ts"; +import { isSurfaceKind, type Surface, type SurfaceKind } from "./types.ts"; -export interface SurfacePartParseResult { +export interface SurfaceParseResult { + surfaces: Surface[]; + // Deprecated compatibility alias for older deep imports. parts: Surface[]; errors: string[]; } @@ -82,14 +84,14 @@ const filteredArray = (schema: z.ZodType) => const strictKitId = z.string().refine(isKnownKit, (id) => ({ message: `unknown kit "${id}" — known: ${KIT_IDS.join(", ")}`, })); -const strictHtmlPart = z.object({ +const strictHtmlSurface = z.object({ kind: z.literal("html"), html: requiredString("html"), kits: z.array(strictKitId).optional(), }); // Loose mode keeps only known kit ids and omits the field entirely when none // remain — so a junk `kits` never lingers as an empty or undefined key. -const looseHtmlPart = z +const looseHtmlSurface = z .object({ kind: z.literal("html"), html: requiredString("html"), @@ -100,29 +102,29 @@ const looseHtmlPart = z return { kind: "html" as const, html: p.html, ...(kits.length > 0 ? { kits } : {}) }; }); -const strictMarkdownPart = z.object({ +const strictMarkdownSurface = z.object({ kind: z.literal("markdown"), markdown: requiredString("markdown"), }); // Loose mode drops a blank markdown surface rather than publishing an empty card. -const looseMarkdownPart = z +const looseMarkdownSurface = z .object({ kind: z.literal("markdown"), markdown: z.string() }) .refine((p) => p.markdown.trim().length > 0, { message: 'markdown surface requires non-empty "markdown"', }); -const strictMermaidPart = z.object({ +const strictMermaidSurface = z.object({ kind: z.literal("mermaid"), mermaid: requiredString("mermaid"), }); // Loose mode drops a blank mermaid surface rather than publishing an empty card. -const looseMermaidPart = z +const looseMermaidSurface = z .object({ kind: z.literal("mermaid"), mermaid: z.string() }) .refine((p) => p.mermaid.trim().length > 0, { message: 'mermaid surface requires non-empty "mermaid"', }); -const strictDiffPart = z +const strictDiffSurface = z .object({ kind: z.literal("diff"), patch: z.string().optional(), @@ -132,7 +134,7 @@ const strictDiffPart = z .refine((p) => !!p.patch || (p.files?.length ?? 0) > 0, { message: 'diff surface requires string "patch" or non-empty "files"', }); -const looseDiffPart = z +const looseDiffSurface = z .object({ kind: z.literal("diff"), patch: optionalLooseString, @@ -143,20 +145,20 @@ const looseDiffPart = z message: 'diff surface requires string "patch" or non-empty "files"', }); -const strictImagePart = z.object({ +const strictImageSurface = z.object({ kind: z.literal("image"), assetId: requiredString("assetId"), alt: z.string().optional(), caption: z.string().optional(), }); -const looseImagePart = z.object({ +const looseImageSurface = z.object({ kind: z.literal("image"), assetId: z.string(), alt: optionalLooseString, caption: optionalLooseString, }); -const strictTracePart = z +const strictTraceSurface = z .object({ kind: z.literal("trace"), steps: z.array(strictTraceStep).optional(), @@ -166,7 +168,7 @@ const strictTracePart = z .refine((p) => !!p.assetId || (p.steps?.length ?? 0) > 0, { message: 'trace surface requires "assetId" or non-empty "steps"', }); -const looseTracePart = z +const looseTraceSurface = z .object({ kind: z.literal("trace"), steps: filteredArray(looseTraceStep).optional(), @@ -177,13 +179,13 @@ const looseTracePart = z message: 'trace surface requires "assetId" or non-empty "steps"', }); -const strictTerminalPart = z.object({ +const strictTerminalSurface = z.object({ kind: z.literal("terminal"), text: requiredString("text"), cols: z.number().optional(), title: z.string().optional(), }); -const looseTerminalPart = z.object({ +const looseTerminalSurface = z.object({ kind: z.literal("terminal"), text: z.string(), cols: optionalLooseNumber, @@ -195,7 +197,7 @@ const looseTerminalPart = z.object({ // drops the surface if `data` is absent. The transform fixes zod's inference: // z.unknown() marks the key optional, but data is always present after the // refine, so the output type must be { kind: "json"; data: unknown }. -const strictJsonPart = z +const strictJsonSurface = z .object({ kind: z.literal("json"), data: z.unknown(), @@ -204,7 +206,7 @@ const strictJsonPart = z message: 'json surface requires "data"', }) .transform((p) => ({ kind: "json" as const, data: p.data })); -const looseJsonPart = z +const looseJsonSurface = z .object({ kind: z.literal("json"), data: z.unknown(), @@ -214,14 +216,14 @@ const looseJsonPart = z }) .transform((p) => ({ kind: "json" as const, data: p.data })); -const strictCodePart = z.object({ +const strictCodeSurface = z.object({ kind: z.literal("code"), code: requiredString("code"), language: z.string().optional(), title: z.string().optional(), lineStart: z.number().int().min(1).optional(), }); -const looseCodePart = z.object({ +const looseCodeSurface = z.object({ kind: z.literal("code"), code: z.string(), language: optionalLooseString, @@ -229,56 +231,72 @@ const looseCodePart = z.object({ lineStart: optionalLooseNumber, }); -const looseSurfacePart = z.union([ - looseHtmlPart, - looseMarkdownPart, - looseMermaidPart, - looseDiffPart, - looseImagePart, - looseTracePart, - looseTerminalPart, - looseJsonPart, - looseCodePart, +const looseSurfaceSchema = z.union([ + looseHtmlSurface, + looseMarkdownSurface, + looseMermaidSurface, + looseDiffSurface, + looseImageSurface, + looseTraceSurface, + looseTerminalSurface, + looseJsonSurface, + looseCodeSurface, ]); -// Runtime SurfacePart parser shared by REST and MCP. REST uses strict mode to +const strictSurfaceSchemas = { + html: strictHtmlSurface, + markdown: strictMarkdownSurface, + mermaid: strictMermaidSurface, + diff: strictDiffSurface, + image: strictImageSurface, + trace: strictTraceSurface, + terminal: strictTerminalSurface, + json: strictJsonSurface, + code: strictCodeSurface, +} satisfies Record>; + +// Runtime surface parser shared by REST and MCP. REST uses strict mode to // reject malformed input before it reaches storage; MCP uses tolerant mode so -// slightly-off tool calls still publish whatever valid parts they contain. +// slightly-off tool calls still publish whatever valid surfaces they contain. // Async because mermaid validation awaits the parser (@mermaid-js/parser). -async function parseSurfaceParts( +async function parseSurfaceList( raw: unknown, opts: { strict?: boolean } = {}, -): Promise { - if (!Array.isArray(raw)) return { parts: [], errors: ["parts must be an array"] }; +): Promise { + if (!Array.isArray(raw)) return surfaceResult([], ["surfaces must be an array"]); if (opts.strict === true) { - const results = await Promise.all(raw.map((part, i) => parseStrictPart(part, i))); - return { - parts: results.flatMap((r) => (r.part ? [r.part] : [])), - errors: results.flatMap((r) => r.errors), - }; + const results = await Promise.all(raw.map((surface, i) => parseStrictSurface(surface, i))); + return surfaceResult( + results.flatMap((r) => (r.surface ? [r.surface] : [])), + results.flatMap((r) => r.errors), + ); } - const parts: Surface[] = []; - for (const part of raw) { - const parsed = looseSurfacePart.safeParse(part); + const surfaces: Surface[] = []; + for (const surface of raw) { + const parsed = looseSurfaceSchema.safeParse(surface); if (!parsed.success) continue; if ((await validateSemantics(parsed.data as Surface)).length === 0) - parts.push(parsed.data as Surface); + surfaces.push(parsed.data as Surface); } - return { parts, errors: [] }; + return surfaceResult(surfaces, []); +} + +function surfaceResult(surfaces: Surface[], errors: string[]): SurfaceParseResult { + return { surfaces, parts: surfaces, errors }; } export const coerceSurfaces = (raw: unknown): Promise => - parseSurfaceParts(raw).then((r) => r.parts); + parseSurfaceList(raw).then((r) => r.surfaces); export async function validateSurfaces( raw: unknown, -): Promise<{ ok: true; parts: Surface[] } | { ok: false; error: string }> { - const result = await parseSurfaceParts(raw, { strict: true }); +): Promise<{ ok: true; surfaces: Surface[]; parts: Surface[] } | { ok: false; error: string }> { + const result = await parseSurfaceList(raw, { strict: true }); return result.errors.length > 0 ? { ok: false, error: result.errors.join("; ") } - : { ok: true, parts: result.parts }; + : { ok: true, surfaces: result.surfaces, parts: result.surfaces }; } // Renderability checks that run after the structural zod parse succeeds. Strict @@ -310,10 +328,10 @@ function mermaidDiagramType(src: string): string | null { return null; } -async function validateSemantics(part: Surface): Promise { - if (part.kind === "diff" && part.patch) { +async function validateSemantics(surface: Surface): Promise { + if (surface.kind === "diff" && surface.patch) { try { - if (!diffPatchHasContent(part.patch)) + if (!diffPatchHasContent(surface.patch)) return [ 'diff surface "patch" did not parse to any file — expected a unified/git patch with --- /+++ headers and @@ hunks', ]; @@ -323,69 +341,48 @@ async function validateSemantics(part: Surface): Promise { ]; } } - if (part.kind === "mermaid") { - const diagramType = mermaidDiagramType(part.mermaid); + if (surface.kind === "mermaid") { + const diagramType = mermaidDiagramType(surface.mermaid); if (!diagramType) - return ['mermaid part has no diagram type (first line should be e.g. "flowchart TD")']; + return ['mermaid surface has no diagram type (first line should be e.g. "flowchart TD")']; try { - await parseMermaid(diagramType as never, part.mermaid); + await parseMermaid(diagramType as never, surface.mermaid); } catch (e) { // Unsupported diagram types (flowchart, sequence, etc. — still on Jison) // skip validation; the viewer's graceful fallback handles render failures. if (e instanceof Error && /unknown diagram type/i.test(e.message)) return []; const msg = e instanceof Error ? (e.message.split("\n")[0] ?? "parse error") : "parse error"; - return [`mermaid part failed to parse: ${msg}`]; + return [`mermaid surface failed to parse: ${msg}`]; } } return []; } -async function parseStrictPart( +async function parseStrictSurface( raw: unknown, index: number, -): Promise<{ part: Surface | null; errors: string[] }> { - const path = `parts[${index}]`; +): Promise<{ surface: Surface | null; errors: string[] }> { + const path = `surfaces[${index}]`; if (!raw || typeof raw !== "object") - return { part: null, errors: [`${path}: must be an object`] }; + return { surface: null, errors: [`${path}: must be an object`] }; const kind = (raw as { kind?: unknown }).kind; const schema = schemaForKind(kind); - if (!schema) return { part: null, errors: [`${path}: unknown part kind`] }; + if (!schema) return { surface: null, errors: [`${path}: unknown surface kind`] }; const parsed = schema.safeParse(raw); - if (!parsed.success) return { part: null, errors: formatZodErrors(parsed.error, path) }; + if (!parsed.success) return { surface: null, errors: formatZodErrors(parsed.error, path) }; const semantic = await validateSemantics(parsed.data); return semantic.length > 0 - ? { part: null, errors: semantic.map((m) => `${path}: ${m}`) } - : { part: parsed.data, errors: [] }; + ? { surface: null, errors: semantic.map((m) => `${path}: ${m}`) } + : { surface: parsed.data, errors: [] }; } function schemaForKind(kind: unknown): z.ZodType | null { - switch (kind) { - case "html": - return strictHtmlPart; - case "markdown": - return strictMarkdownPart; - case "mermaid": - return strictMermaidPart; - case "diff": - return strictDiffPart; - case "image": - return strictImagePart; - case "trace": - return strictTracePart; - case "terminal": - return strictTerminalPart; - case "json": - return strictJsonPart; - case "code": - return strictCodePart; - default: - return null; - } + return isSurfaceKind(kind) ? strictSurfaceSchemas[kind] : null; } -function formatZodErrors(error: z.ZodError, prefix = "parts"): string[] { +function formatZodErrors(error: z.ZodError, prefix = "surfaces"): string[] { return error.issues.map((issue) => { const suffix = issue.path.length > 0 ? `.${issue.path.join(".")}` : ""; return `${prefix}${suffix}: ${issue.message}`; diff --git a/server/types.ts b/server/types.ts index cea3fe4..3fbc964 100644 --- a/server/types.ts +++ b/server/types.ts @@ -14,10 +14,10 @@ export interface Session { // A post is an ordered list of surfaces. Each surface declares its own kind; // the post itself is kind-agnostic. An `html` surface is arbitrary agent -// markup (rendered sandboxed in an iframe); `diff`, `image`, `trace`, -// `markdown`, `terminal`, and `mermaid` surfaces are structured data rendered by -// the trusted viewer. A snippet is just a post with one html surface; a -// diagram-with-its-diff is `[html, diff]`. +// markup rendered in an opaque-origin iframe. Rich text/code kinds are structured +// data rendered into sandboxed documents; image/trace/json stay as data rendered +// natively by the trusted viewer. A snippet is just a post with one html surface; +// a diagram-with-its-diff is `[html, diff]`. // The canonical, ordered list of every surface kind — the single source of // truth. `SurfaceKind` derives from it, and the MCP tool schemas (mcpSpec.ts) // build their `kind` enums from it, so a kind can't be added to the model @@ -39,6 +39,67 @@ export const SURFACE_KINDS = [ ] as const; export type SurfaceKind = (typeof SURFACE_KINDS)[number]; +export type SurfaceContentField = + | "html" + | "markdown" + | "mermaid" + | "patch" + | "text" + | "data" + | "code"; + +export interface SurfaceKindMetadata { + // Primary inline content slot used by content-only edits and feed previews. + // Kinds without one are either by-reference assets or structured timelines. + contentField?: SurfaceContentField; + // Kinds served as opaque-origin HTML documents from /s/:id?part=N. + sandboxed: boolean; + // Stable iframe selector hook for sandboxed kinds that need kind-specific CSS. + frameClass?: string; +} + +export const SURFACE_KIND_METADATA = { + html: { contentField: "html", sandboxed: true }, + diff: { contentField: "patch", sandboxed: true, frameClass: "diffframe" }, + image: { sandboxed: false }, + trace: { sandboxed: false }, + markdown: { contentField: "markdown", sandboxed: true, frameClass: "mdframe" }, + terminal: { contentField: "text", sandboxed: true, frameClass: "termframe" }, + mermaid: { contentField: "mermaid", sandboxed: true, frameClass: "mermaidframe" }, + json: { contentField: "data", sandboxed: false }, + code: { contentField: "code", sandboxed: true, frameClass: "codeframe" }, +} as const satisfies Record; + +export const SURFACE_KIND_LIST = SURFACE_KINDS.join(", "); +export const SANDBOXED_SURFACE_KINDS = SURFACE_KINDS.filter( + (kind) => SURFACE_KIND_METADATA[kind].sandboxed, +); +export const NATIVE_SURFACE_KINDS = SURFACE_KINDS.filter( + (kind) => !SURFACE_KIND_METADATA[kind].sandboxed, +); +export const SURFACE_CONTENT_FIELDS = Object.fromEntries( + SURFACE_KINDS.flatMap((kind) => { + const meta = SURFACE_KIND_METADATA[kind]; + const field = "contentField" in meta ? meta.contentField : undefined; + return field ? [[kind, field]] : []; + }), +) as Partial>; +export const SURFACE_FRAME_CLASSES = Object.fromEntries( + SURFACE_KINDS.flatMap((kind) => { + const meta = SURFACE_KIND_METADATA[kind]; + const frameClass = "frameClass" in meta ? meta.frameClass : undefined; + return frameClass ? [[kind, frameClass]] : []; + }), +) as Partial>; + +export function isSurfaceKind(kind: unknown): kind is SurfaceKind { + return typeof kind === "string" && Object.hasOwn(SURFACE_KIND_METADATA, kind); +} + +export function isSandboxedSurfaceKind(kind: unknown): kind is SurfaceKind { + return isSurfaceKind(kind) && SURFACE_KIND_METADATA[kind].sandboxed; +} + export interface HtmlSurface { kind: "html"; html: string; @@ -47,22 +108,19 @@ export interface HtmlSurface { kits?: string[]; } -// A markdown surface is prose the trusted viewer renders — explanations, plans, -// tradeoff write-ups. Unlike an html surface it is NOT sandboxed: the viewer -// renders it to HTML in its own origin, so raw HTML embedded in the source is -// escaped, not executed (see MarkdownPart.tsx). Agents wanting live markup use -// an html surface instead. +// A markdown surface is prose — explanations, plans, tradeoff write-ups. The +// server renders it to HTML with raw HTML escaped, then serves that HTML as a +// sandboxed rich surface document. Agents wanting live markup use an html surface +// instead. export interface MarkdownSurface { kind: "markdown"; markdown: string; } -// A mermaid surface is diagram source (flowchart, sequence, ERD, gantt, …) the -// trusted viewer renders to SVG with the mermaid library. Like markdown it is -// NOT sandboxed: mermaid renders in the viewer's own origin with -// securityLevel 'strict', sanitizing the SVG and disabling scripts/HTML labels -// (see MermaidPart.tsx). Agents wanting hand-drawn vector art use an html surface -// with inline instead. +// A mermaid surface is diagram source (flowchart, sequence, ERD, gantt, …). +// Mermaid needs a DOM, so /s/:id serves a sandboxed self-rendering document that +// loads mermaid from the CDN allowlist. Agents wanting hand-drawn vector art use +// an html surface with inline instead. export interface MermaidSurface { kind: "mermaid"; mermaid: string; diff --git a/test/api.test.ts b/test/api.test.ts index 0a97139..505ce5c 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -157,7 +157,7 @@ test("publish into unknown session 404s instead of silently creating", async () assert.equal(res.status, 404); }); -test("publishes a combined html+diff surface; /s server-renders both parts opaque-sandboxed", async () => { +test("publishes a combined html+diff surface; /s server-renders both surfaces opaque-sandboxed", async () => { const app = makeApp(); const res = await app.request( "/api/surfaces", @@ -212,10 +212,10 @@ test("publishes a combined html+diff surface; /s server-renders both parts opaqu assert.ok(!("html" in updated.surfaces[0]), "updated html body is not echoed"); assert.ok(!("patch" in updated.surfaces[1]), "updated diff body is not echoed"); - // /s renders the html part... + // /s renders the html surface... const part0 = await app.request(`/s/${surface.id}?part=0`); assert.ok((await part0.text()).includes("

diagram

")); - // ...and now also server-renders the diff part (no viewer round-trip): the + // ...and now also server-renders the diff surface (no viewer round-trip): the // @pierre/diffs SSR output wraps each file in a . const part1 = await app.request(`/s/${surface.id}?part=1`); assert.equal(part1.status, 200); @@ -310,7 +310,7 @@ test("GET /s/:id emits absolute token-free canonical and preview image URLs", as } }); -test("GET /s/:id?part=0 still serves an opaque sandboxed part document", async () => { +test("GET /s/:id?part=0 still serves an opaque sandboxed surface document", async () => { const app = makeApp(); const res = await app.request( "/api/snippets", @@ -376,7 +376,7 @@ test("/s served versioned + themed is cacheable; an unpinned load is not", async assert.match(bare.headers.get("cache-control") ?? "", /no-cache/); }); -test("a snippet's kits ride the html part and inject the kit CSS/JS at /s", async () => { +test("a snippet's kits ride the html surface and inject the kit CSS/JS at /s", async () => { const app = makeApp(); const res = await app.request( "/api/snippets", @@ -385,7 +385,7 @@ test("a snippet's kits ride the html part and inject the kit CSS/JS at /s", asyn assert.equal(res.status, 201); const surface = (await res.json()) as any; - // the kits persist on the stored html part + // the kits persist on the stored html surface const full = (await (await app.request(`/api/surfaces/${surface.id}`)).json()) as any; assert.deepEqual(full.surfaces[0].kits, ["slides"]); @@ -428,7 +428,7 @@ test("GET /api/kits advertises the available kits without the css payload", asyn } }); -test("REST surface routes reject malformed parts before storage", async () => { +test("REST surface routes reject malformed surfaces before storage", async () => { const app = makeApp(); const badCreate = await app.request("/api/surfaces", json({ parts: [{ kind: "image" }] })); @@ -453,7 +453,7 @@ test("REST surface routes reject malformed parts before storage", async () => { assert.equal(unchanged.surfaces[0].html, "

x

"); }); -test("publish_surface MCP tool round-trips a diff part", async () => { +test("publish_surface MCP tool round-trips a diff surface", async () => { const app = makeApp(); const list = (await (await app.request("/mcp", mcpCall(1, "tools/list"))).json()) as any; const names = list.result.tools.map((t: any) => t.name); @@ -479,7 +479,7 @@ test("publish_surface MCP tool round-trips a diff part", async () => { assert.equal(full.surfaces[0].patch, "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-x\n+y"); }); -test("publishes a markdown part; /s server-renders it to sandboxed html", async () => { +test("publishes a markdown surface; /s server-renders it to sandboxed html", async () => { const app = makeApp(); const res = await app.request( "/api/surfaces", @@ -505,7 +505,7 @@ test("publishes a markdown part; /s server-renders it to sandboxed html", async assert.match(doc.headers.get("content-security-policy") ?? "", /\bsandbox\b/); }); -test("publish_surface MCP tool keeps markdown parts and drops empty ones", async () => { +test("publish_surface MCP tool keeps markdown surfaces and drops empty ones", async () => { const app = makeApp(); const published = (await ( await app.request( @@ -529,7 +529,7 @@ test("publish_surface MCP tool keeps markdown parts and drops empty ones", async assert.equal(full.surfaces[0].markdown, "real prose"); }); -test("publish_surface MCP tool round-trips a terminal part", async () => { +test("publish_surface MCP tool round-trips a terminal surface", async () => { const app = makeApp(); const published = (await ( await app.request( @@ -558,7 +558,7 @@ test("publish_surface MCP tool round-trips a terminal part", async () => { assert.ok((await doc.text()).includes("term-body")); }); -test("publishes a mermaid part; /s emits a self-rendering CDN doc", async () => { +test("publishes a mermaid surface; /s emits a self-rendering CDN doc", async () => { const app = makeApp(); const res = await app.request( "/api/surfaces", @@ -585,7 +585,7 @@ test("publishes a mermaid part; /s emits a self-rendering CDN doc", async () => assert.match(doc.headers.get("content-security-policy") ?? "", /\bsandbox\b/); }); -test("publishes a json part; round-trips data and 404s on /s", async () => { +test("publishes a json surface; round-trips data and 404s on /s", async () => { const app = makeApp(); const data = { name: "sideshow", @@ -611,7 +611,7 @@ test("publishes a json part; round-trips data and 404s on /s", async () => { assert.equal((await app.request(`/s/${surface.id}?part=0`)).status, 404); }); -test("json part with null data is valid (null is a JSON value)", async () => { +test("json surface with null data is valid (null is a JSON value)", async () => { const app = makeApp(); const res = await app.request( "/api/surfaces", @@ -623,13 +623,13 @@ test("json part with null data is valid (null is a JSON value)", async () => { assert.equal(full.surfaces[0].data, null); }); -test("json part without data key is rejected", async () => { +test("json surface without data key is rejected", async () => { const app = makeApp(); const res = await app.request("/api/surfaces", json({ title: "Bad", parts: [{ kind: "json" }] })); assert.equal(res.status, 400); }); -test("publishes a code part; round-trips code/lang/title and 404s on /s", async () => { +test("publishes a code surface; round-trips code/lang/title and 404s on /s", async () => { const app = makeApp(); const res = await app.request( "/api/surfaces", @@ -658,7 +658,7 @@ test("publishes a code part; round-trips code/lang/title and 404s on /s", async assert.ok(body.includes("a.ts")); }); -test("code part without code is rejected", async () => { +test("code surface without code is rejected", async () => { const app = makeApp(); const res = await app.request( "/api/surfaces", @@ -667,7 +667,7 @@ test("code part without code is rejected", async () => { assert.equal(res.status, 400); }); -test("code part with lineStart round-trips", async () => { +test("code surface with lineStart round-trips", async () => { const app = makeApp(); const res = await app.request( "/api/surfaces", @@ -690,7 +690,7 @@ test("code part with lineStart round-trips", async () => { assert.equal(full.surfaces[0].lineStart, 80); }); -test("publish_surface MCP tool keeps mermaid parts and drops empty ones", async () => { +test("publish_surface MCP tool keeps mermaid surfaces and drops empty ones", async () => { const app = makeApp(); const published = (await ( await app.request( diff --git a/test/assets.test.ts b/test/assets.test.ts index 7beafd8..078916d 100644 --- a/test/assets.test.ts +++ b/test/assets.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { coerceParts } from "../server/mcpHttp.ts"; +import { coerceSurfaces, validateSurfaces } from "../server/postSurfaces.ts"; import { collectAssetIds, type EvictionCandidate, @@ -8,7 +8,6 @@ import { selectEvictions, type Surface, } from "../server/types.ts"; -import { validateSurfaces } from "../server/postSurfaces.ts"; // --- selectEvictions --- @@ -54,7 +53,7 @@ test("selectEvictions falls back to referenced assets only as a last resort", () // --- collectAssetIds --- test("collectAssetIds gathers image and trace asset ids, ignoring html/diff", () => { - const parts: Surface[] = [ + const surfaces: Surface[] = [ { kind: "html", html: "" }, // raw-url embeds are invisible here { kind: "diff", patch: "x" }, { kind: "image", assetId: "img1" }, @@ -62,7 +61,7 @@ test("collectAssetIds gathers image and trace asset ids, ignoring html/diff", () { kind: "trace", steps: [{ label: "inline only" }] }, // no assetId -> nothing ]; const out = new Set(); - collectAssetIds(parts, out); + collectAssetIds(surfaces, out); assert.deepEqual([...out].sort(), ["img1", "tr1"]); }); @@ -76,9 +75,9 @@ test("surfacesByteLength counts image/trace surfaces without throwing", () => { assert.ok(n > 0); }); -// --- SurfacePart validation/coercion --- +// --- Surface validation/coercion --- -test("validateSurfaces accepts all supported part kinds", async () => { +test("validateSurfaces accepts all supported surface kinds", async () => { const result = await validateSurfaces([ { kind: "html", html: "

x

" }, { kind: "html", html: "
", kits: ["issues"] }, @@ -98,7 +97,7 @@ test("validateSurfaces accepts all supported part kinds", async () => { assert.equal(result.ok, true); if (result.ok) assert.deepEqual( - result.parts.map((p) => p.kind), + result.surfaces.map((p) => p.kind), [ "html", "html", @@ -118,8 +117,8 @@ test("validateSurfaces accepts all supported part kinds", async () => { ); }); -test("validateSurfaces rejects malformed parts", async () => { - for (const parts of [ +test("validateSurfaces rejects malformed surfaces", async () => { + for (const surfaces of [ [{ kind: "html", html: 1 }], [{ kind: "html", html: "

x

", kits: ["nope"] }], // unknown kit id (strict) [{ kind: "diff" }], @@ -131,8 +130,8 @@ test("validateSurfaces rejects malformed parts", async () => { [{ kind: "code" }], // missing code [{ kind: "unknown" }], ]) { - const result = await validateSurfaces(parts); - assert.equal(result.ok, false, JSON.stringify(parts)); + const result = await validateSurfaces(surfaces); + assert.equal(result.ok, false, JSON.stringify(surfaces)); } }); @@ -206,47 +205,47 @@ test("validateSurfaces rejects invalid mermaid with a parse error (supported typ false, `mermaid ${JSON.stringify(mermaid).slice(0, 40)} should be rejected`, ); - if (!result.ok) assert.match(result.error, /mermaid part failed to parse/); + if (!result.ok) assert.match(result.error, /mermaid surface failed to parse/); } }); -test("coerceParts drops an invalid mermaid part but keeps a valid one", async () => { - const parts = await coerceParts([ +test("coerceSurfaces drops an invalid mermaid surface but keeps a valid one", async () => { + const surfaces = await coerceSurfaces([ { kind: "mermaid", mermaid: 'pie title Pets\n "Dogs" : 386' }, { kind: "mermaid", mermaid: "pie\n !!broken!!" }, // dropped (parse error) { kind: "html", html: "

kept

" }, ]); - assert.equal(parts.length, 2); - assert.equal(parts[0].kind, "mermaid"); - assert.equal(parts[1].kind, "html"); + assert.equal(surfaces.length, 2); + assert.equal(surfaces[0].kind, "mermaid"); + assert.equal(surfaces[1].kind, "html"); }); -test("coerceParts drops a diff patch with no content but keeps a valid one", async () => { - const parts = await coerceParts([ +test("coerceSurfaces drops a diff patch with no content but keeps a valid one", async () => { + const surfaces = await coerceSurfaces([ { kind: "diff", patch: "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b" }, { kind: "diff", patch: "not a patch" }, // dropped (no content) { kind: "html", html: "

kept

" }, ]); - assert.equal(parts.length, 2); - assert.equal(parts[0].kind, "diff"); - assert.equal(parts[1].kind, "html"); + assert.equal(surfaces.length, 2); + assert.equal(surfaces[0].kind, "diff"); + assert.equal(surfaces[1].kind, "html"); }); -test("coerceParts keeps valid image parts and drops ones without an assetId", async () => { - const parts = await coerceParts([ +test("coerceSurfaces keeps valid image surfaces and drops ones without an assetId", async () => { + const surfaces = await coerceSurfaces([ { kind: "image", assetId: "x", alt: "a", caption: "c" }, { kind: "image" }, // no assetId -> dropped ]); - assert.deepEqual(parts, [{ kind: "image", assetId: "x", alt: "a", caption: "c" }]); + assert.deepEqual(surfaces, [{ kind: "image", assetId: "x", alt: "a", caption: "c" }]); }); -test("coerceParts accepts trace by steps, by assetId, or both; drops empty/malformed", async () => { - const parts = await coerceParts([ +test("coerceSurfaces accepts trace by steps, by assetId, or both; drops empty/malformed", async () => { + const surfaces = await coerceSurfaces([ { kind: "trace", steps: [{ label: "ok" }, { detail: "no label" }], title: "T" }, { kind: "trace", assetId: "file1" }, { kind: "trace" }, // neither steps nor assetId -> dropped ]); - assert.deepEqual(parts, [ + assert.deepEqual(surfaces, [ { kind: "trace", steps: [{ label: "ok" }], title: "T" }, { kind: "trace", assetId: "file1" }, ]); diff --git a/test/cli.test.ts b/test/cli.test.ts index 1528855..506b02e 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -238,7 +238,7 @@ async function waitFor(pred: () => boolean, timeoutMs = 10_000) { } } -test("publish --kit puts the (deduped) kit ids on the html part", async () => { +test("publish --kit puts the (deduped) kit ids on the html surface", async () => { const server = await serveApp(); try { const dir = mkdtempSync(join(tmpdir(), "sideshow-kit-")); diff --git a/test/kits.test.ts b/test/kits.test.ts index 87bdb2f..6a078cc 100644 --- a/test/kits.test.ts +++ b/test/kits.test.ts @@ -55,7 +55,7 @@ test("isKnownKit gates on the registry", () => { // --- renderHtmlPage --- -test("renderHtmlPage injects kit css/js only when the part opts in", () => { +test("renderHtmlPage injects kit css/js only when the surface opts in", () => { const bare = renderHtmlPage({ title: "t", html: "

x

", origin: "http://x" }); assert.doesNotMatch(bare, /\.deck-ctl/); assert.doesNotMatch(bare, /querySelector\('\.deck'\)/); @@ -86,13 +86,13 @@ test("kitSummaries advertises each kit without leaking the css/js payload", () = // --- validation: strict (REST) rejects, loose (MCP) filters --- -test("validateSurfaces accepts an html part with known kits", async () => { +test("validateSurfaces accepts an html surface with known kits", async () => { const r = await validateSurfaces([ { kind: "html", html: "

x

", kits: ["issues", "slides"] }, ]); assert.equal(r.ok, true); if (r.ok) - assert.deepEqual(r.parts[0], { kind: "html", html: "

x

", kits: ["issues", "slides"] }); + assert.deepEqual(r.surfaces[0], { kind: "html", html: "

x

", kits: ["issues", "slides"] }); }); test("validateSurfaces rejects an unknown kit id with the valid set", async () => { @@ -101,14 +101,14 @@ test("validateSurfaces rejects an unknown kit id with the valid set", async () = if (!r.ok) assert.match(r.error, /unknown kit "bogus".*issues/); }); -test("coerceSurfaces filters unknown kits rather than dropping the part", async () => { - const parts = await coerceSurfaces([ +test("coerceSurfaces filters unknown kits rather than dropping the surface", async () => { + const surfaces = await coerceSurfaces([ { kind: "html", html: "

x

", kits: ["issues", "bogus"] }, ]); - assert.deepEqual(parts, [{ kind: "html", html: "

x

", kits: ["issues"] }]); + assert.deepEqual(surfaces, [{ kind: "html", html: "

x

", kits: ["issues"] }]); }); test("coerceSurfaces drops an all-unknown kits field entirely", async () => { - const parts = await coerceSurfaces([{ kind: "html", html: "

x

", kits: ["nope"] }]); - assert.deepEqual(parts, [{ kind: "html", html: "

x

" }]); + const surfaces = await coerceSurfaces([{ kind: "html", html: "

x

", kits: ["nope"] }]); + assert.deepEqual(surfaces, [{ kind: "html", html: "

x

" }]); }); diff --git a/test/mcpSpec.test.ts b/test/mcpSpec.test.ts index c01ed1c..3f11a6a 100644 --- a/test/mcpSpec.test.ts +++ b/test/mcpSpec.test.ts @@ -3,7 +3,16 @@ import { test } from "node:test"; import { z } from "zod"; import { HTTP_MCP_TOOLS, STDIO_MCP_INPUT_SCHEMAS } from "../server/mcpSpec.ts"; import { validateSurfaces } from "../server/postSurfaces.ts"; -import { SURFACE_KINDS, type Surface } from "../server/types.ts"; +import { + isSandboxedSurfaceKind, + isSurfaceKind, + SANDBOXED_SURFACE_KINDS, + SURFACE_CONTENT_FIELDS, + SURFACE_FRAME_CLASSES, + SURFACE_KIND_METADATA, + SURFACE_KINDS, + type Surface, +} from "../server/types.ts"; // This suite is the guard against the regression where `json` and `code` // surfaces shipped to CLI/REST but were never added to the MCP tool schemas — @@ -73,3 +82,22 @@ test("the runtime validator accepts a minimal example of every kind", async () = assert.ok(result.ok, `validator rejected kind "${kind}": ${result.ok ? "" : result.error}`); } }); + +test("surface-kind metadata covers every kind and drives derived helpers", () => { + assert.deepEqual(Object.keys(SURFACE_KIND_METADATA).sort(), [...SURFACE_KINDS].sort()); + for (const kind of SURFACE_KINDS) { + assert.equal(isSurfaceKind(kind), true); + assert.equal(isSandboxedSurfaceKind(kind), SANDBOXED_SURFACE_KINDS.includes(kind)); + if (SURFACE_KIND_METADATA[kind].sandboxed) { + assert.ok(isSandboxedSurfaceKind(kind)); + } + } + assert.equal(isSurfaceKind("bogus"), false); + assert.equal(isSurfaceKind("toString"), false); + assert.equal(isSandboxedSurfaceKind("bogus"), false); + assert.equal(SURFACE_CONTENT_FIELDS.html, "html"); + assert.equal(SURFACE_CONTENT_FIELDS.diff, "patch"); + assert.equal(SURFACE_CONTENT_FIELDS.json, "data"); + assert.equal(SURFACE_FRAME_CLASSES.markdown, "mdframe"); + assert.equal(SURFACE_FRAME_CLASSES.html, undefined); +}); diff --git a/test/surfacePage.test.ts b/test/surfacePage.test.ts index 3e3f7a0..faa93a9 100644 --- a/test/surfacePage.test.ts +++ b/test/surfacePage.test.ts @@ -34,7 +34,7 @@ function cspDirectives(doc: string): Record { return out; } -// The CDN allowlist html parts may load from. This is a deliberate, fixed set — +// The CDN allowlist html surfaces may load from. This is a deliberate, fixed set — // the test pins it so widening it (a new origin, a wildcard) is a conscious edit // that updates this list, never an accident. const ALLOWED_CDNS = [ @@ -90,10 +90,10 @@ test("the document title is HTML-escaped so a crafted title can't break out", () assert.ok(!page.includes("