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.
@@ -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
+ }