@agent-native/core-corpus 0.217.0 → 0.219.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/corpus/README.md +1 -1
- package/corpus/templates/analytics/.agents/skills/hubspot/SKILL.md +5 -3
- package/corpus/templates/analytics/CHANGELOG.md +2 -0
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +38 -167
- package/corpus/templates/analytics/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
- package/corpus/templates/assets/CHANGELOG.md +6 -0
- package/corpus/templates/assets/app/components/layout/Sidebar.tsx +51 -117
- package/corpus/templates/assets/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
- package/corpus/templates/brain/app/components/layout/Sidebar.tsx +34 -101
- package/corpus/templates/brain/app/routes/sources.tsx +1 -1
- package/corpus/templates/brain/vitest.config.ts +7 -0
- package/corpus/templates/calendar/CHANGELOG.md +6 -0
- package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +1 -1
- package/corpus/templates/calendar/changelog/2026-10-16-booking-links-show-localized-visibility-badges.md +6 -0
- package/corpus/templates/chat/app/components/layout/Sidebar.tsx +62 -101
- package/corpus/templates/clips/CHANGELOG.md +7 -20
- package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +20 -77
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +63 -90
- package/corpus/templates/clips/app/components/sharing/share-test-support.tsx +180 -0
- package/corpus/templates/clips/app/components/sharing/share-ui.tsx +5 -584
- package/corpus/templates/clips/changelog/2026-10-16-share-dialogs-make-it-easier-to-see-who-has-access-and-manag.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-21-share-panels-describe-general-access-in-a-sentence.md +6 -0
- package/corpus/templates/community/qr/package.json +1 -1
- package/corpus/templates/content/CHANGELOG.md +7 -0
- package/corpus/templates/content/app/components/editor/CommentStack.tsx +301 -0
- package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +770 -179
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +75 -27
- package/corpus/templates/content/app/components/editor/SuggestionText.tsx +18 -3
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +89 -22
- package/corpus/templates/content/app/components/editor/block-field-comment-threads.ts +15 -0
- package/corpus/templates/content/app/components/editor/block-field-comments.tsx +5 -9
- package/corpus/templates/content/app/components/editor/comment-stacks.ts +143 -0
- package/corpus/templates/content/app/components/editor/extensions/SuggestionHighlight.ts +383 -34
- package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +31 -0
- package/corpus/templates/content/app/components/editor/suggestions/presentation-rebase.ts +82 -4
- package/corpus/templates/content/app/global.css +31 -0
- package/corpus/templates/content/app/i18n-data.ts +8 -0
- package/corpus/templates/content/changelog/2026-10-16-accepting-a-suggestion-on-a-page-that-changed-since-it-was-s.md +6 -0
- package/corpus/templates/content/changelog/2026-10-20-suggested-edits-that-add-remove-split-or-merge-paragraphs-li.md +6 -0
- package/corpus/templates/content/changelog/2026-10-22-blocks-with-several-comment-threads-fold-into-one-stack-in-t.md +6 -0
- package/corpus/templates/content/changelog/2026-10-22-opening-a-page-no-longer-saves-an-edit-again-after-another-o.md +6 -0
- package/corpus/templates/content/docs/product/capabilities/content.comment.page-owned.md +8 -1
- package/corpus/templates/content/shared/suggestion-diff.ts +23 -6
- package/corpus/templates/content/shared/suggestion-formatting.ts +44 -14
- package/corpus/templates/content/shared/suggestion-text.ts +20 -5
- package/corpus/templates/design/.generated/bridge/interact-links.generated.ts +8 -8
- package/corpus/templates/design/CHANGELOG.md +8 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +18 -1
- package/corpus/templates/design/app/components/design/bridge/interact-links.bridge.ts +8 -8
- package/corpus/templates/design/app/components/editor/PromptDialog.tsx +15 -13
- package/corpus/templates/design/app/i18n/en-US.ts +1 -1
- package/corpus/templates/design/app/pages/DesignSystems.tsx +1 -1
- package/corpus/templates/design/app/pages/Index.tsx +18 -64
- package/corpus/templates/design/app/pages/design-editor/domains/use-editor-modes.ts +15 -6
- package/corpus/templates/design/app/pages/design-editor/domains/use-editor-screen-rendering.tsx +29 -5
- package/corpus/templates/design/app/pages/design-editor/prototype-navigation-transition.ts +423 -0
- package/corpus/templates/design/app/pages/design-editor/view/single-screen-canvas.tsx +35 -9
- package/corpus/templates/design/changelog/2026-10-16-design-pages-use-localized-visibility-badges-and-a-shared-ow.md +6 -0
- package/corpus/templates/design/changelog/2026-10-20-the-design-full-app-toggle-on-the-home-page-no-longer-hides-.md +6 -0
- package/corpus/templates/design/changelog/2026-10-22-prototype-links-can-play-code-authored-screen-transitions.md +6 -0
- package/corpus/templates/design/shared/motion-authored-css-import.ts +938 -7
- package/corpus/templates/design/shared/motion-html-source.ts +1689 -135
- package/corpus/templates/design/shared/motion-prototype-transition.ts +329 -0
- package/corpus/templates/design/shared/motion-react-source.ts +2154 -126
- package/corpus/templates/design/shared/motion-text-runtime-v1.fixture.txt +221 -0
- package/corpus/templates/dispatch/CHANGELOG.md +4 -0
- package/corpus/templates/dispatch/app/i18n-data.ts +75 -0
- package/corpus/templates/dispatch/changelog/2026-10-21-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
- package/corpus/templates/factory/app/components/layout/Sidebar.tsx +34 -100
- package/corpus/templates/forms/app/pages/FormsListPage.tsx +1 -1
- package/corpus/templates/plan/CHANGELOG.md +6 -0
- package/corpus/templates/plan/app/components/layout/Sidebar.tsx +34 -109
- package/corpus/templates/plan/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
- package/corpus/templates/slides/CHANGELOG.md +1 -0
- package/corpus/templates/slides/app/components/deck/DeckCard.tsx +1 -1
- package/corpus/templates/slides/app/components/design-system/DesignSystemCard.tsx +1 -1
- package/corpus/templates/slides/app/pages/Index.tsx +7 -2
- package/corpus/templates/slides/changelog/2026-10-16-deck-lists-use-the-shared-ownership-filter-and-visibility-ba.md +6 -0
- package/package.json +1 -1
- package/corpus/templates/chat/app/lib/sidebar-thread-state.ts +0 -29
- package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +0 -83
|
@@ -33,15 +33,18 @@ import {
|
|
|
33
33
|
import {
|
|
34
34
|
decodeMotionTextDocument,
|
|
35
35
|
injectMotionTextRuntime,
|
|
36
|
+
MOTION_TEXT_RUNTIME_SOURCE,
|
|
36
37
|
MOTION_TEXT_RUNTIME_ATTRIBUTE,
|
|
37
38
|
MOTION_TEXT_SOURCE_ATTRIBUTE,
|
|
38
39
|
MOTION_TEXT_SOURCE_VERSION,
|
|
40
|
+
MOTION_TEXT_SOURCE_VERSION_PROVENANCE,
|
|
39
41
|
readMotionTextDocument,
|
|
40
42
|
removeMotionTextRuntime,
|
|
41
43
|
type MotionTextDocument,
|
|
42
44
|
} from "./motion-text-code";
|
|
43
45
|
import {
|
|
44
46
|
MOTION_DEFAULT_PLAYBACK_MODE,
|
|
47
|
+
parseMotionPlaybackMode,
|
|
45
48
|
readTimelinePlaybackMode,
|
|
46
49
|
type MotionPlaybackMode,
|
|
47
50
|
type MotionTimeline,
|
|
@@ -50,6 +53,8 @@ import {
|
|
|
50
53
|
const NODE_ID_ATTRIBUTE = "data-agent-native-node-id";
|
|
51
54
|
const MANAGED_STYLE_ATTRIBUTE = "data-agent-native-motion";
|
|
52
55
|
const SAFE_NODE_ID = /^[A-Za-z0-9_-]{1,512}$/;
|
|
56
|
+
const MOTION_TEXT_V1_RUNTIME_SHA256 =
|
|
57
|
+
"ba4c65a0893f8c551897d6ede05f9a2167d70832a7c8050b152859fb1828addb";
|
|
53
58
|
|
|
54
59
|
const FATAL_HTML_PARSE_ERRORS = new Set([
|
|
55
60
|
"control-character-in-input-stream",
|
|
@@ -67,6 +72,7 @@ type HtmlNode = {
|
|
|
67
72
|
tagName?: string;
|
|
68
73
|
value?: string;
|
|
69
74
|
attrs?: Array<{ name: string; value: string }>;
|
|
75
|
+
parentNode?: HtmlNode;
|
|
70
76
|
childNodes?: HtmlNode[];
|
|
71
77
|
content?: HtmlNode;
|
|
72
78
|
insideTemplate?: boolean;
|
|
@@ -188,20 +194,23 @@ function parseHtml(source: string): ParsedHtml {
|
|
|
188
194
|
}
|
|
189
195
|
|
|
190
196
|
const nodes: HtmlNode[] = [];
|
|
191
|
-
const pending: Array<{
|
|
192
|
-
|
|
193
|
-
|
|
197
|
+
const pending: Array<{
|
|
198
|
+
node: HtmlNode;
|
|
199
|
+
insideTemplate: boolean;
|
|
200
|
+
parent?: HtmlNode;
|
|
201
|
+
}> = [{ node: document, insideTemplate: false }];
|
|
194
202
|
while (pending.length > 0) {
|
|
195
|
-
const { node, insideTemplate } = pending.pop()!;
|
|
203
|
+
const { node, insideTemplate, parent } = pending.pop()!;
|
|
204
|
+
if (parent) node.parentNode = parent;
|
|
196
205
|
node.insideTemplate = insideTemplate;
|
|
197
206
|
nodes.push(node);
|
|
198
207
|
pending.push(
|
|
199
208
|
...(node.childNodes ?? [])
|
|
200
209
|
.reverse()
|
|
201
|
-
.map((child) => ({ node: child, insideTemplate })),
|
|
210
|
+
.map((child) => ({ node: child, insideTemplate, parent: node })),
|
|
202
211
|
);
|
|
203
212
|
if (node.tagName === "template" && node.content) {
|
|
204
|
-
pending.push({ node: node.content, insideTemplate: true });
|
|
213
|
+
pending.push({ node: node.content, insideTemplate: true, parent: node });
|
|
205
214
|
}
|
|
206
215
|
}
|
|
207
216
|
return { nodes };
|
|
@@ -275,117 +284,1439 @@ function findManagedStyle(
|
|
|
275
284
|
"Managed Motion style elements cannot live inside an HTML template.",
|
|
276
285
|
);
|
|
277
286
|
}
|
|
278
|
-
if (marked.length > 1) {
|
|
279
|
-
sourceError(
|
|
280
|
-
"ambiguous-managed-style",
|
|
281
|
-
"HTML contains more than one managed Motion style element.",
|
|
282
|
-
);
|
|
287
|
+
if (marked.length > 1) {
|
|
288
|
+
sourceError(
|
|
289
|
+
"ambiguous-managed-style",
|
|
290
|
+
"HTML contains more than one managed Motion style element.",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const node = marked[0];
|
|
294
|
+
if (!node) return null;
|
|
295
|
+
const range = assertSourceRanges(source, node);
|
|
296
|
+
return { node, css: textContent(node), range };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function motionTextError(error: Error): MotionHtmlSourceError {
|
|
300
|
+
return new MotionHtmlSourceError("invalid-motion-text", error.message);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function readMotionText(source: string): MotionTextDocument | null {
|
|
304
|
+
const read = readMotionTextDocument(source);
|
|
305
|
+
if (read.status === "invalid") throw motionTextError(read.error);
|
|
306
|
+
return read.status === "valid" ? read.document : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function assertMotionTextOutsideTemplates(nodes: HtmlNode[]): void {
|
|
310
|
+
if (
|
|
311
|
+
nodes.some(
|
|
312
|
+
(node) =>
|
|
313
|
+
node.insideTemplate &&
|
|
314
|
+
node.tagName === "script" &&
|
|
315
|
+
(attributes(node, MOTION_TEXT_SOURCE_ATTRIBUTE).length > 0 ||
|
|
316
|
+
attributes(node, MOTION_TEXT_RUNTIME_ATTRIBUTE).length > 0),
|
|
317
|
+
)
|
|
318
|
+
) {
|
|
319
|
+
sourceError(
|
|
320
|
+
"invalid-motion-text",
|
|
321
|
+
"Managed Motion Text scripts cannot live inside an HTML template.",
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function targetsFromDocuments(
|
|
327
|
+
document: MotionCodeDocument | null,
|
|
328
|
+
textDocument: MotionTextDocument | null,
|
|
329
|
+
): string[] {
|
|
330
|
+
return [
|
|
331
|
+
...new Set([
|
|
332
|
+
...(document?.tracks.map((track) => track.targetNodeId) ?? []),
|
|
333
|
+
...(document?.nativeStyles?.map((style) => style.targetNodeId) ?? []),
|
|
334
|
+
...(textDocument?.animations.map((animation) => animation.targetNodeId) ??
|
|
335
|
+
[]),
|
|
336
|
+
]),
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function pathTrimTargetsFromDocument(
|
|
341
|
+
document: MotionCodeDocument | null,
|
|
342
|
+
): string[] {
|
|
343
|
+
if (!document) return [];
|
|
344
|
+
return [
|
|
345
|
+
...new Set([
|
|
346
|
+
...document.tracks
|
|
347
|
+
.filter((track) => track.property === "path-trim")
|
|
348
|
+
.map((track) => track.targetNodeId),
|
|
349
|
+
...(document.nativeStyles ?? [])
|
|
350
|
+
.filter((style) => style.enabled !== false && style.styleKey === "Path")
|
|
351
|
+
.map((style) => style.targetNodeId),
|
|
352
|
+
]),
|
|
353
|
+
];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function pathTrimTargetsFromTimeline(timeline: MotionTimeline): string[] {
|
|
357
|
+
return [
|
|
358
|
+
...new Set([
|
|
359
|
+
...timeline.tracks
|
|
360
|
+
.filter((track) => track.property === "path-trim")
|
|
361
|
+
.map((track) => track.targetNodeId),
|
|
362
|
+
...(timeline.nativeStyles ?? [])
|
|
363
|
+
.filter((style) => style.enabled !== false && style.styleKey === "Path")
|
|
364
|
+
.map((style) => style.targetNodeId),
|
|
365
|
+
]),
|
|
366
|
+
];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function applyPathTrimTargets(source: string, targetNodeIds: string[]): string {
|
|
370
|
+
try {
|
|
371
|
+
return applyMotionPathTrimSource(source, targetNodeIds, "html");
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error instanceof MotionPathTrimSourceError) {
|
|
374
|
+
sourceError("invalid-path-trim", error.message);
|
|
375
|
+
}
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function restorePathTrimTargets(source: string): string {
|
|
381
|
+
try {
|
|
382
|
+
return restoreMotionPathTrimSource(source, "html");
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error instanceof MotionPathTrimSourceError) {
|
|
385
|
+
sourceError("invalid-path-trim", error.message);
|
|
386
|
+
}
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function assertSafeTargetId(targetNodeId: string): void {
|
|
392
|
+
if (!SAFE_NODE_ID.test(targetNodeId)) {
|
|
393
|
+
sourceError(
|
|
394
|
+
"unsupported-target",
|
|
395
|
+
`Motion target ID "${targetNodeId}" is not a safe static HTML node ID.`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function isRuntimeStyleMutationHandler(name: string, value = ""): boolean {
|
|
401
|
+
const normalized = name.toLowerCase();
|
|
402
|
+
return (
|
|
403
|
+
((normalized === "x-data" || normalized === "v-scope") &&
|
|
404
|
+
!isStaticRuntimeScopeData(value)) ||
|
|
405
|
+
isRuntimeLifecycleMutationAttribute(name) ||
|
|
406
|
+
normalized === "x-show" ||
|
|
407
|
+
normalized.startsWith("x-show.") ||
|
|
408
|
+
normalized === "v-show" ||
|
|
409
|
+
normalized.startsWith("v-show.") ||
|
|
410
|
+
normalized === ":class" ||
|
|
411
|
+
normalized.startsWith(":class.") ||
|
|
412
|
+
normalized === "x-bind:class" ||
|
|
413
|
+
normalized.startsWith("x-bind:class.") ||
|
|
414
|
+
normalized === "v-bind:class" ||
|
|
415
|
+
normalized.startsWith("v-bind:class.") ||
|
|
416
|
+
normalized === ":style" ||
|
|
417
|
+
normalized.startsWith(":style.") ||
|
|
418
|
+
normalized === "x-bind:style" ||
|
|
419
|
+
normalized.startsWith("x-bind:style.") ||
|
|
420
|
+
normalized === "v-bind:style" ||
|
|
421
|
+
normalized.startsWith("v-bind:style.") ||
|
|
422
|
+
normalized === "x-on" ||
|
|
423
|
+
normalized.startsWith("x-on:") ||
|
|
424
|
+
normalized === "v-on" ||
|
|
425
|
+
normalized.startsWith("v-on:") ||
|
|
426
|
+
normalized.startsWith("@")
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function isRuntimeLifecycleMutationAttribute(name: string): boolean {
|
|
431
|
+
const normalized = name.toLowerCase();
|
|
432
|
+
return (
|
|
433
|
+
normalized === "x-init" ||
|
|
434
|
+
normalized.startsWith("x-init.") ||
|
|
435
|
+
normalized === "x-effect" ||
|
|
436
|
+
normalized.startsWith("x-effect.")
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function hasPotentialRuntimeMutationHandler(
|
|
441
|
+
attrs: readonly { name: string; value: string }[] | undefined,
|
|
442
|
+
): boolean {
|
|
443
|
+
return (attrs ?? []).some(
|
|
444
|
+
({ name, value }) =>
|
|
445
|
+
isRuntimeStyleMutationHandler(name, value) ||
|
|
446
|
+
isNativeInlineEventHandler(name),
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function isRuntimeStyleMutationScope(name: string): boolean {
|
|
451
|
+
const normalized = name.toLowerCase();
|
|
452
|
+
return normalized === "x-data" || normalized === "v-scope";
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function isStaticRuntimeScopeData(value: string): boolean {
|
|
456
|
+
const normalized = value.trim();
|
|
457
|
+
if (!normalized || normalized === "{}") return true;
|
|
458
|
+
if (!normalized.startsWith("{") || !normalized.endsWith("}")) return false;
|
|
459
|
+
|
|
460
|
+
const entries = splitStaticObjectProperties(normalized.slice(1, -1).trim());
|
|
461
|
+
return (
|
|
462
|
+
entries !== null &&
|
|
463
|
+
entries.every((entry) => {
|
|
464
|
+
const match =
|
|
465
|
+
/^(?:[A-Za-z_$][\w$]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*:\s*(.+)$/s.exec(
|
|
466
|
+
entry,
|
|
467
|
+
);
|
|
468
|
+
return match !== null && isStaticRuntimeScopeValue(match[1]!.trim());
|
|
469
|
+
})
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function splitStaticObjectProperties(value: string): string[] | null {
|
|
474
|
+
if (!value) return [];
|
|
475
|
+
const properties: string[] = [];
|
|
476
|
+
let quote: "'" | '"' | null = null;
|
|
477
|
+
let escaped = false;
|
|
478
|
+
let start = 0;
|
|
479
|
+
|
|
480
|
+
for (let index = 0; index < value.length; index++) {
|
|
481
|
+
const character = value[index]!;
|
|
482
|
+
if (quote) {
|
|
483
|
+
if (escaped) {
|
|
484
|
+
escaped = false;
|
|
485
|
+
} else if (character === "\\") {
|
|
486
|
+
escaped = true;
|
|
487
|
+
} else if (character === quote) {
|
|
488
|
+
quote = null;
|
|
489
|
+
}
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (character === "'" || character === '"') {
|
|
493
|
+
quote = character;
|
|
494
|
+
} else if (character === ",") {
|
|
495
|
+
properties.push(value.slice(start, index).trim());
|
|
496
|
+
start = index + 1;
|
|
497
|
+
} else if ("{}[]()".includes(character)) {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (quote || escaped) return null;
|
|
503
|
+
properties.push(value.slice(start).trim());
|
|
504
|
+
return properties.every(Boolean) ? properties : null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function isStaticRuntimeScopeValue(value: string): boolean {
|
|
508
|
+
return /^(?:true|false|null|undefined|-?(?:\d+(?:\.\d*)?|\.\d+)|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')$/.test(
|
|
509
|
+
value,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function findRuntimeStyleMutationAttribute(target: HtmlNode):
|
|
514
|
+
| {
|
|
515
|
+
attribute: { name: string; value: string };
|
|
516
|
+
location: "target" | "ancestor" | "runtime-scope";
|
|
517
|
+
}
|
|
518
|
+
| undefined {
|
|
519
|
+
const ancestors = [target];
|
|
520
|
+
let current = target.parentNode;
|
|
521
|
+
let outermostScopeIndex = -1;
|
|
522
|
+
while (current) {
|
|
523
|
+
ancestors.push(current);
|
|
524
|
+
if (current.attrs?.some(({ name }) => isRuntimeStyleMutationScope(name))) {
|
|
525
|
+
outermostScopeIndex = ancestors.length - 1;
|
|
526
|
+
}
|
|
527
|
+
current = current.parentNode;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
for (let index = 0; index < ancestors.length; index++) {
|
|
531
|
+
const node = ancestors[index]!;
|
|
532
|
+
const attribute = node.attrs?.find(({ name, value }) =>
|
|
533
|
+
isRuntimeStyleMutationHandler(name, value),
|
|
534
|
+
);
|
|
535
|
+
if (attribute) {
|
|
536
|
+
return {
|
|
537
|
+
attribute,
|
|
538
|
+
location: index === 0 ? "target" : "ancestor",
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (outermostScopeIndex >= 0) {
|
|
544
|
+
const scope = ancestors[outermostScopeIndex]!;
|
|
545
|
+
const attribute = findRuntimeStyleMutationInScope(scope);
|
|
546
|
+
if (attribute) return { attribute, location: "runtime-scope" };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const EXECUTABLE_SCRIPT_TYPES = new Set([
|
|
553
|
+
"application/ecmascript",
|
|
554
|
+
"application/javascript",
|
|
555
|
+
"application/x-ecmascript",
|
|
556
|
+
"application/x-javascript",
|
|
557
|
+
"text/ecmascript",
|
|
558
|
+
"text/javascript",
|
|
559
|
+
"text/javascript1.0",
|
|
560
|
+
"text/javascript1.1",
|
|
561
|
+
"text/javascript1.2",
|
|
562
|
+
"text/javascript1.3",
|
|
563
|
+
"text/javascript1.4",
|
|
564
|
+
"text/javascript1.5",
|
|
565
|
+
"text/jscript",
|
|
566
|
+
"text/livescript",
|
|
567
|
+
"text/x-ecmascript",
|
|
568
|
+
"text/x-javascript",
|
|
569
|
+
]);
|
|
570
|
+
|
|
571
|
+
function isExecutableScript(node: HtmlNode): boolean {
|
|
572
|
+
const type = attributes(node, "type")[0]?.value.trim().toLowerCase();
|
|
573
|
+
if (!type || type === "module") return true;
|
|
574
|
+
return EXECUTABLE_SCRIPT_TYPES.has(type.split(";", 1)[0]!.trim());
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const JAVASCRIPT_URL_ATTRIBUTES = new Set([
|
|
578
|
+
"href",
|
|
579
|
+
"src",
|
|
580
|
+
"xlink:href",
|
|
581
|
+
"action",
|
|
582
|
+
"formaction",
|
|
583
|
+
"data",
|
|
584
|
+
]);
|
|
585
|
+
|
|
586
|
+
function isJavascriptUrl(value: string): boolean {
|
|
587
|
+
return value
|
|
588
|
+
.replace(/[\u0000-\u0020\u007f]/g, "")
|
|
589
|
+
.toLowerCase()
|
|
590
|
+
.startsWith("javascript:");
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function isJavascriptUrlAttribute(name: string, value: string): boolean {
|
|
594
|
+
return (
|
|
595
|
+
JAVASCRIPT_URL_ATTRIBUTES.has(name.toLowerCase()) && isJavascriptUrl(value)
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function srcdocContainsExecutableContentOrNestedFrame(value: string): boolean {
|
|
600
|
+
return parseHtml(value).nodes.some((node) => {
|
|
601
|
+
if (node.insideTemplate) return false;
|
|
602
|
+
if (
|
|
603
|
+
(node.tagName === "script" && isExecutableScript(node)) ||
|
|
604
|
+
node.tagName === "iframe" ||
|
|
605
|
+
(node.attrs ?? []).some(
|
|
606
|
+
({ name, value: attributeValue }) =>
|
|
607
|
+
isEventHandlerAttribute(name) ||
|
|
608
|
+
isRuntimeLifecycleMutationAttribute(name) ||
|
|
609
|
+
isJavascriptUrlAttribute(name, attributeValue),
|
|
610
|
+
)
|
|
611
|
+
) {
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
return false;
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function isManagedMotionTextRuntime(source: string, node: HtmlNode): boolean {
|
|
619
|
+
const marker = attributes(node, MOTION_TEXT_RUNTIME_ATTRIBUTE);
|
|
620
|
+
if (
|
|
621
|
+
marker.length !== 1 ||
|
|
622
|
+
node.attrs?.length !== 1 ||
|
|
623
|
+
!["v1", `v${MOTION_TEXT_SOURCE_VERSION}`].includes(marker[0]?.value ?? "")
|
|
624
|
+
) {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const textRead = readMotionTextDocument(source);
|
|
629
|
+
if (textRead.status !== "valid") return false;
|
|
630
|
+
const sourceMarker = parseHtml(source)
|
|
631
|
+
.nodes.filter((candidate) => candidate.tagName === "script")
|
|
632
|
+
.flatMap((candidate) =>
|
|
633
|
+
attributes(candidate, MOTION_TEXT_SOURCE_ATTRIBUTE),
|
|
634
|
+
);
|
|
635
|
+
if (sourceMarker.length !== 1) return false;
|
|
636
|
+
const sourceVersion =
|
|
637
|
+
textRead.document[MOTION_TEXT_SOURCE_VERSION_PROVENANCE] ??
|
|
638
|
+
MOTION_TEXT_SOURCE_VERSION;
|
|
639
|
+
const expectedMarker = `v${sourceVersion}`;
|
|
640
|
+
if (
|
|
641
|
+
marker[0]?.value !== expectedMarker ||
|
|
642
|
+
sourceMarker[0]?.value !== expectedMarker
|
|
643
|
+
) {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const runtime = extractManagedMotionTextRuntime(textContent(node).trim());
|
|
648
|
+
if (
|
|
649
|
+
!runtime ||
|
|
650
|
+
!runtimeAnimationsMatch(runtime.payload, textRead.document, sourceVersion)
|
|
651
|
+
) {
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (sourceVersion === 1) {
|
|
656
|
+
return (
|
|
657
|
+
bytesToHex(sha256(new TextEncoder().encode(runtime.template))) ===
|
|
658
|
+
MOTION_TEXT_V1_RUNTIME_SHA256
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
return runtime.template === MOTION_TEXT_RUNTIME_SOURCE;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
interface ManagedMotionTextRuntimePayload {
|
|
665
|
+
animations: unknown[];
|
|
666
|
+
playbackMode: MotionPlaybackMode;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function extractManagedMotionTextRuntime(
|
|
670
|
+
source: string,
|
|
671
|
+
): { template: string; payload: ManagedMotionTextRuntimePayload } | null {
|
|
672
|
+
if (!source.endsWith(");")) return null;
|
|
673
|
+
|
|
674
|
+
let markerIndex = source.lastIndexOf("})(", source.length - 3);
|
|
675
|
+
while (markerIndex >= 0) {
|
|
676
|
+
const payloadSource = source.slice(markerIndex + 3, -2);
|
|
677
|
+
let payload: unknown;
|
|
678
|
+
try {
|
|
679
|
+
payload = JSON.parse(payloadSource);
|
|
680
|
+
} catch {
|
|
681
|
+
markerIndex = source.lastIndexOf("})(", markerIndex - 1);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (isManagedMotionTextRuntimePayload(payload)) {
|
|
685
|
+
return {
|
|
686
|
+
template: `${source.slice(0, markerIndex + 3)}__MOTION_TEXT_SOURCE__);`,
|
|
687
|
+
payload: {
|
|
688
|
+
animations: payload.animations,
|
|
689
|
+
playbackMode: payload.playbackMode,
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
markerIndex = source.lastIndexOf("})(", markerIndex - 1);
|
|
694
|
+
}
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function isManagedMotionTextRuntimePayload(
|
|
699
|
+
value: unknown,
|
|
700
|
+
): value is ManagedMotionTextRuntimePayload {
|
|
701
|
+
return (
|
|
702
|
+
value !== null &&
|
|
703
|
+
typeof value === "object" &&
|
|
704
|
+
Array.isArray((value as { animations?: unknown }).animations) &&
|
|
705
|
+
parseMotionPlaybackMode(
|
|
706
|
+
(value as { playbackMode?: unknown }).playbackMode,
|
|
707
|
+
) !== null
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function runtimeAnimationsMatch(
|
|
712
|
+
payload: ManagedMotionTextRuntimePayload,
|
|
713
|
+
document: MotionTextDocument,
|
|
714
|
+
sourceVersion: 1 | typeof MOTION_TEXT_SOURCE_VERSION,
|
|
715
|
+
): boolean {
|
|
716
|
+
if (sourceVersion === MOTION_TEXT_SOURCE_VERSION) {
|
|
717
|
+
return (
|
|
718
|
+
JSON.stringify(payload.animations) === JSON.stringify(document.animations)
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const legacyAnimations = document.animations.map((animation) => ({
|
|
723
|
+
targetNodeId: animation.targetNodeId,
|
|
724
|
+
unit: animation.unit,
|
|
725
|
+
direction: animation.direction,
|
|
726
|
+
staggerMs: animation.staggerMs,
|
|
727
|
+
delayMs: animation.delayMs,
|
|
728
|
+
property: animation.property,
|
|
729
|
+
durationMs: animation.durationMs,
|
|
730
|
+
keyframes: animation.keyframes,
|
|
731
|
+
}));
|
|
732
|
+
return (
|
|
733
|
+
JSON.stringify(payload.animations) === JSON.stringify(legacyAnimations)
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function assertExecutableScriptsSafe(
|
|
738
|
+
source: string,
|
|
739
|
+
nodes: readonly HtmlNode[],
|
|
740
|
+
targetNodeIds: readonly string[],
|
|
741
|
+
generatedIds: Map<number | undefined, string>,
|
|
742
|
+
): void {
|
|
743
|
+
for (const node of nodes) {
|
|
744
|
+
if (node.insideTemplate) continue;
|
|
745
|
+
|
|
746
|
+
const targetMutatingHandler = node.attrs?.find(
|
|
747
|
+
({ name, value }) =>
|
|
748
|
+
(isEventHandlerAttribute(name) ||
|
|
749
|
+
isRuntimeLifecycleMutationAttribute(name)) &&
|
|
750
|
+
handlerMayMutateMotionTarget(
|
|
751
|
+
value,
|
|
752
|
+
node,
|
|
753
|
+
nodes,
|
|
754
|
+
targetNodeIds,
|
|
755
|
+
generatedIds,
|
|
756
|
+
),
|
|
757
|
+
);
|
|
758
|
+
if (targetMutatingHandler) {
|
|
759
|
+
sourceError(
|
|
760
|
+
"unsupported-target",
|
|
761
|
+
`HTML Motion targets cannot be synchronized safely while an inline ${targetMutatingHandler.name} handler may mutate a Motion target.`,
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const javascriptUrl = node.attrs?.find(({ name, value }) =>
|
|
766
|
+
isJavascriptUrlAttribute(name, value),
|
|
767
|
+
);
|
|
768
|
+
if (javascriptUrl) {
|
|
769
|
+
sourceError(
|
|
770
|
+
"unsupported-target",
|
|
771
|
+
`HTML Motion targets cannot be synchronized safely while ${javascriptUrl.name} contains a javascript URL.`,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const srcdoc = attributes(node, "srcdoc")[0]?.value;
|
|
776
|
+
if (
|
|
777
|
+
node.tagName === "iframe" &&
|
|
778
|
+
srcdoc &&
|
|
779
|
+
srcdocContainsExecutableContentOrNestedFrame(srcdoc)
|
|
780
|
+
) {
|
|
781
|
+
sourceError(
|
|
782
|
+
"unsupported-target",
|
|
783
|
+
"HTML Motion targets cannot be synchronized safely while an iframe srcdoc contains an executable script, event handler, javascript URL, or nested frame.",
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (node.tagName !== "script") continue;
|
|
788
|
+
if (
|
|
789
|
+
!isExecutableScript(node) &&
|
|
790
|
+
!hasPotentialRuntimeMutationHandler(node.attrs)
|
|
791
|
+
) {
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (
|
|
795
|
+
!hasPotentialRuntimeMutationHandler(node.attrs) &&
|
|
796
|
+
isManagedMotionTextRuntime(source, node)
|
|
797
|
+
) {
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
sourceError(
|
|
801
|
+
"unsupported-target",
|
|
802
|
+
"HTML Motion targets cannot be synchronized safely while an executable script may change their class or style.",
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function isRuntimeEventHandler(name: string): boolean {
|
|
808
|
+
const normalized = name.toLowerCase();
|
|
809
|
+
return (
|
|
810
|
+
normalized === "x-on" ||
|
|
811
|
+
normalized.startsWith("x-on:") ||
|
|
812
|
+
normalized === "v-on" ||
|
|
813
|
+
normalized.startsWith("v-on:") ||
|
|
814
|
+
normalized.startsWith("@")
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function isEventHandlerAttribute(name: string): boolean {
|
|
819
|
+
return isRuntimeEventHandler(name) || isNativeInlineEventHandler(name);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function isNativeInlineEventHandler(name: string): boolean {
|
|
823
|
+
return /^on[a-z]/i.test(name);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function handlerMayMutateMotionTarget(
|
|
827
|
+
value: string,
|
|
828
|
+
handlerNode: HtmlNode,
|
|
829
|
+
nodes: readonly HtmlNode[],
|
|
830
|
+
targetNodeIds: readonly string[],
|
|
831
|
+
generatedIds: Map<number | undefined, string>,
|
|
832
|
+
): boolean {
|
|
833
|
+
if (targetNodeIds.length > 0 && mayInjectRuntimeStylesheet(value)) {
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
if (mayMutateThroughDomTraversal(value)) {
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const targetIds = new Set(targetNodeIds);
|
|
841
|
+
if (mayMutateThroughAlternateDomRoot(value, nodes, targetIds, generatedIds)) {
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
844
|
+
const directTargetMutation =
|
|
845
|
+
DIRECT_ELEMENT_MUTATION.test(value) || handlerTargetAliasMayMutate(value);
|
|
846
|
+
if (
|
|
847
|
+
directTargetMutation &&
|
|
848
|
+
isMotionTargetNode(handlerNode, targetIds, generatedIds)
|
|
849
|
+
) {
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
if (
|
|
853
|
+
(DIRECT_EVENT_TARGET_MUTATION.test(value) ||
|
|
854
|
+
eventTargetAliasMayMutate(value)) &&
|
|
855
|
+
containsMotionTarget(handlerNode, nodes, targetIds, generatedIds)
|
|
856
|
+
) {
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
if (
|
|
860
|
+
handlerElementSelectorMayMutateMotionTarget(
|
|
861
|
+
value,
|
|
862
|
+
handlerNode,
|
|
863
|
+
nodes,
|
|
864
|
+
targetIds,
|
|
865
|
+
generatedIds,
|
|
866
|
+
)
|
|
867
|
+
) {
|
|
868
|
+
return true;
|
|
869
|
+
}
|
|
870
|
+
return handlerSelectsAndMutatesMotionTarget(
|
|
871
|
+
value,
|
|
872
|
+
nodes,
|
|
873
|
+
targetIds,
|
|
874
|
+
generatedIds,
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const MUTATION_MEMBER_ACCESS = String.raw`(?:\?\.|\.)\s*(?:style\b|classList\b|className\b|setAttribute(?:NS)?\s*\(|removeAttribute\s*\(|toggleAttribute\s*\(|animate\s*\()|\[\s*["'](?:style|classList|className|setAttribute(?:NS)?|removeAttribute|toggleAttribute|animate)["']\s*\]`;
|
|
879
|
+
const MUTATION_MEMBER_ACCESS_PATTERN = new RegExp(MUTATION_MEMBER_ACCESS, "i");
|
|
880
|
+
|
|
881
|
+
function handlerElementSelectorMayMutateMotionTarget(
|
|
882
|
+
value: string,
|
|
883
|
+
handlerNode: HtmlNode,
|
|
884
|
+
nodes: readonly HtmlNode[],
|
|
885
|
+
targetIds: ReadonlySet<string>,
|
|
886
|
+
generatedIds: Map<number | undefined, string>,
|
|
887
|
+
): boolean {
|
|
888
|
+
const selectorNames = String.raw`querySelector(?:All)?|getElementById|getElementsBy(?:ClassName|Name|TagName)`;
|
|
889
|
+
const scope = String.raw`(?:this|\$el|(?:event|e|\$event)\s*(?:\.\s*(?:currentTarget|target)|\[\s*["'](?:currentTarget|target)["']\s*\]))`;
|
|
890
|
+
const selectorCall = new RegExp(
|
|
891
|
+
String.raw`(?:^|[^\w$])${scope}\s*(?:\.\s*(${selectorNames})|\[\s*["'](${selectorNames})["']\s*\])\s*\(`,
|
|
892
|
+
"gi",
|
|
893
|
+
);
|
|
894
|
+
const hasMutation = MUTATION_MEMBER_ACCESS_PATTERN.test(value);
|
|
895
|
+
if (!hasMutation) return false;
|
|
896
|
+
|
|
897
|
+
const aliases = assignedAliases(
|
|
898
|
+
value,
|
|
899
|
+
/^(?:this|\$el|(?:event|e|\$event)\s*(?:\.\s*(?:currentTarget|target)|\[\s*["'](?:currentTarget|target)["']\s*\]))/i,
|
|
900
|
+
);
|
|
901
|
+
const escapedAliases = [...aliases]
|
|
902
|
+
.map((alias) => alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
903
|
+
.join("|");
|
|
904
|
+
if (escapedAliases) {
|
|
905
|
+
const aliasSelectorCall = new RegExp(
|
|
906
|
+
String.raw`\b(?:${escapedAliases})\s*(?:\.\s*(${selectorNames})|\[\s*["'](${selectorNames})["']\s*\])\s*\(`,
|
|
907
|
+
"gi",
|
|
908
|
+
);
|
|
909
|
+
if (aliasSelectorCall.test(value)) {
|
|
910
|
+
return containsMotionTarget(handlerNode, nodes, targetIds, generatedIds);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (!selectorCall.test(value)) return false;
|
|
915
|
+
return containsMotionTarget(handlerNode, nodes, targetIds, generatedIds);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const DIRECT_ELEMENT_MUTATION = new RegExp(
|
|
919
|
+
String.raw`(?:\bthis\b|\b(?:event|e)\s*(?:\.\s*(?:currentTarget|target)|\[\s*["'](?:currentTarget|target)["']\s*\])|\$el|\$event\s*(?:\.\s*(?:currentTarget|target)|\[\s*["'](?:currentTarget|target)["']\s*\]))\s*(?:${MUTATION_MEMBER_ACCESS})`,
|
|
920
|
+
"i",
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
const DIRECT_EVENT_TARGET_MUTATION = new RegExp(
|
|
924
|
+
String.raw`(?:\b(?:event|e|\$event)\s*(?:\.\s*target|\[\s*["']target["']\s*\]))\s*(?:${MUTATION_MEMBER_ACCESS})`,
|
|
925
|
+
"i",
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
function mayInjectRuntimeStylesheet(value: string): boolean {
|
|
929
|
+
if (
|
|
930
|
+
/\b(?:adoptedStyleSheets|CSSStyleSheet|styleSheets|cssRules|insertRule|addRule|replaceSync)\b/i.test(
|
|
931
|
+
value,
|
|
932
|
+
)
|
|
933
|
+
) {
|
|
934
|
+
return true;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const createElementCalls = /\bcreateElement(?:NS)?\s*\(([^)]*)\)/gi;
|
|
938
|
+
let match: RegExpExecArray | null;
|
|
939
|
+
while ((match = createElementCalls.exec(value)) !== null) {
|
|
940
|
+
const argument = match[1]?.trim() ?? "";
|
|
941
|
+
const tag = /^(['"])([^'"]+)\1$/.exec(argument)?.[2]?.toLowerCase();
|
|
942
|
+
if (!tag || tag === "style" || tag === "link") return true;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
return /(?:innerHTML|insertAdjacentHTML)\s*(?:\+=|=|\()\s*[^;]*(?:<\s*style\b|<\s*link\b)/i.test(
|
|
946
|
+
value,
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function mayMutateThroughDomTraversal(value: string): boolean {
|
|
951
|
+
const traversesDom =
|
|
952
|
+
/(?:\.\s*(?:parentElement|parentNode|children|childNodes|firstElementChild|lastElementChild|firstChild|lastChild|nextElementSibling|previousElementSibling|nextSibling|previousSibling|closest|matches)\b|\[\s*["'](?:parentElement|parentNode|children|childNodes|firstElementChild|lastElementChild|firstChild|lastChild|nextElementSibling|previousElementSibling|nextSibling|previousSibling|closest|matches)["']\s*\])/i.test(
|
|
953
|
+
value,
|
|
954
|
+
);
|
|
955
|
+
return traversesDom && MUTATION_MEMBER_ACCESS_PATTERN.test(value);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function mayMutateThroughAlternateDomRoot(
|
|
959
|
+
value: string,
|
|
960
|
+
nodes: readonly HtmlNode[],
|
|
961
|
+
targetIds: ReadonlySet<string>,
|
|
962
|
+
generatedIds: Map<number | undefined, string>,
|
|
963
|
+
): boolean {
|
|
964
|
+
const root = String.raw`(?:this|\$el|(?:event|e|\$event)\s*(?:\.\s*(?:currentTarget|target)|\[\s*["'](?:currentTarget|target)["']\s*\]))`;
|
|
965
|
+
const selectorNames = String.raw`querySelector(?:All)?|getElementById|getElementsBy(?:ClassName|Name|TagName)`;
|
|
966
|
+
const selectorCalls = new RegExp(
|
|
967
|
+
String.raw`(?<![\w$])${root}\s*(?:\.\s*(?:ownerDocument)|\.\s*getRootNode\s*\(\s*\)|\[\s*["'](?:ownerDocument|getRootNode)["']\s*\](?:\s*\(\s*\))?)\s*(?:\.\s*(${selectorNames})|\[\s*["'](${selectorNames})["']\s*\])\s*\(`,
|
|
968
|
+
"gi",
|
|
969
|
+
);
|
|
970
|
+
let match: RegExpExecArray | null;
|
|
971
|
+
|
|
972
|
+
while ((match = selectorCalls.exec(value)) !== null) {
|
|
973
|
+
const callStartIndex = match.index;
|
|
974
|
+
const method = match[1] ?? match[2]!;
|
|
975
|
+
const openParenIndex = selectorCalls.lastIndex - 1;
|
|
976
|
+
const callEndIndex = findSelectorCallEnd(value, openParenIndex);
|
|
977
|
+
if (callEndIndex === null) return true;
|
|
978
|
+
if (
|
|
979
|
+
!alternateRootSelectorCallFeedsMutationSink(
|
|
980
|
+
value,
|
|
981
|
+
callStartIndex,
|
|
982
|
+
callEndIndex,
|
|
983
|
+
method,
|
|
984
|
+
)
|
|
985
|
+
) {
|
|
986
|
+
selectorCalls.lastIndex = callEndIndex;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const selectorArgument = readStaticSelectorArgument(
|
|
991
|
+
value.slice(openParenIndex + 1, callEndIndex - 1),
|
|
992
|
+
);
|
|
993
|
+
if (!selectorArgument) return true;
|
|
994
|
+
|
|
995
|
+
const selector = selectorForDocumentCall(method, selectorArgument);
|
|
996
|
+
if (!selector) return true;
|
|
997
|
+
if (selectorMayTargetMotionNode(selector, nodes, targetIds, generatedIds)) {
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function alternateRootSelectorCallFeedsMutationSink(
|
|
1006
|
+
source: string,
|
|
1007
|
+
callStartIndex: number,
|
|
1008
|
+
callEndIndex: number,
|
|
1009
|
+
method: string,
|
|
1010
|
+
): boolean {
|
|
1011
|
+
const afterCall = source.slice(callEndIndex);
|
|
1012
|
+
if (selectedElementHasActualMutationSink(afterCall)) return true;
|
|
1013
|
+
|
|
1014
|
+
const assignment =
|
|
1015
|
+
/(?:\b(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*=\s*$/.exec(
|
|
1016
|
+
source.slice(0, callStartIndex),
|
|
1017
|
+
);
|
|
1018
|
+
if (
|
|
1019
|
+
assignment &&
|
|
1020
|
+
identifierHasActualMutationSink(afterCall, assignment[1]!)
|
|
1021
|
+
) {
|
|
1022
|
+
return true;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (method.toLowerCase() === "queryselectorall") {
|
|
1026
|
+
const callbackStatus = querySelectorAllCallbackMutationStatus(afterCall);
|
|
1027
|
+
const forOfStatus = querySelectorAllForOfMutationStatus(
|
|
1028
|
+
source,
|
|
1029
|
+
callStartIndex,
|
|
1030
|
+
callEndIndex,
|
|
1031
|
+
);
|
|
1032
|
+
if (callbackStatus === true || forOfStatus === true) return true;
|
|
1033
|
+
if (callbackStatus === false || forOfStatus === false) return false;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
return destructuredSelectorCallHasActualMutationSink(
|
|
1037
|
+
source.slice(0, callStartIndex),
|
|
1038
|
+
afterCall,
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const ACTUAL_MUTATION_MEMBER_ACCESS = String.raw`(?:\?\.|\.)\s*(?:style(?:\s*(?:\.\s*[A-Za-z_$][\w$]*|\[[^\]]+\]))*\s*(?:=(?!=)|\+=|-=|\+\+|--)|style\s*(?:\?\.|\.)\s*(?:setProperty|removeProperty)\s*\(|classList\s*(?:\?\.|\.)\s*(?:add|remove|toggle|replace)\s*\(|className\s*(?:=(?!=)|\+=)|(?:setAttribute(?:NS)?|removeAttribute|toggleAttribute|animate)\s*\()|\[\s*["'](?:setAttribute(?:NS)?|removeAttribute|toggleAttribute|animate)["']\s*\]\s*\(`;
|
|
1043
|
+
|
|
1044
|
+
function selectedElementHasActualMutationSink(suffix: string): boolean {
|
|
1045
|
+
return new RegExp(
|
|
1046
|
+
String.raw`^\s*(?:\[\s*\d+\s*\]\s*)?${ACTUAL_MUTATION_MEMBER_ACCESS}`,
|
|
1047
|
+
"i",
|
|
1048
|
+
).test(suffix);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function identifierHasActualMutationSink(
|
|
1052
|
+
source: string,
|
|
1053
|
+
identifier: string,
|
|
1054
|
+
): boolean {
|
|
1055
|
+
const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1056
|
+
return (
|
|
1057
|
+
new RegExp(
|
|
1058
|
+
`\\b${escaped}\\s*(?:${ACTUAL_MUTATION_MEMBER_ACCESS})`,
|
|
1059
|
+
"i",
|
|
1060
|
+
).test(source) ||
|
|
1061
|
+
new RegExp(
|
|
1062
|
+
`\\bObject\\s*\\.\\s*assign\\s*\\(\\s*${escaped}\\s*(?:\\?\\.|\\.)\\s*style\\b`,
|
|
1063
|
+
"i",
|
|
1064
|
+
).test(source)
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function identifierAliasHasActualMutationSink(
|
|
1069
|
+
source: string,
|
|
1070
|
+
identifier: string,
|
|
1071
|
+
): boolean {
|
|
1072
|
+
return identifierAliases(source, identifier).some((alias) =>
|
|
1073
|
+
identifierHasActualMutationSink(source, alias),
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function styleAliasHasActualMutationSink(
|
|
1078
|
+
source: string,
|
|
1079
|
+
identifier: string,
|
|
1080
|
+
): boolean {
|
|
1081
|
+
return identifierAliases(source, identifier).some((alias) => {
|
|
1082
|
+
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1083
|
+
return (
|
|
1084
|
+
new RegExp(
|
|
1085
|
+
`\\b${escaped}\\s*(?:(?:\\.\\s*[A-Za-z_$][\\w$]*|\\[\\s*[^\\]]+\\s*\\])\\s*(?:=(?!=)|\\+=|-=|\\+\\+|--)|(?:\\?\\.|\\.)\\s*(?:setProperty|removeProperty)\\s*\\()`,
|
|
1086
|
+
"i",
|
|
1087
|
+
).test(source) ||
|
|
1088
|
+
new RegExp(
|
|
1089
|
+
`\\bObject\\s*\\.\\s*assign\\s*\\(\\s*${escaped}(?:\\s*[,\\)])`,
|
|
1090
|
+
"i",
|
|
1091
|
+
).test(source)
|
|
1092
|
+
);
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function destructuredBindingsMayMutate(
|
|
1097
|
+
bindings: string,
|
|
1098
|
+
source: string,
|
|
1099
|
+
): boolean {
|
|
1100
|
+
return bindings.split(",").some((binding) => {
|
|
1101
|
+
const [property, alias] = binding.split(/\s*:\s*/);
|
|
1102
|
+
const name = alias?.trim() || property?.trim();
|
|
1103
|
+
if (!name) return false;
|
|
1104
|
+
if (property?.trim() === "classList") {
|
|
1105
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1106
|
+
return new RegExp(
|
|
1107
|
+
`\\b${escaped}\\s*(?:\\?\\.|\\.)\\s*(?:add|remove|toggle|replace)\\s*\\(`,
|
|
1108
|
+
"i",
|
|
1109
|
+
).test(source);
|
|
1110
|
+
}
|
|
1111
|
+
return (
|
|
1112
|
+
property?.trim() === "style" &&
|
|
1113
|
+
styleAliasHasActualMutationSink(source, name)
|
|
1114
|
+
);
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function querySelectorAllCallbackMutationStatus(
|
|
1119
|
+
source: string,
|
|
1120
|
+
): boolean | null {
|
|
1121
|
+
const methodCall =
|
|
1122
|
+
/^\s*[\])]*\s*\.\s*(?:forEach|map|filter|some|every)\s*\(/i.exec(source);
|
|
1123
|
+
const callback =
|
|
1124
|
+
/^\s*[\])]*\s*\.\s*(?:forEach|map|filter|some|every)\s*\(\s*(?:async\s+)?(?:\(\s*([\w$]+)\s*\)|([\w$]+)|\(\s*\{\s*([^}]+)\s*\}\s*\))\s*=>/i.exec(
|
|
1125
|
+
source,
|
|
1126
|
+
);
|
|
1127
|
+
if (!methodCall) return null;
|
|
1128
|
+
if (!callback) return true;
|
|
1129
|
+
|
|
1130
|
+
const methodOpenIndex = methodCall[0].lastIndexOf("(");
|
|
1131
|
+
const callEnd = findSelectorCallEnd(
|
|
1132
|
+
source,
|
|
1133
|
+
methodCall.index + methodOpenIndex,
|
|
1134
|
+
);
|
|
1135
|
+
if (callEnd === null) return true;
|
|
1136
|
+
|
|
1137
|
+
const callbackBody = source.slice(callback[0].length, callEnd - 1);
|
|
1138
|
+
const callbackParameter = callback[1] ?? callback[2];
|
|
1139
|
+
if (callbackParameter) {
|
|
1140
|
+
return identifierAliasHasActualMutationSink(
|
|
1141
|
+
callbackBody,
|
|
1142
|
+
callbackParameter,
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
return (
|
|
1146
|
+
callback[3] !== undefined &&
|
|
1147
|
+
destructuredBindingsMayMutate(callback[3], callbackBody)
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function destructuredSelectorCallHasActualMutationSink(
|
|
1152
|
+
prefix: string,
|
|
1153
|
+
suffix: string,
|
|
1154
|
+
): boolean {
|
|
1155
|
+
const assignment = /\b(?:const|let|var)\s*\{\s*([^}]+)\s*\}\s*=\s*$/.exec(
|
|
1156
|
+
prefix,
|
|
1157
|
+
);
|
|
1158
|
+
if (!assignment) return false;
|
|
1159
|
+
|
|
1160
|
+
return destructuredBindingsMayMutate(assignment[1]!, suffix);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function assignedAliases(
|
|
1164
|
+
source: string,
|
|
1165
|
+
initialExpression: RegExp,
|
|
1166
|
+
): Set<string> {
|
|
1167
|
+
const aliases = new Set<string>();
|
|
1168
|
+
const assignments =
|
|
1169
|
+
/(?:\b(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*=\s*([^;,\n]+)/g;
|
|
1170
|
+
let match: RegExpExecArray | null;
|
|
1171
|
+
let changed = true;
|
|
1172
|
+
while (changed) {
|
|
1173
|
+
changed = false;
|
|
1174
|
+
assignments.lastIndex = 0;
|
|
1175
|
+
while ((match = assignments.exec(source)) !== null) {
|
|
1176
|
+
const name = match[1]!;
|
|
1177
|
+
const expression = match[2]!.trim().replace(/^\(|\)$/g, "");
|
|
1178
|
+
const sourceAlias = /^([A-Za-z_$][\w$]*)\b/.exec(expression)?.[1];
|
|
1179
|
+
if (
|
|
1180
|
+
!aliases.has(name) &&
|
|
1181
|
+
(initialExpression.test(expression) ||
|
|
1182
|
+
(sourceAlias !== undefined && aliases.has(sourceAlias)))
|
|
1183
|
+
) {
|
|
1184
|
+
aliases.add(name);
|
|
1185
|
+
changed = true;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return aliases;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function identifierMutationSink(identifier: string): RegExp {
|
|
1193
|
+
const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1194
|
+
return new RegExp(`\\b${escaped}\\s*(?:${MUTATION_MEMBER_ACCESS})`, "i");
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function identifierMayFeedMutationSink(
|
|
1198
|
+
source: string,
|
|
1199
|
+
identifier: string,
|
|
1200
|
+
): boolean {
|
|
1201
|
+
const aliases = identifierAliases(source, identifier);
|
|
1202
|
+
return aliases.some((alias) => {
|
|
1203
|
+
if (elementMutationSinkForIdentifier(alias).test(source)) return true;
|
|
1204
|
+
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1205
|
+
return new RegExp(
|
|
1206
|
+
`\\b${escaped}(?:\\s*(?:\\?\\.|\\.)\\s*[A-Za-z_$][\\w$]*|\\s*\\[\\s*["'][^"']+["']\\s*\\]){1,8}\\s*(?:${MUTATION_MEMBER_ACCESS})`,
|
|
1207
|
+
"i",
|
|
1208
|
+
).test(source);
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function handlerTargetAliasMayMutate(value: string): boolean {
|
|
1213
|
+
const targetExpression =
|
|
1214
|
+
/^(?:this|\$el|(?:event|e|\$event)\s*(?:\.\s*(?:currentTarget|target)|\[\s*["'](?:currentTarget|target)["']\s*\]))/i;
|
|
1215
|
+
const aliases = assignedAliases(value, targetExpression);
|
|
1216
|
+
return [...aliases].some((alias) =>
|
|
1217
|
+
identifierMutationSink(alias).test(value),
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function eventTargetAliasMayMutate(value: string): boolean {
|
|
1222
|
+
const targetExpression =
|
|
1223
|
+
/^(?:event|e|\$event)\s*(?:\.\s*target|\[\s*["']target["']\s*\])/i;
|
|
1224
|
+
const aliases = assignedAliases(value, targetExpression);
|
|
1225
|
+
return [...aliases].some((alias) =>
|
|
1226
|
+
identifierMutationSink(alias).test(value),
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function containsMotionTarget(
|
|
1231
|
+
node: HtmlNode,
|
|
1232
|
+
nodes: readonly HtmlNode[],
|
|
1233
|
+
targetNodeIds: ReadonlySet<string>,
|
|
1234
|
+
generatedIds: Map<number | undefined, string>,
|
|
1235
|
+
): boolean {
|
|
1236
|
+
return nodes.some((candidate) => {
|
|
1237
|
+
if (!isMotionTargetNode(candidate, targetNodeIds, generatedIds))
|
|
1238
|
+
return false;
|
|
1239
|
+
let current = candidate.parentNode;
|
|
1240
|
+
while (current) {
|
|
1241
|
+
if (current === node) return true;
|
|
1242
|
+
current = current.parentNode;
|
|
1243
|
+
}
|
|
1244
|
+
return false;
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function isMotionTargetNode(
|
|
1249
|
+
node: HtmlNode,
|
|
1250
|
+
targetNodeIds: ReadonlySet<string>,
|
|
1251
|
+
generatedIds: Map<number | undefined, string>,
|
|
1252
|
+
): boolean {
|
|
1253
|
+
const targetAttrs = attributes(node, NODE_ID_ATTRIBUTE);
|
|
1254
|
+
if (targetAttrs.some(({ value }) => targetNodeIds.has(value))) return true;
|
|
1255
|
+
const generatedId = generatedIds.get(
|
|
1256
|
+
node.sourceCodeLocation?.startTag?.startOffset,
|
|
1257
|
+
);
|
|
1258
|
+
return generatedId !== undefined && targetNodeIds.has(generatedId);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function handlerSelectsAndMutatesMotionTarget(
|
|
1262
|
+
value: string,
|
|
1263
|
+
nodes: readonly HtmlNode[],
|
|
1264
|
+
targetIds: ReadonlySet<string>,
|
|
1265
|
+
generatedIds: Map<number | undefined, string>,
|
|
1266
|
+
): boolean {
|
|
1267
|
+
const usesDocumentSelector =
|
|
1268
|
+
/\b(?:document|window|globalThis|self)\b/i.test(value) &&
|
|
1269
|
+
/\b(?:querySelector(?:All)?|getElementById|getElementsBy(?:ClassName|Name|TagName))\b/i.test(
|
|
1270
|
+
value,
|
|
1271
|
+
);
|
|
1272
|
+
const destructuredMutationCandidate =
|
|
1273
|
+
/\b(?:const|let|var)\s*\{[^}]*\b(?:style|classList)\b[^}]*\}\s*=|\(\s*\{[^}]*\b(?:style|classList)\b[^}]*\}\s*\)\s*=>/i.test(
|
|
1274
|
+
value,
|
|
1275
|
+
);
|
|
1276
|
+
if (
|
|
1277
|
+
!usesDocumentSelector ||
|
|
1278
|
+
(!MUTATION_MEMBER_ACCESS_PATTERN.test(value) &&
|
|
1279
|
+
!destructuredMutationCandidate)
|
|
1280
|
+
)
|
|
1281
|
+
return false;
|
|
1282
|
+
|
|
1283
|
+
const selectorCalls =
|
|
1284
|
+
/\b(?:document|window|globalThis|self)\s*(?:\.\s*document|\[\s*["']document["']\s*\])?\s*(?:\.\s*(querySelector(?:All)?|getElementById|getElementsBy(?:ClassName|Name|TagName))|\[\s*["'](querySelector(?:All)?|getElementById|getElementsBy(?:ClassName|Name|TagName))["']\s*\])\s*\(/gi;
|
|
1285
|
+
let foundSelectorCall = false;
|
|
1286
|
+
let match: RegExpExecArray | null;
|
|
1287
|
+
|
|
1288
|
+
while ((match = selectorCalls.exec(value)) !== null) {
|
|
1289
|
+
foundSelectorCall = true;
|
|
1290
|
+
const openParenIndex = selectorCalls.lastIndex - 1;
|
|
1291
|
+
const method = match[1] ?? match[2]!;
|
|
1292
|
+
const callEndIndex = findSelectorCallEnd(value, openParenIndex);
|
|
1293
|
+
if (callEndIndex === null) return true;
|
|
1294
|
+
if (
|
|
1295
|
+
!selectorCallFeedsMutationSink(value, match.index, callEndIndex, method)
|
|
1296
|
+
) {
|
|
1297
|
+
selectorCalls.lastIndex = callEndIndex;
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const selectorArgument = readStaticSelectorArgument(
|
|
1302
|
+
value.slice(openParenIndex + 1, callEndIndex - 1),
|
|
1303
|
+
);
|
|
1304
|
+
if (!selectorArgument) return true;
|
|
1305
|
+
|
|
1306
|
+
const selector = selectorForDocumentCall(method, selectorArgument);
|
|
1307
|
+
if (!selector) return true;
|
|
1308
|
+
|
|
1309
|
+
const traversal = selectorCallTraversal(
|
|
1310
|
+
value,
|
|
1311
|
+
match.index,
|
|
1312
|
+
callEndIndex,
|
|
1313
|
+
method,
|
|
1314
|
+
);
|
|
1315
|
+
if (
|
|
1316
|
+
selectorMayTargetMotionNode(
|
|
1317
|
+
selector,
|
|
1318
|
+
nodes,
|
|
1319
|
+
targetIds,
|
|
1320
|
+
generatedIds,
|
|
1321
|
+
traversal,
|
|
1322
|
+
)
|
|
1323
|
+
) {
|
|
1324
|
+
return true;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
return !foundSelectorCall;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function findSelectorCallEnd(
|
|
1332
|
+
source: string,
|
|
1333
|
+
openParenIndex: number,
|
|
1334
|
+
): number | null {
|
|
1335
|
+
let depth = 0;
|
|
1336
|
+
let quote: "'" | '"' | "`" | null = null;
|
|
1337
|
+
let escaped = false;
|
|
1338
|
+
|
|
1339
|
+
for (let index = openParenIndex; index < source.length; index++) {
|
|
1340
|
+
const character = source[index]!;
|
|
1341
|
+
if (quote) {
|
|
1342
|
+
if (escaped) escaped = false;
|
|
1343
|
+
else if (character === "\\") escaped = true;
|
|
1344
|
+
else if (character === quote) quote = null;
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
if (character === "'" || character === '"' || character === "`") {
|
|
1348
|
+
quote = character;
|
|
1349
|
+
} else if (character === "(") {
|
|
1350
|
+
depth++;
|
|
1351
|
+
} else if (character === ")" && --depth === 0) {
|
|
1352
|
+
return index + 1;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function readStaticSelectorArgument(source: string): string | null {
|
|
1360
|
+
const argument = source.trim();
|
|
1361
|
+
const quote = argument[0];
|
|
1362
|
+
if (
|
|
1363
|
+
(quote !== "'" && quote !== '"') ||
|
|
1364
|
+
argument[argument.length - 1] !== quote
|
|
1365
|
+
) {
|
|
1366
|
+
return null;
|
|
1367
|
+
}
|
|
1368
|
+
const value = argument.slice(1, -1);
|
|
1369
|
+
return value.includes("\\") || value.includes(quote) ? null : value;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function selectorCallFeedsMutationSink(
|
|
1373
|
+
source: string,
|
|
1374
|
+
callStartIndex: number,
|
|
1375
|
+
callEndIndex: number,
|
|
1376
|
+
method: string,
|
|
1377
|
+
): boolean {
|
|
1378
|
+
const afterCall = source.slice(callEndIndex);
|
|
1379
|
+
if (SELECTED_ELEMENT_MUTATION_SINK.test(afterCall)) return true;
|
|
1380
|
+
if (
|
|
1381
|
+
destructuredSelectorCallHasActualMutationSink(
|
|
1382
|
+
source.slice(0, callStartIndex),
|
|
1383
|
+
afterCall,
|
|
1384
|
+
)
|
|
1385
|
+
) {
|
|
1386
|
+
return true;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
if (method.toLowerCase() === "queryselectorall") {
|
|
1390
|
+
const callbackStatus = querySelectorAllCallbackMutationStatus(afterCall);
|
|
1391
|
+
if (callbackStatus !== null) return callbackStatus;
|
|
1392
|
+
if (
|
|
1393
|
+
arrayFromSelectorCallbackMayMutate(source, callStartIndex, callEndIndex)
|
|
1394
|
+
) {
|
|
1395
|
+
return true;
|
|
1396
|
+
}
|
|
1397
|
+
const forOfStatus = querySelectorAllForOfMutationStatus(
|
|
1398
|
+
source,
|
|
1399
|
+
callStartIndex,
|
|
1400
|
+
callEndIndex,
|
|
1401
|
+
);
|
|
1402
|
+
if (forOfStatus !== null) return forOfStatus;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const assignment =
|
|
1406
|
+
/(?:\b(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*=\s*$/.exec(
|
|
1407
|
+
source.slice(0, callStartIndex),
|
|
1408
|
+
);
|
|
1409
|
+
if (assignment) {
|
|
1410
|
+
return identifierMayFeedMutationSink(afterCall, assignment[1]!);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const remainingExpression = afterCall.trimStart();
|
|
1414
|
+
if (!remainingExpression || /^[;),&|?]/.test(remainingExpression))
|
|
1415
|
+
return false;
|
|
1416
|
+
return true;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function arrayFromSelectorCallbackMayMutate(
|
|
1420
|
+
source: string,
|
|
1421
|
+
callStartIndex: number,
|
|
1422
|
+
callEndIndex: number,
|
|
1423
|
+
): boolean {
|
|
1424
|
+
if (!/\bArray\s*\.\s*from\s*\(\s*$/.test(source.slice(0, callStartIndex)))
|
|
1425
|
+
return false;
|
|
1426
|
+
|
|
1427
|
+
const callback =
|
|
1428
|
+
/^\s*,\s*(?:async\s+)?(?:\(\s*([A-Za-z_$][\w$]*)(?:\s*,[^)]*)?\)|([A-Za-z_$][\w$]*))\s*=>/i.exec(
|
|
1429
|
+
source.slice(callEndIndex),
|
|
1430
|
+
);
|
|
1431
|
+
const callbackParameter = callback?.[1] ?? callback?.[2];
|
|
1432
|
+
return (
|
|
1433
|
+
callbackParameter !== undefined &&
|
|
1434
|
+
identifierAliasHasActualMutationSink(
|
|
1435
|
+
source.slice(callEndIndex),
|
|
1436
|
+
callbackParameter,
|
|
1437
|
+
)
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function querySelectorAllForOfMutationStatus(
|
|
1442
|
+
source: string,
|
|
1443
|
+
callStartIndex: number,
|
|
1444
|
+
callEndIndex: number,
|
|
1445
|
+
): boolean | null {
|
|
1446
|
+
const prefix = source.slice(0, callStartIndex);
|
|
1447
|
+
const forOfHeader = /\bfor\s*\(([^)]*)\bof\s*(?:\[\s*\.\.\.\s*)?$/i.exec(
|
|
1448
|
+
prefix,
|
|
1449
|
+
);
|
|
1450
|
+
if (!forOfHeader) return null;
|
|
1451
|
+
|
|
1452
|
+
const binding = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*$/.exec(
|
|
1453
|
+
forOfHeader[1]!,
|
|
1454
|
+
);
|
|
1455
|
+
if (!binding) return true;
|
|
1456
|
+
|
|
1457
|
+
const afterCall = source.slice(callEndIndex);
|
|
1458
|
+
const loopHeaderEnd = /^\s*\]?\s*\)\s*/.exec(afterCall);
|
|
1459
|
+
if (!loopHeaderEnd) return true;
|
|
1460
|
+
|
|
1461
|
+
const loopBody = afterCall.slice(loopHeaderEnd[0].length);
|
|
1462
|
+
if (loopBody.startsWith("{")) {
|
|
1463
|
+
const bodyEnd = findMatchingBlockEnd(loopBody, 0);
|
|
1464
|
+
if (bodyEnd === null) return true;
|
|
1465
|
+
return identifierAliasHasActualMutationSink(
|
|
1466
|
+
loopBody.slice(1, bodyEnd),
|
|
1467
|
+
binding[1]!,
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
const statementEnd = loopBody.indexOf(";");
|
|
1472
|
+
const statement =
|
|
1473
|
+
statementEnd < 0 ? loopBody : loopBody.slice(0, statementEnd);
|
|
1474
|
+
return identifierAliasHasActualMutationSink(statement, binding[1]!);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function findMatchingBlockEnd(
|
|
1478
|
+
source: string,
|
|
1479
|
+
openBraceIndex: number,
|
|
1480
|
+
): number | null {
|
|
1481
|
+
let depth = 0;
|
|
1482
|
+
let quote: "'" | '"' | "`" | null = null;
|
|
1483
|
+
let escaped = false;
|
|
1484
|
+
|
|
1485
|
+
for (let index = openBraceIndex; index < source.length; index++) {
|
|
1486
|
+
const character = source[index]!;
|
|
1487
|
+
if (quote) {
|
|
1488
|
+
if (escaped) escaped = false;
|
|
1489
|
+
else if (character === "\\") escaped = true;
|
|
1490
|
+
else if (character === quote) quote = null;
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
if (character === "'" || character === '"' || character === "`") {
|
|
1494
|
+
quote = character;
|
|
1495
|
+
} else if (character === "{") {
|
|
1496
|
+
depth++;
|
|
1497
|
+
} else if (character === "}" && --depth === 0) {
|
|
1498
|
+
return index;
|
|
1499
|
+
}
|
|
283
1500
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const range = assertSourceRanges(source, node);
|
|
287
|
-
return { node, css: textContent(node), range };
|
|
1501
|
+
|
|
1502
|
+
return null;
|
|
288
1503
|
}
|
|
289
1504
|
|
|
290
|
-
|
|
291
|
-
|
|
1505
|
+
const SELECTED_ELEMENT_MUTATION_SINK = new RegExp(
|
|
1506
|
+
`^\\s*(?:\\[\\s*\\d+\\s*\\]\\s*)?(?:${MUTATION_MEMBER_ACCESS})`,
|
|
1507
|
+
"i",
|
|
1508
|
+
);
|
|
1509
|
+
|
|
1510
|
+
function elementMutationSinkForIdentifier(identifier: string): RegExp {
|
|
1511
|
+
const escapedIdentifier = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1512
|
+
return new RegExp(
|
|
1513
|
+
`\\b${escapedIdentifier}\\s*(?:${MUTATION_MEMBER_ACCESS})`,
|
|
1514
|
+
"i",
|
|
1515
|
+
);
|
|
292
1516
|
}
|
|
293
1517
|
|
|
294
|
-
function
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
1518
|
+
function identifierAliases(source: string, initial: string): string[] {
|
|
1519
|
+
const aliases = new Set([initial]);
|
|
1520
|
+
const assignments =
|
|
1521
|
+
/(?:\b(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\b/g;
|
|
1522
|
+
let changed = true;
|
|
1523
|
+
while (changed) {
|
|
1524
|
+
changed = false;
|
|
1525
|
+
assignments.lastIndex = 0;
|
|
1526
|
+
let match: RegExpExecArray | null;
|
|
1527
|
+
while ((match = assignments.exec(source)) !== null) {
|
|
1528
|
+
if (!aliases.has(match[2]!) || aliases.has(match[1]!)) continue;
|
|
1529
|
+
aliases.add(match[1]!);
|
|
1530
|
+
changed = true;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return [...aliases];
|
|
298
1534
|
}
|
|
299
1535
|
|
|
300
|
-
function
|
|
1536
|
+
function selectorCallTraversal(
|
|
1537
|
+
source: string,
|
|
1538
|
+
callStartIndex: number,
|
|
1539
|
+
callEndIndex: number,
|
|
1540
|
+
method: string,
|
|
1541
|
+
): { steps: string[]; unsupported: boolean } | null {
|
|
1542
|
+
if (method.toLowerCase() === "queryselectorall") return null;
|
|
1543
|
+
|
|
1544
|
+
const afterCall = source.slice(callEndIndex);
|
|
301
1545
|
if (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
node.insideTemplate &&
|
|
305
|
-
node.tagName === "script" &&
|
|
306
|
-
(attributes(node, MOTION_TEXT_SOURCE_ATTRIBUTE).length > 0 ||
|
|
307
|
-
attributes(node, MOTION_TEXT_RUNTIME_ATTRIBUTE).length > 0),
|
|
1546
|
+
/(?:\.\s*(?:parentElement|parentNode|children|childNodes|firstElementChild|lastElementChild|firstChild|lastChild|closest|matches|querySelector(?:All)?)\b|\[\s*["'](?:parentElement|parentNode|children|childNodes|firstElementChild|lastElementChild|firstChild|lastChild|closest|matches|querySelector(?:All)?)["']\s*\])/i.test(
|
|
1547
|
+
afterCall,
|
|
308
1548
|
)
|
|
309
1549
|
) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
1550
|
+
return { steps: [], unsupported: true };
|
|
1551
|
+
}
|
|
1552
|
+
const parseSteps = (suffix: string) => {
|
|
1553
|
+
const access =
|
|
1554
|
+
/\s*(?:\?\.|\.)\s*(nextElementSibling|previousElementSibling)\b|\s*\[\s*["'](nextElementSibling|previousElementSibling)["']\s*\]/iy;
|
|
1555
|
+
const steps: string[] = [];
|
|
1556
|
+
let offset = 0;
|
|
1557
|
+
while (true) {
|
|
1558
|
+
access.lastIndex = offset;
|
|
1559
|
+
const match = access.exec(suffix);
|
|
1560
|
+
if (!match) break;
|
|
1561
|
+
steps.push((match[1] ?? match[2]!).toLowerCase());
|
|
1562
|
+
offset = access.lastIndex;
|
|
1563
|
+
}
|
|
1564
|
+
if (steps.length > 0) {
|
|
1565
|
+
return SELECTED_ELEMENT_MUTATION_SINK.test(suffix.slice(offset))
|
|
1566
|
+
? { steps, unsupported: false }
|
|
1567
|
+
: null;
|
|
1568
|
+
}
|
|
1569
|
+
return null;
|
|
1570
|
+
};
|
|
1571
|
+
|
|
1572
|
+
const direct = parseSteps(afterCall);
|
|
1573
|
+
if (direct) return direct;
|
|
1574
|
+
|
|
1575
|
+
const assignment =
|
|
1576
|
+
/(?:\b(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*=\s*$/.exec(
|
|
1577
|
+
source.slice(0, callStartIndex),
|
|
313
1578
|
);
|
|
1579
|
+
const receiver = assignment?.[1];
|
|
1580
|
+
if (!receiver) {
|
|
1581
|
+
return /^\s*(?:\?\.|\.)[A-Za-z_$]/.test(afterCall) &&
|
|
1582
|
+
!SELECTED_ELEMENT_MUTATION_SINK.test(afterCall)
|
|
1583
|
+
? { steps: [], unsupported: true }
|
|
1584
|
+
: null;
|
|
1585
|
+
}
|
|
1586
|
+
const escaped = receiver.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1587
|
+
const traversal = new RegExp(
|
|
1588
|
+
`\\b${escaped}((?:(?:\\?\\.|\\.)\\s*(?:nextElementSibling|previousElementSibling)\\b|\\[\\s*["'](?:nextElementSibling|previousElementSibling)["']\\s*\\]))+`,
|
|
1589
|
+
"i",
|
|
1590
|
+
);
|
|
1591
|
+
const match = traversal.exec(afterCall);
|
|
1592
|
+
if (!match) {
|
|
1593
|
+
const intermediate = new RegExp(
|
|
1594
|
+
`\\b${escaped}(?:\\s*(?:\\?\\.|\\.)\\s*|\\s*\\[\\s*["'][^"']+["']\\s*\\])([A-Za-z_$][\\w$]*)[^;]*(?:${MUTATION_MEMBER_ACCESS})`,
|
|
1595
|
+
"i",
|
|
1596
|
+
).exec(afterCall);
|
|
1597
|
+
return intermediate &&
|
|
1598
|
+
!/^(?:style|classList|className|setAttribute|removeAttribute|animate)$/i.test(
|
|
1599
|
+
intermediate[1]!,
|
|
1600
|
+
)
|
|
1601
|
+
? { steps: [], unsupported: true }
|
|
1602
|
+
: null;
|
|
314
1603
|
}
|
|
315
|
-
}
|
|
316
1604
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
1605
|
+
const steps: string[] = [];
|
|
1606
|
+
const access =
|
|
1607
|
+
/(?:\?\.|\.)\s*(nextElementSibling|previousElementSibling)\b|\[\s*["'](nextElementSibling|previousElementSibling)["']\s*\]/gi;
|
|
1608
|
+
let accessMatch: RegExpExecArray | null;
|
|
1609
|
+
while ((accessMatch = access.exec(match[1]!)) !== null) {
|
|
1610
|
+
steps.push((accessMatch[1] ?? accessMatch[2]!).toLowerCase());
|
|
1611
|
+
}
|
|
1612
|
+
const suffix = afterCall.slice(match.index + match[0].length);
|
|
1613
|
+
if (SELECTED_ELEMENT_MUTATION_SINK.test(suffix)) {
|
|
1614
|
+
return { steps, unsupported: false };
|
|
1615
|
+
}
|
|
1616
|
+
const assignedTraversal =
|
|
1617
|
+
/^\s*;?\s*(?:\b(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*=/.exec(suffix);
|
|
1618
|
+
if (
|
|
1619
|
+
assignedTraversal &&
|
|
1620
|
+
identifierMayFeedMutationSink(suffix, assignedTraversal[1]!)
|
|
1621
|
+
) {
|
|
1622
|
+
return { steps, unsupported: false };
|
|
1623
|
+
}
|
|
1624
|
+
return { steps, unsupported: true };
|
|
329
1625
|
}
|
|
330
1626
|
|
|
331
|
-
function
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
1627
|
+
function selectorForDocumentCall(
|
|
1628
|
+
method: string,
|
|
1629
|
+
argument: string,
|
|
1630
|
+
): string | null {
|
|
1631
|
+
switch (method.toLowerCase()) {
|
|
1632
|
+
case "queryselector":
|
|
1633
|
+
case "queryselectorall":
|
|
1634
|
+
return argument;
|
|
1635
|
+
case "getelementbyid":
|
|
1636
|
+
return `#${argument}`;
|
|
1637
|
+
case "getelementsbyclassname":
|
|
1638
|
+
return `.${argument}`;
|
|
1639
|
+
case "getelementsbyname":
|
|
1640
|
+
return `[name="${argument}"]`;
|
|
1641
|
+
case "getelementsbytagname":
|
|
1642
|
+
return argument;
|
|
1643
|
+
default:
|
|
1644
|
+
return null;
|
|
1645
|
+
}
|
|
345
1646
|
}
|
|
346
1647
|
|
|
347
|
-
function
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
1648
|
+
function selectorMayTargetMotionNode(
|
|
1649
|
+
selector: string,
|
|
1650
|
+
nodes: readonly HtmlNode[],
|
|
1651
|
+
targetNodeIds: ReadonlySet<string>,
|
|
1652
|
+
generatedIds: Map<number | undefined, string>,
|
|
1653
|
+
traversal: { steps: string[]; unsupported: boolean } | null = null,
|
|
1654
|
+
): boolean {
|
|
1655
|
+
const parsed = parseMotionAuthoredCssSimpleSelector(selector);
|
|
1656
|
+
if (!parsed) return true;
|
|
1657
|
+
const selectorAttribute =
|
|
1658
|
+
parsed.kind === "id"
|
|
1659
|
+
? "id"
|
|
1660
|
+
: parsed.kind === "class"
|
|
1661
|
+
? "class"
|
|
1662
|
+
: parsed.kind === "attribute"
|
|
1663
|
+
? parsed.name
|
|
1664
|
+
: null;
|
|
359
1665
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
1666
|
+
const motionTargets = nodes.filter((node) =>
|
|
1667
|
+
isMotionTargetNode(node, targetNodeIds, generatedIds),
|
|
1668
|
+
);
|
|
1669
|
+
if (traversal?.unsupported) return motionTargets.length > 0;
|
|
1670
|
+
|
|
1671
|
+
if (
|
|
1672
|
+
motionTargets.some((node) =>
|
|
1673
|
+
(node.attrs ?? []).some(({ name }) =>
|
|
1674
|
+
isDynamicHtmlAttribute(name, selectorAttribute),
|
|
1675
|
+
),
|
|
1676
|
+
)
|
|
1677
|
+
) {
|
|
1678
|
+
return true;
|
|
368
1679
|
}
|
|
369
|
-
}
|
|
370
1680
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
1681
|
+
let candidates = nodes.filter(
|
|
1682
|
+
(node) => node.tagName && matchesHtmlSelector(node, parsed),
|
|
1683
|
+
);
|
|
1684
|
+
for (const step of traversal?.steps ?? []) {
|
|
1685
|
+
candidates = candidates.flatMap((node) => {
|
|
1686
|
+
const parent = node.parentNode;
|
|
1687
|
+
const siblings = (parent?.childNodes ?? []).filter(
|
|
1688
|
+
(sibling) => sibling.tagName !== undefined,
|
|
1689
|
+
);
|
|
1690
|
+
const index = siblings.indexOf(node);
|
|
1691
|
+
const nextIndex = index + (step === "nextelementsibling" ? 1 : -1);
|
|
1692
|
+
const sibling = siblings[nextIndex];
|
|
1693
|
+
return sibling ? [sibling] : [];
|
|
1694
|
+
});
|
|
379
1695
|
}
|
|
1696
|
+
|
|
1697
|
+
return motionTargets.some((target) => candidates.includes(target));
|
|
380
1698
|
}
|
|
381
1699
|
|
|
382
|
-
function
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
1700
|
+
function findRuntimeStyleMutationInScope(
|
|
1701
|
+
scope: HtmlNode,
|
|
1702
|
+
): { name: string; value: string } | undefined {
|
|
1703
|
+
const pending = [...(scope.childNodes ?? [])];
|
|
1704
|
+
if (scope.content) pending.push(scope.content);
|
|
1705
|
+
|
|
1706
|
+
while (pending.length > 0) {
|
|
1707
|
+
const node = pending.pop()!;
|
|
1708
|
+
if (node.insideTemplate || node.tagName === "template") continue;
|
|
1709
|
+
|
|
1710
|
+
const attribute = node.attrs?.find(({ name, value }) =>
|
|
1711
|
+
isRuntimeStyleMutationHandler(name, value),
|
|
387
1712
|
);
|
|
1713
|
+
if (attribute) return attribute;
|
|
1714
|
+
|
|
1715
|
+
pending.push(...(node.childNodes ?? []));
|
|
1716
|
+
if (node.content) pending.push(node.content);
|
|
388
1717
|
}
|
|
1718
|
+
|
|
1719
|
+
return undefined;
|
|
389
1720
|
}
|
|
390
1721
|
|
|
391
1722
|
function assertSafeSourceFilePath(sourceFilePath: string): string {
|
|
@@ -538,9 +1869,23 @@ function assertTargetsExist(
|
|
|
538
1869
|
source: string,
|
|
539
1870
|
targetNodeIds: readonly string[],
|
|
540
1871
|
sourceFilePath?: string,
|
|
1872
|
+
checkExecutableScripts = true,
|
|
1873
|
+
checkEditSafety = true,
|
|
541
1874
|
): void {
|
|
542
1875
|
if (targetNodeIds.length === 0) return;
|
|
543
1876
|
const { nodes } = parseHtml(source);
|
|
1877
|
+
const generatedTargets = sourceFilePath
|
|
1878
|
+
? generatedHtmlTargets(source, sourceFilePath)
|
|
1879
|
+
: [];
|
|
1880
|
+
const generatedIds = new Map(
|
|
1881
|
+
generatedTargets.map((target) => [
|
|
1882
|
+
target.node.sourceCodeLocation?.startTag?.startOffset,
|
|
1883
|
+
target.nodeId,
|
|
1884
|
+
]),
|
|
1885
|
+
);
|
|
1886
|
+
if (checkExecutableScripts) {
|
|
1887
|
+
assertExecutableScriptsSafe(source, nodes, targetNodeIds, generatedIds);
|
|
1888
|
+
}
|
|
544
1889
|
const targets = new Map<string, HtmlNode[]>();
|
|
545
1890
|
for (const node of nodes) {
|
|
546
1891
|
if (!node.tagName) continue;
|
|
@@ -558,12 +1903,7 @@ function assertTargetsExist(
|
|
|
558
1903
|
targets.set(nodeId, matches);
|
|
559
1904
|
}
|
|
560
1905
|
const generatedById = new Map(
|
|
561
|
-
|
|
562
|
-
? generatedHtmlTargets(source, sourceFilePath).map((target) => [
|
|
563
|
-
target.nodeId,
|
|
564
|
-
target.node,
|
|
565
|
-
])
|
|
566
|
-
: [],
|
|
1906
|
+
generatedTargets.map((target) => [target.nodeId, target.node]),
|
|
567
1907
|
);
|
|
568
1908
|
|
|
569
1909
|
for (const nodeId of targetNodeIds) {
|
|
@@ -606,23 +1946,39 @@ function assertTargetsExist(
|
|
|
606
1946
|
`Motion target "${nodeId}" is on a non-rendered ${tagName} element.`,
|
|
607
1947
|
);
|
|
608
1948
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
name
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
if (conflictingMotionBinding) {
|
|
615
|
-
sourceError(
|
|
616
|
-
"unsupported-target",
|
|
617
|
-
`Motion target "${nodeId}" uses ${conflictingMotionBinding.name}, which cannot be synchronized with CSS Motion yet.`,
|
|
618
|
-
);
|
|
619
|
-
}
|
|
620
|
-
const inlineStyle = target.attrs?.find(({ name }) => name === "style");
|
|
621
|
-
if (inlineStyle && hasInlineMotionDeclarations(inlineStyle.value)) {
|
|
622
|
-
sourceError(
|
|
623
|
-
"unsupported-target",
|
|
624
|
-
`Motion target "${nodeId}" uses style, which cannot be synchronized with CSS Motion yet.`,
|
|
1949
|
+
if (checkEditSafety) {
|
|
1950
|
+
const conflictingMotionBinding = target.attrs?.find(
|
|
1951
|
+
({ name }) =>
|
|
1952
|
+
name.startsWith("x-transition") ||
|
|
1953
|
+
[":style", "x-bind", "x-bind:style", "v-bind:style"].includes(name),
|
|
625
1954
|
);
|
|
1955
|
+
if (conflictingMotionBinding) {
|
|
1956
|
+
sourceError(
|
|
1957
|
+
"unsupported-target",
|
|
1958
|
+
`Motion target "${nodeId}" uses ${conflictingMotionBinding.name}, which cannot be synchronized with CSS Motion yet.`,
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
const runtimeStyleMutation = findRuntimeStyleMutationAttribute(target);
|
|
1962
|
+
if (runtimeStyleMutation) {
|
|
1963
|
+
const { attribute, location } = runtimeStyleMutation;
|
|
1964
|
+
const runtimeScope =
|
|
1965
|
+
location === "target"
|
|
1966
|
+
? `uses ${attribute.name}`
|
|
1967
|
+
: location === "ancestor"
|
|
1968
|
+
? `has an ancestor using ${attribute.name}`
|
|
1969
|
+
: `is in a runtime scope using ${attribute.name}`;
|
|
1970
|
+
sourceError(
|
|
1971
|
+
"unsupported-target",
|
|
1972
|
+
`Motion target "${nodeId}" ${runtimeScope}, whose runtime expression cannot be proven free of style mutations.`,
|
|
1973
|
+
);
|
|
1974
|
+
}
|
|
1975
|
+
const inlineStyle = target.attrs?.find(({ name }) => name === "style");
|
|
1976
|
+
if (inlineStyle && hasInlineMotionDeclarations(inlineStyle.value)) {
|
|
1977
|
+
sourceError(
|
|
1978
|
+
"unsupported-target",
|
|
1979
|
+
`Motion target "${nodeId}" uses style, which cannot be synchronized with CSS Motion yet.`,
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
626
1982
|
}
|
|
627
1983
|
}
|
|
628
1984
|
}
|
|
@@ -685,10 +2041,57 @@ interface AuthoredHtmlMotion {
|
|
|
685
2041
|
rangesToRemove: MotionAuthoredCssSourceRange[];
|
|
686
2042
|
}
|
|
687
2043
|
|
|
2044
|
+
function htmlStyleTextMatchesSource(
|
|
2045
|
+
parsedText: string,
|
|
2046
|
+
sourceText: string,
|
|
2047
|
+
): boolean {
|
|
2048
|
+
const normalizeLineEndings = (text: string) => text.replace(/\r\n?/g, "\n");
|
|
2049
|
+
return normalizeLineEndings(parsedText) === normalizeLineEndings(sourceText);
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function staticAuthoredHtmlStyleContents(
|
|
2053
|
+
source: string,
|
|
2054
|
+
nodes: readonly HtmlNode[],
|
|
2055
|
+
): string[] {
|
|
2056
|
+
return nodes.flatMap((node) => {
|
|
2057
|
+
if (
|
|
2058
|
+
node.tagName !== "style" ||
|
|
2059
|
+
node.insideTemplate ||
|
|
2060
|
+
!isPotentialCssStyle(node) ||
|
|
2061
|
+
attributes(node, MANAGED_STYLE_ATTRIBUTE).length > 0
|
|
2062
|
+
) {
|
|
2063
|
+
return [];
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
const location = node.sourceCodeLocation;
|
|
2067
|
+
const contentStart = location?.startTag?.endOffset;
|
|
2068
|
+
const contentEnd = location?.endTag?.startOffset;
|
|
2069
|
+
if (
|
|
2070
|
+
contentStart === undefined ||
|
|
2071
|
+
contentEnd === undefined ||
|
|
2072
|
+
contentStart < 0 ||
|
|
2073
|
+
contentEnd < contentStart ||
|
|
2074
|
+
contentEnd > source.length
|
|
2075
|
+
) {
|
|
2076
|
+
return [];
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
const css = source.slice(contentStart, contentEnd);
|
|
2080
|
+
if (
|
|
2081
|
+
!htmlStyleTextMatchesSource(textContent(node), css) ||
|
|
2082
|
+
hasCssTemplateInterpolation(css)
|
|
2083
|
+
) {
|
|
2084
|
+
return [];
|
|
2085
|
+
}
|
|
2086
|
+
return [css];
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
|
|
688
2090
|
function readAuthoredHtmlMotion(
|
|
689
2091
|
source: string,
|
|
690
2092
|
nodes: HtmlNode[],
|
|
691
2093
|
sourceFilePath?: string,
|
|
2094
|
+
skipUnresolvedStylesheetCheck = false,
|
|
692
2095
|
): AuthoredHtmlMotion {
|
|
693
2096
|
const generatedIds = sourceFilePath
|
|
694
2097
|
? new Map(
|
|
@@ -703,6 +2106,7 @@ function readAuthoredHtmlMotion(
|
|
|
703
2106
|
const importedTargetNodeIds = new Set<string>();
|
|
704
2107
|
const usedKeyframeNames = new Set<string>();
|
|
705
2108
|
const keyframeNameCounts = new Map<string, number>();
|
|
2109
|
+
const transitionCssBlocks = staticAuthoredHtmlStyleContents(source, nodes);
|
|
706
2110
|
let durationMs = 1;
|
|
707
2111
|
|
|
708
2112
|
for (const node of nodes) {
|
|
@@ -727,7 +2131,7 @@ function readAuthoredHtmlMotion(
|
|
|
727
2131
|
continue;
|
|
728
2132
|
}
|
|
729
2133
|
const css = source.slice(contentStart, contentEnd);
|
|
730
|
-
if (textContent(node)
|
|
2134
|
+
if (!htmlStyleTextMatchesSource(textContent(node), css)) {
|
|
731
2135
|
if (/(?:^|[;{\s])(?:-?[a-z]+-)?animation(?:-[a-z-]+)?\s*:/i.test(css)) {
|
|
732
2136
|
sourceError(
|
|
733
2137
|
"unsupported-authored-motion",
|
|
@@ -736,8 +2140,11 @@ function readAuthoredHtmlMotion(
|
|
|
736
2140
|
}
|
|
737
2141
|
continue;
|
|
738
2142
|
}
|
|
739
|
-
const result = importAuthoredCssAnimations(
|
|
740
|
-
|
|
2143
|
+
const result = importAuthoredCssAnimations(
|
|
2144
|
+
css,
|
|
2145
|
+
(selector) =>
|
|
2146
|
+
resolveAuthoredHtmlTarget(source, nodes, selector, generatedIds),
|
|
2147
|
+
{ transitionCssBlocks },
|
|
741
2148
|
);
|
|
742
2149
|
if (result.status === "absent") {
|
|
743
2150
|
for (const name of result.definedKeyframeNames) {
|
|
@@ -748,10 +2155,10 @@ function readAuthoredHtmlMotion(
|
|
|
748
2155
|
if (result.status === "unsupported") {
|
|
749
2156
|
sourceError("unsupported-authored-motion", result.reason);
|
|
750
2157
|
}
|
|
751
|
-
if (hasUnresolvedStylesheet(nodes)) {
|
|
2158
|
+
if (!skipUnresolvedStylesheetCheck && hasUnresolvedStylesheet(nodes)) {
|
|
752
2159
|
sourceError(
|
|
753
2160
|
"unresolved-stylesheet",
|
|
754
|
-
"Resolve external
|
|
2161
|
+
"Resolve external or runtime-generated styles before editing or adding Motion.",
|
|
755
2162
|
);
|
|
756
2163
|
}
|
|
757
2164
|
for (const name of result.definedKeyframeNames) {
|
|
@@ -817,7 +2224,11 @@ function readAuthoredHtmlMotion(
|
|
|
817
2224
|
}
|
|
818
2225
|
|
|
819
2226
|
function hasUnresolvedStylesheet(nodes: readonly HtmlNode[]): boolean {
|
|
820
|
-
return
|
|
2227
|
+
return (
|
|
2228
|
+
hasLinkedStylesheet(nodes) ||
|
|
2229
|
+
hasImportedStylesheet(nodes) ||
|
|
2230
|
+
hasDynamicStyleContent(nodes)
|
|
2231
|
+
);
|
|
821
2232
|
}
|
|
822
2233
|
|
|
823
2234
|
function dynamicBoundAttributeName(attributeName: string): string | null {
|
|
@@ -834,6 +2245,9 @@ function hasLinkedStylesheet(nodes: readonly HtmlNode[]): boolean {
|
|
|
834
2245
|
if (node.tagName !== "link" || node.insideTemplate) return false;
|
|
835
2246
|
const relAttributes = attributes(node, "rel");
|
|
836
2247
|
const hrefAttributes = attributes(node, "href");
|
|
2248
|
+
const relTokens = relAttributes.flatMap((attribute) =>
|
|
2249
|
+
attribute.value.split(/[\t\n\f\r ]+/).map((value) => value.toLowerCase()),
|
|
2250
|
+
);
|
|
837
2251
|
const objectBinding = (node.attrs ?? []).some((attribute) =>
|
|
838
2252
|
["x-bind", "v-bind"].includes(attribute.name.toLowerCase()),
|
|
839
2253
|
);
|
|
@@ -849,17 +2263,93 @@ function hasLinkedStylesheet(nodes: readonly HtmlNode[]): boolean {
|
|
|
849
2263
|
hrefAttributes.some((attribute) => attribute.value.trim()) ||
|
|
850
2264
|
hasBoundAttribute("href") ||
|
|
851
2265
|
objectBinding;
|
|
852
|
-
const stylesheetRel =
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
2266
|
+
const stylesheetRel = relTokens.includes("stylesheet");
|
|
2267
|
+
const stylePreload =
|
|
2268
|
+
relTokens.includes("preload") &&
|
|
2269
|
+
(attributes(node, "as").some(
|
|
2270
|
+
(attribute) => attribute.value.toLowerCase() === "style",
|
|
2271
|
+
) ||
|
|
2272
|
+
hasBoundAttribute("as") ||
|
|
2273
|
+
objectBinding) &&
|
|
2274
|
+
(attributes(node, "onload").length > 0 ||
|
|
2275
|
+
hasBoundAttribute("onload") ||
|
|
2276
|
+
objectBinding);
|
|
857
2277
|
return (
|
|
858
|
-
hasHref &&
|
|
2278
|
+
hasHref &&
|
|
2279
|
+
(stylesheetRel ||
|
|
2280
|
+
hasBoundAttribute("rel") ||
|
|
2281
|
+
stylePreload ||
|
|
2282
|
+
objectBinding)
|
|
859
2283
|
);
|
|
860
2284
|
});
|
|
861
2285
|
}
|
|
862
2286
|
|
|
2287
|
+
function hasDynamicStyleContent(nodes: readonly HtmlNode[]): boolean {
|
|
2288
|
+
return nodes.some((node) => {
|
|
2289
|
+
if (node.tagName !== "style" || node.insideTemplate) return false;
|
|
2290
|
+
if (
|
|
2291
|
+
hasPotentialRuntimeMutationHandler(node.attrs) ||
|
|
2292
|
+
(node.attrs ?? []).some(({ name }) => {
|
|
2293
|
+
const normalized = name.toLowerCase();
|
|
2294
|
+
return (
|
|
2295
|
+
(normalized.startsWith("x-") && normalized !== "x-cloak") ||
|
|
2296
|
+
(normalized.startsWith("v-") && normalized !== "v-cloak") ||
|
|
2297
|
+
(normalized.startsWith("ng-") && normalized !== "ng-cloak") ||
|
|
2298
|
+
normalized.startsWith(":")
|
|
2299
|
+
);
|
|
2300
|
+
})
|
|
2301
|
+
) {
|
|
2302
|
+
return true;
|
|
2303
|
+
}
|
|
2304
|
+
if (!isPotentialCssStyle(node)) return false;
|
|
2305
|
+
if (hasCssTemplateInterpolation(textContent(node))) {
|
|
2306
|
+
return true;
|
|
2307
|
+
}
|
|
2308
|
+
return (node.attrs ?? []).some((attribute) => {
|
|
2309
|
+
const name = attribute.name.toLowerCase();
|
|
2310
|
+
return (
|
|
2311
|
+
[
|
|
2312
|
+
"x-text",
|
|
2313
|
+
"x-html",
|
|
2314
|
+
"v-text",
|
|
2315
|
+
"v-html",
|
|
2316
|
+
"ng-bind",
|
|
2317
|
+
"ng-bind-html",
|
|
2318
|
+
].includes(name) ||
|
|
2319
|
+
["innerhtml", "textcontent"].includes(
|
|
2320
|
+
dynamicBoundAttributeName(name) ?? "",
|
|
2321
|
+
)
|
|
2322
|
+
);
|
|
2323
|
+
});
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
function hasCssTemplateInterpolation(css: string): boolean {
|
|
2328
|
+
let quote: "'" | '"' | null = null;
|
|
2329
|
+
let escaped = false;
|
|
2330
|
+
|
|
2331
|
+
for (let index = 0; index < css.length; index++) {
|
|
2332
|
+
const character = css[index]!;
|
|
2333
|
+
if (quote) {
|
|
2334
|
+
if (escaped) escaped = false;
|
|
2335
|
+
else if (character === "\\") escaped = true;
|
|
2336
|
+
else if (character === quote) quote = null;
|
|
2337
|
+
continue;
|
|
2338
|
+
}
|
|
2339
|
+
if (character === "'" || character === '"') {
|
|
2340
|
+
quote = character;
|
|
2341
|
+
} else if (
|
|
2342
|
+
css.startsWith("{{", index) ||
|
|
2343
|
+
css.startsWith("${", index) ||
|
|
2344
|
+
css.startsWith("<%", index)
|
|
2345
|
+
) {
|
|
2346
|
+
return true;
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
return false;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
863
2353
|
function hasImportedStylesheet(nodes: readonly HtmlNode[]): boolean {
|
|
864
2354
|
for (const node of nodes) {
|
|
865
2355
|
if (
|
|
@@ -907,6 +2397,21 @@ function isPotentialCssStyle(node: HtmlNode): boolean {
|
|
|
907
2397
|
function isSupportedAuthoredHtmlStyle(node: HtmlNode): boolean {
|
|
908
2398
|
for (const attribute of node.attrs ?? []) {
|
|
909
2399
|
const name = attribute.name.toLowerCase();
|
|
2400
|
+
if (
|
|
2401
|
+
[
|
|
2402
|
+
"x-text",
|
|
2403
|
+
"x-html",
|
|
2404
|
+
"v-text",
|
|
2405
|
+
"v-html",
|
|
2406
|
+
"ng-bind",
|
|
2407
|
+
"ng-bind-html",
|
|
2408
|
+
].includes(name) ||
|
|
2409
|
+
["innerhtml", "textcontent"].includes(
|
|
2410
|
+
dynamicBoundAttributeName(name) ?? "",
|
|
2411
|
+
)
|
|
2412
|
+
) {
|
|
2413
|
+
return false;
|
|
2414
|
+
}
|
|
910
2415
|
if (
|
|
911
2416
|
/^(?::|x-bind:|v-bind:)(?:media|disabled|title|scoped|type)$/.test(name)
|
|
912
2417
|
) {
|
|
@@ -982,6 +2487,7 @@ function isDynamicHtmlAttribute(
|
|
|
982
2487
|
const normalized = name.toLowerCase();
|
|
983
2488
|
return (
|
|
984
2489
|
normalized === "x-bind" ||
|
|
2490
|
+
normalized === "v-bind" ||
|
|
985
2491
|
[
|
|
986
2492
|
`:${selectorAttribute}`,
|
|
987
2493
|
`x-bind:${selectorAttribute}`,
|
|
@@ -1018,9 +2524,15 @@ function matchesHtmlSelector(
|
|
|
1018
2524
|
function removeAuthoredHtmlMotion(
|
|
1019
2525
|
source: string,
|
|
1020
2526
|
sourceFilePath?: string,
|
|
2527
|
+
cleanupOnly = false,
|
|
1021
2528
|
): string {
|
|
1022
2529
|
const { nodes } = parseHtml(source);
|
|
1023
|
-
const authored = readAuthoredHtmlMotion(
|
|
2530
|
+
const authored = readAuthoredHtmlMotion(
|
|
2531
|
+
source,
|
|
2532
|
+
nodes,
|
|
2533
|
+
sourceFilePath,
|
|
2534
|
+
cleanupOnly,
|
|
2535
|
+
);
|
|
1024
2536
|
return authored.rangesToRemove.length > 0
|
|
1025
2537
|
? removeAuthoredCssSourceRanges(source, authored.rangesToRemove)
|
|
1026
2538
|
: source;
|
|
@@ -1029,6 +2541,10 @@ function removeAuthoredHtmlMotion(
|
|
|
1029
2541
|
function readSource(
|
|
1030
2542
|
source: string,
|
|
1031
2543
|
sourceFilePath?: string,
|
|
2544
|
+
options: {
|
|
2545
|
+
allowUnmanagedExecutableScripts?: boolean;
|
|
2546
|
+
cleanupOnly?: boolean;
|
|
2547
|
+
} = {},
|
|
1032
2548
|
): Exclude<MotionHtmlSourceReadResult, { status: "invalid" }> {
|
|
1033
2549
|
const { nodes } = parseHtml(source);
|
|
1034
2550
|
assertMotionTextOutsideTemplates(nodes);
|
|
@@ -1062,7 +2578,12 @@ function readSource(
|
|
|
1062
2578
|
);
|
|
1063
2579
|
}
|
|
1064
2580
|
}
|
|
1065
|
-
const authoredMotion = readAuthoredHtmlMotion(
|
|
2581
|
+
const authoredMotion = readAuthoredHtmlMotion(
|
|
2582
|
+
source,
|
|
2583
|
+
nodes,
|
|
2584
|
+
sourceFilePath,
|
|
2585
|
+
options.cleanupOnly,
|
|
2586
|
+
);
|
|
1066
2587
|
if (authoredMotion.document) {
|
|
1067
2588
|
try {
|
|
1068
2589
|
document = mergeAuthoredCssMotionDocument(
|
|
@@ -1079,14 +2600,24 @@ function readSource(
|
|
|
1079
2600
|
}
|
|
1080
2601
|
}
|
|
1081
2602
|
const textDocument = readMotionText(source);
|
|
1082
|
-
if (
|
|
2603
|
+
if (
|
|
2604
|
+
!options.cleanupOnly &&
|
|
2605
|
+
(document || textDocument) &&
|
|
2606
|
+
hasUnresolvedStylesheet(nodes)
|
|
2607
|
+
) {
|
|
1083
2608
|
sourceError(
|
|
1084
2609
|
"unresolved-stylesheet",
|
|
1085
|
-
"Resolve external
|
|
2610
|
+
"Resolve external or runtime-generated styles before editing or adding Motion.",
|
|
1086
2611
|
);
|
|
1087
2612
|
}
|
|
1088
2613
|
const targetNodeIds = targetsFromDocuments(document, textDocument);
|
|
1089
|
-
assertTargetsExist(
|
|
2614
|
+
assertTargetsExist(
|
|
2615
|
+
source,
|
|
2616
|
+
targetNodeIds,
|
|
2617
|
+
sourceFilePath,
|
|
2618
|
+
!options.allowUnmanagedExecutableScripts,
|
|
2619
|
+
!options.cleanupOnly,
|
|
2620
|
+
);
|
|
1090
2621
|
const pathTrimmedSource = applyPathTrimTargets(
|
|
1091
2622
|
source,
|
|
1092
2623
|
pathTrimTargetsFromDocument(document),
|
|
@@ -1098,10 +2629,10 @@ function readSource(
|
|
|
1098
2629
|
);
|
|
1099
2630
|
}
|
|
1100
2631
|
if (!document && !textDocument) {
|
|
1101
|
-
if (hasUnresolvedStylesheet(nodes)) {
|
|
2632
|
+
if (!options.cleanupOnly && hasUnresolvedStylesheet(nodes)) {
|
|
1102
2633
|
sourceError(
|
|
1103
2634
|
"unresolved-stylesheet",
|
|
1104
|
-
"Resolve external
|
|
2635
|
+
"Resolve external or runtime-generated styles before editing or adding Motion.",
|
|
1105
2636
|
);
|
|
1106
2637
|
}
|
|
1107
2638
|
return { status: "absent" };
|
|
@@ -1382,10 +2913,33 @@ export function removeMotionHtmlSource(
|
|
|
1382
2913
|
source: string,
|
|
1383
2914
|
sourceFilePath?: string,
|
|
1384
2915
|
): MotionHtmlSourceRemoval {
|
|
1385
|
-
const read = readSource(source, sourceFilePath
|
|
2916
|
+
const read = readSource(source, sourceFilePath, {
|
|
2917
|
+
allowUnmanagedExecutableScripts: true,
|
|
2918
|
+
cleanupOnly: true,
|
|
2919
|
+
});
|
|
1386
2920
|
if (read.status === "absent") return { source, changed: false };
|
|
1387
2921
|
|
|
1388
|
-
const
|
|
2922
|
+
const managedRuntimeScripts = parseHtml(source).nodes.filter(
|
|
2923
|
+
(node) =>
|
|
2924
|
+
node.tagName === "script" &&
|
|
2925
|
+
attributes(node, MOTION_TEXT_RUNTIME_ATTRIBUTE).length > 0,
|
|
2926
|
+
);
|
|
2927
|
+
if (
|
|
2928
|
+
managedRuntimeScripts.some(
|
|
2929
|
+
(node) => !isManagedMotionTextRuntime(source, node),
|
|
2930
|
+
)
|
|
2931
|
+
) {
|
|
2932
|
+
sourceError(
|
|
2933
|
+
"invalid-motion-text",
|
|
2934
|
+
"The managed Motion Text runtime does not match a supported generated version.",
|
|
2935
|
+
);
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
const withoutAuthored = removeAuthoredHtmlMotion(
|
|
2939
|
+
source,
|
|
2940
|
+
sourceFilePath,
|
|
2941
|
+
true,
|
|
2942
|
+
);
|
|
1389
2943
|
const { nodes } = parseHtml(withoutAuthored);
|
|
1390
2944
|
const managedStyle = findManagedStyle(withoutAuthored, nodes);
|
|
1391
2945
|
let withoutStyle = withoutAuthored;
|