@sanity-labs/slides 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +241 -0
  2. package/SKILL.md +119 -0
  3. package/dist/cli.d.ts +38 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +386 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/core/components.d.ts +179 -0
  8. package/dist/core/components.d.ts.map +1 -0
  9. package/dist/core/components.js +40 -0
  10. package/dist/core/components.js.map +1 -0
  11. package/dist/core/fake-runtime.d.ts +138 -0
  12. package/dist/core/fake-runtime.d.ts.map +1 -0
  13. package/dist/core/fake-runtime.js +210 -0
  14. package/dist/core/fake-runtime.js.map +1 -0
  15. package/dist/core/font-resolver.d.ts +28 -0
  16. package/dist/core/font-resolver.d.ts.map +1 -0
  17. package/dist/core/font-resolver.js +30 -0
  18. package/dist/core/font-resolver.js.map +1 -0
  19. package/dist/core/geometry.d.ts +71 -0
  20. package/dist/core/geometry.d.ts.map +1 -0
  21. package/dist/core/geometry.js +44 -0
  22. package/dist/core/geometry.js.map +1 -0
  23. package/dist/core/index.d.ts +19 -0
  24. package/dist/core/index.d.ts.map +1 -0
  25. package/dist/core/index.js +20 -0
  26. package/dist/core/index.js.map +1 -0
  27. package/dist/core/manifest.d.ts +123 -0
  28. package/dist/core/manifest.d.ts.map +1 -0
  29. package/dist/core/manifest.js +43 -0
  30. package/dist/core/manifest.js.map +1 -0
  31. package/dist/core/op-translator-pptx.d.ts +150 -0
  32. package/dist/core/op-translator-pptx.d.ts.map +1 -0
  33. package/dist/core/op-translator-pptx.js +245 -0
  34. package/dist/core/op-translator-pptx.js.map +1 -0
  35. package/dist/core/pptx-runtime.d.ts +103 -0
  36. package/dist/core/pptx-runtime.d.ts.map +1 -0
  37. package/dist/core/pptx-runtime.js +405 -0
  38. package/dist/core/pptx-runtime.js.map +1 -0
  39. package/dist/core/reconciler.d.ts +113 -0
  40. package/dist/core/reconciler.d.ts.map +1 -0
  41. package/dist/core/reconciler.js +453 -0
  42. package/dist/core/reconciler.js.map +1 -0
  43. package/dist/core/runtime.d.ts +161 -0
  44. package/dist/core/runtime.d.ts.map +1 -0
  45. package/dist/core/runtime.js +11 -0
  46. package/dist/core/runtime.js.map +1 -0
  47. package/dist/core/template.d.ts +32 -0
  48. package/dist/core/template.d.ts.map +1 -0
  49. package/dist/core/template.js +3 -0
  50. package/dist/core/template.js.map +1 -0
  51. package/dist/dev/auto-examples.d.ts +6 -0
  52. package/dist/dev/auto-examples.d.ts.map +1 -0
  53. package/dist/dev/auto-examples.js +79 -0
  54. package/dist/dev/auto-examples.js.map +1 -0
  55. package/dist/dev/bin/slides-dev.d.ts +3 -0
  56. package/dist/dev/bin/slides-dev.d.ts.map +1 -0
  57. package/dist/dev/bin/slides-dev.js +87 -0
  58. package/dist/dev/bin/slides-dev.js.map +1 -0
  59. package/dist/dev/bin/slides-dev.mjs +24 -0
  60. package/dist/dev/compose-deck.d.ts +18 -0
  61. package/dist/dev/compose-deck.d.ts.map +1 -0
  62. package/dist/dev/compose-deck.js +19 -0
  63. package/dist/dev/compose-deck.js.map +1 -0
  64. package/dist/dev/deck-viewer.d.ts +19 -0
  65. package/dist/dev/deck-viewer.d.ts.map +1 -0
  66. package/dist/dev/deck-viewer.js +237 -0
  67. package/dist/dev/deck-viewer.js.map +1 -0
  68. package/dist/dev/dev-server/client/entry.d.ts +2 -0
  69. package/dist/dev/dev-server/client/entry.d.ts.map +1 -0
  70. package/dist/dev/dev-server/client/entry.js +12 -0
  71. package/dist/dev/dev-server/client/entry.js.map +1 -0
  72. package/dist/dev/dev-server/output.d.ts +8 -0
  73. package/dist/dev/dev-server/output.d.ts.map +1 -0
  74. package/dist/dev/dev-server/output.js +32 -0
  75. package/dist/dev/dev-server/output.js.map +1 -0
  76. package/dist/dev/dev-server/server-only-stub.d.ts +7 -0
  77. package/dist/dev/dev-server/server-only-stub.d.ts.map +1 -0
  78. package/dist/dev/dev-server/server-only-stub.js +12 -0
  79. package/dist/dev/dev-server/server-only-stub.js.map +1 -0
  80. package/dist/dev/dev-server/start.d.ts +14 -0
  81. package/dist/dev/dev-server/start.d.ts.map +1 -0
  82. package/dist/dev/dev-server/start.js +135 -0
  83. package/dist/dev/dev-server/start.js.map +1 -0
  84. package/dist/dev/index.d.ts +5 -0
  85. package/dist/dev/index.d.ts.map +1 -0
  86. package/dist/dev/index.js +5 -0
  87. package/dist/dev/index.js.map +1 -0
  88. package/dist/dev/lib/cn.d.ts +3 -0
  89. package/dist/dev/lib/cn.d.ts.map +1 -0
  90. package/dist/dev/lib/cn.js +3 -0
  91. package/dist/dev/lib/cn.js.map +1 -0
  92. package/dist/dev/slide-canvas.d.ts +12 -0
  93. package/dist/dev/slide-canvas.d.ts.map +1 -0
  94. package/dist/dev/slide-canvas.js +123 -0
  95. package/dist/dev/slide-canvas.js.map +1 -0
  96. package/dist/dev/styles.css +37 -0
  97. package/dist/dev/ui/icon-button.d.ts +12 -0
  98. package/dist/dev/ui/icon-button.d.ts.map +1 -0
  99. package/dist/dev/ui/icon-button.js +6 -0
  100. package/dist/dev/ui/icon-button.js.map +1 -0
  101. package/dist/dev/ui/kbd.d.ts +6 -0
  102. package/dist/dev/ui/kbd.d.ts.map +1 -0
  103. package/dist/dev/ui/kbd.js +4 -0
  104. package/dist/dev/ui/kbd.js.map +1 -0
  105. package/dist/dev/ui/text-button.d.ts +10 -0
  106. package/dist/dev/ui/text-button.d.ts.map +1 -0
  107. package/dist/dev/ui/text-button.js +6 -0
  108. package/dist/dev/ui/text-button.js.map +1 -0
  109. package/dist/dev/url-state.d.ts +7 -0
  110. package/dist/dev/url-state.d.ts.map +1 -0
  111. package/dist/dev/url-state.js +13 -0
  112. package/dist/dev/url-state.js.map +1 -0
  113. package/dist/dev/use-keyboard-nav.d.ts +17 -0
  114. package/dist/dev/use-keyboard-nav.d.ts.map +1 -0
  115. package/dist/dev/use-keyboard-nav.js +53 -0
  116. package/dist/dev/use-keyboard-nav.js.map +1 -0
  117. package/dist/index.d.ts +17 -0
  118. package/dist/index.d.ts.map +1 -0
  119. package/dist/index.js +17 -0
  120. package/dist/index.js.map +1 -0
  121. package/dist/mcp/errors.d.ts +57 -0
  122. package/dist/mcp/errors.d.ts.map +1 -0
  123. package/dist/mcp/errors.js +44 -0
  124. package/dist/mcp/errors.js.map +1 -0
  125. package/dist/mcp/index.d.ts +29 -0
  126. package/dist/mcp/index.d.ts.map +1 -0
  127. package/dist/mcp/index.js +29 -0
  128. package/dist/mcp/index.js.map +1 -0
  129. package/dist/mcp/naming.d.ts +37 -0
  130. package/dist/mcp/naming.d.ts.map +1 -0
  131. package/dist/mcp/naming.js +43 -0
  132. package/dist/mcp/naming.js.map +1 -0
  133. package/dist/mcp/render.d.ts +45 -0
  134. package/dist/mcp/render.d.ts.map +1 -0
  135. package/dist/mcp/render.js +77 -0
  136. package/dist/mcp/render.js.map +1 -0
  137. package/dist/mcp/schema.d.ts +54 -0
  138. package/dist/mcp/schema.d.ts.map +1 -0
  139. package/dist/mcp/schema.js +55 -0
  140. package/dist/mcp/schema.js.map +1 -0
  141. package/dist/mcp/server.d.ts +63 -0
  142. package/dist/mcp/server.d.ts.map +1 -0
  143. package/dist/mcp/server.js +196 -0
  144. package/dist/mcp/server.js.map +1 -0
  145. package/dist/scaffold/index.d.ts +39 -0
  146. package/dist/scaffold/index.d.ts.map +1 -0
  147. package/dist/scaffold/index.js +84 -0
  148. package/dist/scaffold/index.js.map +1 -0
  149. package/dist/scaffold/template-base/README.md +134 -0
  150. package/dist/scaffold/template-base/_gitignore +4 -0
  151. package/dist/scaffold/template-base/package.json +35 -0
  152. package/dist/scaffold/template-base/src/components/Cover.tsx +30 -0
  153. package/dist/scaffold/template-base/src/index.ts +27 -0
  154. package/dist/scaffold/template-base/src/preview.tsx +9 -0
  155. package/dist/scaffold/template-base/tsconfig.build.json +10 -0
  156. package/dist/scaffold/template-base/tsconfig.json +18 -0
  157. package/package.json +164 -0
  158. package/src/__tests__/fixtures/test-template/index.tsx +77 -0
  159. package/src/__tests__/pptx-mcp.test.ts +85 -0
  160. package/src/__tests__/pptx-smoke.test.ts +45 -0
  161. package/src/__tests__/preview.test.ts +28 -0
  162. package/src/cli.ts +426 -0
  163. package/src/core/__snapshots__/reconciler.test.ts.snap +320 -0
  164. package/src/core/components.test.ts +57 -0
  165. package/src/core/components.ts +196 -0
  166. package/src/core/fake-runtime.test.ts +174 -0
  167. package/src/core/fake-runtime.ts +302 -0
  168. package/src/core/font-resolver.ts +46 -0
  169. package/src/core/geometry.test.ts +58 -0
  170. package/src/core/geometry.ts +91 -0
  171. package/src/core/index.ts +69 -0
  172. package/src/core/manifest.test.ts +33 -0
  173. package/src/core/manifest.ts +150 -0
  174. package/src/core/op-translator-pptx.test.ts +204 -0
  175. package/src/core/op-translator-pptx.ts +365 -0
  176. package/src/core/pptx-runtime.test.ts +137 -0
  177. package/src/core/pptx-runtime.ts +504 -0
  178. package/src/core/reconciler.test.ts +644 -0
  179. package/src/core/reconciler.ts +603 -0
  180. package/src/core/runtime.ts +150 -0
  181. package/src/core/template.test.ts +136 -0
  182. package/src/core/template.ts +37 -0
  183. package/src/dev/auto-examples.ts +89 -0
  184. package/src/dev/bin/slides-dev.mjs +24 -0
  185. package/src/dev/bin/slides-dev.ts +101 -0
  186. package/src/dev/compose-deck.test.ts +68 -0
  187. package/src/dev/compose-deck.ts +40 -0
  188. package/src/dev/deck-viewer.tsx +677 -0
  189. package/src/dev/dev-server/client/entry.tsx +15 -0
  190. package/src/dev/dev-server/client/index.html +24 -0
  191. package/src/dev/dev-server/output.ts +37 -0
  192. package/src/dev/dev-server/server-only-stub.ts +12 -0
  193. package/src/dev/dev-server/start.ts +155 -0
  194. package/src/dev/index.ts +4 -0
  195. package/src/dev/lib/cn.ts +3 -0
  196. package/src/dev/slide-canvas.test.tsx +66 -0
  197. package/src/dev/slide-canvas.tsx +170 -0
  198. package/src/dev/styles.css +37 -0
  199. package/src/dev/ui/icon-button.tsx +31 -0
  200. package/src/dev/ui/kbd.tsx +20 -0
  201. package/src/dev/ui/text-button.tsx +31 -0
  202. package/src/dev/url-state.test.ts +22 -0
  203. package/src/dev/url-state.ts +17 -0
  204. package/src/dev/use-keyboard-nav.ts +64 -0
  205. package/src/index.ts +17 -0
  206. package/src/mcp/errors.test.ts +51 -0
  207. package/src/mcp/errors.ts +76 -0
  208. package/src/mcp/index.ts +45 -0
  209. package/src/mcp/naming.test.ts +39 -0
  210. package/src/mcp/naming.ts +49 -0
  211. package/src/mcp/render.ts +110 -0
  212. package/src/mcp/schema.test.ts +86 -0
  213. package/src/mcp/schema.ts +93 -0
  214. package/src/mcp/server.test.ts +309 -0
  215. package/src/mcp/server.ts +276 -0
  216. package/src/scaffold/index.ts +102 -0
  217. package/src/scaffold/template-base/README.md +134 -0
  218. package/src/scaffold/template-base/_gitignore +4 -0
  219. package/src/scaffold/template-base/package.json +35 -0
  220. package/src/scaffold/template-base/src/components/Cover.tsx +30 -0
  221. package/src/scaffold/template-base/src/index.ts +27 -0
  222. package/src/scaffold/template-base/src/preview.tsx +9 -0
  223. package/src/scaffold/template-base/tsconfig.build.json +10 -0
  224. package/src/scaffold/template-base/tsconfig.json +18 -0
@@ -0,0 +1,64 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ export type KeyboardActions = {
4
+ readonly onPrev: () => void;
5
+ readonly onNext: () => void;
6
+ readonly onFirst: () => void;
7
+ readonly onLast: () => void;
8
+ readonly onZoomIn: () => void;
9
+ readonly onZoomOut: () => void;
10
+ readonly onZoomFit: () => void;
11
+ readonly onToggleNav: () => void;
12
+ readonly onShowHelp: () => void;
13
+ };
14
+
15
+ const isTypingTarget = (el: EventTarget | null): boolean => {
16
+ if (!(el instanceof HTMLElement)) return false;
17
+ if (el.isContentEditable) return true;
18
+ const tag = el.tagName;
19
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
20
+ };
21
+
22
+ export const useKeyboardNav = (actions: KeyboardActions): void => {
23
+ const ref = useRef(actions);
24
+ ref.current = actions;
25
+
26
+ useEffect(() => {
27
+ const onKey = (e: KeyboardEvent) => {
28
+ if (isTypingTarget(e.target)) return;
29
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
30
+ const handler = SHORTCUTS[e.key];
31
+ if (!handler) return;
32
+ e.preventDefault();
33
+ handler(ref.current);
34
+ };
35
+ window.addEventListener('keydown', onKey);
36
+ return () => window.removeEventListener('keydown', onKey);
37
+ }, []);
38
+ };
39
+
40
+ const SHORTCUTS: Record<string, (a: KeyboardActions) => void> = {
41
+ ArrowDown: (a) => a.onNext(),
42
+ ArrowUp: (a) => a.onPrev(),
43
+ ArrowRight: (a) => a.onNext(),
44
+ ArrowLeft: (a) => a.onPrev(),
45
+ j: (a) => a.onNext(),
46
+ k: (a) => a.onPrev(),
47
+ Home: (a) => a.onFirst(),
48
+ End: (a) => a.onLast(),
49
+ '0': (a) => a.onZoomFit(),
50
+ '+': (a) => a.onZoomIn(),
51
+ '=': (a) => a.onZoomIn(),
52
+ '-': (a) => a.onZoomOut(),
53
+ '[': (a) => a.onToggleNav(),
54
+ '?': (a) => a.onShowHelp(),
55
+ };
56
+
57
+ export const SHORTCUT_LIST: ReadonlyArray<{ keys: string; label: string }> = [
58
+ { keys: '↑ ↓ / j k', label: 'Previous / next slide' },
59
+ { keys: 'Home / End', label: 'First / last slide' },
60
+ { keys: '0', label: 'Auto zoom' },
61
+ { keys: '+ / –', label: 'Zoom in / out' },
62
+ { keys: '[', label: 'Toggle navigation' },
63
+ { keys: '?', label: 'Show this help' },
64
+ ];
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@sanity-labs/slides` — root export.
3
+ *
4
+ * The renderer (React reconciler + PPTX runtime + `Template` type +
5
+ * primitives). What template authors `import` to write their slides.
6
+ *
7
+ * Other subpaths:
8
+ * - `@sanity-labs/slides/mcp` → MCP server framework
9
+ * - `@sanity-labs/slides/dev` → browser dev viewer
10
+ * - `@sanity-labs/slides/sanity` → Sanity reference template
11
+ * - `@sanity-labs/slides/scaffold` → scaffold-a-new-template
12
+ *
13
+ * The `slidesctl` bin exposes the MCP server + generator + scaffolder as a
14
+ * single CLI driven by Claude (or any MCP client) — see the README.
15
+ */
16
+
17
+ export * from './core/index.js';
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { errorResult, formatZodIssue, zodErrorResult } from './errors.js';
4
+
5
+ describe('formatZodIssue', () => {
6
+ test('formats a path-bearing issue', () => {
7
+ const result = z.object({ a: z.object({ b: z.string() }) }).safeParse({ a: { b: 1 } });
8
+ expect(result.success).toBe(false);
9
+ if (!result.success) {
10
+ const issue = result.error.issues[0];
11
+ if (!issue) throw new Error('expected at least one issue');
12
+ const formatted = formatZodIssue(issue);
13
+ expect(formatted.path).toBe('a.b');
14
+ expect(formatted.message).toMatch(/string/i);
15
+ }
16
+ });
17
+
18
+ test('uses (root) for top-level issues', () => {
19
+ const result = z.string().safeParse(42);
20
+ expect(result.success).toBe(false);
21
+ if (!result.success) {
22
+ const issue = result.error.issues[0];
23
+ if (!issue) throw new Error('expected at least one issue');
24
+ expect(formatZodIssue(issue).path).toBe('(root)');
25
+ }
26
+ });
27
+ });
28
+
29
+ describe('zodErrorResult', () => {
30
+ test('returns isError: true with bullets and a hint', () => {
31
+ const parse = z.object({ x: z.string() }).safeParse({});
32
+ if (parse.success) throw new Error('expected failure');
33
+ const result = zodErrorResult('Validation error in foo:', parse.error, 'Try again.');
34
+ expect(result.isError).toBe(true);
35
+ const text = result.content[0]?.text ?? '';
36
+ expect(text).toMatch(/Validation error in foo/);
37
+ expect(text).toMatch(/x:/);
38
+ expect(text).toMatch(/Try again\./);
39
+ expect(result.structuredContent.error.code).toBe('validation_error');
40
+ expect(result.structuredContent.error.issues?.[0]?.path).toBe('x');
41
+ });
42
+ });
43
+
44
+ describe('errorResult', () => {
45
+ test('builds a generic actionable error', () => {
46
+ const result = errorResult('boom', 'Something bad. Retry later.');
47
+ expect(result.isError).toBe(true);
48
+ expect(result.content[0]?.text).toBe('Something bad. Retry later.');
49
+ expect(result.structuredContent.error.code).toBe('boom');
50
+ });
51
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Error formatting for MCP tool responses.
3
+ *
4
+ * Per SEP-1303 and the mcp-best-practices doc: tool errors are returned
5
+ * **inside the result with `isError: true`** rather than as JSON-RPC errors,
6
+ * with messages that include actionable next steps so the LLM can self-correct.
7
+ */
8
+
9
+ import type { ZodError, ZodIssue } from 'zod';
10
+
11
+ /**
12
+ * The shape of an MCP tool result error response (subset we use).
13
+ *
14
+ * Includes `[x: string]: unknown` so it's assignable to the SDK's wider
15
+ * `CallToolResult` type (which carries an open index signature). The fields
16
+ * we set are the only ones we depend on.
17
+ */
18
+ export type ToolErrorResult = {
19
+ isError: true;
20
+ content: Array<{ type: 'text'; text: string }>;
21
+ structuredContent: { error: ToolErrorPayload };
22
+ [extra: string]: unknown;
23
+ };
24
+
25
+ /** Structured-content payload for tool errors, exposed for callers that want to inspect. */
26
+ export interface ToolErrorPayload {
27
+ /** Stable error code, e.g., `validation_error`, `unknown_component`. */
28
+ readonly code: string;
29
+ /** Human-readable message including suggested next steps. */
30
+ readonly message: string;
31
+ /** For validation errors: the per-field issues. */
32
+ readonly issues?: ReadonlyArray<{ readonly path: string; readonly message: string }>;
33
+ }
34
+
35
+ /** Format a single Zod issue as a one-line bullet for inclusion in an error message. */
36
+ export const formatZodIssue = (issue: ZodIssue): { path: string; message: string } => ({
37
+ path: issue.path.length > 0 ? issue.path.map(String).join('.') : '(root)',
38
+ message: issue.message,
39
+ });
40
+
41
+ /**
42
+ * Build a tool-error result for a Zod validation failure.
43
+ *
44
+ * The text is structured so an LLM can read off (a) which fields failed,
45
+ * (b) why, and (c) the next-step hint. The structuredContent carries the
46
+ * machine-readable shape for non-LLM callers.
47
+ */
48
+ export const zodErrorResult = (context: string, error: ZodError, hint: string): ToolErrorResult => {
49
+ const issues = error.issues.map(formatZodIssue);
50
+ const bullets = issues.map((i) => ` • ${i.path}: ${i.message}`).join('\n');
51
+ const text = `${context}\n${bullets}\n${hint}`;
52
+ return {
53
+ isError: true,
54
+ content: [{ type: 'text', text }],
55
+ structuredContent: {
56
+ error: {
57
+ code: 'validation_error',
58
+ message: text,
59
+ issues,
60
+ },
61
+ },
62
+ };
63
+ };
64
+
65
+ /** Build a generic actionable tool error, optionally carrying field-level issues. */
66
+ export const errorResult = (
67
+ code: string,
68
+ message: string,
69
+ issues?: ReadonlyArray<{ readonly path: string; readonly message: string }>,
70
+ ): ToolErrorResult => ({
71
+ isError: true,
72
+ content: [{ type: 'text', text: message }],
73
+ structuredContent: {
74
+ error: issues === undefined ? { code, message } : { code, message, issues },
75
+ },
76
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * react-pptx-mcp — template-agnostic MCP server framework.
3
+ *
4
+ * The substrate a template package wires into to expose its slide-component
5
+ * library as an MCP server. Iterates the template's components, derives one
6
+ * MCP tool per slide type (default name `slides_add_<component>`; override
7
+ * via `SlideServerConfig.toolPrefix`), plus a discovery tool (`slides_list`)
8
+ * and a one-shot create tool (`slides_create`), then starts a stdio transport.
9
+ *
10
+ * The `start({ transport })` shape is the seam for Streamable HTTP / remote
11
+ * transports later. Today only stdio is implemented.
12
+ *
13
+ * Public API:
14
+ *
15
+ * ```ts
16
+ * import { createSlideServer } from '../mcp/index.js';
17
+ * import { PptxSlidesRuntime } from '../core/index.js'; // or any SlidesRuntime
18
+ *
19
+ * const runtime = new PptxSlidesRuntime({ outputDir: '/tmp/decks' });
20
+ * const server = createSlideServer({ template: myTemplate, runtime });
21
+ * await server.start({ transport: 'stdio' });
22
+ * ```
23
+ */
24
+
25
+ export {
26
+ createSlideServer,
27
+ type SlideServer,
28
+ type SlideServerConfig,
29
+ type StartOptions,
30
+ } from './server.js';
31
+ export { renderSlides, type RenderResult, type RenderIssue, type SlideSpec } from './render.js';
32
+ export {
33
+ componentToTool,
34
+ deriveComponentTools,
35
+ type DerivedTool,
36
+ type JsonSchema,
37
+ } from './schema.js';
38
+ export { DEFAULT_COMPONENT_TOOL_PREFIX, componentToolName, toSnakeCase } from './naming.js';
39
+ export {
40
+ errorResult,
41
+ formatZodIssue,
42
+ zodErrorResult,
43
+ type ToolErrorPayload,
44
+ type ToolErrorResult,
45
+ } from './errors.js';
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { DEFAULT_COMPONENT_TOOL_PREFIX, componentToolName, toSnakeCase } from './naming.js';
3
+
4
+ describe('toSnakeCase', () => {
5
+ test('PascalCase → snake_case', () => {
6
+ expect(toSnakeCase('Cover')).toBe('cover');
7
+ expect(toSnakeCase('TwoColumn')).toBe('two_column');
8
+ expect(toSnakeCase('SectionDivider')).toBe('section_divider');
9
+ });
10
+
11
+ test('handles consecutive caps', () => {
12
+ expect(toSnakeCase('HTTPServer')).toBe('http_server');
13
+ expect(toSnakeCase('IOQueue')).toBe('io_queue');
14
+ expect(toSnakeCase('XMLHttpRequest')).toBe('xml_http_request');
15
+ });
16
+
17
+ test('passes through already-snake', () => {
18
+ expect(toSnakeCase('two_column')).toBe('two_column');
19
+ });
20
+
21
+ test('replaces kebab-case', () => {
22
+ expect(toSnakeCase('two-column')).toBe('two_column');
23
+ });
24
+ });
25
+
26
+ describe('componentToolName', () => {
27
+ test('joins prefix and snake_case component name', () => {
28
+ expect(componentToolName('Cover')).toBe('slides_add_cover');
29
+ expect(componentToolName('TwoColumn')).toBe('slides_add_two_column');
30
+ });
31
+
32
+ test('default prefix is exposed for downstream consumers', () => {
33
+ expect(DEFAULT_COMPONENT_TOOL_PREFIX).toBe('slides_add_');
34
+ });
35
+
36
+ test('respects a custom prefix', () => {
37
+ expect(componentToolName('Cover', 'report_add_')).toBe('report_add_cover');
38
+ });
39
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Tool-name conversion helpers.
3
+ *
4
+ * MCP tool names follow `{service}_{action}_{resource}` snake_case per
5
+ * Anthropic's mcp-builder guidance. The default `service_action` prefix is
6
+ * `slides_add_` since this framework's first target is slide decks; brand
7
+ * servers can override via `SlideServerConfig.toolPrefix` when they ship a
8
+ * different verb or service.
9
+ *
10
+ * Examples (with the default prefix):
11
+ * Cover → slides_add_cover
12
+ * TwoColumn → slides_add_two_column
13
+ * SectionDivider → slides_add_section_divider
14
+ */
15
+
16
+ /** The default tool-name prefix used for brand-component-derived tools. */
17
+ export const DEFAULT_COMPONENT_TOOL_PREFIX = 'slides_add_';
18
+
19
+ /**
20
+ * Convert a PascalCase / camelCase identifier to snake_case.
21
+ *
22
+ * Behavior:
23
+ * - `TwoColumn` → `two_column`
24
+ * - `HTTPServer` → `http_server` (consecutive caps treated as a unit, then lowercased)
25
+ * - `IOQueue` → `io_queue`
26
+ * - already-snake or kebab text passes through with the kebabs replaced.
27
+ *
28
+ * Template components are expected to use PascalCase by convention, but this
29
+ * function is forgiving so a brand author who slips can still get a stable
30
+ * tool name without the framework refusing to start.
31
+ */
32
+ export const toSnakeCase = (name: string): string =>
33
+ name
34
+ // boundary between a run of caps and a following Title-case word: `HTTPServer` → `HTTP_Server`
35
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
36
+ // boundary between lowercase/digit and a cap: `twoColumn` → `two_Column`
37
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
38
+ .replace(/-/g, '_')
39
+ .toLowerCase();
40
+
41
+ /**
42
+ * Build the MCP tool name for a brand component.
43
+ *
44
+ * `prefix` defaults to `DEFAULT_COMPONENT_TOOL_PREFIX` (`slides_add_`).
45
+ */
46
+ export const componentToolName = (
47
+ componentName: string,
48
+ prefix: string = DEFAULT_COMPONENT_TOOL_PREFIX,
49
+ ): string => `${prefix}${toSnakeCase(componentName)}`;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The full pipeline from a list of slide specs to a `.pptx` file on disk.
3
+ *
4
+ * Shared by the `slides_create` MCP tool and the `generate` CLI subcommand a
5
+ * template binary would normally ship. Both call this; both branch on the
6
+ * tagged `Result` it returns.
7
+ */
8
+
9
+ import { createElement, Fragment, type ReactElement, type ReactNode } from 'react';
10
+ import { renderToOps, ReconcilerError, type SlidesRuntime, type Template } from '../core/index.js';
11
+
12
+ /** One slide spec: which component, what props. */
13
+ export interface SlideSpec {
14
+ readonly component: string;
15
+ readonly props: Record<string, unknown>;
16
+ }
17
+
18
+ /** A field-level validation issue. */
19
+ export interface RenderIssue {
20
+ readonly path: string;
21
+ readonly message: string;
22
+ }
23
+
24
+ /** Result of a render. Tagged union for explicit branching. */
25
+ export type RenderResult =
26
+ | { readonly ok: true; readonly filePath: string; readonly slideCount: number }
27
+ | {
28
+ readonly ok: false;
29
+ readonly code:
30
+ | 'unknown_component'
31
+ | 'validation_error'
32
+ | 'reconciler_error'
33
+ | 'runtime_error';
34
+ readonly message: string;
35
+ readonly issues?: readonly RenderIssue[];
36
+ };
37
+
38
+ /**
39
+ * Render a list of slide specs to a `.pptx` file. Validates every spec
40
+ * against its component's schema before constructing the tree.
41
+ *
42
+ * @param template - The template (slide-component vocabulary + tokens).
43
+ * @param runtime - The PPTX runtime to write through.
44
+ * @param title - Deck title (also the `.pptx` filename stem).
45
+ * @param slides - The slide specs, in order.
46
+ */
47
+ export const renderSlides = async (params: {
48
+ readonly template: Template;
49
+ readonly runtime: SlidesRuntime;
50
+ readonly title: string;
51
+ readonly slides: ReadonlyArray<SlideSpec>;
52
+ }): Promise<RenderResult> => {
53
+ const { template, runtime, title, slides } = params;
54
+
55
+ const children: ReactNode[] = [];
56
+ for (let i = 0; i < slides.length; i++) {
57
+ const spec = slides[i];
58
+ if (spec === undefined) continue;
59
+ const component = template.components[spec.component];
60
+ if (!component) {
61
+ const known = Object.keys(template.components).sort().join(', ') || '(none)';
62
+ return {
63
+ ok: false,
64
+ code: 'unknown_component',
65
+ message:
66
+ `slides[${i}].component "${spec.component}" is not a slide type in template ` +
67
+ `"${template.name}". Known types: ${known}. Call slides_list to see them with descriptions.`,
68
+ };
69
+ }
70
+ const parsed = component.schema.safeParse(spec.props);
71
+ if (!parsed.success) {
72
+ const issues: RenderIssue[] = parsed.error.issues.map((issue) => ({
73
+ path: issue.path.length > 0 ? issue.path.map(String).join('.') : '(root)',
74
+ message: issue.message,
75
+ }));
76
+ const bullets = issues.map((it) => ` • ${it.path}: ${it.message}`).join('\n');
77
+ return {
78
+ ok: false,
79
+ code: 'validation_error',
80
+ message: `Validation error in slides[${i}].props (slide type "${spec.component}"):\n${bullets}`,
81
+ issues,
82
+ };
83
+ }
84
+ children.push(createElement(component.component, { key: i, ...parsed.data }));
85
+ }
86
+ const tree: ReactElement = createElement(Fragment, null, ...children);
87
+
88
+ try {
89
+ const { deckId } = await runtime.createDeckFromMaster(template.name, title);
90
+ const reconciled = renderToOps({ tree, template, deckId });
91
+ await runtime.applyOps(deckId, reconciled.ops);
92
+ runtime.attachManifest(deckId, reconciled.manifest);
93
+ const { filePath } = await runtime.write(deckId);
94
+ return { ok: true, filePath, slideCount: slides.length };
95
+ } catch (err) {
96
+ const message = err instanceof Error ? err.message : String(err);
97
+ if (err instanceof ReconcilerError) {
98
+ return {
99
+ ok: false,
100
+ code: 'reconciler_error',
101
+ message: `Reconciler rejected the slide tree: ${message}.`,
102
+ };
103
+ }
104
+ return {
105
+ ok: false,
106
+ code: 'runtime_error',
107
+ message: `Error generating PPTX: ${message}.`,
108
+ };
109
+ }
110
+ };
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { CANVAS_16_9, type Template } from '../core/index.js';
4
+ import { componentToTool, deriveComponentTools } from './schema.js';
5
+
6
+ const TestBrand: Template = {
7
+ name: 'test',
8
+ canvas: CANVAS_16_9,
9
+ fonts: { display: ['Inter'], body: ['Inter'], mono: ['Mono'] },
10
+ colors: { 'fg.base': '#000000' },
11
+ typography: { 'body-md': { fontFamily: 'body', fontSize: 18, lineHeight: 1.5 } },
12
+ spacing: { md: 12 },
13
+ components: {
14
+ Cover: {
15
+ component: () => null,
16
+ schema: z
17
+ .object({
18
+ title: z.string().min(1).describe('The cover title.'),
19
+ subtitle: z.string().optional().describe('Optional subtitle.'),
20
+ })
21
+ .strict(),
22
+ description: 'Use as the first slide. Sets title and stance.',
23
+ },
24
+ TwoColumn: {
25
+ component: () => null,
26
+ schema: z
27
+ .object({
28
+ left: z.string(),
29
+ right: z.string(),
30
+ })
31
+ .strict(),
32
+ description: 'Use to compare two parallel ideas of equal weight.',
33
+ },
34
+ SectionDivider: {
35
+ component: () => null,
36
+ schema: z.object({ label: z.string() }).strict(),
37
+ description: 'Use to separate major sections of the deck.',
38
+ },
39
+ },
40
+ };
41
+
42
+ describe('deriveComponentTools', () => {
43
+ test('returns one tool per brand component, in object order', () => {
44
+ const tools = deriveComponentTools(TestBrand);
45
+ expect(tools.map((t) => t.name)).toEqual([
46
+ 'slides_add_cover',
47
+ 'slides_add_two_column',
48
+ 'slides_add_section_divider',
49
+ ]);
50
+ });
51
+
52
+ test('description is taken from the brand component', () => {
53
+ const tools = deriveComponentTools(TestBrand);
54
+ const cover = tools.find((t) => t.componentName === 'Cover');
55
+ expect(cover?.description).toBe('Use as the first slide. Sets title and stance.');
56
+ });
57
+
58
+ test('inputShape carries the raw Zod shape', () => {
59
+ const cover = TestBrand.components.Cover;
60
+ if (!cover) throw new Error('Cover missing from test brand');
61
+ const tool = componentToTool('Cover', cover);
62
+ expect(Object.keys(tool.inputShape).sort()).toEqual(['subtitle', 'title']);
63
+ });
64
+ });
65
+
66
+ describe('componentToTool — JSON Schema derivation', () => {
67
+ test('produces a JSON-Schema-7-shaped object with required fields', () => {
68
+ const cover = TestBrand.components.Cover;
69
+ if (!cover) throw new Error('Cover missing from test brand');
70
+ const tool = componentToTool('Cover', cover);
71
+ const schema = tool.inputJsonSchema;
72
+ expect(schema.type).toBe('object');
73
+ expect(schema.required).toEqual(['title']);
74
+ const properties = schema.properties as Record<string, { type?: string; description?: string }>;
75
+ expect(properties.title?.type).toBe('string');
76
+ expect(properties.title?.description).toBe('The cover title.');
77
+ expect(properties.subtitle?.type).toBe('string');
78
+ });
79
+
80
+ test('preserves .strict() as additionalProperties: false', () => {
81
+ const cover = TestBrand.components.Cover;
82
+ if (!cover) throw new Error('Cover missing from test brand');
83
+ const tool = componentToTool('Cover', cover);
84
+ expect(tool.inputJsonSchema.additionalProperties).toBe(false);
85
+ });
86
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Schema-derivation helpers.
3
+ *
4
+ * Two things happen here:
5
+ *
6
+ * 1. **Template component → MCP tool definition.** We iterate `brand.components`
7
+ * and return a list of `DerivedTool` records — each one is everything the
8
+ * server needs to call `McpServer.registerTool`.
9
+ *
10
+ * 2. **Zod → JSON Schema conversion.** The MCP wire format uses JSON Schema
11
+ * for tool input/output. The SDK's `registerTool` accepts Zod and converts
12
+ * internally; we ALSO produce JSON Schema explicitly via `zod-to-json-schema`
13
+ * so callers (and unit tests) can inspect the derived shape without
14
+ * spinning up a server.
15
+ */
16
+
17
+ import { zodToJsonSchema, type Options as ZodToJsonSchemaOptions } from 'zod-to-json-schema';
18
+
19
+ import type { z } from 'zod';
20
+ import type { Template, TemplateComponent } from '../core/index.js';
21
+ import { componentToolName } from './naming.js';
22
+
23
+ /** A JSON Schema document. We keep this loose; consumers can refine if needed. */
24
+ export type JsonSchema = Record<string, unknown>;
25
+
26
+ /**
27
+ * Everything the server needs to register a single brand-component-backed tool.
28
+ *
29
+ * Exposed as part of the package's public surface so callers (CLI smoke tests,
30
+ * doc generators, anything inspecting the brand) can list the derived tools
31
+ * without instantiating a server.
32
+ */
33
+ export interface DerivedTool {
34
+ /** The MCP tool name, e.g. `slides_add_cover`. */
35
+ readonly name: string;
36
+ /** The tool description (LLM-facing, taken from the brand component). */
37
+ readonly description: string;
38
+ /** The input shape. `Record<string, ZodType>` is what the SDK expects. */
39
+ readonly inputShape: z.ZodRawShape;
40
+ /** The Zod object schema, retained for runtime validation in tool handlers. */
41
+ readonly inputSchema: z.ZodObject<z.ZodRawShape>;
42
+ /** JSON Schema representation of the input, for inspection. */
43
+ readonly inputJsonSchema: JsonSchema;
44
+ /** Stable component name from the brand (e.g., `Cover`). */
45
+ readonly componentName: string;
46
+ }
47
+
48
+ /**
49
+ * Convert a single brand component to a derived-tool record.
50
+ *
51
+ * Pure function: same component → same DerivedTool. The conversion of
52
+ * `schema` to JSON Schema runs through `zod-to-json-schema`, which produces
53
+ * Draft-07 by default (matching the MCP wire format).
54
+ *
55
+ * `toolPrefix` defaults to `'slides_add_'`. Servers that want a different
56
+ * verb or service prefix (e.g., `'report_add_'`) override it.
57
+ */
58
+ export const componentToTool = (
59
+ componentName: string,
60
+ component: TemplateComponent,
61
+ toolPrefix?: string,
62
+ ): DerivedTool => {
63
+ const inputShape = component.schema.shape;
64
+ const inputJsonSchema = zodToJsonSchema(component.schema, JSON_SCHEMA_OPTIONS);
65
+ return {
66
+ name: componentToolName(componentName, toolPrefix),
67
+ description: component.description,
68
+ inputShape,
69
+ inputSchema: component.schema,
70
+ inputJsonSchema,
71
+ componentName,
72
+ };
73
+ };
74
+
75
+ /** Derive every per-component tool from a brand. */
76
+ export const deriveComponentTools = (brand: Template, toolPrefix?: string): DerivedTool[] =>
77
+ Object.entries(brand.components).map(([name, component]) =>
78
+ componentToTool(name, component, toolPrefix),
79
+ );
80
+
81
+ /**
82
+ * Options passed to `zod-to-json-schema`.
83
+ *
84
+ * - `target: 'jsonSchema7'` matches the dialect MCP servers emit (and what the
85
+ * official SDK produces internally when given a Zod schema).
86
+ * - `$refStrategy: 'none'` inlines all sub-schemas — simpler for LLMs to read
87
+ * in tool descriptions, and the schemas are typically shallow so the inline
88
+ * bloat is negligible.
89
+ */
90
+ const JSON_SCHEMA_OPTIONS: Partial<ZodToJsonSchemaOptions> = {
91
+ target: 'jsonSchema7',
92
+ $refStrategy: 'none',
93
+ };