@flamingo-stack/openframe-frontend-core 0.0.533 → 0.0.535-1762.4618.1
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/dist/{chunk-NAV4FRNL.cjs → chunk-2AMYXNQ2.cjs} +26 -26
- package/dist/{chunk-NAV4FRNL.cjs.map → chunk-2AMYXNQ2.cjs.map} +1 -1
- package/dist/{chunk-2XGHEJZK.js → chunk-5OPVJBHQ.js} +4 -4
- package/dist/{chunk-4QKXNWH5.js → chunk-6OZWDOP5.js} +2 -2
- package/dist/{chunk-6YXN5RHW.js → chunk-7TQDW3PE.js} +2 -2
- package/dist/{chunk-U555TMPC.js → chunk-A5EA3YO7.js} +5 -5
- package/dist/{chunk-DFQLUK75.cjs → chunk-CMIHA6TT.cjs} +3 -3
- package/dist/{chunk-DFQLUK75.cjs.map → chunk-CMIHA6TT.cjs.map} +1 -1
- package/dist/{chunk-QZ2NAPLF.cjs → chunk-DK6IYFLD.cjs} +9 -9
- package/dist/{chunk-QZ2NAPLF.cjs.map → chunk-DK6IYFLD.cjs.map} +1 -1
- package/dist/{chunk-LE3BJ5H2.js → chunk-DRJC7TTW.js} +2 -2
- package/dist/{chunk-ENPGPZIB.cjs → chunk-ENOER2QA.cjs} +61 -61
- package/dist/{chunk-ENPGPZIB.cjs.map → chunk-ENOER2QA.cjs.map} +1 -1
- package/dist/{chunk-LIUARTOZ.js → chunk-GBWS62LR.js} +106 -285
- package/dist/chunk-GBWS62LR.js.map +1 -0
- package/dist/{chunk-2MYDGLJS.cjs → chunk-GF5A23J3.cjs} +87 -87
- package/dist/{chunk-2MYDGLJS.cjs.map → chunk-GF5A23J3.cjs.map} +1 -1
- package/dist/{chunk-4ZRHVOEB.cjs → chunk-GRGFMXNO.cjs} +31 -31
- package/dist/{chunk-4ZRHVOEB.cjs.map → chunk-GRGFMXNO.cjs.map} +1 -1
- package/dist/{chunk-46FKXLNE.js → chunk-GS4R5UQ7.js} +2 -2
- package/dist/{chunk-2TUOBFPK.js → chunk-HNZDQ4SH.js} +2 -2
- package/dist/{chunk-KMB66NYX.cjs → chunk-HSRHPF32.cjs} +7 -7
- package/dist/{chunk-KMB66NYX.cjs.map → chunk-HSRHPF32.cjs.map} +1 -1
- package/dist/{chunk-RKITTO5V.cjs → chunk-JW4BTXVH.cjs} +14 -14
- package/dist/{chunk-RKITTO5V.cjs.map → chunk-JW4BTXVH.cjs.map} +1 -1
- package/dist/{chunk-MVGL5NNJ.js → chunk-JWJQHURJ.js} +2 -2
- package/dist/{chunk-5U67WTAS.js → chunk-JWWZFEFI.js} +2 -2
- package/dist/{chunk-JDW2IL7A.cjs → chunk-JZMQQBUC.cjs} +11 -11
- package/dist/{chunk-JDW2IL7A.cjs.map → chunk-JZMQQBUC.cjs.map} +1 -1
- package/dist/{chunk-XN4JKIJY.js → chunk-QHOVACZK.js} +2 -2
- package/dist/{chunk-UPHN2VRZ.cjs → chunk-T4GNFVT3.cjs} +800 -979
- package/dist/chunk-T4GNFVT3.cjs.map +1 -0
- package/dist/{chunk-GNOK5J34.cjs → chunk-T743Z3KN.cjs} +37 -37
- package/dist/{chunk-GNOK5J34.cjs.map → chunk-T743Z3KN.cjs.map} +1 -1
- package/dist/{chunk-J4BE4FIR.cjs → chunk-TOWXHZF5.cjs} +4 -4
- package/dist/{chunk-J4BE4FIR.cjs.map → chunk-TOWXHZF5.cjs.map} +1 -1
- package/dist/{chunk-6SMLYZLH.js → chunk-URBYMQ4Z.js} +4 -4
- package/dist/{chunk-Z3NOI7X7.cjs → chunk-WL3PP4D6.cjs} +26 -26
- package/dist/{chunk-Z3NOI7X7.cjs.map → chunk-WL3PP4D6.cjs.map} +1 -1
- package/dist/{chunk-YLB6XXO6.js → chunk-WXRQYGE5.js} +2 -2
- package/dist/{chunk-6W3ULZ4X.js → chunk-XPKCOZQE.js} +6 -6
- package/dist/{chunk-OLOKQQAP.cjs → chunk-ZUVBTDLG.cjs} +5 -5
- package/dist/{chunk-OLOKQQAP.cjs.map → chunk-ZUVBTDLG.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/chat-input.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +132 -132
- package/dist/components/index.js +11 -11
- package/dist/components/meeting-scheduler/index.cjs +34 -34
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/file-upload.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -1
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/utils/social-embed-cache.d.ts +16 -16
- package/dist/utils/social-embed-cache.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/chat-input.tsx +84 -32
- package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +1 -5
- package/src/components/ui/file-upload.tsx +5 -1
- package/src/components/ui/markdown/rich/__tests__/shortcodes.test.ts +58 -0
- package/src/components/ui/markdown/rich/embed-overrides.tsx +13 -7
- package/src/components/ui/markdown/rich/shortcodes.ts +15 -0
- package/src/utils/social-embed-cache.ts +63 -336
- package/dist/chunk-LIUARTOZ.js.map +0 -1
- package/dist/chunk-UPHN2VRZ.cjs.map +0 -1
- /package/dist/{chunk-2XGHEJZK.js.map → chunk-5OPVJBHQ.js.map} +0 -0
- /package/dist/{chunk-4QKXNWH5.js.map → chunk-6OZWDOP5.js.map} +0 -0
- /package/dist/{chunk-6YXN5RHW.js.map → chunk-7TQDW3PE.js.map} +0 -0
- /package/dist/{chunk-U555TMPC.js.map → chunk-A5EA3YO7.js.map} +0 -0
- /package/dist/{chunk-LE3BJ5H2.js.map → chunk-DRJC7TTW.js.map} +0 -0
- /package/dist/{chunk-46FKXLNE.js.map → chunk-GS4R5UQ7.js.map} +0 -0
- /package/dist/{chunk-2TUOBFPK.js.map → chunk-HNZDQ4SH.js.map} +0 -0
- /package/dist/{chunk-MVGL5NNJ.js.map → chunk-JWJQHURJ.js.map} +0 -0
- /package/dist/{chunk-5U67WTAS.js.map → chunk-JWWZFEFI.js.map} +0 -0
- /package/dist/{chunk-XN4JKIJY.js.map → chunk-QHOVACZK.js.map} +0 -0
- /package/dist/{chunk-6SMLYZLH.js.map → chunk-URBYMQ4Z.js.map} +0 -0
- /package/dist/{chunk-YLB6XXO6.js.map → chunk-WXRQYGE5.js.map} +0 -0
- /package/dist/{chunk-6W3ULZ4X.js.map → chunk-XPKCOZQE.js.map} +0 -0
|
@@ -168,6 +168,13 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
168
168
|
} = rest as typeof rest & { rows?: number }
|
|
169
169
|
|
|
170
170
|
const [value, setValue] = useState('')
|
|
171
|
+
// Whether the editor currently holds at least one committed mention chip —
|
|
172
|
+
// it alone needs the tall line box (see the editor's className). Read from the
|
|
173
|
+
// DOM, NOT by testing the draft against `MENTION_GLOBAL`: that regex also
|
|
174
|
+
// matches ordinary typed text (`meet @3:00`, `@prod:us-east`), which would
|
|
175
|
+
// switch the editor to a 36px line box with no chip on screen. Chips are only
|
|
176
|
+
// ever created by `rebuildDom`, so the two writers below cover every path.
|
|
177
|
+
const [hasChips, setHasChips] = useState(false)
|
|
171
178
|
const [focused, setFocused] = useState(false)
|
|
172
179
|
const [isStopping, setIsStopping] = useState(false)
|
|
173
180
|
// The ghost preview is dismissed as soon as the user edits the draft (typing
|
|
@@ -224,7 +231,12 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
224
231
|
if (typeof caret === 'number') placeCaretAtOffset(el, caret)
|
|
225
232
|
else placeCaretAtEnd(el)
|
|
226
233
|
}
|
|
234
|
+
setHasChips(el.querySelector('[data-token]') !== null)
|
|
227
235
|
}
|
|
236
|
+
// Advance the mirror HERE, not only on the next render: `fire` clears the
|
|
237
|
+
// draft and then reads it back within the same tick to decide whether a
|
|
238
|
+
// refused send may restore it.
|
|
239
|
+
valueRef.current = next
|
|
228
240
|
setValue(next)
|
|
229
241
|
}, [])
|
|
230
242
|
|
|
@@ -233,19 +245,31 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
233
245
|
(message: string) => {
|
|
234
246
|
const can = (message.length > 0 || allowEmptySend) && !sending && !disabled
|
|
235
247
|
if (!can || !onSend) return
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
248
|
+
// Clear FIRST and put it back only if the host refuses, rather than
|
|
249
|
+
// clearing once the send settles. The editor stays typable while `sending`
|
|
250
|
+
// (see `contentEditable` below), and a host whose `onSend` is async —
|
|
251
|
+
// `TicketReplyComposer` awaits a round trip — leaves a window in which the
|
|
252
|
+
// user starts their next message; a deferred clear would silently delete
|
|
253
|
+
// it. Restoring is guarded on the box still being empty for the same
|
|
254
|
+
// reason. `applyValue` advances `valueRef` synchronously, so the guard
|
|
255
|
+
// reads the cleared draft even when `onSend` refuses in the same tick.
|
|
256
|
+
const sentMeta = metaRef.current
|
|
257
|
+
metaRef.current = new Map()
|
|
258
|
+
applyValue('')
|
|
259
|
+
shouldRefocusRef.current = true
|
|
260
|
+
focusEditor()
|
|
261
|
+
const restore = () => {
|
|
262
|
+
if (valueRef.current.length > 0) return
|
|
263
|
+
metaRef.current = sentMeta
|
|
264
|
+
applyValue(message)
|
|
242
265
|
}
|
|
266
|
+
const result = onSend(message)
|
|
243
267
|
if (result instanceof Promise) {
|
|
244
268
|
void result.then((ok) => {
|
|
245
|
-
if (ok
|
|
269
|
+
if (ok === false) restore()
|
|
246
270
|
})
|
|
247
|
-
} else if (result
|
|
248
|
-
|
|
271
|
+
} else if (result === false) {
|
|
272
|
+
restore()
|
|
249
273
|
}
|
|
250
274
|
},
|
|
251
275
|
[allowEmptySend, sending, disabled, onSend, focusEditor, applyValue],
|
|
@@ -351,6 +375,9 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
351
375
|
const syncFromDom = useCallback(() => {
|
|
352
376
|
const el = editorRef.current
|
|
353
377
|
if (!el) return
|
|
378
|
+
// Typing can DELETE a chip (they are atomic `contentEditable=false` nodes),
|
|
379
|
+
// so the flag has to be re-read here too, not just where chips are built.
|
|
380
|
+
setHasChips(el.querySelector('[data-token]') !== null)
|
|
354
381
|
setValue(serialize(el))
|
|
355
382
|
}, [])
|
|
356
383
|
|
|
@@ -452,14 +479,13 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
452
479
|
const hasContent = value.trim().length > 0 || allowEmptySend
|
|
453
480
|
const sendDisabled = sending || disabled || !hasContent
|
|
454
481
|
const isEmpty = value.length === 0
|
|
455
|
-
const isDisabled = sending || disabled
|
|
456
482
|
// Non-destructive ghost preview (e.g. a hovered quick-action's full prompt).
|
|
457
483
|
// Shown whenever a `previewText` is set (even over an existing draft): the
|
|
458
484
|
// editor's real value is NEVER touched — it's just visually hidden behind the
|
|
459
485
|
// ghost while previewing, and reappears verbatim once `previewText` clears.
|
|
460
486
|
// Declarative, no `setValue` / draft save-restore. Suppressed the moment the
|
|
461
487
|
// user edits the draft (`previewDismissed`) so typing is never hidden.
|
|
462
|
-
const showPreview = !
|
|
488
|
+
const showPreview = !sending && !disabled && !!previewText && !previewDismissed
|
|
463
489
|
|
|
464
490
|
return (
|
|
465
491
|
<div
|
|
@@ -493,24 +519,28 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
493
519
|
}}
|
|
494
520
|
className={cn(
|
|
495
521
|
// `items-center` keeps the empty single-line text AND a mention chip
|
|
496
|
-
// vertically centered the same way. `py-1.5`
|
|
497
|
-
// editor's `leading-9` (36px) →
|
|
498
|
-
//
|
|
499
|
-
// (incl. its ~3.5px vertical-align overhead), so there's almost
|
|
500
|
-
// slack for the chip to drift in → it reads as centered
|
|
501
|
-
//
|
|
522
|
+
// vertically centered the same way. With a chip in the box, `py-1.5`
|
|
523
|
+
// (6px) pairs with the editor's `leading-9` (36px) → that row is
|
|
524
|
+
// 36 + 2*6 = 48px. The 36px line box is just barely above the 32px
|
|
525
|
+
// chip (incl. its ~3.5px vertical-align overhead), so there's almost
|
|
526
|
+
// no slack for the chip to drift in → it reads as centered.
|
|
527
|
+
// A chipless row is shorter than its content box and the `min-h-*`
|
|
528
|
+
// here is what sets its height: 48px from `md` up, 44px below.
|
|
502
529
|
"flex w-full items-center gap-2 px-3 py-1.5 min-h-11 md:min-h-12 cursor-text group transition-colors duration-200",
|
|
503
530
|
!hideBorder && "rounded-[6px] border bg-ods-card border-ods-border",
|
|
504
|
-
!hideBorder && (focused ? "border-ods-accent" : !
|
|
505
|
-
!hideBorder &&
|
|
531
|
+
!hideBorder && (focused ? "border-ods-accent" : !disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover"),
|
|
532
|
+
!hideBorder && disabled && "!cursor-not-allowed bg-ods-bg",
|
|
506
533
|
hideBorder && "bg-transparent",
|
|
507
534
|
)}
|
|
508
535
|
>
|
|
509
536
|
{startIcon && <span className="flex h-6 shrink-0 items-center">{startIcon}</span>}
|
|
510
537
|
|
|
511
538
|
<div className="relative flex-1 min-w-0">
|
|
539
|
+
{/* No chip-line override on the placeholder: it only renders while
|
|
540
|
+
the draft is EMPTY, so the editor is always on its plain line
|
|
541
|
+
box here. */}
|
|
512
542
|
{isEmpty && !showPreview && (
|
|
513
|
-
<span className="pointer-events-none absolute left-0 top-0 select-none text-h4
|
|
543
|
+
<span className="pointer-events-none absolute left-0 top-0 select-none text-h4 text-ods-text-secondary">
|
|
514
544
|
{disabled ? "Connection lost. Waiting to reconnect..." : placeholder}
|
|
515
545
|
</span>
|
|
516
546
|
)}
|
|
@@ -525,7 +555,7 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
525
555
|
text; clearing `previewText` restores the (opacity-hidden)
|
|
526
556
|
editor. */}
|
|
527
557
|
{showPreview && (
|
|
528
|
-
<p className="pointer-events-none absolute inset-x-0 top-0 m-0 select-none truncate text-h4
|
|
558
|
+
<p className={cn("pointer-events-none absolute inset-x-0 top-0 m-0 select-none truncate text-h4 text-ods-text-secondary", hasChips && "!leading-9")}>
|
|
529
559
|
{previewText}
|
|
530
560
|
</p>
|
|
531
561
|
)}
|
|
@@ -540,7 +570,21 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
540
570
|
role="textbox"
|
|
541
571
|
aria-multiline="true"
|
|
542
572
|
aria-label={typeof placeholder === 'string' ? placeholder : 'Message'}
|
|
543
|
-
|
|
573
|
+
// `sending` blocks SENDING, never typing. Taking
|
|
574
|
+
// `contentEditable` away under a focused editor blurs it, and
|
|
575
|
+
// iOS dismisses the keyboard on blur; restoring it re-establishes
|
|
576
|
+
// the input session (and the refocus-after-send effect below
|
|
577
|
+
// re-presents the keyboard once more), so on a phone the keyboard
|
|
578
|
+
// flashed shut and open again around every send — repeatedly,
|
|
579
|
+
// because a host's `sending` toggles once per phase of a turn
|
|
580
|
+
// (`chatLoading` in `embeddable-chat` is `isTyping ||
|
|
581
|
+
// isCompacting`, and the legacy Mingo page adds dialog creation).
|
|
582
|
+
// Nothing is lost by staying editable: `fire()` and the send
|
|
583
|
+
// button are both gated on `sending` already, so a draft typed
|
|
584
|
+
// while a turn streams simply waits for its turn — Enter is a
|
|
585
|
+
// no-op until the send button lights back up. Only `disabled` —
|
|
586
|
+
// the transport is gone — takes the editor out of service.
|
|
587
|
+
contentEditable={!disabled}
|
|
544
588
|
suppressContentEditableWarning
|
|
545
589
|
spellCheck
|
|
546
590
|
onInput={handleInput}
|
|
@@ -558,17 +602,25 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
|
558
602
|
// see the preview comment above) and is only made transparent;
|
|
559
603
|
// the absolutely-positioned single-line preview paints over it.
|
|
560
604
|
showPreview && "opacity-0",
|
|
561
|
-
// `leading-9` (36px) sits just above the 32px chip's
|
|
562
|
-
// (~35.5px incl. vertical-align overhead): the LINE
|
|
563
|
-
// height (deterministic 48px, no overflow) AND the
|
|
564
|
-
// keeps the chip centered
|
|
565
|
-
// `
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
|
|
605
|
+
// WITH chips, `leading-9` (36px) sits just above the 32px chip's
|
|
606
|
+
// line-box need (~35.5px incl. vertical-align overhead): the LINE
|
|
607
|
+
// drives row height (deterministic 48px, no overflow) AND the
|
|
608
|
+
// near-zero slack keeps the chip centered against the `align-top`
|
|
609
|
+
// `h-9` wrapper `buildChipEl` gives it. ~4px gap between wrapped
|
|
610
|
+
// chip rows.
|
|
611
|
+
// WITHOUT chips there is nothing tall to center, and forcing the
|
|
612
|
+
// same 36px box on 14px phone text (`text-h4`) put a blank line's
|
|
613
|
+
// worth of air between every wrapped row — so plain drafts keep
|
|
614
|
+
// `text-h4`'s own responsive line-height (20px / 24px at md).
|
|
615
|
+
// `min-h-*` pins the single-line box even when EMPTY: a
|
|
616
|
+
// non-editable (`contentEditable=false`, i.e. `disabled`) empty div
|
|
617
|
+
// would otherwise collapse to 0px, and `items-center` would
|
|
618
|
+
// re-center the shorter wrapper in the row — dropping the `top-0`
|
|
619
|
+
// placeholder down.
|
|
620
|
+
"text-h4 text-ods-text-primary",
|
|
621
|
+
hasChips ? "min-h-9 !leading-9" : "min-h-[var(--font-line-space-h4-body)]",
|
|
570
622
|
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
|
|
571
|
-
|
|
623
|
+
disabled && "cursor-not-allowed",
|
|
572
624
|
)}
|
|
573
625
|
/>
|
|
574
626
|
</div>
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
2
|
|
|
3
|
-
exports[`RichMarkdownRenderer golden parity > renders fixture: audit-reddit-embed-bq 1`] = `
|
|
4
|
-
"<div class="simple-markdown-renderer "><div class="content-wrapper max-w-none"><article class="prose prose-lg max-w-none"><div data-mock="reddit" data-url="https://www.reddit.com/r/msp/comments/abc/post/"></div>
|
|
5
|
-
<script async src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script></article></div></div>"
|
|
6
|
-
`;
|
|
3
|
+
exports[`RichMarkdownRenderer golden parity > renders fixture: audit-reddit-embed-bq 1`] = `"<div class="simple-markdown-renderer "><div class="content-wrapper max-w-none"><article class="prose prose-lg max-w-none"><div data-mock="reddit" data-url="https://www.reddit.com/r/msp/comments/abc/post/"></div></article></div></div>"`;
|
|
7
4
|
|
|
8
5
|
exports[`RichMarkdownRenderer golden parity > renders fixture: audit-style-attrs 1`] = `
|
|
9
6
|
"<div class="simple-markdown-renderer "><div class="content-wrapper max-w-none"><article class="prose prose-lg max-w-none"><div class="takeaway" style="padding: 8px;">Key point</div>
|
|
@@ -160,7 +157,6 @@ exports[`RichMarkdownRenderer golden parity > renders fixture: hostile-onerror 1
|
|
|
160
157
|
|
|
161
158
|
exports[`RichMarkdownRenderer golden parity > renders fixture: hostile-script 1`] = `
|
|
162
159
|
"<div class="simple-markdown-renderer "><div class="content-wrapper max-w-none"><article class="prose prose-lg max-w-none"><p class="first:mt-0 last:mb-0 text-ods-text-primary font-sans text-[16px] md:text-[18px] lg:text-[20px] leading-[1.6] my-4">before</p>
|
|
163
|
-
<p class="first:mt-0 last:mb-0 text-ods-text-primary font-sans text-[16px] md:text-[18px] lg:text-[20px] leading-[1.6] my-4"><script>alert(1)</script></p>
|
|
164
160
|
<p class="first:mt-0 last:mb-0 text-ods-text-primary font-sans text-[16px] md:text-[18px] lg:text-[20px] leading-[1.6] my-4">after</p></article></div></div>"
|
|
165
161
|
`;
|
|
166
162
|
|
|
@@ -325,7 +325,11 @@ export function FileUpload<T extends FileUploadCandidate = File>({
|
|
|
325
325
|
// already on screen, an unreadable selection), and this runs from a click
|
|
326
326
|
// handler where a rejection would go unhandled.
|
|
327
327
|
try {
|
|
328
|
-
|
|
328
|
+
// Skip the empty (cancelled) case rather than routing it through
|
|
329
|
+
// handleFiles, whose first act is to clear a validation message the user
|
|
330
|
+
// may still be reading.
|
|
331
|
+
const picked = await pickFiles()
|
|
332
|
+
if (picked.length > 0) handleFiles(picked)
|
|
329
333
|
} catch (err) {
|
|
330
334
|
setValidationError(err instanceof Error ? err.message : "Could not open the file picker")
|
|
331
335
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { processShortcodes } from '../shortcodes';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Step 1.5 of processShortcodes: pasted social embed markup ships a
|
|
6
|
+
* `widgets.js` loader `<script>` alongside its blockquote. Scripts never
|
|
7
|
+
* execute on this surface, but before the strip they leaked into the
|
|
8
|
+
* article body as VISIBLE escaped source text (2026-08 Reddit embed
|
|
9
|
+
* incident). These tests pin the strip's contract.
|
|
10
|
+
*/
|
|
11
|
+
describe('processShortcodes script stripping', () => {
|
|
12
|
+
it('strips the Reddit widgets.js loader that accompanies pasted blockquote embeds', () => {
|
|
13
|
+
const input =
|
|
14
|
+
'<blockquote class="reddit-embed-bq" data-embed-height="316">' +
|
|
15
|
+
'<a href="https://www.reddit.com/r/sysadmin/comments/1op05cl/anyone_using_splashtop_as_their_main_remote/">Anyone using Splashtop?</a>' +
|
|
16
|
+
'</blockquote>' +
|
|
17
|
+
'<script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>';
|
|
18
|
+
const output = processShortcodes(input);
|
|
19
|
+
expect(output).not.toContain('<script');
|
|
20
|
+
expect(output).not.toContain('widgets.js');
|
|
21
|
+
expect(output).toContain('reddit-embed-bq');
|
|
22
|
+
expect(output).toContain('/comments/1op05cl/');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('strips the Twitter widgets.js loader', () => {
|
|
26
|
+
const input =
|
|
27
|
+
'<blockquote class="twitter-tweet"><a href="https://twitter.com/user/status/123">tweet</a></blockquote>' +
|
|
28
|
+
'<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>';
|
|
29
|
+
const output = processShortcodes(input);
|
|
30
|
+
expect(output).not.toContain('<script');
|
|
31
|
+
expect(output).toContain('twitter-tweet');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('strips inline script elements with body content', () => {
|
|
35
|
+
const output = processShortcodes('before <script>alert("x")</script> after');
|
|
36
|
+
expect(output).not.toContain('script');
|
|
37
|
+
expect(output).toContain('before');
|
|
38
|
+
expect(output).toContain('after');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('preserves script tags inside fenced code blocks', () => {
|
|
42
|
+
const input = 'Example:\n\n```html\n<script src="https://example.com/app.js"></script>\n```\n';
|
|
43
|
+
const output = processShortcodes(input);
|
|
44
|
+
expect(output).toContain('<script src="https://example.com/app.js"></script>');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('preserves script tags inside inline code', () => {
|
|
48
|
+
const input = 'Use `<script defer></script>` for deferred loading.';
|
|
49
|
+
const output = processShortcodes(input);
|
|
50
|
+
expect(output).toContain('`<script defer></script>`');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('leaves an unclosed script opener alone (falls through to the escape pre-pass)', () => {
|
|
54
|
+
const input = 'An unclosed <script> opener in prose.';
|
|
55
|
+
const output = processShortcodes(input);
|
|
56
|
+
expect(output).toContain('<script>');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -153,13 +153,15 @@ export function buildRichEmbedOverrides({
|
|
|
153
153
|
return (standard.div as any)(divProps);
|
|
154
154
|
},
|
|
155
155
|
|
|
156
|
-
//
|
|
157
|
-
// `<blockquote class="reddit-embed-bq">…<a href="post-url">`
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
156
|
+
// Pasted-from-the-platform embed markup lives in published blog posts as
|
|
157
|
+
// `<blockquote class="reddit-embed-bq">…<a href="post-url">` (58 blocks
|
|
158
|
+
// across 9 posts) — and Twitter's equivalent is
|
|
159
|
+
// `<blockquote class="twitter-tweet">…<a href="status-url">`. Each pair
|
|
160
|
+
// ships a `widgets.js` loader script, which `processShortcodes` strips
|
|
161
|
+
// from the source text (Step 1.5). The composition rehydrates the
|
|
162
|
+
// blockquote itself: extract the post URL from the first matching link
|
|
163
|
+
// and render the platform's embed-client SSOT. Other blockquotes fall
|
|
164
|
+
// through to the engine's base blockquote.
|
|
163
165
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
164
166
|
blockquote: (bqProps: any) => {
|
|
165
167
|
const { node, className } = bqProps;
|
|
@@ -168,6 +170,10 @@ export function buildRichEmbedOverrides({
|
|
|
168
170
|
const postUrl = findFirstHref(node, /reddit\.com/);
|
|
169
171
|
if (postUrl) return <RedditEmbedClient url={postUrl} />;
|
|
170
172
|
}
|
|
173
|
+
if (classNames.includes('twitter-tweet')) {
|
|
174
|
+
const tweetUrl = findFirstHref(node, /(?:twitter\.com|x\.com)\/[^/]+\/status\//);
|
|
175
|
+
if (tweetUrl) return <TwitterEmbedClient url={tweetUrl} />;
|
|
176
|
+
}
|
|
171
177
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
172
178
|
return (standard.blockquote as any)(bqProps);
|
|
173
179
|
},
|
|
@@ -78,6 +78,21 @@ export const processShortcodes = (content: string): string => {
|
|
|
78
78
|
return placeholder;
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
// Step 1.5: Strip well-formed `<script …>…</script>` elements from prose.
|
|
82
|
+
// Pasted social embed markup ships a loader script alongside its markup
|
|
83
|
+
// (Reddit: `<blockquote class="reddit-embed-bq">…` + embed.reddit.com
|
|
84
|
+
// widgets.js; Twitter: platform.twitter.com widgets.js). Scripts NEVER
|
|
85
|
+
// execute on this surface — the sanitize stack strips real ones — but the
|
|
86
|
+
// engine's text pre-pass escapes the tag first, so it used to render as
|
|
87
|
+
// VISIBLE source text under every embed. Runs after Step 1 so script tags
|
|
88
|
+
// inside code fences / inline code stay literal sample code; an UNCLOSED
|
|
89
|
+
// opener is left alone (falls through to today's escape-to-text behavior,
|
|
90
|
+
// which is the safe degrade). Quantifiers are hard-bounded (ReDoS).
|
|
91
|
+
processedContent = processedContent.replace(
|
|
92
|
+
/<script\b[^>]{0,2000}>[\s\S]{0,20000}?<\/script\s{0,10}>/gi,
|
|
93
|
+
''
|
|
94
|
+
);
|
|
95
|
+
|
|
81
96
|
// Step 2: Temporarily replace markdown links to protect them
|
|
82
97
|
const markdownLinks: string[] = [];
|
|
83
98
|
processedContent = processedContent.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match) => {
|