@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.
- package/README.md +241 -0
- package/SKILL.md +119 -0
- package/dist/cli.d.ts +38 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +386 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/components.d.ts +179 -0
- package/dist/core/components.d.ts.map +1 -0
- package/dist/core/components.js +40 -0
- package/dist/core/components.js.map +1 -0
- package/dist/core/fake-runtime.d.ts +138 -0
- package/dist/core/fake-runtime.d.ts.map +1 -0
- package/dist/core/fake-runtime.js +210 -0
- package/dist/core/fake-runtime.js.map +1 -0
- package/dist/core/font-resolver.d.ts +28 -0
- package/dist/core/font-resolver.d.ts.map +1 -0
- package/dist/core/font-resolver.js +30 -0
- package/dist/core/font-resolver.js.map +1 -0
- package/dist/core/geometry.d.ts +71 -0
- package/dist/core/geometry.d.ts.map +1 -0
- package/dist/core/geometry.js +44 -0
- package/dist/core/geometry.js.map +1 -0
- package/dist/core/index.d.ts +19 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +20 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/manifest.d.ts +123 -0
- package/dist/core/manifest.d.ts.map +1 -0
- package/dist/core/manifest.js +43 -0
- package/dist/core/manifest.js.map +1 -0
- package/dist/core/op-translator-pptx.d.ts +150 -0
- package/dist/core/op-translator-pptx.d.ts.map +1 -0
- package/dist/core/op-translator-pptx.js +245 -0
- package/dist/core/op-translator-pptx.js.map +1 -0
- package/dist/core/pptx-runtime.d.ts +103 -0
- package/dist/core/pptx-runtime.d.ts.map +1 -0
- package/dist/core/pptx-runtime.js +405 -0
- package/dist/core/pptx-runtime.js.map +1 -0
- package/dist/core/reconciler.d.ts +113 -0
- package/dist/core/reconciler.d.ts.map +1 -0
- package/dist/core/reconciler.js +453 -0
- package/dist/core/reconciler.js.map +1 -0
- package/dist/core/runtime.d.ts +161 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +11 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/template.d.ts +32 -0
- package/dist/core/template.d.ts.map +1 -0
- package/dist/core/template.js +3 -0
- package/dist/core/template.js.map +1 -0
- package/dist/dev/auto-examples.d.ts +6 -0
- package/dist/dev/auto-examples.d.ts.map +1 -0
- package/dist/dev/auto-examples.js +79 -0
- package/dist/dev/auto-examples.js.map +1 -0
- package/dist/dev/bin/slides-dev.d.ts +3 -0
- package/dist/dev/bin/slides-dev.d.ts.map +1 -0
- package/dist/dev/bin/slides-dev.js +87 -0
- package/dist/dev/bin/slides-dev.js.map +1 -0
- package/dist/dev/bin/slides-dev.mjs +24 -0
- package/dist/dev/compose-deck.d.ts +18 -0
- package/dist/dev/compose-deck.d.ts.map +1 -0
- package/dist/dev/compose-deck.js +19 -0
- package/dist/dev/compose-deck.js.map +1 -0
- package/dist/dev/deck-viewer.d.ts +19 -0
- package/dist/dev/deck-viewer.d.ts.map +1 -0
- package/dist/dev/deck-viewer.js +237 -0
- package/dist/dev/deck-viewer.js.map +1 -0
- package/dist/dev/dev-server/client/entry.d.ts +2 -0
- package/dist/dev/dev-server/client/entry.d.ts.map +1 -0
- package/dist/dev/dev-server/client/entry.js +12 -0
- package/dist/dev/dev-server/client/entry.js.map +1 -0
- package/dist/dev/dev-server/output.d.ts +8 -0
- package/dist/dev/dev-server/output.d.ts.map +1 -0
- package/dist/dev/dev-server/output.js +32 -0
- package/dist/dev/dev-server/output.js.map +1 -0
- package/dist/dev/dev-server/server-only-stub.d.ts +7 -0
- package/dist/dev/dev-server/server-only-stub.d.ts.map +1 -0
- package/dist/dev/dev-server/server-only-stub.js +12 -0
- package/dist/dev/dev-server/server-only-stub.js.map +1 -0
- package/dist/dev/dev-server/start.d.ts +14 -0
- package/dist/dev/dev-server/start.d.ts.map +1 -0
- package/dist/dev/dev-server/start.js +135 -0
- package/dist/dev/dev-server/start.js.map +1 -0
- package/dist/dev/index.d.ts +5 -0
- package/dist/dev/index.d.ts.map +1 -0
- package/dist/dev/index.js +5 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/dev/lib/cn.d.ts +3 -0
- package/dist/dev/lib/cn.d.ts.map +1 -0
- package/dist/dev/lib/cn.js +3 -0
- package/dist/dev/lib/cn.js.map +1 -0
- package/dist/dev/slide-canvas.d.ts +12 -0
- package/dist/dev/slide-canvas.d.ts.map +1 -0
- package/dist/dev/slide-canvas.js +123 -0
- package/dist/dev/slide-canvas.js.map +1 -0
- package/dist/dev/styles.css +37 -0
- package/dist/dev/ui/icon-button.d.ts +12 -0
- package/dist/dev/ui/icon-button.d.ts.map +1 -0
- package/dist/dev/ui/icon-button.js +6 -0
- package/dist/dev/ui/icon-button.js.map +1 -0
- package/dist/dev/ui/kbd.d.ts +6 -0
- package/dist/dev/ui/kbd.d.ts.map +1 -0
- package/dist/dev/ui/kbd.js +4 -0
- package/dist/dev/ui/kbd.js.map +1 -0
- package/dist/dev/ui/text-button.d.ts +10 -0
- package/dist/dev/ui/text-button.d.ts.map +1 -0
- package/dist/dev/ui/text-button.js +6 -0
- package/dist/dev/ui/text-button.js.map +1 -0
- package/dist/dev/url-state.d.ts +7 -0
- package/dist/dev/url-state.d.ts.map +1 -0
- package/dist/dev/url-state.js +13 -0
- package/dist/dev/url-state.js.map +1 -0
- package/dist/dev/use-keyboard-nav.d.ts +17 -0
- package/dist/dev/use-keyboard-nav.d.ts.map +1 -0
- package/dist/dev/use-keyboard-nav.js +53 -0
- package/dist/dev/use-keyboard-nav.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/errors.d.ts +57 -0
- package/dist/mcp/errors.d.ts.map +1 -0
- package/dist/mcp/errors.js +44 -0
- package/dist/mcp/errors.js.map +1 -0
- package/dist/mcp/index.d.ts +29 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +29 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/naming.d.ts +37 -0
- package/dist/mcp/naming.d.ts.map +1 -0
- package/dist/mcp/naming.js +43 -0
- package/dist/mcp/naming.js.map +1 -0
- package/dist/mcp/render.d.ts +45 -0
- package/dist/mcp/render.d.ts.map +1 -0
- package/dist/mcp/render.js +77 -0
- package/dist/mcp/render.js.map +1 -0
- package/dist/mcp/schema.d.ts +54 -0
- package/dist/mcp/schema.d.ts.map +1 -0
- package/dist/mcp/schema.js +55 -0
- package/dist/mcp/schema.js.map +1 -0
- package/dist/mcp/server.d.ts +63 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +196 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/scaffold/index.d.ts +39 -0
- package/dist/scaffold/index.d.ts.map +1 -0
- package/dist/scaffold/index.js +84 -0
- package/dist/scaffold/index.js.map +1 -0
- package/dist/scaffold/template-base/README.md +134 -0
- package/dist/scaffold/template-base/_gitignore +4 -0
- package/dist/scaffold/template-base/package.json +35 -0
- package/dist/scaffold/template-base/src/components/Cover.tsx +30 -0
- package/dist/scaffold/template-base/src/index.ts +27 -0
- package/dist/scaffold/template-base/src/preview.tsx +9 -0
- package/dist/scaffold/template-base/tsconfig.build.json +10 -0
- package/dist/scaffold/template-base/tsconfig.json +18 -0
- package/package.json +164 -0
- package/src/__tests__/fixtures/test-template/index.tsx +77 -0
- package/src/__tests__/pptx-mcp.test.ts +85 -0
- package/src/__tests__/pptx-smoke.test.ts +45 -0
- package/src/__tests__/preview.test.ts +28 -0
- package/src/cli.ts +426 -0
- package/src/core/__snapshots__/reconciler.test.ts.snap +320 -0
- package/src/core/components.test.ts +57 -0
- package/src/core/components.ts +196 -0
- package/src/core/fake-runtime.test.ts +174 -0
- package/src/core/fake-runtime.ts +302 -0
- package/src/core/font-resolver.ts +46 -0
- package/src/core/geometry.test.ts +58 -0
- package/src/core/geometry.ts +91 -0
- package/src/core/index.ts +69 -0
- package/src/core/manifest.test.ts +33 -0
- package/src/core/manifest.ts +150 -0
- package/src/core/op-translator-pptx.test.ts +204 -0
- package/src/core/op-translator-pptx.ts +365 -0
- package/src/core/pptx-runtime.test.ts +137 -0
- package/src/core/pptx-runtime.ts +504 -0
- package/src/core/reconciler.test.ts +644 -0
- package/src/core/reconciler.ts +603 -0
- package/src/core/runtime.ts +150 -0
- package/src/core/template.test.ts +136 -0
- package/src/core/template.ts +37 -0
- package/src/dev/auto-examples.ts +89 -0
- package/src/dev/bin/slides-dev.mjs +24 -0
- package/src/dev/bin/slides-dev.ts +101 -0
- package/src/dev/compose-deck.test.ts +68 -0
- package/src/dev/compose-deck.ts +40 -0
- package/src/dev/deck-viewer.tsx +677 -0
- package/src/dev/dev-server/client/entry.tsx +15 -0
- package/src/dev/dev-server/client/index.html +24 -0
- package/src/dev/dev-server/output.ts +37 -0
- package/src/dev/dev-server/server-only-stub.ts +12 -0
- package/src/dev/dev-server/start.ts +155 -0
- package/src/dev/index.ts +4 -0
- package/src/dev/lib/cn.ts +3 -0
- package/src/dev/slide-canvas.test.tsx +66 -0
- package/src/dev/slide-canvas.tsx +170 -0
- package/src/dev/styles.css +37 -0
- package/src/dev/ui/icon-button.tsx +31 -0
- package/src/dev/ui/kbd.tsx +20 -0
- package/src/dev/ui/text-button.tsx +31 -0
- package/src/dev/url-state.test.ts +22 -0
- package/src/dev/url-state.ts +17 -0
- package/src/dev/use-keyboard-nav.ts +64 -0
- package/src/index.ts +17 -0
- package/src/mcp/errors.test.ts +51 -0
- package/src/mcp/errors.ts +76 -0
- package/src/mcp/index.ts +45 -0
- package/src/mcp/naming.test.ts +39 -0
- package/src/mcp/naming.ts +49 -0
- package/src/mcp/render.ts +110 -0
- package/src/mcp/schema.test.ts +86 -0
- package/src/mcp/schema.ts +93 -0
- package/src/mcp/server.test.ts +309 -0
- package/src/mcp/server.ts +276 -0
- package/src/scaffold/index.ts +102 -0
- package/src/scaffold/template-base/README.md +134 -0
- package/src/scaffold/template-base/_gitignore +4 -0
- package/src/scaffold/template-base/package.json +35 -0
- package/src/scaffold/template-base/src/components/Cover.tsx +30 -0
- package/src/scaffold/template-base/src/index.ts +27 -0
- package/src/scaffold/template-base/src/preview.tsx +9 -0
- package/src/scaffold/template-base/tsconfig.build.json +10 -0
- package/src/scaffold/template-base/tsconfig.json +18 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createElement } from 'react';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
8
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
9
|
+
import {
|
|
10
|
+
Box,
|
|
11
|
+
Text,
|
|
12
|
+
CANVAS_16_9,
|
|
13
|
+
PptxSlidesRuntime,
|
|
14
|
+
Slide,
|
|
15
|
+
type Template,
|
|
16
|
+
type TemplateComponent,
|
|
17
|
+
} from '../core/index.js';
|
|
18
|
+
import { createSlideServer, type SlideServer } from './server.js';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// A small template-agnostic test fixture. Two components, both return real <Slide>s.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const CoverProps = z
|
|
25
|
+
.object({
|
|
26
|
+
title: z.string().min(1).describe('Cover title.'),
|
|
27
|
+
subtitle: z.string().optional(),
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
type CoverProps = z.infer<typeof CoverProps>;
|
|
31
|
+
|
|
32
|
+
const Cover: TemplateComponent<CoverProps> = {
|
|
33
|
+
component: ({ title, subtitle }: CoverProps) =>
|
|
34
|
+
createElement(
|
|
35
|
+
Slide,
|
|
36
|
+
null,
|
|
37
|
+
createElement(
|
|
38
|
+
Box,
|
|
39
|
+
{ rect: { x: 54, y: 54, w: 600, h: 80 }, slotId: 'cover:title' },
|
|
40
|
+
createElement(Text, null, title),
|
|
41
|
+
),
|
|
42
|
+
subtitle === undefined
|
|
43
|
+
? null
|
|
44
|
+
: createElement(
|
|
45
|
+
Box,
|
|
46
|
+
{ rect: { x: 54, y: 140, w: 600, h: 40 }, slotId: 'cover:subtitle' },
|
|
47
|
+
createElement(Text, null, subtitle),
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
schema: CoverProps as unknown as z.ZodObject<z.ZodRawShape>,
|
|
51
|
+
description: 'Use as the first slide. Sets title and stance.',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const TwoColumnProps = z
|
|
55
|
+
.object({
|
|
56
|
+
left: z.string().min(1),
|
|
57
|
+
right: z.string().min(1),
|
|
58
|
+
})
|
|
59
|
+
.strict();
|
|
60
|
+
type TwoColumnProps = z.infer<typeof TwoColumnProps>;
|
|
61
|
+
|
|
62
|
+
const TwoColumn: TemplateComponent<TwoColumnProps> = {
|
|
63
|
+
component: ({ left, right }: TwoColumnProps) =>
|
|
64
|
+
createElement(
|
|
65
|
+
Slide,
|
|
66
|
+
null,
|
|
67
|
+
createElement(
|
|
68
|
+
Box,
|
|
69
|
+
{ rect: { x: 54, y: 54, w: 300, h: 300 } },
|
|
70
|
+
createElement(Text, null, left),
|
|
71
|
+
),
|
|
72
|
+
createElement(
|
|
73
|
+
Box,
|
|
74
|
+
{ rect: { x: 386, y: 54, w: 300, h: 300 } },
|
|
75
|
+
createElement(Text, null, right),
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
schema: TwoColumnProps as unknown as z.ZodObject<z.ZodRawShape>,
|
|
79
|
+
description: 'Use to compare two parallel ideas of equal weight.',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const TestTemplate: Template = {
|
|
83
|
+
name: 'test',
|
|
84
|
+
canvas: CANVAS_16_9,
|
|
85
|
+
fonts: { display: ['Inter'], body: ['Inter'], mono: ['Courier New'] },
|
|
86
|
+
colors: {},
|
|
87
|
+
typography: {},
|
|
88
|
+
spacing: {},
|
|
89
|
+
components: {
|
|
90
|
+
Cover: Cover as unknown as TemplateComponent,
|
|
91
|
+
TwoColumn: TwoColumn as unknown as TemplateComponent,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Test harness
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
interface Harness {
|
|
100
|
+
readonly server: SlideServer;
|
|
101
|
+
readonly client: Client;
|
|
102
|
+
readonly outputDir: string;
|
|
103
|
+
close(): Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let activeDir: string | undefined;
|
|
107
|
+
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
activeDir = await fs.mkdtemp(join(tmpdir(), 'react-pptx-mcp-test-'));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterEach(async () => {
|
|
113
|
+
if (activeDir) await fs.rm(activeDir, { recursive: true, force: true });
|
|
114
|
+
activeDir = undefined;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const makeHarness = async (): Promise<Harness> => {
|
|
118
|
+
if (!activeDir) throw new Error('activeDir not set; beforeEach failed?');
|
|
119
|
+
const runtime = new PptxSlidesRuntime({ outputDir: activeDir });
|
|
120
|
+
const server = createSlideServer({ template: TestTemplate, runtime });
|
|
121
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
122
|
+
const client = new Client({ name: 'test-client', version: '0.0.0' });
|
|
123
|
+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
124
|
+
return {
|
|
125
|
+
server,
|
|
126
|
+
client,
|
|
127
|
+
outputDir: activeDir,
|
|
128
|
+
close: async () => {
|
|
129
|
+
await client.close();
|
|
130
|
+
await server.close();
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Tests
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
describe('createSlideServer — tool registration', () => {
|
|
140
|
+
test('exposes slides_list, slides_create, plus one per slide type', async () => {
|
|
141
|
+
const h = await makeHarness();
|
|
142
|
+
try {
|
|
143
|
+
const list = await h.client.listTools();
|
|
144
|
+
const names = list.tools.map((t) => t.name).sort();
|
|
145
|
+
expect(names).toEqual([
|
|
146
|
+
'slides_add_cover',
|
|
147
|
+
'slides_add_two_column',
|
|
148
|
+
'slides_create',
|
|
149
|
+
'slides_list',
|
|
150
|
+
]);
|
|
151
|
+
} finally {
|
|
152
|
+
await h.close();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('per-slide-type tool description carries the template component description', async () => {
|
|
157
|
+
const h = await makeHarness();
|
|
158
|
+
try {
|
|
159
|
+
const list = await h.client.listTools();
|
|
160
|
+
const cover = list.tools.find((t) => t.name === 'slides_add_cover');
|
|
161
|
+
expect(cover?.description).toBe('Use as the first slide. Sets title and stance.');
|
|
162
|
+
} finally {
|
|
163
|
+
await h.close();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('every tool advertises an outputSchema', async () => {
|
|
168
|
+
const h = await makeHarness();
|
|
169
|
+
try {
|
|
170
|
+
const list = await h.client.listTools();
|
|
171
|
+
for (const tool of list.tools) {
|
|
172
|
+
expect(tool.outputSchema, `tool ${tool.name} should expose an outputSchema`).toBeDefined();
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
await h.close();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('slides_list', () => {
|
|
181
|
+
test('returns the template name and every slide type with description', async () => {
|
|
182
|
+
const h = await makeHarness();
|
|
183
|
+
try {
|
|
184
|
+
const result = await h.client.callTool({ name: 'slides_list', arguments: {} });
|
|
185
|
+
expect(result.isError).toBeFalsy();
|
|
186
|
+
const sc = result.structuredContent as {
|
|
187
|
+
template: string;
|
|
188
|
+
slides: Array<{ name: string; toolName: string; description: string }>;
|
|
189
|
+
};
|
|
190
|
+
expect(sc.template).toBe('test');
|
|
191
|
+
expect(sc.slides.map((s) => s.name).sort()).toEqual(['Cover', 'TwoColumn']);
|
|
192
|
+
const cover = sc.slides.find((s) => s.name === 'Cover');
|
|
193
|
+
expect(cover?.toolName).toBe('slides_add_cover');
|
|
194
|
+
expect(cover?.description).toMatch(/first slide/);
|
|
195
|
+
} finally {
|
|
196
|
+
await h.close();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('slides_add_<component> — schema-introspection tools', () => {
|
|
202
|
+
test('valid props echo back as a slide spec', async () => {
|
|
203
|
+
const h = await makeHarness();
|
|
204
|
+
try {
|
|
205
|
+
const result = await h.client.callTool({
|
|
206
|
+
name: 'slides_add_cover',
|
|
207
|
+
arguments: { title: 'Q2 Review' },
|
|
208
|
+
});
|
|
209
|
+
expect(result.isError).toBeFalsy();
|
|
210
|
+
expect(result.structuredContent).toEqual({
|
|
211
|
+
slide: { component: 'Cover', props: { title: 'Q2 Review' } },
|
|
212
|
+
});
|
|
213
|
+
} finally {
|
|
214
|
+
await h.close();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('slides_create', () => {
|
|
220
|
+
test('happy path: writes a .pptx file and returns the absolute path', async () => {
|
|
221
|
+
const h = await makeHarness();
|
|
222
|
+
try {
|
|
223
|
+
const result = await h.client.callTool({
|
|
224
|
+
name: 'slides_create',
|
|
225
|
+
arguments: {
|
|
226
|
+
title: 'Pitch Deck',
|
|
227
|
+
slides: [
|
|
228
|
+
{ component: 'Cover', props: { title: 'Hello', subtitle: 'world' } },
|
|
229
|
+
{ component: 'TwoColumn', props: { left: 'A', right: 'B' } },
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
expect(result.isError).toBeFalsy();
|
|
234
|
+
const sc = result.structuredContent as { filePath: string; slideCount: number };
|
|
235
|
+
expect(sc.slideCount).toBe(2);
|
|
236
|
+
expect(sc.filePath).toMatch(/Pitch-Deck\.pptx$/);
|
|
237
|
+
const buf = await fs.readFile(sc.filePath);
|
|
238
|
+
// PPTX is a ZIP container. Check magic number.
|
|
239
|
+
expect(buf[0]).toBe(0x50);
|
|
240
|
+
expect(buf[1]).toBe(0x4b);
|
|
241
|
+
} finally {
|
|
242
|
+
await h.close();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('error path: invalid props surface field paths and a re-call hint', async () => {
|
|
247
|
+
const h = await makeHarness();
|
|
248
|
+
try {
|
|
249
|
+
const result = await h.client.callTool({
|
|
250
|
+
name: 'slides_create',
|
|
251
|
+
arguments: {
|
|
252
|
+
title: 'X',
|
|
253
|
+
slides: [{ component: 'Cover', props: { subtitle: 'foo' } }],
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
expect(result.isError).toBe(true);
|
|
257
|
+
const sc = result.structuredContent as {
|
|
258
|
+
error: { code: string; message: string; issues: Array<{ path: string }> };
|
|
259
|
+
};
|
|
260
|
+
expect(sc.error.code).toBe('validation_error');
|
|
261
|
+
expect(sc.error.message).toMatch(/slides\[0\]/);
|
|
262
|
+
expect(sc.error.message).toMatch(/title/);
|
|
263
|
+
expect(sc.error.message).toMatch(/Fix the listed fields and retry/);
|
|
264
|
+
expect(sc.error.issues.some((i) => i.path === 'title')).toBe(true);
|
|
265
|
+
} finally {
|
|
266
|
+
await h.close();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('error path: unknown component name lists known options and points at slides_list', async () => {
|
|
271
|
+
const h = await makeHarness();
|
|
272
|
+
try {
|
|
273
|
+
const result = await h.client.callTool({
|
|
274
|
+
name: 'slides_create',
|
|
275
|
+
arguments: {
|
|
276
|
+
title: 'X',
|
|
277
|
+
slides: [{ component: 'NonExistent', props: {} }],
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
expect(result.isError).toBe(true);
|
|
281
|
+
const sc = result.structuredContent as { error: { code: string; message: string } };
|
|
282
|
+
expect(sc.error.code).toBe('unknown_component');
|
|
283
|
+
expect(sc.error.message).toMatch(/NonExistent/);
|
|
284
|
+
expect(sc.error.message).toMatch(/Cover/);
|
|
285
|
+
expect(sc.error.message).toMatch(/TwoColumn/);
|
|
286
|
+
expect(sc.error.message).toMatch(/slides_list/);
|
|
287
|
+
} finally {
|
|
288
|
+
await h.close();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('strict()-rejected unknown props produce a validation error', async () => {
|
|
293
|
+
const h = await makeHarness();
|
|
294
|
+
try {
|
|
295
|
+
const result = await h.client.callTool({
|
|
296
|
+
name: 'slides_create',
|
|
297
|
+
arguments: {
|
|
298
|
+
title: 'X',
|
|
299
|
+
slides: [{ component: 'Cover', props: { title: 'OK', rogueColor: '#ff00ff' } }],
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
expect(result.isError).toBe(true);
|
|
303
|
+
const sc = result.structuredContent as { error: { code: string } };
|
|
304
|
+
expect(sc.error.code).toBe('validation_error');
|
|
305
|
+
} finally {
|
|
306
|
+
await h.close();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The template-agnostic MCP server framework.
|
|
3
|
+
*
|
|
4
|
+
* createSlideServer({ template, runtime }) builds an McpServer that exposes
|
|
5
|
+
* three kinds of tools:
|
|
6
|
+
*
|
|
7
|
+
* 1. **`slides_list`** — returns the list of slide types the loaded template
|
|
8
|
+
* supports, with descriptions. One round-trip to discover the surface.
|
|
9
|
+
*
|
|
10
|
+
* 2. **One tool per slide type**, named `<toolPrefix><snake_case>` (default
|
|
11
|
+
* prefix `slides_add_`). Validates user-supplied props against the
|
|
12
|
+
* component's Zod schema and echoes them back as a slide spec. Used
|
|
13
|
+
* iteratively by an LLM to assemble a deck before calling `slides_create`.
|
|
14
|
+
*
|
|
15
|
+
* 3. **`slides_create`** — full pipeline. Takes `{ title, slides }`, renders
|
|
16
|
+
* via the reconciler, applies through the PPTX runtime, writes the .pptx
|
|
17
|
+
* file to disk, and returns the path.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
21
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
22
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
23
|
+
import { z } from 'zod';
|
|
24
|
+
import type { ZodError } from 'zod';
|
|
25
|
+
import type { SlidesRuntime, Template } from '../core/index.js';
|
|
26
|
+
import { errorResult, zodErrorResult } from './errors.js';
|
|
27
|
+
import { renderSlides } from './render.js';
|
|
28
|
+
import { deriveComponentTools, type DerivedTool } from './schema.js';
|
|
29
|
+
|
|
30
|
+
/** Configuration accepted by createSlideServer. */
|
|
31
|
+
export interface SlideServerConfig {
|
|
32
|
+
/** The template (component vocabulary + tokens) the server exposes. */
|
|
33
|
+
readonly template: Template;
|
|
34
|
+
/** The PPTX runtime used to materialize presentations. */
|
|
35
|
+
readonly runtime: SlidesRuntime;
|
|
36
|
+
/**
|
|
37
|
+
* Override the server's reported name/version. Defaults to a name derived
|
|
38
|
+
* from the template and version `'0.1.0'`.
|
|
39
|
+
*/
|
|
40
|
+
readonly serverInfo?: { readonly name: string; readonly version: string };
|
|
41
|
+
/**
|
|
42
|
+
* Override the per-slide-type tool-name prefix. Default: `'slides_add_'`.
|
|
43
|
+
* Use this when the server emits something other than slide decks (a
|
|
44
|
+
* report-builder might use `'report_add_'`, etc.).
|
|
45
|
+
*/
|
|
46
|
+
readonly toolPrefix?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Options accepted by server.start. */
|
|
50
|
+
export type StartOptions = { readonly transport: 'stdio' };
|
|
51
|
+
|
|
52
|
+
/** The handle returned by createSlideServer. */
|
|
53
|
+
export interface SlideServer {
|
|
54
|
+
/** The underlying MCP server. Exposed for advanced callers. */
|
|
55
|
+
readonly mcp: McpServer;
|
|
56
|
+
/** Tool definitions derived from the template. */
|
|
57
|
+
readonly tools: ReadonlyArray<DerivedTool>;
|
|
58
|
+
/** Connect to the given transport. Lower-level than start. */
|
|
59
|
+
connect(transport: Transport): Promise<void>;
|
|
60
|
+
/** Start serving over the configured transport. */
|
|
61
|
+
start(options: StartOptions): Promise<void>;
|
|
62
|
+
/** Close the server and disconnect any active transport. */
|
|
63
|
+
close(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Construct a template-locked MCP server. */
|
|
67
|
+
export const createSlideServer = (config: SlideServerConfig): SlideServer => {
|
|
68
|
+
const { template, runtime } = config;
|
|
69
|
+
const serverInfo = config.serverInfo ?? {
|
|
70
|
+
name: 'react-pptx-mcp:' + template.name,
|
|
71
|
+
version: '0.1.0',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const mcp = new McpServer(serverInfo);
|
|
75
|
+
const tools = deriveComponentTools(template, config.toolPrefix);
|
|
76
|
+
|
|
77
|
+
registerListTool(mcp, template, tools);
|
|
78
|
+
registerComponentTools(mcp, tools);
|
|
79
|
+
registerCreateTool(mcp, runtime, template);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
mcp,
|
|
83
|
+
tools,
|
|
84
|
+
connect: (transport) => mcp.connect(transport),
|
|
85
|
+
start: async (options) => {
|
|
86
|
+
switch (options.transport) {
|
|
87
|
+
case 'stdio': {
|
|
88
|
+
await mcp.connect(new StdioServerTransport());
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
close: () => mcp.close(),
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// slides_list
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const LIST_OUTPUT_SHAPE = {
|
|
102
|
+
template: z.string().describe('The name of the loaded template.'),
|
|
103
|
+
slides: z
|
|
104
|
+
.array(
|
|
105
|
+
z.object({
|
|
106
|
+
name: z.string().describe('Slide-type name, e.g. "Cover".'),
|
|
107
|
+
toolName: z.string().describe('MCP tool name for this slide type.'),
|
|
108
|
+
description: z.string().describe('When to use this slide type.'),
|
|
109
|
+
}),
|
|
110
|
+
)
|
|
111
|
+
.describe('Every slide type the template exposes.'),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const registerListTool = (
|
|
115
|
+
mcp: McpServer,
|
|
116
|
+
template: Template,
|
|
117
|
+
tools: ReadonlyArray<DerivedTool>,
|
|
118
|
+
): void => {
|
|
119
|
+
mcp.registerTool(
|
|
120
|
+
'slides_list',
|
|
121
|
+
{
|
|
122
|
+
description:
|
|
123
|
+
'List every slide type this template supports, with descriptions and the per-type tool names. ' +
|
|
124
|
+
'Call once at the start of a deck-building session to learn the surface.',
|
|
125
|
+
outputSchema: LIST_OUTPUT_SHAPE,
|
|
126
|
+
annotations: {
|
|
127
|
+
readOnlyHint: true,
|
|
128
|
+
destructiveHint: false,
|
|
129
|
+
idempotentHint: true,
|
|
130
|
+
openWorldHint: false,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
async () => {
|
|
134
|
+
const slides = tools.map((t) => ({
|
|
135
|
+
name: t.componentName,
|
|
136
|
+
toolName: t.name,
|
|
137
|
+
description: t.description,
|
|
138
|
+
}));
|
|
139
|
+
const lines = [
|
|
140
|
+
`Template: ${template.name}`,
|
|
141
|
+
'',
|
|
142
|
+
'Available slide types:',
|
|
143
|
+
...slides.map((s) => ` • ${s.name} (${s.toolName}) — ${s.description}`),
|
|
144
|
+
'',
|
|
145
|
+
'Call slides_add_<type> to validate a single slide. ' +
|
|
146
|
+
'Call slides_create with an array of slide specs to write a .pptx file.',
|
|
147
|
+
].join('\n');
|
|
148
|
+
return {
|
|
149
|
+
content: [{ type: 'text' as const, text: lines }],
|
|
150
|
+
structuredContent: { template: template.name, slides },
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// slides_add_<component>
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
const COMPONENT_OUTPUT_SHAPE = {
|
|
161
|
+
slide: z
|
|
162
|
+
.object({
|
|
163
|
+
component: z.string(),
|
|
164
|
+
props: z.record(z.unknown()),
|
|
165
|
+
})
|
|
166
|
+
.describe('A single validated slide spec, ready to be passed to slides_create.'),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const registerComponentTools = (mcp: McpServer, tools: readonly DerivedTool[]): void => {
|
|
170
|
+
for (const tool of tools) {
|
|
171
|
+
mcp.registerTool(
|
|
172
|
+
tool.name,
|
|
173
|
+
{
|
|
174
|
+
description: tool.description,
|
|
175
|
+
inputSchema: tool.inputShape,
|
|
176
|
+
outputSchema: COMPONENT_OUTPUT_SHAPE,
|
|
177
|
+
annotations: {
|
|
178
|
+
readOnlyHint: true,
|
|
179
|
+
destructiveHint: false,
|
|
180
|
+
idempotentHint: true,
|
|
181
|
+
openWorldHint: false,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
async (rawProps) => {
|
|
185
|
+
const parsed = tool.inputSchema.safeParse(rawProps);
|
|
186
|
+
if (!parsed.success) {
|
|
187
|
+
return zodErrorResult(
|
|
188
|
+
`Validation error in ${tool.name} props:`,
|
|
189
|
+
parsed.error as ZodError,
|
|
190
|
+
"Refer to this tool's input schema and retry.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
content: [
|
|
195
|
+
{
|
|
196
|
+
type: 'text' as const,
|
|
197
|
+
text:
|
|
198
|
+
`Validated ${tool.componentName} props. ` +
|
|
199
|
+
`Pass { component: "${tool.componentName}", props: <these> } ` +
|
|
200
|
+
`as one entry of slides_create.slides.`,
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
structuredContent: {
|
|
204
|
+
slide: { component: tool.componentName, props: parsed.data },
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// slides_create
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
const SLIDE_SPEC_SCHEMA = z
|
|
217
|
+
.object({
|
|
218
|
+
component: z.string().min(1).describe('The slide-type name, e.g. "Cover".'),
|
|
219
|
+
props: z
|
|
220
|
+
.record(z.unknown())
|
|
221
|
+
.describe("Props matching that slide type's input schema (see slides_add_<type>)."),
|
|
222
|
+
})
|
|
223
|
+
.describe('One slide to add. Same shape as the structuredContent of slides_add_<type>.');
|
|
224
|
+
|
|
225
|
+
const CREATE_INPUT_SHAPE = {
|
|
226
|
+
title: z.string().min(1).describe('Deck title — used as the .pptx filename stem.'),
|
|
227
|
+
slides: z.array(SLIDE_SPEC_SCHEMA).min(1).describe('The slides to write, in order.'),
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const CREATE_OUTPUT_SHAPE = {
|
|
231
|
+
filePath: z.string().describe('Absolute path to the generated .pptx file.'),
|
|
232
|
+
slideCount: z.number().int().nonnegative(),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const registerCreateTool = (mcp: McpServer, runtime: SlidesRuntime, template: Template): void => {
|
|
236
|
+
mcp.registerTool(
|
|
237
|
+
'slides_create',
|
|
238
|
+
{
|
|
239
|
+
description:
|
|
240
|
+
'Generate a template-locked .pptx presentation from a sequence of slide specs and write it to disk. ' +
|
|
241
|
+
'Returns the absolute file path. Each spec is { component, props } — ' +
|
|
242
|
+
'use the per-type slides_add_<type> tools first to discover schemas and validate props.',
|
|
243
|
+
inputSchema: CREATE_INPUT_SHAPE,
|
|
244
|
+
outputSchema: CREATE_OUTPUT_SHAPE,
|
|
245
|
+
annotations: {
|
|
246
|
+
readOnlyHint: false,
|
|
247
|
+
destructiveHint: false,
|
|
248
|
+
idempotentHint: false,
|
|
249
|
+
openWorldHint: false,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
async (input) => {
|
|
253
|
+
const result = await renderSlides({
|
|
254
|
+
template,
|
|
255
|
+
runtime,
|
|
256
|
+
title: input.title,
|
|
257
|
+
slides: input.slides,
|
|
258
|
+
});
|
|
259
|
+
if (result.ok) {
|
|
260
|
+
const slideWord = result.slideCount === 1 ? 'slide' : 'slides';
|
|
261
|
+
return {
|
|
262
|
+
content: [
|
|
263
|
+
{
|
|
264
|
+
type: 'text' as const,
|
|
265
|
+
text: `Wrote ${result.slideCount} ${slideWord} to ${result.filePath}.`,
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
structuredContent: { filePath: result.filePath, slideCount: result.slideCount },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const hint =
|
|
272
|
+
result.code === 'validation_error' ? ' Fix the listed fields and retry slides_create.' : '';
|
|
273
|
+
return errorResult(result.code, result.message + hint, result.issues);
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic scaffolder. The `slidesctl scaffold <dir>` subcommand wraps
|
|
3
|
+
* this; the same API is exposed at `@sanity-labs/slides/scaffold` so it can
|
|
4
|
+
* be driven by other tooling.
|
|
5
|
+
*
|
|
6
|
+
* The scaffold stamps every file under `template-base/` into the target
|
|
7
|
+
* directory, applying `__NAME__` / `__IDENT__` substitutions and renaming
|
|
8
|
+
* the `_gitignore` placeholder back to `.gitignore`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const TEMPLATE_BASE = resolvePath(HERE, 'template-base');
|
|
17
|
+
|
|
18
|
+
export type ScaffoldOptions = {
|
|
19
|
+
/** Target directory; created if missing. Must be empty if it exists. */
|
|
20
|
+
readonly target: string;
|
|
21
|
+
/** Template name (used as the package name + Template `name` field). */
|
|
22
|
+
readonly name: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ScaffoldResult = {
|
|
26
|
+
readonly targetPath: string;
|
|
27
|
+
readonly fileCount: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Stamp the template-base into `target`, applying the substitutions.
|
|
32
|
+
*
|
|
33
|
+
* Throws if `target` exists and is non-empty.
|
|
34
|
+
*/
|
|
35
|
+
export const scaffoldTemplate = (options: ScaffoldOptions): ScaffoldResult => {
|
|
36
|
+
const targetPath = resolvePath(process.cwd(), options.target);
|
|
37
|
+
if (existsSync(targetPath) && readdirSync(targetPath).length > 0) {
|
|
38
|
+
throw new Error(`Target directory "${targetPath}" already exists and is not empty.`);
|
|
39
|
+
}
|
|
40
|
+
copyTemplate(TEMPLATE_BASE, targetPath, {
|
|
41
|
+
__NAME__: options.name,
|
|
42
|
+
__IDENT__: toIdentifier(options.name),
|
|
43
|
+
});
|
|
44
|
+
return { targetPath, fileCount: countFiles(targetPath) };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Convert a kebab-case template name into a camelCase JS identifier. Used
|
|
49
|
+
* as the `__IDENT__` substitution in stamped files (e.g. the exported
|
|
50
|
+
* `Template` const).
|
|
51
|
+
*/
|
|
52
|
+
export const toIdentifier = (name: string): string =>
|
|
53
|
+
name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate a template name. Returns an error message string when invalid,
|
|
57
|
+
* `undefined` when ok. Used by interactive prompts.
|
|
58
|
+
*/
|
|
59
|
+
export const validateName = (value: string): string | undefined => {
|
|
60
|
+
if (!value) return 'Required';
|
|
61
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(value)) {
|
|
62
|
+
return 'Must start with a letter or digit and contain only [a-z0-9-].';
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Infer a default template name from a target path. */
|
|
68
|
+
export const defaultName = (target: string): string => {
|
|
69
|
+
const last = (target ?? './my-template').split('/').filter(Boolean).pop() ?? 'my-template';
|
|
70
|
+
return last.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const copyTemplate = (src: string, dst: string, replacements: Record<string, string>): void => {
|
|
74
|
+
mkdirSync(dst, { recursive: true });
|
|
75
|
+
for (const entry of readdirSync(src)) {
|
|
76
|
+
const srcEntry = join(src, entry);
|
|
77
|
+
const dstEntry = join(dst, denormaliseFilename(entry));
|
|
78
|
+
if (statSync(srcEntry).isDirectory()) {
|
|
79
|
+
copyTemplate(srcEntry, dstEntry, replacements);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
writeFileSync(dstEntry, applyReplacements(readFileSync(srcEntry, 'utf8'), replacements));
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const denormaliseFilename = (name: string): string => (name === '_gitignore' ? '.gitignore' : name);
|
|
87
|
+
|
|
88
|
+
const applyReplacements = (content: string, replacements: Record<string, string>): string => {
|
|
89
|
+
let out = content;
|
|
90
|
+
for (const [from, to] of Object.entries(replacements)) out = out.split(from).join(to);
|
|
91
|
+
return out;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const countFiles = (dir: string): number => {
|
|
95
|
+
let n = 0;
|
|
96
|
+
for (const entry of readdirSync(dir)) {
|
|
97
|
+
const full = join(dir, entry);
|
|
98
|
+
if (statSync(full).isDirectory()) n += countFiles(full);
|
|
99
|
+
else n += 1;
|
|
100
|
+
}
|
|
101
|
+
return n;
|
|
102
|
+
};
|