@ptengine/inline-editor 0.0.0-stage → 0.1.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.
Files changed (169) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +286 -2
  3. package/dist/InlineEditor.d.ts +342 -0
  4. package/dist/InlineEditor.js +867 -0
  5. package/dist/PagePreview.d.ts +83 -0
  6. package/dist/PagePreview.js +111 -0
  7. package/dist/assets/AssetsPanel.d.ts +19 -0
  8. package/dist/assets/AssetsPanel.js +23 -0
  9. package/dist/assets/componentLibrary.d.ts +23 -0
  10. package/dist/assets/componentLibrary.js +12 -0
  11. package/dist/canvas-ui/index.d.ts +17 -0
  12. package/dist/canvas-ui/index.js +7 -0
  13. package/dist/canvas-ui/scope.d.ts +13 -0
  14. package/dist/canvas-ui/scope.js +1 -0
  15. package/dist/canvas-ui/selection/classNames.d.ts +30 -0
  16. package/dist/canvas-ui/selection/classNames.js +15 -0
  17. package/dist/canvas-ui/selection/tokens.css +204 -0
  18. package/dist/canvas-ui/tree/TreePanel.d.ts +47 -0
  19. package/dist/canvas-ui/tree/TreePanel.js +132 -0
  20. package/dist/canvas-ui/tree/classNames.d.ts +21 -0
  21. package/dist/canvas-ui/tree/classNames.js +8 -0
  22. package/dist/canvas-ui/tree/order.d.ts +38 -0
  23. package/dist/canvas-ui/tree/order.js +38 -0
  24. package/dist/canvas-ui/tree/tree.css +131 -0
  25. package/dist/chrome/EditorChrome.d.ts +83 -0
  26. package/dist/chrome/EditorChrome.js +10 -0
  27. package/dist/chrome/VersionMenu.d.ts +26 -0
  28. package/dist/chrome/VersionMenu.js +86 -0
  29. package/dist/chrome/topbarPrimitives.d.ts +69 -0
  30. package/dist/chrome/topbarPrimitives.js +39 -0
  31. package/dist/chrome/useDismissOnOutside.d.ts +23 -0
  32. package/dist/chrome/useDismissOnOutside.js +27 -0
  33. package/dist/dialogs/ClickActionDialog.d.ts +24 -0
  34. package/dist/dialogs/ClickActionDialog.js +35 -0
  35. package/dist/dialogs/HtmlEditDialog.d.ts +23 -0
  36. package/dist/dialogs/HtmlEditDialog.js +23 -0
  37. package/dist/dialogs/ImageEditDialog.d.ts +30 -0
  38. package/dist/dialogs/ImageEditDialog.js +57 -0
  39. package/dist/dialogs/ImageSettingDialog.d.ts +16 -0
  40. package/dist/dialogs/ImageSettingDialog.js +17 -0
  41. package/dist/dialogs/InsertElementDialog.d.ts +23 -0
  42. package/dist/dialogs/InsertElementDialog.js +28 -0
  43. package/dist/dialogs/ReplaceVideoDialog.d.ts +18 -0
  44. package/dist/dialogs/ReplaceVideoDialog.js +16 -0
  45. package/dist/dialogs/actionTypes.d.ts +53 -0
  46. package/dist/dialogs/actionTypes.js +74 -0
  47. package/dist/dialogs/htmlValidation.d.ts +24 -0
  48. package/dist/dialogs/htmlValidation.js +37 -0
  49. package/dist/dialogs/imageEdit.d.ts +49 -0
  50. package/dist/dialogs/imageEdit.js +30 -0
  51. package/dist/index.d.ts +101 -0
  52. package/dist/index.js +53 -0
  53. package/dist/kernel/CanvasContextBar.d.ts +23 -0
  54. package/dist/kernel/CanvasContextBar.js +12 -0
  55. package/dist/kernel/CanvasFrame.d.ts +56 -0
  56. package/dist/kernel/CanvasFrame.js +53 -0
  57. package/dist/kernel/ai/aiInlineApply.d.ts +326 -0
  58. package/dist/kernel/ai/aiInlineApply.js +461 -0
  59. package/dist/kernel/ai/imageSource.d.ts +24 -0
  60. package/dist/kernel/ai/imageSource.js +19 -0
  61. package/dist/kernel/ai/insertHtmlGate.d.ts +71 -0
  62. package/dist/kernel/ai/insertHtmlGate.js +123 -0
  63. package/dist/kernel/ai/postApplyCheck.d.ts +56 -0
  64. package/dist/kernel/ai/postApplyCheck.js +74 -0
  65. package/dist/kernel/ai/replaceHtmlGate.d.ts +71 -0
  66. package/dist/kernel/ai/replaceHtmlGate.js +147 -0
  67. package/dist/kernel/blocks/BlockOverlay.d.ts +16 -0
  68. package/dist/kernel/blocks/BlockOverlay.js +66 -0
  69. package/dist/kernel/blocks/annotateAndTile.d.ts +106 -0
  70. package/dist/kernel/blocks/annotateAndTile.js +122 -0
  71. package/dist/kernel/blocks/resolveBlocks.d.ts +153 -0
  72. package/dist/kernel/blocks/resolveBlocks.js +233 -0
  73. package/dist/kernel/canvasCapabilities.d.ts +31 -0
  74. package/dist/kernel/canvasCapabilities.js +39 -0
  75. package/dist/kernel/canvasCommands.d.ts +126 -0
  76. package/dist/kernel/canvasCommands.js +146 -0
  77. package/dist/kernel/canvasErrors.d.ts +1 -0
  78. package/dist/kernel/canvasErrors.js +14 -0
  79. package/dist/kernel/captureGeometry.d.ts +35 -0
  80. package/dist/kernel/captureGeometry.js +25 -0
  81. package/dist/kernel/captureOrchestration.d.ts +48 -0
  82. package/dist/kernel/captureOrchestration.js +43 -0
  83. package/dist/kernel/captureSlices.d.ts +36 -0
  84. package/dist/kernel/captureSlices.js +20 -0
  85. package/dist/kernel/changelist/ChangeListPanel.d.ts +13 -0
  86. package/dist/kernel/changelist/ChangeListPanel.js +47 -0
  87. package/dist/kernel/changelist/ChangeListPopover.d.ts +20 -0
  88. package/dist/kernel/changelist/ChangeListPopover.js +10 -0
  89. package/dist/kernel/changelist/builders.d.ts +89 -0
  90. package/dist/kernel/changelist/builders.js +81 -0
  91. package/dist/kernel/changelist/changeSummary.d.ts +19 -0
  92. package/dist/kernel/changelist/changeSummary.js +85 -0
  93. package/dist/kernel/changelist/clipboard.d.ts +16 -0
  94. package/dist/kernel/changelist/clipboard.js +4 -0
  95. package/dist/kernel/changelist/exportForSave.d.ts +30 -0
  96. package/dist/kernel/changelist/exportForSave.js +11 -0
  97. package/dist/kernel/changelist/mergeForSave.d.ts +79 -0
  98. package/dist/kernel/changelist/mergeForSave.js +40 -0
  99. package/dist/kernel/changelist/moveObserverSafety.d.ts +70 -0
  100. package/dist/kernel/changelist/moveObserverSafety.js +39 -0
  101. package/dist/kernel/changelist/overrides.d.ts +22 -0
  102. package/dist/kernel/changelist/overrides.js +63 -0
  103. package/dist/kernel/changelist/useChangeList.d.ts +185 -0
  104. package/dist/kernel/changelist/useChangeList.js +259 -0
  105. package/dist/kernel/config.d.ts +29 -0
  106. package/dist/kernel/config.js +10 -0
  107. package/dist/kernel/dataUrlToBlob.d.ts +8 -0
  108. package/dist/kernel/dataUrlToBlob.js +17 -0
  109. package/dist/kernel/linkHref.d.ts +25 -0
  110. package/dist/kernel/linkHref.js +16 -0
  111. package/dist/kernel/mobileFit.d.ts +46 -0
  112. package/dist/kernel/mobileFit.js +44 -0
  113. package/dist/kernel/moveState.d.ts +35 -0
  114. package/dist/kernel/moveState.js +14 -0
  115. package/dist/kernel/selection/SelectionOverlay.d.ts +10 -0
  116. package/dist/kernel/selection/SelectionOverlay.js +25 -0
  117. package/dist/kernel/selection/SelectionToolbar.d.ts +30 -0
  118. package/dist/kernel/selection/SelectionToolbar.js +82 -0
  119. package/dist/kernel/selection/SelectorPathBar.d.ts +27 -0
  120. package/dist/kernel/selection/SelectorPathBar.js +55 -0
  121. package/dist/kernel/selection/buildDescendantShelf.d.ts +40 -0
  122. package/dist/kernel/selection/buildDescendantShelf.js +50 -0
  123. package/dist/kernel/selection/buildRootShelf.d.ts +26 -0
  124. package/dist/kernel/selection/buildRootShelf.js +149 -0
  125. package/dist/kernel/selection/elementAiContext.d.ts +313 -0
  126. package/dist/kernel/selection/elementAiContext.js +122 -0
  127. package/dist/kernel/selection/elementGeometryForAi.d.ts +9 -0
  128. package/dist/kernel/selection/elementGeometryForAi.js +15 -0
  129. package/dist/kernel/selection/geometry.d.ts +55 -0
  130. package/dist/kernel/selection/geometry.js +18 -0
  131. package/dist/kernel/selection/resolveAnchors.d.ts +116 -0
  132. package/dist/kernel/selection/resolveAnchors.js +173 -0
  133. package/dist/kernel/selection/shelfRow.d.ts +33 -0
  134. package/dist/kernel/selection/shelfRow.js +19 -0
  135. package/dist/kernel/selection/tagLabels.d.ts +9 -0
  136. package/dist/kernel/selection/tagLabels.js +46 -0
  137. package/dist/kernel/selection/toolbarButtons.d.ts +60 -0
  138. package/dist/kernel/selection/toolbarButtons.js +105 -0
  139. package/dist/kernel/selection/useAiSelectionContext.d.ts +26 -0
  140. package/dist/kernel/selection/useAiSelectionContext.js +26 -0
  141. package/dist/kernel/selection/useCanvasGeometry.d.ts +32 -0
  142. package/dist/kernel/selection/useCanvasGeometry.js +47 -0
  143. package/dist/kernel/selection/useSelectionRestore.d.ts +18 -0
  144. package/dist/kernel/selection/useSelectionRestore.js +25 -0
  145. package/dist/kernel/shrinkImageForUpload.d.ts +45 -0
  146. package/dist/kernel/shrinkImageForUpload.js +61 -0
  147. package/dist/kernel/useCanvasBridge.d.ts +36 -0
  148. package/dist/kernel/useCanvasBridge.js +41 -0
  149. package/dist/kernel/useCanvasGate.d.ts +22 -0
  150. package/dist/kernel/useCanvasGate.js +35 -0
  151. package/dist/kernel/useCanvasSession.d.ts +44 -0
  152. package/dist/kernel/useCanvasSession.js +182 -0
  153. package/dist/kernel/useCanvasStream.d.ts +39 -0
  154. package/dist/kernel/useCanvasStream.js +45 -0
  155. package/dist/kernel/useMobileFit.d.ts +40 -0
  156. package/dist/kernel/useMobileFit.js +139 -0
  157. package/dist/locales.d.ts +1094 -0
  158. package/dist/locales.js +545 -0
  159. package/dist/panels/StylePanel.d.ts +24 -0
  160. package/dist/panels/StylePanel.js +40 -0
  161. package/dist/panels/styleFields.d.ts +39 -0
  162. package/dist/panels/styleFields.js +91 -0
  163. package/dist/tree/LayersPanel.d.ts +30 -0
  164. package/dist/tree/LayersPanel.js +15 -0
  165. package/dist/tree/domTreeAdapter.d.ts +63 -0
  166. package/dist/tree/domTreeAdapter.js +90 -0
  167. package/dist/useInlineEditor.d.ts +158 -0
  168. package/dist/useInlineEditor.js +343 -0
  169. package/package.json +58 -3
@@ -0,0 +1,461 @@
1
+ import { deleteElement, insertElement, replaceImage, setClickAction, setImageAlt } from '../changelist/useChangeList.js';
2
+ import { resolveImageBranch } from '../../dialogs/imageEdit.js';
3
+ import { buildActionObject } from '../../dialogs/actionTypes.js';
4
+ import { evaluateApplyCheck } from './postApplyCheck.js';
5
+ import { sanitizeInsertHtml } from './insertHtmlGate.js';
6
+ import { sanitizeReplaceHtml } from './replaceHtmlGate.js';
7
+ import { imageSourceAllowed } from './imageSource.js';
8
+ export const AI_SAFE_OPS = [
9
+ 'EDIT_TEXT',
10
+ 'CHANGE_STYLE',
11
+ 'INSERT_ELEMENT',
12
+ 'MOVE_ELEMENT',
13
+ 'DELETE_ELEMENT',
14
+ 'CHANGE_IMAGE',
15
+ 'SET_LINK',
16
+ 'EDIT_HTML'
17
+ ];
18
+ export const AI_INSERT_MODES = ['insert', 'append', 'before', 'after'];
19
+ export const AI_MOVE_MODES = ['top', 'bottom', 'before', 'after'];
20
+ const CSS_PROPERTY_RE = /^-?[a-zA-Z][a-zA-Z-]*$/;
21
+ function isSafeStyleValue(value) {
22
+ const lower = value.toLowerCase();
23
+ return (!value.includes('{') &&
24
+ !value.includes('}') &&
25
+ !value.includes('<') &&
26
+ !lower.includes('javascript:'));
27
+ }
28
+ function isWhitelistedOp(op) {
29
+ return (op === 'EDIT_TEXT' ||
30
+ op === 'CHANGE_STYLE' ||
31
+ op === 'INSERT_ELEMENT' ||
32
+ op === 'MOVE_ELEMENT' ||
33
+ op === 'DELETE_ELEMENT' ||
34
+ op === 'CHANGE_IMAGE' ||
35
+ op === 'SET_LINK' ||
36
+ op === 'EDIT_HTML');
37
+ }
38
+ export function parseStyleDeclarations(content) {
39
+ const out = [];
40
+ for (const raw of content.split(';')) {
41
+ const pair = raw.trim();
42
+ if (!pair)
43
+ continue;
44
+ const at = pair.indexOf(':');
45
+ if (at <= 0)
46
+ continue;
47
+ const property = pair.slice(0, at).trim();
48
+ const value = pair.slice(at + 1).trim();
49
+ if (!property || !value)
50
+ continue;
51
+ if (!CSS_PROPERTY_RE.test(property))
52
+ continue;
53
+ if (!isSafeStyleValue(value))
54
+ continue;
55
+ out.push({ property, value });
56
+ }
57
+ return out;
58
+ }
59
+ function parseHttpUrl(raw) {
60
+ if (typeof raw !== 'string' || !raw)
61
+ return null;
62
+ let parsed;
63
+ try {
64
+ parsed = new URL(raw);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed : null;
70
+ }
71
+ export async function applyAiChanges(changes, deps) {
72
+ const applied = [];
73
+ const rejected = [];
74
+ const htmlReplaced = new Set();
75
+ const capsOf = async (selector) => {
76
+ try {
77
+ return (await deps.getCapabilities?.(selector)) ?? null;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ };
83
+ const postCheck = async (entry, before, measurable, mode) => {
84
+ const insertedSelectors = entry.changeIds.flatMap(id => deps.insertedSelectorsOf?.(id) ?? []);
85
+ if (insertedSelectors.length)
86
+ entry.insertedSelectors = insertedSelectors;
87
+ if (!measurable)
88
+ return;
89
+ try {
90
+ await deps.settle?.();
91
+ const measure = async (sel) => {
92
+ try {
93
+ const r = await deps.resolve(sel);
94
+ return r.matched && r.matchCount === 1 ? (r.viewportRect ?? null) : null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ };
100
+ const targetAfter = await measure(entry.selector);
101
+ const inserted = [];
102
+ for (const sel of insertedSelectors)
103
+ inserted.push({ selector: sel, rect: await measure(sel) });
104
+ entry.check = evaluateApplyCheck({
105
+ op: entry.op,
106
+ mode,
107
+ targetBefore: before,
108
+ targetAfter,
109
+ inserted,
110
+ viewportWidth: deps.viewportWidth?.()
111
+ });
112
+ }
113
+ catch {
114
+ }
115
+ };
116
+ for (let index = 0; index < changes.length; index++) {
117
+ const change = changes[index];
118
+ const selector = typeof change?.selector === 'string' ? change.selector : '';
119
+ if (!change || !isWhitelistedOp(change.abTestType)) {
120
+ rejected.push({ index, selector, reason: 'NOT_IN_WHITELIST' });
121
+ continue;
122
+ }
123
+ const needsContent = change.abTestType === 'EDIT_TEXT' ||
124
+ change.abTestType === 'CHANGE_STYLE' ||
125
+ change.abTestType === 'INSERT_ELEMENT' ||
126
+ change.abTestType === 'EDIT_HTML';
127
+ const missingField = (needsContent && (typeof change.content !== 'string' || !change.content)) ||
128
+ (change.abTestType === 'CHANGE_IMAGE' &&
129
+ (typeof change.src !== 'string' || !change.src)) ||
130
+ (change.abTestType === 'SET_LINK' && (typeof change.url !== 'string' || !change.url));
131
+ if (!selector || missingField) {
132
+ rejected.push({ index, selector, reason: 'INVALID_SHAPE' });
133
+ continue;
134
+ }
135
+ const inLiveScope = selector === deps.scope.selector ||
136
+ (deps.scope.descendants?.() ?? []).includes(selector);
137
+ const grant = deps.scope.grant;
138
+ const inGrant = !!grant && (selector === grant.selector || grant.descendants.includes(selector));
139
+ if (!inLiveScope && !inGrant) {
140
+ rejected.push({
141
+ index,
142
+ selector,
143
+ reason: 'OUT_OF_SCOPE',
144
+ detail: `scope: ${deps.scope.selector}${grant ? `, granted: ${grant.selector}` : ''}`
145
+ });
146
+ continue;
147
+ }
148
+ const grantHit = !inLiveScope && grant ? grant : null;
149
+ let hit;
150
+ try {
151
+ hit = await deps.resolve(selector);
152
+ }
153
+ catch {
154
+ rejected.push({ index, selector, reason: 'ELEMENTS_NOT_FOUND' });
155
+ continue;
156
+ }
157
+ if (!hit.matched || hit.matchCount === 0) {
158
+ rejected.push({ index, selector, reason: 'ELEMENTS_NOT_FOUND' });
159
+ continue;
160
+ }
161
+ if (hit.matchCount > 1) {
162
+ rejected.push({ index, selector, reason: 'AMBIGUOUS_SELECTOR' });
163
+ continue;
164
+ }
165
+ const before = hit.viewportRect ?? null;
166
+ const measurable = before !== null;
167
+ const summary = change.rationale?.summary;
168
+ const ancestors = grantHit
169
+ ? selector === grantHit.selector
170
+ ? grantHit.ancestors
171
+ : [grantHit.selector, ...grantHit.ancestors]
172
+ :
173
+ (deps.scope.ancestorsOf?.(selector) ??
174
+ (selector === deps.scope.selector
175
+ ? deps.scope.ancestors
176
+ : [deps.scope.selector, ...deps.scope.ancestors]));
177
+ const base = { identifier: selector, selector, ancestors };
178
+ const partialIds = [];
179
+ let partialOp = 'CHANGE_STYLE';
180
+ try {
181
+ if (change.abTestType === 'EDIT_TEXT') {
182
+ if (change.content.includes('<')) {
183
+ rejected.push({ index, selector, reason: 'INVALID_TEXT_CONTENT' });
184
+ continue;
185
+ }
186
+ const caps = await capsOf(selector);
187
+ if (caps && !caps.hasText && (caps.childElementCount ?? 0) >= 1) {
188
+ rejected.push({
189
+ index,
190
+ selector,
191
+ reason: 'TEXT_TARGET_IS_CONTAINER',
192
+ detail: `container: ${caps.childElementCount} child element(s), no direct text — target the text leaf inside it`
193
+ });
194
+ continue;
195
+ }
196
+ const changeId = await deps.addContent({
197
+ ...base,
198
+ html: change.content,
199
+ mode: 'replace',
200
+ isTextChange: true
201
+ });
202
+ const entry = { index, selector, op: 'EDIT_TEXT', summary, changeIds: [changeId] };
203
+ applied.push(entry);
204
+ await postCheck(entry, before, measurable);
205
+ continue;
206
+ }
207
+ if (change.abTestType === 'DELETE_ELEMENT') {
208
+ const changeId = await deleteElement({ ...base, addStyle: deps.addStyle, addContent: deps.addContent });
209
+ applied.push({ index, selector, op: 'DELETE_ELEMENT', summary, changeIds: [changeId] });
210
+ continue;
211
+ }
212
+ if (change.abTestType === 'EDIT_HTML') {
213
+ if (htmlReplaced.has(selector)) {
214
+ rejected.push({
215
+ index,
216
+ selector,
217
+ reason: 'INVALID_SHAPE',
218
+ detail: 'this selector was already replaced in this batch'
219
+ });
220
+ continue;
221
+ }
222
+ const gate = sanitizeReplaceHtml(change.content, {
223
+ allowedImageOrigins: deps.allowedImageOrigins,
224
+ allowedImageUrls: deps.allowedImageUrls
225
+ });
226
+ if (gate.ok === false) {
227
+ rejected.push({
228
+ index,
229
+ selector,
230
+ reason: 'INVALID_HTML_CONTENT',
231
+ detail: JSON.stringify(gate.problem)
232
+ });
233
+ continue;
234
+ }
235
+ const changeId = await deps.addContent({
236
+ ...base,
237
+ html: gate.html,
238
+ mode: 'replace'
239
+ });
240
+ htmlReplaced.add(selector);
241
+ const entry = { index, selector, op: 'EDIT_HTML', summary, changeIds: [changeId] };
242
+ applied.push(entry);
243
+ await postCheck(entry, before, measurable);
244
+ continue;
245
+ }
246
+ if (change.abTestType === 'INSERT_ELEMENT') {
247
+ if (!AI_INSERT_MODES.includes(change.mode)) {
248
+ rejected.push({
249
+ index,
250
+ selector,
251
+ reason: 'INVALID_MODE',
252
+ detail: `insert mode: ${String(change.mode)}`
253
+ });
254
+ continue;
255
+ }
256
+ const insertMode = change.mode;
257
+ const caps = await capsOf(selector);
258
+ if (caps && (insertMode === 'insert' || insertMode === 'append') && (caps.isImg || !caps.canHaveChildren)) {
259
+ rejected.push({
260
+ index,
261
+ selector,
262
+ reason: 'INSERT_TARGET_NO_CHILDREN',
263
+ detail: `${caps.isImg ? '<img>' : 'this element'} cannot hold children — use before / after`
264
+ });
265
+ continue;
266
+ }
267
+ if (caps && (insertMode === 'before' || insertMode === 'after') && caps.canInsertSibling === false) {
268
+ rejected.push({
269
+ index,
270
+ selector,
271
+ reason: 'INSERT_NO_SIBLING',
272
+ detail: 'this element cannot take a sibling here — use insert / append on its container'
273
+ });
274
+ continue;
275
+ }
276
+ const gate = sanitizeInsertHtml(change.content, {
277
+ allowedImageOrigins: deps.allowedImageOrigins,
278
+ allowedImageUrls: deps.allowedImageUrls
279
+ });
280
+ if (gate.ok === false) {
281
+ rejected.push({
282
+ index,
283
+ selector,
284
+ reason: 'INVALID_HTML_CONTENT',
285
+ detail: JSON.stringify(gate.problem)
286
+ });
287
+ continue;
288
+ }
289
+ const changeId = await insertElement({ ...base, addStyle: deps.addStyle, addContent: deps.addContent }, { html: gate.html, mode: change.mode });
290
+ const entry = { index, selector, op: 'INSERT_ELEMENT', summary, changeIds: [changeId] };
291
+ applied.push(entry);
292
+ await postCheck(entry, before, measurable, insertMode);
293
+ continue;
294
+ }
295
+ if (change.abTestType === 'MOVE_ELEMENT') {
296
+ if (!AI_MOVE_MODES.includes(change.mode)) {
297
+ rejected.push({
298
+ index,
299
+ selector,
300
+ reason: 'INVALID_MODE',
301
+ detail: `move mode: ${String(change.mode)}`
302
+ });
303
+ continue;
304
+ }
305
+ const target = change.targetSelector ?? '';
306
+ const moveAncestors = deps.scope.ancestorsOf?.(selector) ?? deps.scope.ancestors;
307
+ if (!target || !moveAncestors.includes(target)) {
308
+ rejected.push({
309
+ index,
310
+ selector,
311
+ reason: 'INVALID_MOVE_TARGET',
312
+ detail: `not an ancestor: ${target || '(empty)'}`
313
+ });
314
+ continue;
315
+ }
316
+ let targetHit;
317
+ try {
318
+ targetHit = await deps.resolve(target);
319
+ }
320
+ catch {
321
+ rejected.push({ index, selector, reason: 'INVALID_MOVE_TARGET', detail: 'target unresolved' });
322
+ continue;
323
+ }
324
+ if (!targetHit.matched || targetHit.matchCount !== 1) {
325
+ rejected.push({
326
+ index,
327
+ selector,
328
+ reason: 'INVALID_MOVE_TARGET',
329
+ detail: `target matches ${targetHit.matchCount}`
330
+ });
331
+ continue;
332
+ }
333
+ const changeId = await deps.addMove({
334
+ ...base,
335
+ targetIdentifier: target,
336
+ targetSelector: target,
337
+ mode: change.mode
338
+ });
339
+ applied.push({ index, selector, op: 'MOVE_ELEMENT', summary, changeIds: [changeId] });
340
+ continue;
341
+ }
342
+ if (change.abTestType === 'CHANGE_IMAGE') {
343
+ const src = parseHttpUrl(change.src);
344
+ if (!src ||
345
+ !imageSourceAllowed(change.src, {
346
+ allowedImageOrigins: deps.allowedImageOrigins,
347
+ allowedImageUrls: deps.allowedImageUrls
348
+ })) {
349
+ rejected.push({
350
+ index,
351
+ selector,
352
+ reason: 'UNSAFE_IMAGE_SRC',
353
+ detail: src ? `origin not allowed: ${src.origin}` : 'not an http(s) url'
354
+ });
355
+ continue;
356
+ }
357
+ let caps = null;
358
+ try {
359
+ caps = (await deps.getCapabilities?.(selector)) ?? null;
360
+ }
361
+ catch {
362
+ caps = null;
363
+ }
364
+ const branch = caps ? resolveImageBranch(caps) : null;
365
+ if (branch !== 'img-src' && branch !== 'background') {
366
+ rejected.push({
367
+ index,
368
+ selector,
369
+ reason: 'IMAGE_TARGET_UNSUPPORTED',
370
+ detail: caps ? 'neither <img> nor a background-image box' : 'capabilities unavailable'
371
+ });
372
+ continue;
373
+ }
374
+ partialOp = 'CHANGE_IMAGE';
375
+ const imageDeps = {
376
+ ...base,
377
+ addStyle: deps.addStyle,
378
+ addContent: deps.addContent,
379
+ addAttribute: deps.addAttribute
380
+ };
381
+ partialIds.push(await replaceImage(imageDeps, { branch, url: change.src }));
382
+ if (typeof change.alt === 'string' && change.alt) {
383
+ partialIds.push(await setImageAlt(imageDeps, change.alt));
384
+ }
385
+ const entry = { index, selector, op: 'CHANGE_IMAGE', summary, changeIds: partialIds };
386
+ applied.push(entry);
387
+ await postCheck(entry, before, measurable);
388
+ continue;
389
+ }
390
+ if (change.abTestType === 'SET_LINK') {
391
+ const url = parseHttpUrl(change.url);
392
+ const raw = change.url;
393
+ if (!url || (url.origin !== deps.pageOrigin && !deps.allowedLinkUrls.includes(raw))) {
394
+ rejected.push({
395
+ index,
396
+ selector,
397
+ reason: 'UNSAFE_LINK_URL',
398
+ detail: url ? `origin not allowed: ${url.origin}` : 'not an http(s) url'
399
+ });
400
+ continue;
401
+ }
402
+ let caps = null;
403
+ try {
404
+ caps = (await deps.getCapabilities?.(selector)) ?? null;
405
+ }
406
+ catch {
407
+ caps = null;
408
+ }
409
+ if (!caps?.canEditLink) {
410
+ rejected.push({
411
+ index,
412
+ selector,
413
+ reason: 'LINK_TARGET_UNSUPPORTED',
414
+ detail: caps ? 'canEditLink is false' : 'capabilities unavailable'
415
+ });
416
+ continue;
417
+ }
418
+ const changeId = await setClickAction({
419
+ ...base,
420
+ addStyle: deps.addStyle,
421
+ addContent: deps.addContent,
422
+ addAction: deps.addAction
423
+ }, buildActionObject({
424
+ type: 'link',
425
+ config: { linkURL: raw, openNewTab: !!change.newTab }
426
+ }));
427
+ applied.push({ index, selector, op: 'SET_LINK', summary, changeIds: [changeId] });
428
+ continue;
429
+ }
430
+ const declarations = parseStyleDeclarations(change.content);
431
+ if (!declarations.length) {
432
+ rejected.push({ index, selector, reason: 'INVALID_STYLE_CONTENT' });
433
+ continue;
434
+ }
435
+ for (const d of declarations) {
436
+ partialIds.push(await deps.addStyle({ ...base, property: d.property, value: d.value }));
437
+ }
438
+ const entry = { index, selector, op: 'CHANGE_STYLE', summary, changeIds: partialIds };
439
+ applied.push(entry);
440
+ await postCheck(entry, before, measurable);
441
+ }
442
+ catch (e) {
443
+ if (partialIds.length) {
444
+ applied.push({
445
+ index,
446
+ selector,
447
+ op: partialOp,
448
+ summary,
449
+ changeIds: partialIds
450
+ });
451
+ }
452
+ rejected.push({
453
+ index,
454
+ selector,
455
+ reason: 'APPLY_FAILED',
456
+ detail: String(e?.message ?? e)
457
+ });
458
+ }
459
+ }
460
+ return { applied, rejected };
461
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * AI 改动里**图片地址**的来源判定 —— 换图(`CHANGE_IMAGE`)、整块重写(`EDIT_HTML`)、
3
+ * 插入(`INSERT_ELEMENT`)三处共用这一份,免得三道闸各写一套、口径漂移。
4
+ *
5
+ * 两条路放行,其余一律拒(fail closed):
6
+ *
7
+ * 1. **origin 白名单**:客户页自身 + 我们的上传 / 生图域名(宿主注入)。按 origin 比,
8
+ * 不按前缀比 —— `https://cdn.pt.com.evil.com/x.png` 的前缀与白名单项吻合,origin 却是另一个站。
9
+ * 2. **本页已出现过的图片地址**:编辑器这次会话在这个页面上**自己读到**的图(`anchor-image:` /
10
+ * `element-image:` 行、发给 agent 的 html 里的 `<img src>`),整串精确相等才算。
11
+ * 存在的理由:Shopify 这类站的图全在 `cdn.shopify.com`,不在客户页 origin 下 —— 只认 origin 的话,
12
+ * AI 连「把页面上已有的商品图放进新模块」「整块重写时把原图原样写回去」都会被拒。
13
+ * 按整串而不是按 origin 放:放行整个 `cdn.shopify.com` 等于允许任意 Shopify 店铺的任意图片。
14
+ * 与 `SET_LINK` 的 `allowedLinkUrls`(本会话出现过的链接)同一个思路。
15
+ */
16
+ export interface ImageSourcePolicy {
17
+ /** 空数组 = 这一路全拒。 */
18
+ allowedImageOrigins: string[];
19
+ /** 本会话在这个页面上读到过的图片地址(完整 URL)。缺省 = 这一路不放行任何地址。 */
20
+ allowedImageUrls?: string[];
21
+ }
22
+ /** 只认 http(s) 的绝对地址;解析失败返回 null。 */
23
+ export declare function parseImageUrl(raw: string | null | undefined): URL | null;
24
+ export declare function imageSourceAllowed(raw: string | null | undefined, policy: ImageSourcePolicy): boolean;
@@ -0,0 +1,19 @@
1
+ export function parseImageUrl(raw) {
2
+ if (!raw)
3
+ return null;
4
+ try {
5
+ const url = new URL(raw);
6
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url : null;
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ export function imageSourceAllowed(raw, policy) {
13
+ const url = parseImageUrl(raw);
14
+ if (!url)
15
+ return false;
16
+ if (policy.allowedImageOrigins.includes(url.origin))
17
+ return true;
18
+ return (policy.allowedImageUrls ?? []).some(known => parseImageUrl(known)?.href === url.href);
19
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * AI 新增元素时那段 HTML 的闸。
3
+ *
4
+ * ## 为什么这一道单独存在
5
+ *
6
+ * 其余四个 op 的 content 都是「不含 `<` 的纯文本」或「CSS 声明」—— 注入面靠**禁字符**就
7
+ * 挡住了。`INSERT_ELEMENT` 不行:它的 content 就是要被当成标记写进客户页面的,而那段
8
+ * HTML 由模型产出。所以这里是本项目**唯一**一处真的要审查标记的地方。
9
+ *
10
+ * ## 用真解析器,不用正则
11
+ *
12
+ * 正则挡不住这类东西:`<img/src=x onerror=alert(1)>`(无空格属性)、`<svg><script>`、
13
+ * `<a href="java&#115;cript:...">`(实体编码)、`<div style="background:url(javascript:...)">`。
14
+ * 所以走 `DOMParser` 解析成真 DOM 再逐节点审,最后**从解析结果重新序列化** ——
15
+ * 这一步顺带把畸形标记规整掉,写进页面的是我们看过的那份,而不是模型给的原串。
16
+ *
17
+ * ## 拒绝,而不是清洗
18
+ *
19
+ * 遇到不允许的标签/属性一律**整条拒绝**,不做「摘掉再用」:
20
+ * · 清洗会静默改变意图 —— 用户看到一个和 AI 描述不一样的元素,而没有任何提示;
21
+ * · 与现役 engage-editor 的口径一致(`INVALID_TEXT_CONTENT` / `INVALID_STYLE_CONTENT`
22
+ * 都是整条拒绝),错误码也照这个思路加。
23
+ *
24
+ * 代价是模型偶尔会因为一个多余属性被打回整条。这是有意的取舍:被打回时用户看到「这条被
25
+ * 跳过」并可以再问一次,而清洗后的错误元素他看不出来。
26
+ */
27
+ import { type ImageSourcePolicy } from './imageSource.js';
28
+ /**
29
+ * 一段插入 HTML 的字节上限。
30
+ *
31
+ * 原来是 2048 —— 按「加一个小东西」定的。2026-09-30 起方案可以是**一整块新模块**(对比表、
32
+ * 评价区、FAQ、购买区块),内联样式写一个这样的模块 2KB 远远不够,于是与整块重写同一档
33
+ * (`REPLACE_HTML_BUDGET_BYTES`)。小元素照样小,这只是上限;模型偶尔会吐一整屏 markup,仍要封顶。
34
+ */
35
+ export declare const INSERT_HTML_BUDGET_BYTES = 8192;
36
+ export type InsertHtmlReject = {
37
+ reason: 'EMPTY';
38
+ } | {
39
+ reason: 'TOO_LARGE';
40
+ bytes: number;
41
+ } | {
42
+ reason: 'TAG_NOT_ALLOWED';
43
+ detail: string;
44
+ } | {
45
+ reason: 'ATTR_NOT_ALLOWED';
46
+ detail: string;
47
+ } | {
48
+ reason: 'UNSAFE_ATTR_VALUE';
49
+ detail: string;
50
+ } | {
51
+ reason: 'UNSAFE_IMAGE_SRC';
52
+ detail: string;
53
+ };
54
+ export type InsertHtmlOutcome =
55
+ /** `html` 是**重新序列化后**的那份 —— 写进页面用它,不要用原串。 */
56
+ {
57
+ ok: true;
58
+ html: string;
59
+ } | {
60
+ ok: false;
61
+ problem: InsertHtmlReject;
62
+ };
63
+ /**
64
+ * 审查并规整一段将被插入客户页的 HTML。
65
+ *
66
+ * 依赖 `DOMParser`(产品页与 jsdom 都有)。拿不到解析器时**一律拒绝** ——
67
+ * 「审不了就放过」是这类闸最糟的降级。
68
+ */
69
+ export declare function sanitizeInsertHtml(raw: string,
70
+ /** `img[src]` 的来源规则(见 `imageSource.ts`)。缺省 = 任何 img 都拒(fail closed)。 */
71
+ images?: ImageSourcePolicy): InsertHtmlOutcome;