@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,603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reconciler — walks a React element tree and emits a sequence of
|
|
3
|
+
* `SlideOp`s plus a generation manifest.
|
|
4
|
+
*
|
|
5
|
+
* # Why a custom walker (not `react-reconciler`)
|
|
6
|
+
*
|
|
7
|
+
* The natural reach is for the `react-reconciler` package — it's what
|
|
8
|
+
* react-three-fiber, react-pdf, and Ink use. But the constraints of this
|
|
9
|
+
* surface make a custom walker a strictly simpler fit:
|
|
10
|
+
*
|
|
11
|
+
* 1. **No state.** Template components are pure. No hooks, no effects, no refs.
|
|
12
|
+
* The whole reason to use `react-reconciler` is the scheduler + commit
|
|
13
|
+
* phases that make state-driven re-renders work. We don't have state.
|
|
14
|
+
* 2. **Write-once.** A `renderToOps()` call produces one batch and exits.
|
|
15
|
+
* There's no incremental update model to honor.
|
|
16
|
+
* 3. **No host node mutation.** Output is an immutable op list. The 30+ host-
|
|
17
|
+
* config methods `react-reconciler` requires (`appendChild`,
|
|
18
|
+
* `removeChild`, `commitMount`, etc.) all collapse to "push to an array."
|
|
19
|
+
* 4. **Tree shape is shallow and known.** `Slide` → `Box` | `Image` →
|
|
20
|
+
* text runs. The walker can encode this structure directly with much
|
|
21
|
+
* better error messages than a generic reconciler would surface.
|
|
22
|
+
*
|
|
23
|
+
* If we ever add state-driven re-renders (probably never; the docs commit to
|
|
24
|
+
* forward-only generation), we can swap in `react-reconciler` behind the same
|
|
25
|
+
* `renderToOps` signature. The signature is the contract.
|
|
26
|
+
*
|
|
27
|
+
* # What the walker does
|
|
28
|
+
*
|
|
29
|
+
* 1. Resolve function components by invoking them with their props (no hooks).
|
|
30
|
+
* 2. Flatten fragments and arrays.
|
|
31
|
+
* 3. For each `<Slide>`, emit `createSlide`, then walk its children for
|
|
32
|
+
* `<Box>`s and `<Image>`s.
|
|
33
|
+
* 4. For each `<Box>`, emit `createShape` (TEXT_BOX), then — if `fill` is
|
|
34
|
+
* set — `updateShapeProperties` carrying the resolved fill color, then
|
|
35
|
+
* collect its text runs into a single `insertText` + per-run
|
|
36
|
+
* `updateTextStyle` calls.
|
|
37
|
+
* 5. For each `<Image>`, emit `createImage` with the resolved URL and (if
|
|
38
|
+
* no slotId is set) the user-supplied alt-text. Record the artifact
|
|
39
|
+
* reference in the manifest, deduped by identifier.
|
|
40
|
+
*
|
|
41
|
+
* Errors throw with paths like "Slide[0] > Box[1] > unexpected child" so the
|
|
42
|
+
* brand-component author has enough information to localize the problem.
|
|
43
|
+
*
|
|
44
|
+
* # Determinism
|
|
45
|
+
*
|
|
46
|
+
* Object IDs are generated from a counter seeded per call. Same input tree →
|
|
47
|
+
* same output ops → same snapshot. This is what makes layer-2 golden tests in
|
|
48
|
+
* `docs/testing-strategy.md` work.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import {
|
|
52
|
+
Children,
|
|
53
|
+
Fragment,
|
|
54
|
+
isValidElement,
|
|
55
|
+
type FunctionComponent,
|
|
56
|
+
type ReactElement,
|
|
57
|
+
type ReactNode,
|
|
58
|
+
} from 'react';
|
|
59
|
+
import {
|
|
60
|
+
Box,
|
|
61
|
+
Color,
|
|
62
|
+
Text,
|
|
63
|
+
Image,
|
|
64
|
+
Slide,
|
|
65
|
+
isPrimitive,
|
|
66
|
+
type BoxFill,
|
|
67
|
+
type BoxProps,
|
|
68
|
+
type ColorProps,
|
|
69
|
+
type TextProps,
|
|
70
|
+
type ImageProps,
|
|
71
|
+
type SlideProps,
|
|
72
|
+
} from './components.js';
|
|
73
|
+
import { isFontRole, resolveFontRole } from './font-resolver.js';
|
|
74
|
+
import { ptToEmu, type Rect } from './geometry.js';
|
|
75
|
+
import type { ArtifactRef, GenerationManifest, ReconcileResult, SlotId } from './manifest.js';
|
|
76
|
+
import type { Template } from './template.js';
|
|
77
|
+
import type { EmuRect, ShapeProperties, SlideOp, TextRange, TextStyle } from './runtime.js';
|
|
78
|
+
|
|
79
|
+
/** Inputs to a single render. */
|
|
80
|
+
export interface RenderToOpsInput {
|
|
81
|
+
/** The React element tree. Top-level should be a `<Slide>` or a fragment of `<Slide>`s. */
|
|
82
|
+
readonly tree: ReactNode;
|
|
83
|
+
/** The template whose tokens are in scope. */
|
|
84
|
+
readonly template: Template;
|
|
85
|
+
/**
|
|
86
|
+
* Target deck ID (re-fill case) or `null` for a new deck.
|
|
87
|
+
*
|
|
88
|
+
* Ops emission doesn't differ between the two today, but the manifest
|
|
89
|
+
* records it, and downstream runtime adapters may use it (e.g., to skip
|
|
90
|
+
* `createSlide` if the deck is being re-filled rather than rebuilt).
|
|
91
|
+
*/
|
|
92
|
+
readonly deckId: string | null;
|
|
93
|
+
/**
|
|
94
|
+
* Template artifacts the deck references *in addition to* any artifacts the
|
|
95
|
+
* reconciler itself records by walking `<Image>` primitives. Provided by
|
|
96
|
+
* brand-component code that resolved textures/logos/etc. before invoking
|
|
97
|
+
* the reconciler. The reconciler merges these with the artifacts it
|
|
98
|
+
* discovers, deduping by identifier (last-wins on duplicates within either
|
|
99
|
+
* source).
|
|
100
|
+
*/
|
|
101
|
+
readonly artifacts?: readonly ArtifactRef[];
|
|
102
|
+
/**
|
|
103
|
+
* Override for the manifest's `generatedAt` timestamp. Tests pin this to
|
|
104
|
+
* keep snapshots deterministic.
|
|
105
|
+
*/
|
|
106
|
+
readonly now?: () => string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Internal mutable state carried through the walk. */
|
|
110
|
+
interface WalkContext {
|
|
111
|
+
readonly template: Template;
|
|
112
|
+
readonly ops: SlideOp[];
|
|
113
|
+
readonly slots: Map<SlotId, string>;
|
|
114
|
+
/**
|
|
115
|
+
* Template artifacts discovered while walking, keyed by identifier so a single
|
|
116
|
+
* texture or logo referenced from multiple slides only appears once in the
|
|
117
|
+
* manifest. Insertion order is preserved by the underlying Map.
|
|
118
|
+
*/
|
|
119
|
+
readonly artifacts: Map<string, ArtifactRef>;
|
|
120
|
+
/** Counter for generating unique slide / shape / image IDs. */
|
|
121
|
+
idCounter: number;
|
|
122
|
+
/** The slide currently being walked, for error context. */
|
|
123
|
+
currentSlideIndex: number;
|
|
124
|
+
/** The box being walked (if any), for error context. */
|
|
125
|
+
currentBoxIndex: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Render a React tree to ops + manifest.
|
|
130
|
+
*
|
|
131
|
+
* The function is synchronous: function components are invoked inline, and the
|
|
132
|
+
* walk is purely structural. There's no scheduling, no async boundaries.
|
|
133
|
+
*/
|
|
134
|
+
export const renderToOps = (input: RenderToOpsInput): ReconcileResult => {
|
|
135
|
+
const ctx: WalkContext = {
|
|
136
|
+
template: input.template,
|
|
137
|
+
ops: [],
|
|
138
|
+
slots: new Map(),
|
|
139
|
+
artifacts: new Map(),
|
|
140
|
+
idCounter: 0,
|
|
141
|
+
currentSlideIndex: -1,
|
|
142
|
+
currentBoxIndex: -1,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Seed with caller-supplied artifacts first so reconciler-discovered
|
|
146
|
+
// artifacts on the same identifier overwrite (more recent / context-bound
|
|
147
|
+
// resolution wins). Insertion order is preserved.
|
|
148
|
+
if (input.artifacts !== undefined) {
|
|
149
|
+
for (const artifact of input.artifacts) {
|
|
150
|
+
ctx.artifacts.set(artifact.identifier, artifact);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const slides = collectSlides(input.tree, ctx);
|
|
155
|
+
slides.forEach((slide, index) => {
|
|
156
|
+
ctx.currentSlideIndex = index;
|
|
157
|
+
ctx.currentBoxIndex = -1;
|
|
158
|
+
walkSlide(slide, index, ctx);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const manifest: GenerationManifest = {
|
|
162
|
+
manifestVersion: '1',
|
|
163
|
+
generatedBy: 'react-pptx',
|
|
164
|
+
generatedAt: (input.now ?? defaultNow)(),
|
|
165
|
+
templateName: input.template.name,
|
|
166
|
+
deckId: input.deckId,
|
|
167
|
+
slots: Object.fromEntries(ctx.slots) as Record<SlotId, string>,
|
|
168
|
+
artifacts: [...ctx.artifacts.values()],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return { ops: ctx.ops, manifest };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const defaultNow = (): string => new Date().toISOString();
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Tree walk
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve a node to its concrete element form, invoking function components.
|
|
182
|
+
*
|
|
183
|
+
* Returns:
|
|
184
|
+
* - An array of resolved primitives if the node is a fragment / array / function
|
|
185
|
+
* component that returned multiple children.
|
|
186
|
+
* - A single primitive element if the node resolves to one.
|
|
187
|
+
* - Empty array for falsy / boolean / null nodes (React's "render nothing").
|
|
188
|
+
*
|
|
189
|
+
* This is what makes `<Cover/>` work: `Cover` is a function component that
|
|
190
|
+
* returns `<Slide>...</Slide>`, so resolving it yields the underlying Slide.
|
|
191
|
+
*/
|
|
192
|
+
const resolveNode = (node: ReactNode, ctx: WalkContext): ReactElement[] => {
|
|
193
|
+
if (node === null || node === undefined || node === false || node === true) {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
if (typeof node === 'string' || typeof node === 'number') {
|
|
197
|
+
throw new ReconcilerError(
|
|
198
|
+
`Unexpected text node "${node}" at top level — text must live inside a <Box>.`,
|
|
199
|
+
ctx,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (Array.isArray(node)) {
|
|
203
|
+
return node.flatMap((child) => resolveNode(child, ctx));
|
|
204
|
+
}
|
|
205
|
+
if (!isValidElement(node)) {
|
|
206
|
+
throw new ReconcilerError(`Unsupported child of type ${typeof node}.`, ctx);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// React.Fragment: descend into children.
|
|
210
|
+
if (node.type === Fragment) {
|
|
211
|
+
const props = node.props as { children?: ReactNode };
|
|
212
|
+
return resolveNode(props.children, ctx);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Primitive host element: pass through.
|
|
216
|
+
if (isPrimitive(node.type)) {
|
|
217
|
+
return [node];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Function component: invoke with its props (no hooks; brand components are pure).
|
|
221
|
+
if (typeof node.type === 'function') {
|
|
222
|
+
const Component = node.type as FunctionComponent<unknown>;
|
|
223
|
+
const result = invokeComponent(Component, node.props, ctx);
|
|
224
|
+
return resolveNode(result, ctx);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Class components, intrinsic strings, etc.: not supported.
|
|
228
|
+
const typeName = describeType(node.type);
|
|
229
|
+
throw new ReconcilerError(`Unsupported element type ${typeName}.`, ctx);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const collectSlides = (tree: ReactNode, ctx: WalkContext): ReactElement[] => {
|
|
233
|
+
const resolved = resolveNode(tree, ctx);
|
|
234
|
+
for (const el of resolved) {
|
|
235
|
+
if (el.type !== Slide) {
|
|
236
|
+
throw new ReconcilerError(
|
|
237
|
+
`Top-level children must be <Slide> elements; got <${describeType(el.type)}>.`,
|
|
238
|
+
ctx,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return resolved;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const walkSlide = (slide: ReactElement, index: number, ctx: WalkContext): void => {
|
|
246
|
+
const props = slide.props as SlideProps;
|
|
247
|
+
const slideId = makeId('slide', ctx);
|
|
248
|
+
ctx.ops.push({
|
|
249
|
+
type: 'createSlide',
|
|
250
|
+
slideId,
|
|
251
|
+
insertAt: index,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const children = resolveNode(props.children, ctx);
|
|
255
|
+
children.forEach((child, childIndex) => {
|
|
256
|
+
ctx.currentBoxIndex = childIndex;
|
|
257
|
+
if (child.type === Box) {
|
|
258
|
+
walkBox(child, slideId, ctx);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (child.type === Image) {
|
|
262
|
+
walkImage(child, slideId, ctx);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
throw new ReconcilerError(
|
|
266
|
+
`<Slide> children must be <Box> or <Image> elements; got <${describeType(child.type)}>.`,
|
|
267
|
+
ctx,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const walkBox = (box: ReactElement, slideId: string, ctx: WalkContext): void => {
|
|
273
|
+
const props = box.props as BoxProps;
|
|
274
|
+
const shapeId = makeId('shape', ctx);
|
|
275
|
+
ctx.ops.push({
|
|
276
|
+
type: 'createShape',
|
|
277
|
+
slideId,
|
|
278
|
+
shapeId,
|
|
279
|
+
shape: 'TEXT_BOX',
|
|
280
|
+
rect: rectToEmu(props.rect),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Fill applies to the shape itself, before any text. Order matters:
|
|
284
|
+
// createShape → updateShapeProperties → insertText → updateTextStyle
|
|
285
|
+
// This way an empty Box with a fill is a valid full-bleed colored
|
|
286
|
+
// background, and a Box with both fill and text gets the fill behind the
|
|
287
|
+
// text without any z-order ambiguity.
|
|
288
|
+
if (props.fill !== undefined) {
|
|
289
|
+
const properties = boxFillToShapeProperties(props.fill);
|
|
290
|
+
ctx.ops.push({
|
|
291
|
+
type: 'updateShapeProperties',
|
|
292
|
+
objectId: shapeId,
|
|
293
|
+
properties,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (props.slotId !== undefined) {
|
|
298
|
+
if (ctx.slots.has(props.slotId)) {
|
|
299
|
+
throw new ReconcilerError(
|
|
300
|
+
`Duplicate slotId "${props.slotId}" — slot IDs must be unique within a deck.`,
|
|
301
|
+
ctx,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
ctx.slots.set(props.slotId, shapeId);
|
|
305
|
+
// Slot identity is encoded into shape alt-text per generation-model.md.
|
|
306
|
+
// We re-use `updateShapeProperties` semantics by piggybacking through a
|
|
307
|
+
// dedicated op-shape — here, alt-text is set via a property update at the
|
|
308
|
+
// runtime layer. The op carries no fillColor/outlineColor, signalling
|
|
309
|
+
// "alt text only." If alt-text ever deserves its own op type, this is the
|
|
310
|
+
// keeps the SlideOp union narrow.
|
|
311
|
+
//
|
|
312
|
+
// NOTE: `updateShapeProperties` doesn't currently carry alt-text; the
|
|
313
|
+
// runtime adapter resolves slot alt-text from the manifest's `slots` map,
|
|
314
|
+
// not from an op. The manifest is the source of truth for slot identity.
|
|
315
|
+
// This is intentional: ops express "what to do," manifest expresses "what
|
|
316
|
+
// is true after." Encoding alt-text into ops would duplicate the manifest
|
|
317
|
+
// entry.
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Default styles to apply across the whole shape after text is inserted.
|
|
321
|
+
const text = collectTextRuns(props.children, ctx);
|
|
322
|
+
if (text.runs.length === 0) {
|
|
323
|
+
// Empty Box is permitted (colored rectangle, full-bleed background).
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
ctx.ops.push({ type: 'insertText', objectId: shapeId, text: text.full });
|
|
328
|
+
|
|
329
|
+
if (props.textStyle !== undefined && Object.keys(props.textStyle).length > 0) {
|
|
330
|
+
ctx.ops.push({
|
|
331
|
+
type: 'updateTextStyle',
|
|
332
|
+
objectId: shapeId,
|
|
333
|
+
range: { start: 0, end: text.full.length },
|
|
334
|
+
style: resolveTextStyleFonts(props.textStyle, ctx.template, ctx),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (props.paragraphStyle !== undefined && Object.keys(props.paragraphStyle).length > 0) {
|
|
339
|
+
ctx.ops.push({
|
|
340
|
+
type: 'updateParagraphStyle',
|
|
341
|
+
objectId: shapeId,
|
|
342
|
+
range: { start: 0, end: text.full.length },
|
|
343
|
+
style: props.paragraphStyle,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
for (const run of text.runs) {
|
|
348
|
+
if (Object.keys(run.style).length === 0) continue;
|
|
349
|
+
ctx.ops.push({
|
|
350
|
+
type: 'updateTextStyle',
|
|
351
|
+
objectId: shapeId,
|
|
352
|
+
range: run.range,
|
|
353
|
+
style: resolveTextStyleFonts(run.style, ctx.template, ctx),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Walk an `<Image>` element: emit a `createImage` op and record the artifact.
|
|
360
|
+
*
|
|
361
|
+
* Slot/altText interaction: the reconciler emits `createImage` with the
|
|
362
|
+
* user-supplied `altText` (if any) — that's what lands in the FakeRuntime's
|
|
363
|
+
* in-memory model and what the live runtime's translator will set as the
|
|
364
|
+
* page-element description initially. If a `slotId` is also set, the
|
|
365
|
+
* runtime adapter's slot-stamping post-pass
|
|
366
|
+
* (`op-translator.slotRegistryToAltTextRequests`) overwrites the live
|
|
367
|
+
* page-element alt-text with the slot tag, because slot identity is required
|
|
368
|
+
* for re-fill and wins. The op stream still carries the user altText, so the
|
|
369
|
+
* substrate doesn't lose it.
|
|
370
|
+
*/
|
|
371
|
+
const walkImage = (image: ReactElement, slideId: string, ctx: WalkContext): void => {
|
|
372
|
+
const props = image.props as ImageProps;
|
|
373
|
+
const imageId = makeId('image', ctx);
|
|
374
|
+
ctx.ops.push({
|
|
375
|
+
type: 'createImage',
|
|
376
|
+
slideId,
|
|
377
|
+
imageId,
|
|
378
|
+
url: props.image.url,
|
|
379
|
+
rect: rectToEmu(props.rect),
|
|
380
|
+
...(props.altText !== undefined ? { altText: props.altText } : {}),
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Dedup by identifier: a logo or texture referenced from many slides should
|
|
384
|
+
// appear in manifest.artifacts exactly once. Last-wins on conflicts (later
|
|
385
|
+
// resolution overrides earlier) — typically a no-op since the brand
|
|
386
|
+
// resolver is deterministic per identifier.
|
|
387
|
+
ctx.artifacts.set(props.image.artifact.identifier, props.image.artifact);
|
|
388
|
+
|
|
389
|
+
if (props.slotId !== undefined) {
|
|
390
|
+
if (ctx.slots.has(props.slotId)) {
|
|
391
|
+
throw new ReconcilerError(
|
|
392
|
+
`Duplicate slotId "${props.slotId}" — slot IDs must be unique within a deck.`,
|
|
393
|
+
ctx,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
ctx.slots.set(props.slotId, imageId);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Resolve a `BoxFill` discriminated union to the shape-properties subset the
|
|
402
|
+
* reconciler emits via `updateShapeProperties`.
|
|
403
|
+
*
|
|
404
|
+
* The discriminant (`kind`) is exhaustively switched so adding a new fill
|
|
405
|
+
* variant in `components.ts` (e.g., `'texture'`) is a compile-time prompt to
|
|
406
|
+
* extend this resolver as well.
|
|
407
|
+
*/
|
|
408
|
+
const boxFillToShapeProperties = (fill: BoxFill): ShapeProperties => {
|
|
409
|
+
switch (fill.kind) {
|
|
410
|
+
case 'solid':
|
|
411
|
+
return { fillColor: fill.color };
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
// Text run collection
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
interface CollectedText {
|
|
420
|
+
/** The full concatenated text for the box. */
|
|
421
|
+
full: string;
|
|
422
|
+
/** Per-run style spans, in document order. */
|
|
423
|
+
runs: Array<{ range: TextRange; style: TextStyle }>;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Recursively collect text runs from a Box's children.
|
|
428
|
+
*
|
|
429
|
+
* Rules:
|
|
430
|
+
* - Strings/numbers contribute raw text with no style.
|
|
431
|
+
* - `<Text>` nests its own style and contributes its children's text.
|
|
432
|
+
* - `<Color>` is sugar for `<Text textStyle={{ foregroundColor }}>`.
|
|
433
|
+
* - Nested function components are resolved (so `<Bullet>foo</Bullet>` works
|
|
434
|
+
* if `Bullet` is a brand component returning `<Text>foo</Text>`).
|
|
435
|
+
* - Anything else is an error.
|
|
436
|
+
*/
|
|
437
|
+
const collectTextRuns = (node: ReactNode, ctx: WalkContext): CollectedText => {
|
|
438
|
+
const acc: CollectedText = { full: '', runs: [] };
|
|
439
|
+
const append = (text: string, style: TextStyle): void => {
|
|
440
|
+
if (text.length === 0) return;
|
|
441
|
+
const start = acc.full.length;
|
|
442
|
+
acc.full += text;
|
|
443
|
+
acc.runs.push({ range: { start, end: start + text.length }, style });
|
|
444
|
+
};
|
|
445
|
+
walkText(node, {}, append, ctx);
|
|
446
|
+
return acc;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const walkText = (
|
|
450
|
+
node: ReactNode,
|
|
451
|
+
inheritedStyle: TextStyle,
|
|
452
|
+
append: (text: string, style: TextStyle) => void,
|
|
453
|
+
ctx: WalkContext,
|
|
454
|
+
): void => {
|
|
455
|
+
if (node === null || node === undefined || node === false || node === true) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (typeof node === 'string') {
|
|
459
|
+
append(node, inheritedStyle);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (typeof node === 'number') {
|
|
463
|
+
append(String(node), inheritedStyle);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (Array.isArray(node)) {
|
|
467
|
+
Children.forEach(node, (child) => walkText(child, inheritedStyle, append, ctx));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (!isValidElement(node)) {
|
|
471
|
+
throw new ReconcilerError(`Unsupported text child of type ${typeof node}.`, ctx);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (node.type === Fragment) {
|
|
475
|
+
const props = node.props as { children?: ReactNode };
|
|
476
|
+
walkText(props.children, inheritedStyle, append, ctx);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (node.type === Text) {
|
|
481
|
+
const props = node.props as TextProps;
|
|
482
|
+
const merged: TextStyle = { ...inheritedStyle, ...(props.textStyle ?? {}) };
|
|
483
|
+
walkText(props.children, merged, append, ctx);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (node.type === Color) {
|
|
488
|
+
const props = node.props as ColorProps;
|
|
489
|
+
const merged: TextStyle = { ...inheritedStyle, foregroundColor: props.color };
|
|
490
|
+
walkText(props.children, merged, append, ctx);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (node.type === Slide || node.type === Box || node.type === Image) {
|
|
495
|
+
throw new ReconcilerError(`<${describeType(node.type)}> cannot appear inside a <Box>.`, ctx);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (typeof node.type === 'function') {
|
|
499
|
+
const Component = node.type as FunctionComponent<unknown>;
|
|
500
|
+
const result = invokeComponent(Component, node.props, ctx);
|
|
501
|
+
walkText(result, inheritedStyle, append, ctx);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
throw new ReconcilerError(`Unsupported text element <${describeType(node.type)}>.`, ctx);
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// Helpers
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Resolve any role-string `fontFamily` in a `TextStyle` to a literal family
|
|
514
|
+
* name using the brand's font stack. Returns the same object reference if the
|
|
515
|
+
* style has no role-string `fontFamily` (the common case for literal-family
|
|
516
|
+
* passes — preserves snapshot stability and avoids unnecessary allocation).
|
|
517
|
+
*
|
|
518
|
+
* Resolution semantics (no backend querying):
|
|
519
|
+
* role keyword in → `brand.fonts[role][0]` out (the brand's first preference).
|
|
520
|
+
*
|
|
521
|
+
* If the brand declares an empty stack for the requested role, this throws
|
|
522
|
+
* with a clear "brand defines no <role> font" error. Strict-fail beats
|
|
523
|
+
* silent-Arial when the source of truth is misconfigured.
|
|
524
|
+
*
|
|
525
|
+
* The reconciler invokes this immediately before pushing every text-style op,
|
|
526
|
+
* so downstream translators never see a role keyword as a literal family.
|
|
527
|
+
*/
|
|
528
|
+
const resolveTextStyleFonts = (
|
|
529
|
+
style: TextStyle,
|
|
530
|
+
template: Template,
|
|
531
|
+
ctx: WalkContext,
|
|
532
|
+
): TextStyle => {
|
|
533
|
+
const family = style.fontFamily;
|
|
534
|
+
if (family === undefined) return style;
|
|
535
|
+
if (!isFontRole(family)) return style; // literal family — pass through.
|
|
536
|
+
try {
|
|
537
|
+
return { ...style, fontFamily: resolveFontRole(template.fonts, family) };
|
|
538
|
+
} catch (err) {
|
|
539
|
+
if (err instanceof Error && err.name === 'EmptyFontStackError') {
|
|
540
|
+
throw new ReconcilerError(
|
|
541
|
+
`Template "${template.name}" defines no ${family} font (fonts.${family} is empty). Every role needs at least one entry, with a system-safe last entry (e.g., "Arial").`,
|
|
542
|
+
ctx,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
throw err;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const rectToEmu = (rect: Rect): EmuRect => ({
|
|
550
|
+
x: ptToEmu(rect.x),
|
|
551
|
+
y: ptToEmu(rect.y),
|
|
552
|
+
w: ptToEmu(rect.w),
|
|
553
|
+
h: ptToEmu(rect.h),
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const makeId = (prefix: string, ctx: WalkContext): string => {
|
|
557
|
+
ctx.idCounter += 1;
|
|
558
|
+
return `${prefix}_${ctx.idCounter}`;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Invoke a function component with its props and return its rendered children.
|
|
563
|
+
*
|
|
564
|
+
* Async components are not supported. React 19 widened the function-component
|
|
565
|
+
* return type to `ReactNode | Promise<ReactNode>`; we narrow back to
|
|
566
|
+
* `ReactNode` here and throw if a Promise is returned.
|
|
567
|
+
*/
|
|
568
|
+
const invokeComponent = (
|
|
569
|
+
Component: FunctionComponent<unknown>,
|
|
570
|
+
props: unknown,
|
|
571
|
+
ctx: WalkContext,
|
|
572
|
+
): ReactNode => {
|
|
573
|
+
const result = Component(props) as ReactNode | Promise<ReactNode>;
|
|
574
|
+
if (typeof result === 'object' && result !== null && 'then' in result) {
|
|
575
|
+
throw new ReconcilerError(
|
|
576
|
+
`<${describeType(Component)}> returned a Promise. Template components must be synchronous (no async, no Suspense).`,
|
|
577
|
+
ctx,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
return result;
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const describeType = (type: unknown): string => {
|
|
584
|
+
if (typeof type === 'string') return type;
|
|
585
|
+
if (typeof type === 'function') {
|
|
586
|
+
const named = type as { displayName?: string; name?: string };
|
|
587
|
+
return named.displayName ?? named.name ?? 'anonymous';
|
|
588
|
+
}
|
|
589
|
+
if (type === Fragment) return 'Fragment';
|
|
590
|
+
return String(type);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/** Error thrown by the reconciler with location info pre-formatted. */
|
|
594
|
+
export class ReconcilerError extends Error {
|
|
595
|
+
constructor(message: string, ctx: Pick<WalkContext, 'currentSlideIndex' | 'currentBoxIndex'>) {
|
|
596
|
+
const path: string[] = [];
|
|
597
|
+
if (ctx.currentSlideIndex >= 0) path.push(`Slide[${ctx.currentSlideIndex}]`);
|
|
598
|
+
if (ctx.currentBoxIndex >= 0) path.push(`Box[${ctx.currentBoxIndex}]`);
|
|
599
|
+
const prefix = path.length > 0 ? `${path.join(' > ')}: ` : '';
|
|
600
|
+
super(`${prefix}${message}`);
|
|
601
|
+
this.name = 'ReconcilerError';
|
|
602
|
+
}
|
|
603
|
+
}
|