@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,24 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Slides Dev</title>
|
|
7
|
+
<style>
|
|
8
|
+
html,
|
|
9
|
+
body {
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 0;
|
|
12
|
+
height: 100%;
|
|
13
|
+
background: #f5f5f7;
|
|
14
|
+
}
|
|
15
|
+
#root {
|
|
16
|
+
height: 100%;
|
|
17
|
+
}
|
|
18
|
+
</style>
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<div id="root"></div>
|
|
22
|
+
<script type="module" src="/entry.tsx"></script>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
|
|
3
|
+
const PKG = 'react-pptx-dev';
|
|
4
|
+
export const formatBanner = (templateName: string, startedInMs: number): string => {
|
|
5
|
+
const title = pc.bold(pc.magenta('slides-dev'));
|
|
6
|
+
const tmpl = pc.dim('•') + ' ' + pc.cyan(templateName);
|
|
7
|
+
const took = pc.dim(`ready in ${startedInMs}ms`);
|
|
8
|
+
return `\n ${title} ${tmpl} ${took}\n`;
|
|
9
|
+
};
|
|
10
|
+
export const formatReady = (params: {
|
|
11
|
+
url: string;
|
|
12
|
+
templatePath: string;
|
|
13
|
+
host: boolean;
|
|
14
|
+
}): string => {
|
|
15
|
+
const arrow = pc.green('➜');
|
|
16
|
+
const lines: string[] = [];
|
|
17
|
+
lines.push(` ${arrow} ${pc.bold('Local:')} ${pc.cyan(params.url)}`);
|
|
18
|
+
if (!params.host) {
|
|
19
|
+
lines.push(` ${arrow} ${pc.bold('Network:')} ${pc.dim('use --host to expose')}`);
|
|
20
|
+
}
|
|
21
|
+
lines.push(` ${arrow} ${pc.bold('Template:')} ${pc.dim(params.templatePath)}`);
|
|
22
|
+
return lines.join('\n') + '\n';
|
|
23
|
+
};
|
|
24
|
+
const stripResolverPrefix = (message: string) => message.replace(/^slides-dev:\s*/, '');
|
|
25
|
+
|
|
26
|
+
export const formatStartupError = (err: unknown): string => {
|
|
27
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
28
|
+
return [
|
|
29
|
+
'',
|
|
30
|
+
` ${pc.red(pc.bold('Failed to start slides-dev'))}`,
|
|
31
|
+
'',
|
|
32
|
+
` ${stripResolverPrefix(message)}`,
|
|
33
|
+
'',
|
|
34
|
+
` ${pc.dim(`See ${PKG} --help for usage.`)}`,
|
|
35
|
+
'',
|
|
36
|
+
].join('\n');
|
|
37
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const browserOnly = (name: string): never => {
|
|
2
|
+
throw new Error(`${name} is not available in the slides-dev browser viewer.`);
|
|
3
|
+
};
|
|
4
|
+
export class PptxSlidesRuntime {
|
|
5
|
+
constructor() {
|
|
6
|
+
browserOnly('PptxSlidesRuntime');
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export const DEFAULT_PPTX_FONT_SUBSTITUTION: Readonly<Record<string, string>> = Object.freeze({});
|
|
10
|
+
|
|
11
|
+
export const translateOpsToPptx = (): never => browserOnly('translateOpsToPptx');
|
|
12
|
+
export const hexToPptxColor = (): never => browserOnly('hexToPptxColor');
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve as pathResolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import react from '@vitejs/plugin-react';
|
|
6
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
7
|
+
import { createServer, type ViteDevServer } from 'vite';
|
|
8
|
+
|
|
9
|
+
const VIRTUAL_ID = 'virtual:slides-dev/template';
|
|
10
|
+
const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_ID}`;
|
|
11
|
+
|
|
12
|
+
export type StartDevServerOptions = {
|
|
13
|
+
readonly cwd: string;
|
|
14
|
+
readonly host?: string;
|
|
15
|
+
readonly port?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type DevServerHandle = {
|
|
19
|
+
readonly server: ViteDevServer;
|
|
20
|
+
readonly url: string;
|
|
21
|
+
readonly templatePath: string;
|
|
22
|
+
readonly startedInMs: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const CLIENT_ROOT = pathResolve(here, 'client');
|
|
27
|
+
const SERVER_ONLY_STUB = pathResolve(here, 'server-only-stub.ts');
|
|
28
|
+
|
|
29
|
+
const SERVER_ONLY_RE = /\/(core|react-pptx)\/(src|dist)\/(pptx-runtime|op-translator-pptx)\.[tj]s$/;
|
|
30
|
+
|
|
31
|
+
export const startDevServer = async (options: StartDevServerOptions): Promise<DevServerHandle> => {
|
|
32
|
+
const startedAt = performance.now();
|
|
33
|
+
const templatePath = await resolveTemplatePath(options);
|
|
34
|
+
const templateExportName = pickTemplateExportName(templatePath);
|
|
35
|
+
|
|
36
|
+
const server = await createServer({
|
|
37
|
+
root: CLIENT_ROOT,
|
|
38
|
+
server: {
|
|
39
|
+
host: options.host ?? 'localhost',
|
|
40
|
+
port: options.port ?? 5173,
|
|
41
|
+
fs: { allow: [CLIENT_ROOT, options.cwd, pathResolve(options.cwd, '../..')] },
|
|
42
|
+
},
|
|
43
|
+
ssr: {
|
|
44
|
+
noExternal: ['pptxgenjs'],
|
|
45
|
+
},
|
|
46
|
+
plugins: [
|
|
47
|
+
react(),
|
|
48
|
+
tailwindcss(),
|
|
49
|
+
{
|
|
50
|
+
name: 'slides-dev:export',
|
|
51
|
+
configureServer(viteServer) {
|
|
52
|
+
viteServer.middlewares.use('/api/export.pptx', async (_req, res) => {
|
|
53
|
+
try {
|
|
54
|
+
const [mod, reconcilerMod, runtimeMod] = await Promise.all([
|
|
55
|
+
viteServer.ssrLoadModule(templatePath),
|
|
56
|
+
viteServer.ssrLoadModule('react-pptx/reconciler'),
|
|
57
|
+
viteServer.ssrLoadModule('react-pptx/pptx-runtime'),
|
|
58
|
+
]);
|
|
59
|
+
const { renderToOps } = reconcilerMod;
|
|
60
|
+
const { PptxSlidesRuntime } = runtimeMod;
|
|
61
|
+
const template = mod[templateExportName];
|
|
62
|
+
if (!template) throw new Error('Template export not found');
|
|
63
|
+
const tree = template.preview ? template.preview() : null;
|
|
64
|
+
if (!tree) throw new Error('Template has no preview()');
|
|
65
|
+
const { ops } = renderToOps({ tree, template, deckId: 'export' });
|
|
66
|
+
const runtime = new PptxSlidesRuntime({});
|
|
67
|
+
const { deckId } = await runtime.createDeckFromMaster(template.name, template.name);
|
|
68
|
+
await runtime.applyOps(deckId, ops);
|
|
69
|
+
const buffer = await runtime.toBuffer(deckId);
|
|
70
|
+
res.setHeader(
|
|
71
|
+
'Content-Type',
|
|
72
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
73
|
+
);
|
|
74
|
+
res.setHeader('Content-Disposition', `attachment; filename="${template.name}.pptx"`);
|
|
75
|
+
res.end(buffer);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
res.statusCode = 500;
|
|
78
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
79
|
+
res.end(err instanceof Error ? err.message : String(err));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
name: 'slides-dev:stub-server-only',
|
|
87
|
+
enforce: 'pre' as const,
|
|
88
|
+
load(id, options) {
|
|
89
|
+
if (options?.ssr) return null;
|
|
90
|
+
const cleanId = id.split('?')[0] ?? '';
|
|
91
|
+
if (SERVER_ONLY_RE.test(cleanId)) {
|
|
92
|
+
return { code: readFileSync(SERVER_ONLY_STUB, 'utf8') };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'slides-dev:virtual-template',
|
|
99
|
+
resolveId(source) {
|
|
100
|
+
if (source === VIRTUAL_ID) return RESOLVED_VIRTUAL_ID;
|
|
101
|
+
return null;
|
|
102
|
+
},
|
|
103
|
+
load(id) {
|
|
104
|
+
if (id !== RESOLVED_VIRTUAL_ID) return null;
|
|
105
|
+
return `export { ${templateExportName} as template } from ${JSON.stringify(templatePath)};`;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
optimizeDeps: {
|
|
110
|
+
include: ['react', 'react-dom', 'react-dom/client'],
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await server.listen();
|
|
115
|
+
const url = server.resolvedUrls?.local[0] ?? `http://localhost:${options.port ?? 5173}/`;
|
|
116
|
+
const startedInMs = Math.round(performance.now() - startedAt);
|
|
117
|
+
return { server, url, templatePath, startedInMs };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const resolveTemplatePath = async (options: StartDevServerOptions): Promise<string> => {
|
|
121
|
+
const pkgPath = pathResolve(options.cwd, 'package.json');
|
|
122
|
+
if (!existsSync(pkgPath)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`slides-dev: ${options.cwd} has no package.json — run from a template package directory.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8')) as { main?: string };
|
|
128
|
+
if (!pkg.main) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`slides-dev: ${pkgPath} has no "main" field — point it at the file that exports your Template.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const templatePath = pathResolve(options.cwd, pkg.main);
|
|
134
|
+
if (!existsSync(templatePath)) {
|
|
135
|
+
throw new Error(`slides-dev: template entry "${templatePath}" not found.`);
|
|
136
|
+
}
|
|
137
|
+
return templatePath;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const pickTemplateExportName = (templatePath: string): string => {
|
|
141
|
+
const patterns: RegExp[] = [
|
|
142
|
+
/export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*defineTemplate\s*\(/,
|
|
143
|
+
/export\s+const\s+([A-Za-z_$][\w$]*)\s*:\s*Template/,
|
|
144
|
+
];
|
|
145
|
+
try {
|
|
146
|
+
const src = readFileSync(templatePath, 'utf8');
|
|
147
|
+
for (const pattern of patterns) {
|
|
148
|
+
const m = src.match(pattern);
|
|
149
|
+
if (m && m[1]) return m[1];
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
/* empty */
|
|
153
|
+
}
|
|
154
|
+
return 'default';
|
|
155
|
+
};
|
package/src/dev/index.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { composeDeck, type ComposeDeckInput, type ComposedDeck } from './compose-deck.js';
|
|
2
|
+
export { SlideCanvas, type SlideCanvasProps } from './slide-canvas.js';
|
|
3
|
+
export { DeckViewer, type DeckViewerProps } from './deck-viewer.js';
|
|
4
|
+
export { deriveAutoPreview } from './auto-examples.js';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { createElement, Fragment } from 'react';
|
|
4
|
+
import { Slide, Box, Text } from '../core/components.js';
|
|
5
|
+
import { CANVAS_16_9 } from '../core/geometry.js';
|
|
6
|
+
import type { Template } from '../core/template.js';
|
|
7
|
+
import { composeDeck } from './compose-deck.js';
|
|
8
|
+
import { SlideCanvas } from './slide-canvas.js';
|
|
9
|
+
|
|
10
|
+
const STUB_TEMPLATE: Template = {
|
|
11
|
+
name: 'stub',
|
|
12
|
+
canvas: CANVAS_16_9,
|
|
13
|
+
fonts: { display: ['Inter'], body: ['Inter'], mono: ['Courier'] },
|
|
14
|
+
colors: {},
|
|
15
|
+
typography: {},
|
|
16
|
+
spacing: {},
|
|
17
|
+
components: {},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const renderHtml = async () => {
|
|
21
|
+
const { deck } = await composeDeck({
|
|
22
|
+
tree: createElement(
|
|
23
|
+
Fragment,
|
|
24
|
+
null,
|
|
25
|
+
createElement(
|
|
26
|
+
Slide,
|
|
27
|
+
null,
|
|
28
|
+
createElement(Box, {
|
|
29
|
+
rect: { x: 0, y: 0, w: 960, h: 540 },
|
|
30
|
+
fill: { kind: 'solid', color: '#000000' },
|
|
31
|
+
}),
|
|
32
|
+
createElement(
|
|
33
|
+
Box,
|
|
34
|
+
{ rect: { x: 20, y: 100, w: 400, h: 80 } },
|
|
35
|
+
createElement(
|
|
36
|
+
Text,
|
|
37
|
+
{ textStyle: { fontFamily: 'Inter', fontSize: 36, foregroundColor: '#ffffff' } },
|
|
38
|
+
'Hello world',
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
template: STUB_TEMPLATE,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const slide = deck.slides.get(deck.slideOrder[0] as string);
|
|
47
|
+
if (!slide) throw new Error('no slide');
|
|
48
|
+
return renderToStaticMarkup(createElement(SlideCanvas, { slide, deck, canvas: CANVAS_16_9 }));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe('<SlideCanvas>', () => {
|
|
52
|
+
test('renders shapes at the slide coordinate space', async () => {
|
|
53
|
+
const html = await renderHtml();
|
|
54
|
+
expect(html).toContain('background:#000000');
|
|
55
|
+
expect(html).toContain('left:0px');
|
|
56
|
+
expect(html).toContain('top:0px');
|
|
57
|
+
expect(html).toContain('left:20px');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('emits text content with style spans applied', async () => {
|
|
61
|
+
const html = await renderHtml();
|
|
62
|
+
expect(html).toContain('Hello world');
|
|
63
|
+
expect(html).toContain('color:#ffffff');
|
|
64
|
+
expect(html).toContain('font-size:36px');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { CSSProperties, ReactElement } from 'react';
|
|
2
|
+
import type { Canvas } from '../core/geometry.js';
|
|
3
|
+
import { EMU_PER_POINT } from '../core/geometry.js';
|
|
4
|
+
import type { FakeDeck, FakeShape, FakeSlide } from '../core/fake-runtime.js';
|
|
5
|
+
import type { ParagraphStyle, TextStyle } from '../core/runtime.js';
|
|
6
|
+
|
|
7
|
+
export type SlideCanvasProps = {
|
|
8
|
+
readonly slide: FakeSlide;
|
|
9
|
+
readonly deck: FakeDeck;
|
|
10
|
+
readonly canvas: Canvas;
|
|
11
|
+
readonly background?: string;
|
|
12
|
+
readonly style?: CSSProperties;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const SlideCanvas = ({
|
|
16
|
+
slide,
|
|
17
|
+
deck,
|
|
18
|
+
canvas,
|
|
19
|
+
background = '#ffffff',
|
|
20
|
+
style,
|
|
21
|
+
}: SlideCanvasProps): ReactElement => {
|
|
22
|
+
const rootStyle: CSSProperties = {
|
|
23
|
+
position: 'relative',
|
|
24
|
+
width: `${canvas.w}px`,
|
|
25
|
+
height: `${canvas.h}px`,
|
|
26
|
+
background,
|
|
27
|
+
overflow: 'hidden',
|
|
28
|
+
isolation: 'isolate',
|
|
29
|
+
textAlign: 'left',
|
|
30
|
+
...style,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div data-slide-id={slide.slideId} style={rootStyle}>
|
|
35
|
+
{slide.shapeIds.map((id) => {
|
|
36
|
+
const shape = deck.shapes.get(id);
|
|
37
|
+
if (!shape) return null;
|
|
38
|
+
return <Shape key={id} shape={shape} />;
|
|
39
|
+
})}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const Shape = ({ shape }: { shape: FakeShape }): ReactElement | null => {
|
|
45
|
+
const { x, y, w, h } = emuRectToPt(shape.rect);
|
|
46
|
+
const baseStyle: CSSProperties = {
|
|
47
|
+
position: 'absolute',
|
|
48
|
+
left: `${x}px`,
|
|
49
|
+
top: `${y}px`,
|
|
50
|
+
width: `${w}px`,
|
|
51
|
+
height: `${h}px`,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (shape.imageUrl) {
|
|
55
|
+
return (
|
|
56
|
+
<img
|
|
57
|
+
src={shape.imageUrl}
|
|
58
|
+
alt={shape.altText ?? ''}
|
|
59
|
+
style={{ ...baseStyle, objectFit: 'fill', display: 'block' }}
|
|
60
|
+
data-object-id={shape.objectId}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { fillColor, outlineColor, outlineWeight } = shape.shapeProperties;
|
|
66
|
+
const boxStyle: CSSProperties = {
|
|
67
|
+
...baseStyle,
|
|
68
|
+
...(fillColor ? { background: fillColor } : {}),
|
|
69
|
+
...(outlineColor ? { border: `${outlineWeight ?? 1}px solid ${outlineColor}` } : {}),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (shape.text.length === 0) {
|
|
73
|
+
return <div data-object-id={shape.objectId} style={boxStyle} />;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const paragraphStyle = composeParagraphStyle(shape);
|
|
77
|
+
const segments = composeTextSegments(shape.text, shape.textStyleSpans);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
data-object-id={shape.objectId}
|
|
82
|
+
style={{
|
|
83
|
+
...boxStyle,
|
|
84
|
+
display: 'flex',
|
|
85
|
+
alignItems: 'flex-start',
|
|
86
|
+
whiteSpace: 'pre-wrap',
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<div style={{ width: '100%', ...paragraphStyle }}>
|
|
90
|
+
{segments.map((seg, i) => (
|
|
91
|
+
<span key={i} style={textStyleToCss(seg.style)}>
|
|
92
|
+
{seg.text}
|
|
93
|
+
</span>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const emuRectToPt = (rect: FakeShape['rect']): FakeShape['rect'] => ({
|
|
101
|
+
x: rect.x / EMU_PER_POINT,
|
|
102
|
+
y: rect.y / EMU_PER_POINT,
|
|
103
|
+
w: rect.w / EMU_PER_POINT,
|
|
104
|
+
h: rect.h / EMU_PER_POINT,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
type Segment = { text: string; style: TextStyle };
|
|
108
|
+
|
|
109
|
+
const composeTextSegments = (text: string, spans: FakeShape['textStyleSpans']): Segment[] => {
|
|
110
|
+
if (spans.length === 0) return [{ text, style: {} }];
|
|
111
|
+
|
|
112
|
+
const cuts = new Set<number>([0, text.length]);
|
|
113
|
+
for (const span of spans) {
|
|
114
|
+
cuts.add(Math.max(0, Math.min(span.range.start, text.length)));
|
|
115
|
+
cuts.add(Math.max(0, Math.min(span.range.end, text.length)));
|
|
116
|
+
}
|
|
117
|
+
const ordered = [...cuts].sort((a, b) => a - b);
|
|
118
|
+
|
|
119
|
+
const segments: Segment[] = [];
|
|
120
|
+
for (let i = 0; i < ordered.length - 1; i++) {
|
|
121
|
+
const start = ordered[i] as number;
|
|
122
|
+
const end = ordered[i + 1] as number;
|
|
123
|
+
if (start >= end) continue;
|
|
124
|
+
let style: TextStyle = {};
|
|
125
|
+
for (const span of spans) {
|
|
126
|
+
if (span.range.start <= start && span.range.end >= end) {
|
|
127
|
+
style = { ...style, ...span.style };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
segments.push({ text: text.slice(start, end), style });
|
|
131
|
+
}
|
|
132
|
+
return segments;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const composeParagraphStyle = (shape: FakeShape): CSSProperties => {
|
|
136
|
+
const spans = shape.paragraphStyleSpans;
|
|
137
|
+
if (spans.length === 0) return {};
|
|
138
|
+
const merged: ParagraphStyle = spans.reduce<ParagraphStyle>(
|
|
139
|
+
(acc, s) => ({ ...acc, ...s.style }),
|
|
140
|
+
{},
|
|
141
|
+
);
|
|
142
|
+
return paragraphStyleToCss(merged);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const textStyleToCss = (style: TextStyle): CSSProperties => {
|
|
146
|
+
const css: CSSProperties = {};
|
|
147
|
+
if (style.fontFamily) {
|
|
148
|
+
const family = /\s/.test(style.fontFamily) ? `"${style.fontFamily}"` : style.fontFamily;
|
|
149
|
+
css.fontFamily = `${family}, system-ui, sans-serif`;
|
|
150
|
+
}
|
|
151
|
+
if (style.fontSize !== undefined) css.fontSize = `${style.fontSize}px`;
|
|
152
|
+
if (style.bold) css.fontWeight = 'bold';
|
|
153
|
+
if (style.italic) css.fontStyle = 'italic';
|
|
154
|
+
if (style.underline) css.textDecoration = 'underline';
|
|
155
|
+
if (style.foregroundColor) css.color = style.foregroundColor;
|
|
156
|
+
if (style.backgroundColor) css.background = style.backgroundColor;
|
|
157
|
+
return css;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const paragraphStyleToCss = (style: ParagraphStyle): CSSProperties => {
|
|
161
|
+
const css: CSSProperties = {};
|
|
162
|
+
if (style.alignment) {
|
|
163
|
+
const map = { START: 'left', CENTER: 'center', END: 'right', JUSTIFIED: 'justify' } as const;
|
|
164
|
+
css.textAlign = map[style.alignment];
|
|
165
|
+
}
|
|
166
|
+
if (style.lineSpacing !== undefined) css.lineHeight = style.lineSpacing;
|
|
167
|
+
if (style.spaceAbove !== undefined) css.marginTop = `${style.spaceAbove}px`;
|
|
168
|
+
if (style.spaceBelow !== undefined) css.marginBottom = `${style.spaceBelow}px`;
|
|
169
|
+
return css;
|
|
170
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
@import 'tailwindcss';
|
|
2
|
+
@source './**/*.{ts,tsx}';
|
|
3
|
+
|
|
4
|
+
@theme {
|
|
5
|
+
--color-paper: #ffffff;
|
|
6
|
+
--color-ink: #0b0b0b;
|
|
7
|
+
--color-brand: #ff5500;
|
|
8
|
+
--color-focus: #2276fc;
|
|
9
|
+
|
|
10
|
+
--color-surface: #ffffff;
|
|
11
|
+
--color-surface-muted: #f7f7f7;
|
|
12
|
+
--color-border: rgb(11 11 11 / 0.08);
|
|
13
|
+
--color-border-strong: rgb(11 11 11 / 0.16);
|
|
14
|
+
--color-text-muted: rgb(11 11 11 / 0.6);
|
|
15
|
+
|
|
16
|
+
--font-sans:
|
|
17
|
+
InterVariable, Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
|
18
|
+
sans-serif;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@layer base {
|
|
22
|
+
html,
|
|
23
|
+
body {
|
|
24
|
+
height: 100%;
|
|
25
|
+
}
|
|
26
|
+
body {
|
|
27
|
+
margin: 0;
|
|
28
|
+
background: var(--color-surface-muted);
|
|
29
|
+
color: var(--color-ink);
|
|
30
|
+
font-family: var(--font-sans);
|
|
31
|
+
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
|
32
|
+
-webkit-font-smoothing: antialiased;
|
|
33
|
+
}
|
|
34
|
+
#root {
|
|
35
|
+
height: 100%;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
|
2
|
+
import { cn } from '../lib/cn.js';
|
|
3
|
+
|
|
4
|
+
export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> & {
|
|
5
|
+
readonly icon: ReactNode;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
readonly selected?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
11
|
+
({ icon, label, selected = false, className, ...rest }, ref) => (
|
|
12
|
+
<button
|
|
13
|
+
ref={ref}
|
|
14
|
+
type="button"
|
|
15
|
+
aria-label={label}
|
|
16
|
+
aria-pressed={selected || undefined}
|
|
17
|
+
data-selected={selected || undefined}
|
|
18
|
+
className={cn(
|
|
19
|
+
'relative inline-flex size-8 shrink-0 items-center justify-center rounded-full text-ink/70',
|
|
20
|
+
'transition-colors hover:bg-ink/5 hover:text-ink',
|
|
21
|
+
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus',
|
|
22
|
+
'data-selected:bg-ink/10 data-selected:text-ink data-selected:hover:bg-ink/15',
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
{...rest}
|
|
26
|
+
>
|
|
27
|
+
{icon}
|
|
28
|
+
</button>
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
IconButton.displayName = 'IconButton';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { cn } from '../lib/cn.js';
|
|
3
|
+
|
|
4
|
+
export const Kbd = ({
|
|
5
|
+
children,
|
|
6
|
+
className,
|
|
7
|
+
}: {
|
|
8
|
+
readonly children: ReactNode;
|
|
9
|
+
readonly className?: string;
|
|
10
|
+
}) => (
|
|
11
|
+
<kbd
|
|
12
|
+
className={cn(
|
|
13
|
+
'inline-flex h-5 min-w-5 items-center justify-center rounded border border-border bg-surface-muted px-1.5',
|
|
14
|
+
'font-mono text-xs font-medium text-text-muted',
|
|
15
|
+
className,
|
|
16
|
+
)}
|
|
17
|
+
>
|
|
18
|
+
{children}
|
|
19
|
+
</kbd>
|
|
20
|
+
);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
|
2
|
+
import { cn } from '../lib/cn.js';
|
|
3
|
+
|
|
4
|
+
export type TextButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
5
|
+
readonly icon?: ReactNode;
|
|
6
|
+
readonly selected?: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const TextButton = forwardRef<HTMLButtonElement, TextButtonProps>(
|
|
10
|
+
({ icon, selected = false, className, children, ...rest }, ref) => (
|
|
11
|
+
<button
|
|
12
|
+
ref={ref}
|
|
13
|
+
type="button"
|
|
14
|
+
aria-pressed={selected || undefined}
|
|
15
|
+
data-selected={selected || undefined}
|
|
16
|
+
className={cn(
|
|
17
|
+
'inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full px-3 text-xs font-medium text-ink/80',
|
|
18
|
+
'transition-colors hover:bg-ink/5 hover:text-ink',
|
|
19
|
+
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus',
|
|
20
|
+
'data-selected:bg-ink/10 data-selected:text-ink data-selected:hover:bg-ink/15',
|
|
21
|
+
icon ? 'pl-2.5' : '',
|
|
22
|
+
className,
|
|
23
|
+
)}
|
|
24
|
+
{...rest}
|
|
25
|
+
>
|
|
26
|
+
{icon ? <span className="shrink-0 text-ink/60">{icon}</span> : null}
|
|
27
|
+
{children}
|
|
28
|
+
</button>
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
TextButton.displayName = 'TextButton';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { parseUrlState, serializeUrlState } from './url-state.js';
|
|
3
|
+
|
|
4
|
+
describe('url-state', () => {
|
|
5
|
+
test('empty hash → defaults', () => {
|
|
6
|
+
expect(parseUrlState('')).toEqual({ slide: 0 });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('round-trips slide', () => {
|
|
10
|
+
expect(parseUrlState(serializeUrlState({ slide: 3 }))).toEqual({ slide: 3 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('omits default slide', () => {
|
|
14
|
+
expect(serializeUrlState({ slide: 0 })).toBe('');
|
|
15
|
+
expect(serializeUrlState({ slide: 2 })).toBe('#slide=2');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('malformed slide → 0', () => {
|
|
19
|
+
expect(parseUrlState('#slide=abc').slide).toBe(0);
|
|
20
|
+
expect(parseUrlState('#slide=-1').slide).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type ViewerUrlState = {
|
|
2
|
+
readonly slide: number;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_URL_STATE: ViewerUrlState = { slide: 0 };
|
|
6
|
+
|
|
7
|
+
export const parseUrlState = (hash: string): ViewerUrlState => {
|
|
8
|
+
const params = new URLSearchParams(hash.replace(/^#/, ''));
|
|
9
|
+
const raw = Number.parseInt(params.get('slide') ?? '', 10);
|
|
10
|
+
const slide = Number.isFinite(raw) && raw >= 0 ? raw : 0;
|
|
11
|
+
return { slide };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const serializeUrlState = (state: ViewerUrlState): string => {
|
|
15
|
+
if (state.slide === 0) return '';
|
|
16
|
+
return `#slide=${state.slide}`;
|
|
17
|
+
};
|