@agent-native/core 0.176.1 → 0.177.0-nightly-20260828155142

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 (71) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/actions/set-workspace-feature-flag.ts +2 -2
  3. package/corpus/templates/analytics/app/components/agents/FeatureFlagsFleetPanel.tsx +16 -1
  4. package/corpus/templates/analytics/changelog/2026-08-27-show-analytics-s-own-registered-flags-in-fleet-feature-flag-.md +6 -0
  5. package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +204 -32
  6. package/corpus/templates/assets/changelog/2026-08-26-templates-across-brand-kits.md +1 -0
  7. package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +61 -68
  8. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +9 -3
  9. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +3 -3
  10. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +8 -3
  11. package/corpus/templates/calendar/app/lib/calendar-timezone.ts +12 -3
  12. package/corpus/templates/calendar/changelog/2026-08-28-all-day-events-stay-in-the-compact-top-bar-in-day-and-week-v.md +6 -0
  13. package/corpus/templates/calendar/changelog/2026-08-28-event-creators-can-choose-whether-a-meeting-shows-as-free-or.md +6 -0
  14. package/corpus/templates/clips/app/global.css +0 -27
  15. package/corpus/templates/clips/app/i18n/en-US.ts +2 -0
  16. package/corpus/templates/clips/app/routes/download.tsx +131 -16
  17. package/corpus/templates/clips/app/routes/share.$shareId.tsx +35 -20
  18. package/corpus/templates/clips/server/routes/api/clips-latest.json.get.ts +77 -22
  19. package/corpus/templates/forms/actions/response-insights.ts +4 -4
  20. package/corpus/templates/forms/actions/retry-response-deliveries.ts +26 -0
  21. package/corpus/templates/forms/server/db/schema.ts +64 -17
  22. package/corpus/templates/forms/server/handlers/submissions.ts +1022 -94
  23. package/corpus/templates/forms/server/lib/integrations.ts +100 -33
  24. package/corpus/templates/forms/server/middleware/00-public-cors.ts +1 -1
  25. package/corpus/templates/forms/server/plugins/db.ts +40 -0
  26. package/corpus/templates/plan/agent-native.json +1 -4
  27. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +6 -1
  28. package/corpus/templates/slides/app/components/editor/EditorActionCluster.tsx +2 -2
  29. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +6 -6
  30. package/corpus/templates/slides/app/components/editor/ImageOverlay.tsx +78 -12
  31. package/corpus/templates/slides/app/components/editor/SlideContextToolbar.tsx +4 -4
  32. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +111 -27
  33. package/corpus/templates/slides/app/components/editor/SlidesLayersPanel.tsx +20 -34
  34. package/corpus/templates/slides/app/components/editor/slide-style.ts +2 -0
  35. package/corpus/templates/slides/app/global.css +35 -0
  36. package/corpus/templates/slides/app/lib/sanitize-slide-html.ts +133 -5
  37. package/corpus/templates/slides/app/lib/slide-image-replacement.ts +675 -0
  38. package/corpus/templates/slides/app/lib/validate-native-slide-html.ts +1 -0
  39. package/corpus/templates/slides/app/pages/DeckEditor.tsx +44 -44
  40. package/corpus/templates/slides/app/root.tsx +1 -22
  41. package/corpus/templates/slides/changelog/2026-08-27-image-fit-controls-apply-cover-crops-and-position.md +6 -0
  42. package/corpus/templates/slides/changelog/2026-08-28-shared-slides-with-a-style-block-render-again.md +6 -0
  43. package/dist/action.d.ts +3 -1
  44. package/dist/client/FeedbackButton.d.ts +12 -0
  45. package/dist/client/FeedbackButton.js +62 -28
  46. package/dist/client/app-providers.js +13 -1
  47. package/dist/client/feedback-context.d.ts +1 -0
  48. package/dist/client/feedback-context.js +3 -2
  49. package/dist/client/host-bridge.d.ts +1 -0
  50. package/dist/client/observability/ThumbsFeedback.js +73 -10
  51. package/dist/client/webmcp.d.ts +4 -0
  52. package/dist/client/webmcp.js +51 -4
  53. package/dist/collab/routes.d.ts +1 -1
  54. package/dist/collab/struct-routes.d.ts +1 -1
  55. package/dist/index.browser.d.ts +1 -1
  56. package/dist/index.browser.js +1 -1
  57. package/dist/observability/routes.d.ts +5 -5
  58. package/dist/observability/routes.js +8 -2
  59. package/dist/observability/store.d.ts +1 -1
  60. package/dist/observability/store.js +18 -3
  61. package/dist/observability/types.d.ts +1 -0
  62. package/dist/progress/routes.d.ts +1 -1
  63. package/dist/resources/handlers.d.ts +2 -2
  64. package/dist/secrets/routes.d.ts +9 -9
  65. package/dist/server/action-routes.d.ts +1 -6
  66. package/dist/server/action-routes.js +58 -17
  67. package/dist/server/agent-chat-plugin.js +8 -1
  68. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  69. package/dist/server/transcribe-voice.d.ts +1 -1
  70. package/docs/content/webmcp.mdx +31 -12
  71. package/package.json +1 -1
@@ -84,6 +84,9 @@ import {
84
84
  import {
85
85
  createPlaceholderImageTarget,
86
86
  imageFileLooksSupported,
87
+ imageOccurrenceInRenderedSlide,
88
+ normalizeImageObjectPosition,
89
+ type ImageObjectPosition,
87
90
  type SlideImageDropPosition,
88
91
  } from "@/lib/slide-image-replacement";
89
92
  import { TAB_ID } from "@/lib/tab-id";
@@ -555,6 +558,22 @@ function buildStyleSnapshot(
555
558
  const paddingBottom = cssPx(computed.paddingBottom);
556
559
  const parsedZIndex = Number(computed.zIndex);
557
560
  const zIndex = Number.isFinite(parsedZIndex) ? parsedZIndex : 0;
561
+ const isImage =
562
+ element.tagName === "IMG" ||
563
+ element.classList.contains("fmd-pptx-image") ||
564
+ element.getAttribute("data-pptx-element-kind") === "image";
565
+ const objectFit =
566
+ element.tagName === "IMG"
567
+ ? computed.objectFit === "contain"
568
+ ? "contain"
569
+ : "cover"
570
+ : undefined;
571
+ const objectPosition =
572
+ element.tagName === "IMG"
573
+ ? normalizeImageObjectPosition(
574
+ element.style.objectPosition || computed.objectPosition,
575
+ )
576
+ : undefined;
558
577
 
559
578
  const selectedTextStyle =
560
579
  inlineTextStyle?.scope === "selection" ? inlineTextStyle : null;
@@ -572,10 +591,9 @@ function buildStyleSnapshot(
572
591
  isText:
573
592
  element.tagName !== "IMG" &&
574
593
  (!!textPreview || element.classList.contains("fmd-text-box")),
575
- isImage:
576
- element.tagName === "IMG" ||
577
- element.classList.contains("fmd-pptx-image") ||
578
- element.getAttribute("data-pptx-element-kind") === "image",
594
+ isImage,
595
+ objectFit,
596
+ objectPosition,
579
597
  color:
580
598
  selectedColor === null || selectedColor === undefined
581
599
  ? normalizedColor(computed.color)
@@ -631,6 +649,7 @@ function selectionItemForElement(
631
649
  element: HTMLElement,
632
650
  runtimeSelector: string,
633
651
  snapshot?: SlideStyleSnapshot,
652
+ imageStyle?: Pick<SlideStyleSnapshot, "objectFit" | "objectPosition">,
634
653
  ): SlideSelectionItem {
635
654
  const identity = getSlideSelectionIdentity(element, runtimeSelector);
636
655
  return {
@@ -647,7 +666,9 @@ function selectionItemForElement(
647
666
  element instanceof HTMLImageElement
648
667
  ? (element.getAttribute("src") ?? undefined)
649
668
  : (element.querySelector("img")?.getAttribute("src") ?? undefined),
650
- style: snapshot ? { ...snapshot, selector: identity.selector } : undefined,
669
+ style: snapshot
670
+ ? { ...snapshot, ...imageStyle, selector: identity.selector }
671
+ : undefined,
651
672
  };
652
673
  }
653
674
 
@@ -719,7 +740,16 @@ interface SlideEditorProps {
719
740
  url: string,
720
741
  position?: SlideImageDropPosition,
721
742
  ) => void;
722
- onToggleObjectFit: (imgSrc: string, newFit: string) => void;
743
+ onToggleObjectFit: (
744
+ imgSrc: string,
745
+ newFit: "cover" | "contain",
746
+ imageOccurrence?: number,
747
+ ) => void;
748
+ onChangeObjectPosition: (
749
+ imgSrc: string,
750
+ objectPosition: ImageObjectPosition,
751
+ imageOccurrence?: number,
752
+ ) => void;
723
753
  /** Current user display info for cursor caret */
724
754
  collabUser?: { name: string; color: string };
725
755
  /** True briefly when AI agent is making edits */
@@ -1197,6 +1227,7 @@ export default function SlideEditor({
1197
1227
  onDropImage,
1198
1228
  onDropImageUrl,
1199
1229
  onToggleObjectFit,
1230
+ onChangeObjectPosition,
1200
1231
  agentActive,
1201
1232
  slideIndex = 0,
1202
1233
  designSystem,
@@ -1235,6 +1266,8 @@ export default function SlideEditor({
1235
1266
  rect: DOMRect;
1236
1267
  src: string;
1237
1268
  objectFit: "cover" | "contain";
1269
+ objectPosition: ImageObjectPosition;
1270
+ imageOccurrence: number;
1238
1271
  } | null>(null);
1239
1272
  const [selectedImg, setSelectedImg] = useState<HTMLImageElement | null>(null);
1240
1273
  const [selectionRect, setSelectionRect] = useState<DOMRect | null>(null);
@@ -2990,10 +3023,11 @@ export default function SlideEditor({
2990
3023
 
2991
3024
  const getPlaceholderTarget = useCallback(
2992
3025
  (placeholder: HTMLElement): string => {
2993
- const slideContent = getSlideContent();
2994
- const placeholders = slideContent
3026
+ const placeholders = containerRef.current
2995
3027
  ? Array.from(
2996
- slideContent.querySelectorAll<HTMLElement>(".fmd-img-placeholder"),
3028
+ containerRef.current.querySelectorAll<HTMLElement>(
3029
+ ".slide-content .fmd-img-placeholder",
3030
+ ),
2997
3031
  )
2998
3032
  : [];
2999
3033
  const index = Math.max(0, placeholders.indexOf(placeholder));
@@ -3002,7 +3036,7 @@ export default function SlideEditor({
3002
3036
  placeholder.textContent?.trim() || "image",
3003
3037
  );
3004
3038
  },
3005
- [getSlideContent],
3039
+ [],
3006
3040
  );
3007
3041
 
3008
3042
  const getImageReplacementTarget = useCallback(
@@ -5793,22 +5827,31 @@ export default function SlideEditor({
5793
5827
  );
5794
5828
  }, [multiSelection.size, multiSelectionRects, slide.id, slideIndex]);
5795
5829
 
5830
+ const publishImageSelection = useCallback(
5831
+ (
5832
+ element: HTMLElement,
5833
+ imageStyle?: Pick<SlideStyleSnapshot, "objectFit" | "objectPosition">,
5834
+ ) => {
5835
+ const selector =
5836
+ getBuilderSelector(element) ??
5837
+ `[data-builder-id="${ensureBuilderId(element)}"]`;
5838
+ const snapshot = buildStyleSnapshot(element, selector);
5839
+ syncSelectionToAppState(
5840
+ buildSelectionState("image", [
5841
+ selectionItemForElement(
5842
+ element,
5843
+ selector,
5844
+ { ...snapshot, isImage: true },
5845
+ imageStyle,
5846
+ ),
5847
+ ]),
5848
+ );
5849
+ },
5850
+ [buildSelectionState],
5851
+ );
5852
+
5796
5853
  const showImageOverlay = useCallback(
5797
5854
  (target: HTMLElement) => {
5798
- const publishImageSelection = (element: HTMLElement) => {
5799
- const selector =
5800
- getBuilderSelector(element) ??
5801
- `[data-builder-id="${ensureBuilderId(element)}"]`;
5802
- const snapshot = buildStyleSnapshot(element, selector);
5803
- syncSelectionToAppState(
5804
- buildSelectionState("image", [
5805
- selectionItemForElement(element, selector, {
5806
- ...snapshot,
5807
- isImage: true,
5808
- }),
5809
- ]),
5810
- );
5811
- };
5812
5855
  if (target.tagName === "IMG") {
5813
5856
  const img = target as HTMLImageElement;
5814
5857
  const rect = img.getBoundingClientRect();
@@ -5818,8 +5861,22 @@ export default function SlideEditor({
5818
5861
  ? "contain"
5819
5862
  : "cover"
5820
5863
  ) as "cover" | "contain";
5864
+ const computedStyle = window.getComputedStyle(img);
5865
+ const position = normalizeImageObjectPosition(
5866
+ img.style.objectPosition || computedStyle.objectPosition,
5867
+ );
5868
+ const imageOccurrence = imageOccurrenceInRenderedSlide(
5869
+ containerRef.current,
5870
+ img,
5871
+ );
5821
5872
  setSelectedImg(img);
5822
- setImageOverlay({ rect, src, objectFit: fit });
5873
+ setImageOverlay({
5874
+ rect,
5875
+ src,
5876
+ objectFit: fit,
5877
+ objectPosition: position,
5878
+ imageOccurrence,
5879
+ });
5823
5880
  publishImageSelection(img);
5824
5881
  return;
5825
5882
  }
@@ -5834,11 +5891,13 @@ export default function SlideEditor({
5834
5891
  rect,
5835
5892
  src: getPlaceholderTarget(placeholder),
5836
5893
  objectFit: "cover",
5894
+ objectPosition: "center center",
5895
+ imageOccurrence: 0,
5837
5896
  });
5838
5897
  publishImageSelection(placeholder);
5839
5898
  }
5840
5899
  },
5841
- [buildSelectionState, getPlaceholderTarget],
5900
+ [getPlaceholderTarget, publishImageSelection],
5842
5901
  );
5843
5902
 
5844
5903
  // Browsers put the dragged element's outerHTML on the "text/html" data
@@ -6993,6 +7052,7 @@ export default function SlideEditor({
6993
7052
  anchorRect={imageOverlay.rect}
6994
7053
  src={imageOverlay.src}
6995
7054
  objectFit={imageOverlay.objectFit}
7055
+ objectPosition={imageOverlay.objectPosition}
6996
7056
  onGenerate={onGenerateImage}
6997
7057
  onLibrary={() => onOpenAssetLibrary(imageOverlay.src)}
6998
7058
  onUpload={() => onUploadImage(imageOverlay.src)}
@@ -7000,8 +7060,32 @@ export default function SlideEditor({
7000
7060
  onToggleObjectFit={() => {
7001
7061
  const newFit =
7002
7062
  imageOverlay.objectFit === "cover" ? "contain" : "cover";
7003
- onToggleObjectFit(imageOverlay.src, newFit);
7063
+ onToggleObjectFit(
7064
+ imageOverlay.src,
7065
+ newFit,
7066
+ imageOverlay.imageOccurrence,
7067
+ );
7004
7068
  setImageOverlay({ ...imageOverlay, objectFit: newFit });
7069
+ if (selectedImg) {
7070
+ publishImageSelection(selectedImg, {
7071
+ objectFit: newFit,
7072
+ objectPosition: imageOverlay.objectPosition,
7073
+ });
7074
+ }
7075
+ }}
7076
+ onChangeObjectPosition={(objectPosition) => {
7077
+ onChangeObjectPosition(
7078
+ imageOverlay.src,
7079
+ objectPosition,
7080
+ imageOverlay.imageOccurrence,
7081
+ );
7082
+ setImageOverlay({ ...imageOverlay, objectPosition });
7083
+ if (selectedImg) {
7084
+ publishImageSelection(selectedImg, {
7085
+ objectFit: imageOverlay.objectFit,
7086
+ objectPosition,
7087
+ });
7088
+ }
7005
7089
  }}
7006
7090
  onClose={() => setImageOverlay(null)}
7007
7091
  />
@@ -2,18 +2,10 @@ import {
2
2
  IconChevronDown,
3
3
  IconChevronRight,
4
4
  IconGripVertical,
5
- IconLayersSubtract,
6
5
  IconX,
7
6
  } from "@tabler/icons-react";
8
7
  import { useState, type DragEvent } from "react";
9
8
 
10
- import { Button } from "@/components/ui/button";
11
- import {
12
- Tooltip,
13
- TooltipContent,
14
- TooltipTrigger,
15
- } from "@/components/ui/tooltip";
16
-
17
9
  export interface SlidesLayerNode {
18
10
  id: string;
19
11
  label: string;
@@ -82,11 +74,12 @@ function LayerRow({
82
74
  return (
83
75
  <div
84
76
  role="treeitem"
77
+ aria-level={depth + 1}
85
78
  aria-expanded={children.length ? expanded : undefined}
86
79
  aria-selected={selected}
87
80
  >
88
81
  <div
89
- className={`group flex min-h-9 items-center gap-1 rounded-md px-2 text-sm transition-colors ${selected ? "bg-accent text-accent-foreground" : "hover:bg-accent/60"} ${dragging ? "opacity-50" : ""}`}
82
+ className={`group flex h-8 items-center gap-1 rounded-[5px] px-2 text-xs text-foreground/90 transition-colors ${selected ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-foreground"} ${dragging ? "opacity-50" : ""}`}
90
83
  style={{ paddingLeft: `${8 + depth * 16}px` }}
91
84
  onDragOver={(event) => event.preventDefault()}
92
85
  onDragEnter={() => setDragging(true)}
@@ -96,14 +89,14 @@ function LayerRow({
96
89
  {children.length ? (
97
90
  <button
98
91
  type="button"
99
- className="rounded p-0.5 text-muted-foreground hover:text-foreground"
92
+ className="rounded-sm p-0.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring"
100
93
  aria-label={expanded ? labels.collapse : labels.expand}
101
94
  onClick={() => setExpanded((value) => !value)}
102
95
  >
103
96
  {expanded ? (
104
- <IconChevronDown size={15} />
97
+ <IconChevronDown size={16} />
105
98
  ) : (
106
- <IconChevronRight size={15} />
99
+ <IconChevronRight size={16} />
107
100
  )}
108
101
  </button>
109
102
  ) : (
@@ -112,7 +105,7 @@ function LayerRow({
112
105
  <button
113
106
  type="button"
114
107
  draggable
115
- className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
108
+ className="flex min-w-0 flex-1 items-center gap-2 py-0.5 text-left outline-none focus-visible:ring-1 focus-visible:ring-ring"
116
109
  onDragStart={(event) => {
117
110
  event.dataTransfer.setData("text/plain", node.id);
118
111
  event.dataTransfer.effectAllowed = "move";
@@ -126,7 +119,7 @@ function LayerRow({
126
119
  }
127
120
  >
128
121
  <IconGripVertical
129
- size={15}
122
+ size={14}
130
123
  className="shrink-0 text-muted-foreground/60"
131
124
  aria-hidden="true"
132
125
  />
@@ -162,31 +155,24 @@ export function SlidesLayersPanel({
162
155
  }: SlidesLayersPanelProps) {
163
156
  return (
164
157
  <aside
165
- className="flex h-full w-64 flex-col border-l border-border bg-background"
158
+ className="flex h-full w-72 min-w-0 flex-col bg-background"
166
159
  aria-label={labels.title}
167
160
  >
168
- <header className="flex h-11 items-center justify-between border-b border-border px-3">
169
- <div className="flex items-center gap-2 text-sm font-medium">
170
- <IconLayersSubtract size={16} aria-hidden="true" />
161
+ <header className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4">
162
+ <h2 className="text-sm font-semibold text-foreground">
171
163
  {labels.title}
172
- </div>
173
- <Tooltip>
174
- <TooltipTrigger asChild>
175
- <Button
176
- variant="ghost"
177
- size="icon"
178
- className="size-7"
179
- onClick={onClose}
180
- aria-label={labels.close}
181
- >
182
- <IconX size={16} />
183
- </Button>
184
- </TooltipTrigger>
185
- <TooltipContent>{labels.close}</TooltipContent>
186
- </Tooltip>
164
+ </h2>
165
+ <button
166
+ type="button"
167
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
168
+ onClick={onClose}
169
+ aria-label={labels.close}
170
+ >
171
+ <IconX className="size-4" />
172
+ </button>
187
173
  </header>
188
174
  <div
189
- className="min-h-0 flex-1 overflow-y-auto p-2"
175
+ className="min-h-0 flex-1 overflow-y-auto py-2"
190
176
  role="tree"
191
177
  aria-label={labels.title}
192
178
  >
@@ -11,6 +11,8 @@ export interface SlideStyleSnapshot {
11
11
  textPreview: string;
12
12
  isText: boolean;
13
13
  isImage: boolean;
14
+ objectFit?: "cover" | "contain";
15
+ objectPosition?: string;
14
16
  isAbsolute: boolean;
15
17
  x: number;
16
18
  y: number;
@@ -1153,6 +1153,8 @@ button[title="Open agent sidebar"] {
1153
1153
  display: flex;
1154
1154
  flex-direction: column;
1155
1155
  gap: 2px;
1156
+ max-height: calc(100vh - 16px);
1157
+ overflow-y: auto;
1156
1158
  animation: overlay-fade-in 0.12s ease-out;
1157
1159
  }
1158
1160
 
@@ -1217,6 +1219,39 @@ button[title="Open agent sidebar"] {
1217
1219
  color: hsl(var(--popover-foreground));
1218
1220
  }
1219
1221
 
1222
+ .image-overlay-position {
1223
+ display: flex;
1224
+ align-items: center;
1225
+ gap: 10px;
1226
+ padding: 8px 14px;
1227
+ border-radius: 8px;
1228
+ font-size: 13px;
1229
+ font-weight: 500;
1230
+ color: hsl(var(--popover-foreground) / 0.8);
1231
+ }
1232
+
1233
+ .image-overlay-position:hover,
1234
+ .image-overlay-position:focus-within {
1235
+ background: hsl(var(--accent));
1236
+ color: hsl(var(--popover-foreground));
1237
+ }
1238
+
1239
+ .image-overlay-position select {
1240
+ min-width: 0;
1241
+ flex: 1;
1242
+ border: 0;
1243
+ outline: none;
1244
+ background: transparent;
1245
+ color: inherit;
1246
+ font: inherit;
1247
+ cursor: pointer;
1248
+ }
1249
+
1250
+ .image-overlay-position option {
1251
+ background: hsl(var(--popover));
1252
+ color: hsl(var(--popover-foreground));
1253
+ }
1254
+
1220
1255
  /* ─── Inline Slide Editor (TipTap) ─── */
1221
1256
 
1222
1257
  .slide-tiptap-editor {
@@ -311,13 +311,144 @@ function cleanNode(
311
311
  return out;
312
312
  }
313
313
 
314
+ /**
315
+ * Elements whose unclosed start tag swallows the rest of the document in a real
316
+ * parser. `embed` is deliberately absent: it is void, so it never has a closing
317
+ * tag and requiring one would truncate every slide that contains a valid one.
318
+ */
319
+ const SWALLOWING_ELEMENTS = /^(script|style|textarea|iframe|object|svg|math)$/i;
320
+
321
+ /** Elements whose children the HTML parser reads as text rather than markup. */
322
+ const RAW_TEXT_ELEMENTS = /^(script|style|textarea|title)$/i;
323
+
324
+ /**
325
+ * Start-tag positions in `html`, skipping comments and anything inside a quoted
326
+ * attribute value.
327
+ *
328
+ * Scanning the serialized string with a bare regex cannot tell a tag from text:
329
+ * `<p title="Use <style> here">` reads as a `<style>` start tag, and truncating
330
+ * there drops the rest of a perfectly valid slide.
331
+ */
332
+ function startTagPositions(
333
+ html: string,
334
+ ): { name: string; index: number; end: number }[] {
335
+ const found: { name: string; index: number; end: number }[] = [];
336
+ for (let i = 0; i < html.length; i++) {
337
+ if (html[i] !== "<") continue;
338
+ if (html.startsWith("<!--", i)) {
339
+ const end = html.indexOf("-->", i + 4);
340
+ i = end === -1 ? html.length : end + 2;
341
+ continue;
342
+ }
343
+ const name = /^<([a-z][a-z0-9-]*)/i.exec(html.slice(i, i + 32))?.[1];
344
+ // Walk to this tag's `>`, stepping over quoted values so a `<` or `>`
345
+ // inside one is not read as markup.
346
+ let cursor = i + 1;
347
+ let quote = "";
348
+ while (cursor < html.length) {
349
+ const char = html[cursor];
350
+ if (quote) {
351
+ if (char === quote) quote = "";
352
+ } else if (char === '"' || char === "'") {
353
+ quote = char;
354
+ } else if (char === ">") {
355
+ break;
356
+ }
357
+ cursor++;
358
+ }
359
+ if (!name) {
360
+ i = cursor;
361
+ continue;
362
+ }
363
+ const lower = name.toLowerCase();
364
+ found.push({ name: lower, index: i, end: cursor });
365
+ if (RAW_TEXT_ELEMENTS.test(lower)) {
366
+ // A raw-text element's body is text, not markup — `content: "<script>"`
367
+ // inside a stylesheet is a CSS string, and reading it as a start tag
368
+ // truncated everything after it. Skip to the close tag; no close tag is
369
+ // the unclosed case the caller is looking for, and everything past it is
370
+ // swallowed anyway, so there is nothing further to find.
371
+ const closing = new RegExp(`</\\s*${lower}\\s*>`, "i").exec(
372
+ html.slice(cursor),
373
+ );
374
+ if (!closing) return found;
375
+ i = cursor + closing.index + closing[0].length - 1;
376
+ continue;
377
+ }
378
+ i = cursor;
379
+ }
380
+ return found;
381
+ }
382
+
383
+ /**
384
+ * Truncates at the first swallowing element that never closes.
385
+ *
386
+ * The regex path has to drop the remainder to agree with `cleanNode`. It must
387
+ * check for the closing tag to do that: the sweep used to match any of these
388
+ * tags and cut to the end of the string unconditionally, which ate the
389
+ * sanitized `<style>` block the pass above it had just emitted — and every
390
+ * heading and paragraph after it. A deck with one stylesheet rendered as an
391
+ * empty slide on the SSR'd share and present pages.
392
+ */
393
+ function dropFromFirstUnclosedRawText(html: string): string {
394
+ for (const { name, index } of startTagPositions(html)) {
395
+ if (!SWALLOWING_ELEMENTS.test(name)) continue;
396
+ const closing = new RegExp(`</\\s*${name}\\s*>`, "i");
397
+ if (!closing.test(html.slice(index))) return html.slice(0, index);
398
+ }
399
+ return html;
400
+ }
401
+
402
+ /**
403
+ * Rewrites `/` attribute separators inside start tags as spaces.
404
+ *
405
+ * `/` is a legal separator between attributes, so `<img src="x"/onerror="…">`
406
+ * is an image with a live handler — and every attribute scrub below is anchored
407
+ * on whitespace, so none of them matched it. Normalizing here rather than
408
+ * widening each scrub to `[\s/]+` is what keeps a legitimate value intact: a
409
+ * URL like `https://cdn.example/onerror=logo.png` lives inside quotes, and this
410
+ * only touches separators outside them.
411
+ */
412
+ function normalizeTagAttributeSeparators(html: string): string {
413
+ const tags = startTagPositions(html);
414
+ if (!tags.length) return html;
415
+ let out = "";
416
+ let copied = 0;
417
+ for (const { index, end } of tags) {
418
+ // Start after the tag name so `</p>` and the opening `<` are untouched.
419
+ const nameEnd = /^<[a-z][a-z0-9-]*/i.exec(html.slice(index, end))?.[0]
420
+ .length;
421
+ if (nameEnd === undefined) continue;
422
+ const from = index + nameEnd;
423
+ let region = "";
424
+ let quote = "";
425
+ for (let i = from; i < end; i++) {
426
+ const char = html[i];
427
+ if (quote) {
428
+ if (char === quote) quote = "";
429
+ region += char;
430
+ } else if (char === '"' || char === "'") {
431
+ quote = char;
432
+ region += char;
433
+ } else if (char === "/") {
434
+ region += " ";
435
+ } else {
436
+ region += char;
437
+ }
438
+ }
439
+ out += html.slice(copied, from) + region;
440
+ copied = end;
441
+ }
442
+ return out + html.slice(copied);
443
+ }
444
+
314
445
  function sanitizeHtmlString(
315
446
  html: string,
316
447
  scopeSelector?: string,
317
448
  allowBlobImages = false,
318
449
  ): string {
319
450
  return (
320
- html
451
+ normalizeTagAttributeSeparators(html)
321
452
  .replace(/<style\b[^>]*>([\s\S]*?)<\/\s*style\s*>/gi, (_match, css) => {
322
453
  const safeCss = sanitizeStyleSheet(String(css), scopeSelector);
323
454
  return safeCss
@@ -335,10 +466,7 @@ function sanitizeHtmlString(
335
466
  // twin runs instead of cleanNode(). An unclosed raw-text or embedding
336
467
  // element swallows the rest of the document in a real parser, so dropping
337
468
  // the remainder is what keeps this path agreeing with the DOM path.
338
- .replace(
339
- /<(script|style|textarea|iframe|object|embed|svg|math)\b[\s\S]*$/i,
340
- "",
341
- )
469
+ .replace(/[\s\S]*/, dropFromFirstUnclosedRawText)
342
470
  .replace(
343
471
  /<(script|iframe|object|embed|form|input|button|select|textarea|meta|base|link|svg|math)\b[^>]*\/?>/gi,
344
472
  "",