promptslide 0.3.17 → 0.3.19
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/CHANGELOG.md +14 -0
- package/README.md +76 -0
- package/dist/index.d.ts +29 -4
- package/dist/index.js +1427 -359
- package/dist/index.js.map +1 -1
- package/package.json +31 -31
- package/src/commands/build.mjs +4 -1
- package/src/commands/clone.mjs +5 -2
- package/src/commands/create.mjs +45 -14
- package/src/commands/pull.mjs +6 -3
- package/src/commands/studio.mjs +4 -3
- package/src/core/annotations/annotation-panel.tsx +18 -12
- package/src/core/index.ts +11 -5
- package/src/core/inspector.tsx +155 -0
- package/src/core/loop/config.d.mts +13 -0
- package/src/core/loop/config.mjs +65 -0
- package/src/core/loop/context.tsx +20 -0
- package/src/core/loop/loop-settings.tsx +292 -0
- package/src/core/loop/use-loop-playback.ts +115 -0
- package/src/core/slide-deck.tsx +459 -125
- package/src/core/slide-renderer.tsx +5 -4
- package/src/core/toolbar-action.tsx +38 -0
- package/src/core/types.ts +14 -0
- package/src/core/use-slide-navigation.ts +27 -0
- package/src/core/view-menu.tsx +130 -0
- package/src/editor/README.md +45 -0
- package/src/editor/geometry.ts +76 -0
- package/src/editor/mount.ts +24 -0
- package/src/editor/plugin.mjs +189 -0
- package/src/editor/source.mjs +363 -0
- package/src/editor/studio.css +978 -0
- package/src/editor/studio.tsx +1964 -0
- package/src/utils/deck-config.mjs +6 -0
- package/src/utils/loop-config.mjs +70 -0
- package/src/vite/config.mjs +23 -2
- package/src/vite/plugin.mjs +21 -4
- package/templates/default/src/deck-config.ts +2 -2
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
import ts from "typescript"
|
|
4
|
+
|
|
5
|
+
export const revision = text => createHash("sha256").update(text).digest("hex")
|
|
6
|
+
export function parse(text) {
|
|
7
|
+
const file = ts.createSourceFile(
|
|
8
|
+
"slide.tsx",
|
|
9
|
+
text,
|
|
10
|
+
ts.ScriptTarget.Latest,
|
|
11
|
+
true,
|
|
12
|
+
ts.ScriptKind.TSX
|
|
13
|
+
)
|
|
14
|
+
if (file.parseDiagnostics.length)
|
|
15
|
+
throw new Error(
|
|
16
|
+
"Invalid TSX: " + ts.flattenDiagnosticMessageText(file.parseDiagnostics[0].messageText, "\n")
|
|
17
|
+
)
|
|
18
|
+
return file
|
|
19
|
+
}
|
|
20
|
+
function nodes(file) {
|
|
21
|
+
const result = []
|
|
22
|
+
function visit(node) {
|
|
23
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) result.push(node)
|
|
24
|
+
ts.forEachChild(node, visit)
|
|
25
|
+
}
|
|
26
|
+
visit(file)
|
|
27
|
+
return result
|
|
28
|
+
}
|
|
29
|
+
const opening = node => (ts.isJsxElement(node) ? node.openingElement : node)
|
|
30
|
+
const literal = node => node && (ts.isStringLiteral(node) || ts.isNumericLiteral(node))
|
|
31
|
+
function jsxText(raw) {
|
|
32
|
+
const compiled = ts.transpileModule(`const value = <p>${raw}</p>`, {
|
|
33
|
+
compilerOptions: { jsx: ts.JsxEmit.React }
|
|
34
|
+
}).outputText
|
|
35
|
+
const parsed = ts.createSourceFile("text.js", compiled, ts.ScriptTarget.Latest, true)
|
|
36
|
+
const call = parsed.statements[0]?.declarationList?.declarations[0]?.initializer
|
|
37
|
+
return call?.arguments?.[2]?.text ?? ""
|
|
38
|
+
}
|
|
39
|
+
function textTarget(node) {
|
|
40
|
+
if (!ts.isJsxElement(node)) return null
|
|
41
|
+
if (node.children.every(child => ts.isJsxText(child)))
|
|
42
|
+
return {
|
|
43
|
+
start: node.openingElement.end,
|
|
44
|
+
end: node.closingElement.getStart(),
|
|
45
|
+
value: jsxText(node.children.map(c => c.text).join(""))
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
node.children.length === 1 &&
|
|
49
|
+
ts.isJsxExpression(node.children[0]) &&
|
|
50
|
+
literal(node.children[0].expression)
|
|
51
|
+
) {
|
|
52
|
+
const child = node.children[0]
|
|
53
|
+
return { start: child.getStart(), end: child.end, value: child.expression.text }
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
// Only direct local array.map(item => JSX) with literal object fields is supported.
|
|
58
|
+
// Ambiguous bindings, spreads, computed expressions and imported data remain read-only.
|
|
59
|
+
const checkers = new WeakMap()
|
|
60
|
+
function localDeclaration(identifier) {
|
|
61
|
+
const file = identifier.getSourceFile()
|
|
62
|
+
let checker = checkers.get(file)
|
|
63
|
+
if (!checker) {
|
|
64
|
+
// Bind just this in-memory file. No imports or library files are needed to
|
|
65
|
+
// distinguish local variables from shadowing parameters and other bindings.
|
|
66
|
+
const options = { noLib: true, noResolve: true, jsx: ts.JsxEmit.Preserve }
|
|
67
|
+
const host = ts.createCompilerHost(options)
|
|
68
|
+
host.getSourceFile = name => (name === file.fileName ? file : undefined)
|
|
69
|
+
const program = ts.createProgram([file.fileName], options, host)
|
|
70
|
+
checker = program.getTypeChecker()
|
|
71
|
+
checkers.set(file, checker)
|
|
72
|
+
}
|
|
73
|
+
const declarations = checker.getSymbolAtLocation(identifier)?.declarations
|
|
74
|
+
return declarations?.length === 1 && ts.isVariableDeclaration(declarations[0])
|
|
75
|
+
? declarations[0]
|
|
76
|
+
: null
|
|
77
|
+
}
|
|
78
|
+
function arrayText(node) {
|
|
79
|
+
if (!ts.isJsxElement(node)) return null
|
|
80
|
+
const children = node.children.filter(c => !ts.isJsxText(c) || c.text.trim())
|
|
81
|
+
if (children.length !== 1 || !ts.isJsxExpression(children[0])) return null
|
|
82
|
+
const expr = children[0].expression
|
|
83
|
+
if (!expr || !ts.isPropertyAccessExpression(expr) || !ts.isIdentifier(expr.expression))
|
|
84
|
+
return null
|
|
85
|
+
let callback = node.parent
|
|
86
|
+
while (callback && !ts.isArrowFunction(callback)) callback = callback.parent
|
|
87
|
+
if (!callback || callback.parameters[0]?.name.getText() !== expr.expression.text) return null
|
|
88
|
+
const call = callback.parent
|
|
89
|
+
if (
|
|
90
|
+
!ts.isCallExpression(call) ||
|
|
91
|
+
!ts.isPropertyAccessExpression(call.expression) ||
|
|
92
|
+
call.expression.name.text !== "map" ||
|
|
93
|
+
!ts.isIdentifier(call.expression.expression)
|
|
94
|
+
)
|
|
95
|
+
return null
|
|
96
|
+
const name = call.expression.expression.text
|
|
97
|
+
const declaration = localDeclaration(call.expression.expression)
|
|
98
|
+
if (
|
|
99
|
+
!declaration ||
|
|
100
|
+
!declaration.initializer ||
|
|
101
|
+
!ts.isArrayLiteralExpression(declaration.initializer)
|
|
102
|
+
)
|
|
103
|
+
return null
|
|
104
|
+
const items = declaration.initializer.elements.map((item, index) => {
|
|
105
|
+
if (!ts.isObjectLiteralExpression(item) || item.properties.some(p => ts.isSpreadAssignment(p)))
|
|
106
|
+
return null
|
|
107
|
+
const props = item.properties.filter(
|
|
108
|
+
p =>
|
|
109
|
+
ts.isPropertyAssignment(p) &&
|
|
110
|
+
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
|
|
111
|
+
p.name.text === expr.name.text
|
|
112
|
+
)
|
|
113
|
+
if (props.length !== 1 || !ts.isStringLiteral(props[0].initializer)) return null
|
|
114
|
+
const value = props[0].initializer
|
|
115
|
+
return { item: index, start: value.getStart(), end: value.end, value: value.text }
|
|
116
|
+
})
|
|
117
|
+
if (items.some(item => !item)) return null
|
|
118
|
+
return { expression: `${name}.indexOf(${expr.expression.text})`, items }
|
|
119
|
+
}
|
|
120
|
+
// Resolve simple local component props to the DOM text node that renders them.
|
|
121
|
+
function propTargets(file, name) {
|
|
122
|
+
const component = file.statements.find(
|
|
123
|
+
statement => ts.isFunctionDeclaration(statement) && statement.name?.text === name
|
|
124
|
+
)
|
|
125
|
+
const parameter = component?.parameters[0]?.name
|
|
126
|
+
if (!parameter || !ts.isObjectBindingPattern(parameter)) return {}
|
|
127
|
+
const targets = {}
|
|
128
|
+
for (const binding of parameter.elements) {
|
|
129
|
+
if (!ts.isIdentifier(binding.name) || binding.dotDotDotToken) continue
|
|
130
|
+
const prop = binding.propertyName?.getText(file) || binding.name.text
|
|
131
|
+
const matches = nodes(component).filter(node => {
|
|
132
|
+
if (!ts.isJsxElement(node) || !/^[a-z]/.test(opening(node).tagName.getText(file)))
|
|
133
|
+
return false
|
|
134
|
+
const children = node.children.filter(child => !ts.isJsxText(child) || child.text.trim())
|
|
135
|
+
return (
|
|
136
|
+
children.length === 1 &&
|
|
137
|
+
ts.isJsxExpression(children[0]) &&
|
|
138
|
+
ts.isIdentifier(children[0].expression ?? {}) &&
|
|
139
|
+
children[0].expression.text === binding.name.text
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
if (matches.length === 1) targets[prop] = matches[0].getStart()
|
|
143
|
+
}
|
|
144
|
+
return targets
|
|
145
|
+
}
|
|
146
|
+
export function describe(text) {
|
|
147
|
+
const file = parse(text)
|
|
148
|
+
return nodes(file).map(node => {
|
|
149
|
+
const open = opening(node)
|
|
150
|
+
const props = {}
|
|
151
|
+
for (const attr of open.attributes.properties) {
|
|
152
|
+
if (!ts.isJsxAttribute(attr)) continue
|
|
153
|
+
const value = attr.initializer
|
|
154
|
+
if (ts.isStringLiteral(value ?? {})) props[attr.name.text] = value.text
|
|
155
|
+
else if (value && ts.isJsxExpression(value) && literal(value.expression))
|
|
156
|
+
props[attr.name.text] = value.expression.text
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
start: node.getStart(),
|
|
160
|
+
tag: open.tagName.getText(file),
|
|
161
|
+
line: file.getLineAndCharacterOfPosition(node.getStart()).line + 1,
|
|
162
|
+
text: textTarget(node)?.value ?? null,
|
|
163
|
+
computedText:
|
|
164
|
+
ts.isJsxElement(node) &&
|
|
165
|
+
!textTarget(node) &&
|
|
166
|
+
!arrayText(node) &&
|
|
167
|
+
node.children.some(
|
|
168
|
+
child => ts.isJsxExpression(child) && child.expression && !literal(child.expression)
|
|
169
|
+
),
|
|
170
|
+
items: arrayText(node)?.items,
|
|
171
|
+
props,
|
|
172
|
+
propTargets: propTargets(file, open.tagName.getText(file)),
|
|
173
|
+
structural: ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent),
|
|
174
|
+
container: ts.isJsxElement(node)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
export function instrument(text, path) {
|
|
179
|
+
const file = parse(text)
|
|
180
|
+
const hash = revision(text)
|
|
181
|
+
const patches = []
|
|
182
|
+
const icons = new Set()
|
|
183
|
+
const iconNamespaces = new Set()
|
|
184
|
+
for (const statement of file.statements) {
|
|
185
|
+
if (!ts.isImportDeclaration(statement) || statement.moduleSpecifier.text !== "lucide-react")
|
|
186
|
+
continue
|
|
187
|
+
const bindings = statement.importClause?.namedBindings
|
|
188
|
+
if (bindings && ts.isNamedImports(bindings))
|
|
189
|
+
for (const specifier of bindings.elements) {
|
|
190
|
+
const imported = (specifier.propertyName || specifier.name).text
|
|
191
|
+
if (/^[A-Z]/.test(imported)) icons.add(specifier.name.text)
|
|
192
|
+
}
|
|
193
|
+
else if (bindings && ts.isNamespaceImport(bindings)) iconNamespaces.add(bindings.name.text)
|
|
194
|
+
}
|
|
195
|
+
for (const node of nodes(file)) {
|
|
196
|
+
const open = opening(node)
|
|
197
|
+
const name = open.tagName.getText(file)
|
|
198
|
+
const isIcon =
|
|
199
|
+
icons.has(name) ||
|
|
200
|
+
(ts.isPropertyAccessExpression(open.tagName) &&
|
|
201
|
+
iconNamespaces.has(open.tagName.expression.getText(file)) &&
|
|
202
|
+
/^[A-Z]/.test(open.tagName.name.text))
|
|
203
|
+
if (!/^(?:[a-z][\w-]*|motion\.[a-z]+)$/.test(name) && !isIcon) continue
|
|
204
|
+
patches.push({
|
|
205
|
+
start: open.tagName.end,
|
|
206
|
+
end: open.tagName.end,
|
|
207
|
+
value: ` data-ps-source={${JSON.stringify(JSON.stringify([path, node.getStart(), hash]))}}${arrayText(node) ? ` data-ps-item={${arrayText(node).expression}}` : ""}`
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
return apply(text, patches)
|
|
211
|
+
}
|
|
212
|
+
function apply(text, patches) {
|
|
213
|
+
patches.sort((a, b) => b.start - a.start || b.end - a.end)
|
|
214
|
+
let boundary = text.length
|
|
215
|
+
for (const patch of patches) {
|
|
216
|
+
if (patch.end > boundary)
|
|
217
|
+
throw new Error("Overlapping edits: save child edits before changing its parent structure.")
|
|
218
|
+
text = text.slice(0, patch.start) + patch.value + text.slice(patch.end)
|
|
219
|
+
boundary = patch.start
|
|
220
|
+
}
|
|
221
|
+
return text
|
|
222
|
+
}
|
|
223
|
+
function styleCode(style) {
|
|
224
|
+
if (!style || typeof style !== "object" || Array.isArray(style)) throw new Error("Invalid styles")
|
|
225
|
+
for (const [key, value] of Object.entries(style)) {
|
|
226
|
+
if (
|
|
227
|
+
!/^(--[\w-]+|[a-zA-Z][a-zA-Z0-9]*)$/.test(key) ||
|
|
228
|
+
!["string", "number"].includes(typeof value) ||
|
|
229
|
+
(typeof value === "number" && !Number.isFinite(value))
|
|
230
|
+
)
|
|
231
|
+
throw new Error("Invalid style value")
|
|
232
|
+
}
|
|
233
|
+
return Object.entries(style)
|
|
234
|
+
.map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
|
235
|
+
.join(", ")
|
|
236
|
+
}
|
|
237
|
+
export function editSource(text, edits) {
|
|
238
|
+
const file = parse(text)
|
|
239
|
+
const all = nodes(file)
|
|
240
|
+
const patches = []
|
|
241
|
+
const pending = edits.map(edit => {
|
|
242
|
+
const node = all.find(n => n.getStart() === edit.start)
|
|
243
|
+
if (!node) throw new Error("Source element no longer exists")
|
|
244
|
+
return { edit, node }
|
|
245
|
+
})
|
|
246
|
+
const deleted = new Set(
|
|
247
|
+
pending.filter(({ edit }) => edit.action === "delete").map(({ node }) => node)
|
|
248
|
+
)
|
|
249
|
+
for (const { edit, node } of pending) {
|
|
250
|
+
let ancestor = node.parent
|
|
251
|
+
while (ancestor && !deleted.has(ancestor)) ancestor = ancestor.parent
|
|
252
|
+
if (ancestor) continue
|
|
253
|
+
// Deletion supersedes changes on this element and all of its descendants.
|
|
254
|
+
if (edit.action === "delete") {
|
|
255
|
+
if (!ts.isJsxElement(node.parent) && !ts.isJsxFragment(node.parent))
|
|
256
|
+
throw new Error("Only direct JSX children can be deleted or duplicated")
|
|
257
|
+
patches.push({ start: node.getStart(), end: node.end, value: "" })
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
const open = opening(node)
|
|
261
|
+
if (edit.props && Object.hasOwn(edit.props, "style"))
|
|
262
|
+
throw new Error("Use the structured style operation")
|
|
263
|
+
const attrEdits = { ...edit.props }
|
|
264
|
+
if (edit.style) {
|
|
265
|
+
const attr = open.attributes.properties.find(
|
|
266
|
+
a => ts.isJsxAttribute(a) && a.name.text === "style"
|
|
267
|
+
)
|
|
268
|
+
const original = attr?.initializer
|
|
269
|
+
const expression =
|
|
270
|
+
original && ts.isJsxExpression(original) ? original.expression?.getText(file) : null
|
|
271
|
+
attrEdits.style = { code: `{ ...(${expression || "{}"}), ${styleCode(edit.style)} }` }
|
|
272
|
+
}
|
|
273
|
+
for (const [name, value] of Object.entries(attrEdits)) {
|
|
274
|
+
if (!["style", "title", "subtitle", "eyebrow", "src", "alt", "className"].includes(name))
|
|
275
|
+
throw new Error("Unsupported property")
|
|
276
|
+
if (name !== "style" && typeof value !== "string") throw new Error("Property must be text")
|
|
277
|
+
if (
|
|
278
|
+
name === "src" &&
|
|
279
|
+
!/^(https?:\/\/|\/|\.\.?\/|data:image\/(png|jpeg|webp|gif);base64,)/i.test(value)
|
|
280
|
+
)
|
|
281
|
+
throw new Error("Use an image URL or a local public path")
|
|
282
|
+
const attr = open.attributes.properties.find(
|
|
283
|
+
a => ts.isJsxAttribute(a) && a.name.text === name
|
|
284
|
+
)
|
|
285
|
+
if (attr && name !== "style") {
|
|
286
|
+
const init = attr.initializer
|
|
287
|
+
if (
|
|
288
|
+
!init ||
|
|
289
|
+
!(ts.isStringLiteral(init) || (ts.isJsxExpression(init) && literal(init.expression)))
|
|
290
|
+
)
|
|
291
|
+
throw new Error("Computed properties cannot be overwritten. Edit their source instead.")
|
|
292
|
+
}
|
|
293
|
+
const code = name === "style" ? value.code : JSON.stringify(value)
|
|
294
|
+
patches.push({
|
|
295
|
+
start: attr ? attr.getStart() : open.attributes.end,
|
|
296
|
+
end: attr ? attr.end : open.attributes.end,
|
|
297
|
+
value: `${attr ? "" : " "}${name}={${code}}`
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
if (edit.text !== undefined || edit.richText) {
|
|
301
|
+
const target =
|
|
302
|
+
edit.item !== undefined
|
|
303
|
+
? arrayText(node)?.items.find(i => i.item === edit.item)
|
|
304
|
+
: textTarget(node)
|
|
305
|
+
if (!target)
|
|
306
|
+
throw new Error(
|
|
307
|
+
"Computed or mixed content cannot be replaced. Select a literal child or edit its source."
|
|
308
|
+
)
|
|
309
|
+
if (edit.item !== undefined && edit.richText)
|
|
310
|
+
throw new Error("Range formatting requires a literal JSX text node")
|
|
311
|
+
const pendingText = String(edit.text ?? target.value)
|
|
312
|
+
let value =
|
|
313
|
+
edit.item !== undefined ? JSON.stringify(pendingText) : `{${JSON.stringify(pendingText)}}`
|
|
314
|
+
if (edit.richText) {
|
|
315
|
+
const { from, to, style } = edit.richText
|
|
316
|
+
if (
|
|
317
|
+
!Number.isInteger(from) ||
|
|
318
|
+
!Number.isInteger(to) ||
|
|
319
|
+
from < 0 ||
|
|
320
|
+
to <= from ||
|
|
321
|
+
to > pendingText.length
|
|
322
|
+
)
|
|
323
|
+
throw new Error("Select a valid text range")
|
|
324
|
+
value = `{${JSON.stringify(pendingText.slice(0, from))}}<span style={{${styleCode(style)}}}>{${JSON.stringify(pendingText.slice(from, to))}}</span>{${JSON.stringify(pendingText.slice(to))}}`
|
|
325
|
+
}
|
|
326
|
+
patches.push({ ...target, value })
|
|
327
|
+
}
|
|
328
|
+
if (edit.action === "duplicate") {
|
|
329
|
+
if (!ts.isJsxElement(node.parent) && !ts.isJsxFragment(node.parent))
|
|
330
|
+
throw new Error("Only direct JSX children can be deleted or duplicated")
|
|
331
|
+
const copies = edit.copies ?? 1
|
|
332
|
+
if (!Number.isInteger(copies) || copies < 1 || copies > 100)
|
|
333
|
+
throw new Error("Choose between 1 and 100 copies")
|
|
334
|
+
const duplicate = editSource(node.getText(file), [
|
|
335
|
+
{ ...edit, start: 0, action: undefined, copies: undefined }
|
|
336
|
+
])
|
|
337
|
+
patches.push({
|
|
338
|
+
start: node.end,
|
|
339
|
+
end: node.end,
|
|
340
|
+
value: ("\n" + duplicate).repeat(copies)
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
if (edit.insert) {
|
|
344
|
+
if (!ts.isJsxElement(node)) throw new Error("Select a container for insertion")
|
|
345
|
+
const kinds = {
|
|
346
|
+
text: '<p style={{position:"absolute",left:80,top:80,fontSize:32}}>{"New text"}</p>',
|
|
347
|
+
shape:
|
|
348
|
+
'<div style={{position:"absolute",left:80,top:80,width:240,height:160,backgroundColor:"var(--primary)"}} />',
|
|
349
|
+
image:
|
|
350
|
+
'<img src="https://placehold.co/640x360/png" alt="New image" style={{position:"absolute",left:80,top:80,width:320,height:180,objectFit:"cover"}} />'
|
|
351
|
+
}
|
|
352
|
+
if (!kinds[edit.insert]) throw new Error("Unknown insertion type")
|
|
353
|
+
patches.push({
|
|
354
|
+
start: node.closingElement.getStart(),
|
|
355
|
+
end: node.closingElement.getStart(),
|
|
356
|
+
value: kinds[edit.insert]
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const result = apply(text, patches)
|
|
361
|
+
parse(result)
|
|
362
|
+
return result
|
|
363
|
+
}
|