promptslide 0.3.17 → 0.3.18
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 +7 -0
- package/dist/index.js +134 -63
- package/dist/index.js.map +1 -1
- package/package.json +31 -31
- package/src/commands/studio.mjs +4 -3
- package/src/core/slide-deck.tsx +88 -6
- package/src/core/slide-renderer.tsx +3 -8
- package/src/editor/README.md +35 -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 +1004 -0
- package/src/editor/studio.tsx +1948 -0
- package/src/vite/config.mjs +23 -2
- package/src/vite/plugin.mjs +3 -2
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto"
|
|
2
|
+
import {
|
|
3
|
+
readFileSync,
|
|
4
|
+
realpathSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
statSync,
|
|
8
|
+
readdirSync
|
|
9
|
+
} from "node:fs"
|
|
10
|
+
import nodePath, { resolve, relative, sep, dirname } from "node:path"
|
|
11
|
+
import { fileURLToPath } from "node:url"
|
|
12
|
+
|
|
13
|
+
import { describe, editSource, instrument, revision } from "./source.mjs"
|
|
14
|
+
|
|
15
|
+
export function sourceModulePath(base, id, paths = nodePath) {
|
|
16
|
+
const path = paths.resolve(id.split("?")[0])
|
|
17
|
+
const rel = paths.relative(base, path)
|
|
18
|
+
if (
|
|
19
|
+
!rel ||
|
|
20
|
+
rel === ".." ||
|
|
21
|
+
rel.startsWith(".." + paths.sep) ||
|
|
22
|
+
paths.isAbsolute(rel) ||
|
|
23
|
+
!/\.[jt]sx$/.test(path)
|
|
24
|
+
)
|
|
25
|
+
return
|
|
26
|
+
return path
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function editorPlugin({ root }) {
|
|
30
|
+
root = realpathSync(root)
|
|
31
|
+
const token = randomBytes(24).toString("hex")
|
|
32
|
+
const base = resolve(root, "src")
|
|
33
|
+
const client = resolve(dirname(fileURLToPath(import.meta.url)), "mount.ts")
|
|
34
|
+
const clientUrl = "/@fs/" + client
|
|
35
|
+
function sourcePath(path) {
|
|
36
|
+
if (typeof path !== "string" || !/\.[jt]sx$/.test(path)) throw new Error("Invalid source path")
|
|
37
|
+
const resolved = realpathSync(resolve(root, path))
|
|
38
|
+
const realBase = realpathSync(base)
|
|
39
|
+
if (!resolved.startsWith(realBase + sep)) throw new Error("Source must be inside project src/")
|
|
40
|
+
return resolved
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
name: "promptslide-editor",
|
|
44
|
+
apply: "serve",
|
|
45
|
+
enforce: "pre",
|
|
46
|
+
transform(code, id) {
|
|
47
|
+
const path = sourceModulePath(base, id)
|
|
48
|
+
if (!path) return
|
|
49
|
+
return { code: instrument(code, relative(root, path)), map: null }
|
|
50
|
+
},
|
|
51
|
+
resolveId(id) {
|
|
52
|
+
if (id === "virtual:promptslide-editor") return "\0" + id
|
|
53
|
+
},
|
|
54
|
+
load(id) {
|
|
55
|
+
if (id !== "\0virtual:promptslide-editor") return
|
|
56
|
+
return `import { mountEditor } from ${JSON.stringify(clientUrl)};
|
|
57
|
+
import { slides } from ${JSON.stringify(resolve(root, "src/deck-config.ts"))};
|
|
58
|
+
import { theme } from ${JSON.stringify(resolve(root, "src/theme.ts"))};
|
|
59
|
+
const dispose = mountEditor(slides, theme, ${JSON.stringify(token)}, import.meta.hot?.data.open ?? false);
|
|
60
|
+
if (import.meta.hot) {
|
|
61
|
+
import.meta.hot.accept();
|
|
62
|
+
import.meta.hot.dispose((data) => {
|
|
63
|
+
data.open = document.querySelector('.ps-edit-action[aria-pressed="true"]') !== null;
|
|
64
|
+
dispose?.();
|
|
65
|
+
window.dispatchEvent(new Event("promptslide:editor-refresh"));
|
|
66
|
+
});
|
|
67
|
+
}`
|
|
68
|
+
},
|
|
69
|
+
transformIndexHtml(html, ctx) {
|
|
70
|
+
const url = new URL(ctx.originalUrl ?? ctx.path, "http://localhost")
|
|
71
|
+
if (url.searchParams.get("pdf") === "true" || url.searchParams.get("export") === "true")
|
|
72
|
+
return
|
|
73
|
+
if (ctx.path !== "/" && ctx.path !== "/index.html") return
|
|
74
|
+
return {
|
|
75
|
+
html,
|
|
76
|
+
tags: [
|
|
77
|
+
{
|
|
78
|
+
tag: "script",
|
|
79
|
+
attrs: { type: "module", src: "/@id/virtual:promptslide-editor" },
|
|
80
|
+
injectTo: "body"
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
configureServer(server) {
|
|
86
|
+
server.middlewares.use("/__promptslide_editor", (req, res) => {
|
|
87
|
+
const send = (status, data) => {
|
|
88
|
+
res.statusCode = status
|
|
89
|
+
res.setHeader("Content-Type", "application/json")
|
|
90
|
+
res.end(JSON.stringify(data))
|
|
91
|
+
}
|
|
92
|
+
if (
|
|
93
|
+
req.method !== "POST" ||
|
|
94
|
+
req.headers["x-promptslide-editor"] !== token ||
|
|
95
|
+
req.headers["sec-fetch-site"] === "cross-site"
|
|
96
|
+
)
|
|
97
|
+
return send(403, { error: "Editor access denied" })
|
|
98
|
+
let body = ""
|
|
99
|
+
req.on("data", chunk => {
|
|
100
|
+
body += chunk
|
|
101
|
+
if (body.length > 2_000_000) req.destroy()
|
|
102
|
+
})
|
|
103
|
+
req.on("end", () => {
|
|
104
|
+
try {
|
|
105
|
+
const data = JSON.parse(body)
|
|
106
|
+
if (data.action === "sources") {
|
|
107
|
+
const paths = []
|
|
108
|
+
function walk(directory) {
|
|
109
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
110
|
+
if (
|
|
111
|
+
entry.name.startsWith(".") ||
|
|
112
|
+
entry.name === "node_modules" ||
|
|
113
|
+
entry.isSymbolicLink()
|
|
114
|
+
)
|
|
115
|
+
continue
|
|
116
|
+
const path = resolve(directory, entry.name)
|
|
117
|
+
if (entry.isDirectory()) walk(path)
|
|
118
|
+
else if (entry.isFile() && /\.[jt]sx$/.test(path))
|
|
119
|
+
paths.push(relative(root, path))
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
walk(base)
|
|
123
|
+
return send(200, { paths: paths.sort() })
|
|
124
|
+
}
|
|
125
|
+
if (data.action === "inspect") {
|
|
126
|
+
const text = readFileSync(sourcePath(data.path), "utf8")
|
|
127
|
+
return send(200, { revision: revision(text), nodes: describe(text) })
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
data.action !== "save" ||
|
|
131
|
+
!Array.isArray(data.files) ||
|
|
132
|
+
!data.files.length ||
|
|
133
|
+
data.files.length > 100
|
|
134
|
+
)
|
|
135
|
+
throw new Error("Invalid edit batch")
|
|
136
|
+
const seen = new Set()
|
|
137
|
+
const prepared = data.files.map(file => {
|
|
138
|
+
const path = sourcePath(file.path)
|
|
139
|
+
if (seen.has(path)) throw new Error("Duplicate file")
|
|
140
|
+
seen.add(path)
|
|
141
|
+
const before = readFileSync(path, "utf8")
|
|
142
|
+
if (revision(before) !== file.revision) {
|
|
143
|
+
const error = new Error(
|
|
144
|
+
`Conflict in ${file.path}. Source changed; discard or export your draft before reloading.`
|
|
145
|
+
)
|
|
146
|
+
error.status = 409
|
|
147
|
+
throw error
|
|
148
|
+
}
|
|
149
|
+
if (!Array.isArray(file.edits) || file.edits.length > 1000)
|
|
150
|
+
throw new Error("Invalid edits")
|
|
151
|
+
return {
|
|
152
|
+
path,
|
|
153
|
+
before,
|
|
154
|
+
after: editSource(before, file.edits),
|
|
155
|
+
mode: statSync(path).mode
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
// No asynchronous gap between revision checks and commits. Roll back our writes if a commit fails.
|
|
159
|
+
const written = []
|
|
160
|
+
try {
|
|
161
|
+
for (const file of prepared) {
|
|
162
|
+
if (readFileSync(file.path, "utf8") !== file.before)
|
|
163
|
+
throw new Error("Source changed during save")
|
|
164
|
+
const temp = file.path + ".promptslide-" + randomBytes(6).toString("hex")
|
|
165
|
+
writeFileSync(temp, file.after, { flag: "wx", mode: file.mode })
|
|
166
|
+
renameSync(temp, file.path)
|
|
167
|
+
written.push(file)
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
for (const file of written.reverse())
|
|
171
|
+
if (readFileSync(file.path, "utf8") === file.after)
|
|
172
|
+
writeFileSync(file.path, file.before)
|
|
173
|
+
throw error
|
|
174
|
+
}
|
|
175
|
+
// Invalidate synchronously so a reload immediately after Save cannot receive
|
|
176
|
+
// the old transform while the filesystem watcher is still debouncing.
|
|
177
|
+
for (const file of prepared) {
|
|
178
|
+
for (const module of server.moduleGraph?.getModulesByFile(file.path) || [])
|
|
179
|
+
server.moduleGraph.invalidateModule(module)
|
|
180
|
+
}
|
|
181
|
+
send(200, { saved: prepared.length })
|
|
182
|
+
} catch (error) {
|
|
183
|
+
send(error.status || 400, { error: error.message })
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -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
|
+
}
|