@marimo-team/islands 0.24.1-dev32 → 0.24.1-dev36
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/{chat-ui-ChbH2I73.js → chat-ui-DktaBw5O.js} +62 -62
- package/dist/{common-nFz87Uyp.js → common-B4PK0RCN.js} +746 -727
- package/dist/{html-to-image-BzT-fb14.js → html-to-image-RKitVHn8.js} +2374 -2351
- package/dist/main.js +82 -82
- package/dist/{process-output-C8Q8G6-g.js → process-output-BEMkC1sa.js} +1 -1
- package/dist/{reveal-component-BfdtFvyL.js → reveal-component-DxaTQmhR.js} +2 -2
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/data-table/__tests__/columns.test.tsx +166 -2
- package/src/components/data-table/columns.tsx +87 -10
- package/src/utils/__tests__/url-parser.test.ts +26 -0
- package/src/utils/json/__tests__/json-parser.test.ts +33 -0
- package/src/utils/json/json-parser.ts +5 -3
- package/src/utils/url-parser.ts +4 -3
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import type { Column } from "@tanstack/react-table";
|
|
4
|
-
import { fireEvent, render } from "@testing-library/react";
|
|
4
|
+
import { fireEvent, render, waitFor } from "@testing-library/react";
|
|
5
5
|
import { I18nProvider } from "react-aria";
|
|
6
|
-
import { describe, expect, it, test, vi } from "vitest";
|
|
6
|
+
import { beforeAll, describe, expect, it, test, vi } from "vitest";
|
|
7
7
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
8
8
|
import { Logger } from "@/utils/Logger";
|
|
9
9
|
import { parseContent } from "@/utils/url-parser";
|
|
@@ -809,6 +809,48 @@ describe("renderCellValue with string + edge whitespace", () => {
|
|
|
809
809
|
</I18nProvider>,
|
|
810
810
|
);
|
|
811
811
|
|
|
812
|
+
it("closes a long-string popover when its shadow-DOM trigger is clicked again", async () => {
|
|
813
|
+
const value = "This string is long enough to render in a cell popover.";
|
|
814
|
+
const result = renderCellValue({
|
|
815
|
+
column: createMockStringColumn(),
|
|
816
|
+
renderValue: () => value,
|
|
817
|
+
getValue: () => value,
|
|
818
|
+
selectCell: undefined,
|
|
819
|
+
cellStyles: "",
|
|
820
|
+
});
|
|
821
|
+
const host = document.createElement("div");
|
|
822
|
+
document.body.append(host);
|
|
823
|
+
try {
|
|
824
|
+
const shadowRoot = host.attachShadow({ mode: "open" });
|
|
825
|
+
const { unmount } = render(
|
|
826
|
+
<I18nProvider locale="en-US">
|
|
827
|
+
<TooltipProvider>{result}</TooltipProvider>
|
|
828
|
+
</I18nProvider>,
|
|
829
|
+
{ container: shadowRoot as unknown as HTMLElement },
|
|
830
|
+
);
|
|
831
|
+
try {
|
|
832
|
+
const trigger =
|
|
833
|
+
shadowRoot.querySelector<HTMLButtonElement>("button[data-state]");
|
|
834
|
+
const getCopyButton = () =>
|
|
835
|
+
document.querySelector('[aria-label="Copy to clipboard"]') ??
|
|
836
|
+
shadowRoot.querySelector('[aria-label="Copy to clipboard"]');
|
|
837
|
+
|
|
838
|
+
expect(trigger).not.toBeNull();
|
|
839
|
+
fireEvent.pointerDown(trigger!);
|
|
840
|
+
fireEvent.click(trigger!);
|
|
841
|
+
await waitFor(() => expect(getCopyButton()).not.toBeNull());
|
|
842
|
+
|
|
843
|
+
fireEvent.pointerDown(trigger!);
|
|
844
|
+
fireEvent.click(trigger!);
|
|
845
|
+
expect(getCopyButton()).toBeNull();
|
|
846
|
+
} finally {
|
|
847
|
+
unmount();
|
|
848
|
+
}
|
|
849
|
+
} finally {
|
|
850
|
+
host.remove();
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
812
854
|
it("renders edge whitespace markers and still detects the URL in the middle", () => {
|
|
813
855
|
const mockColumn = createMockStringColumn();
|
|
814
856
|
const value = " https://example.com ";
|
|
@@ -891,6 +933,128 @@ describe("renderCellValue with string + edge whitespace", () => {
|
|
|
891
933
|
});
|
|
892
934
|
});
|
|
893
935
|
|
|
936
|
+
describe("renderCellValue with JSON document strings", () => {
|
|
937
|
+
beforeAll(() => {
|
|
938
|
+
// JsonViewer reads the color scheme through matchMedia, which jsdom
|
|
939
|
+
// does not implement.
|
|
940
|
+
Object.defineProperty(window, "matchMedia", {
|
|
941
|
+
writable: true,
|
|
942
|
+
value: vi.fn().mockImplementation((query: string) => ({
|
|
943
|
+
matches: false,
|
|
944
|
+
media: query,
|
|
945
|
+
onchange: null,
|
|
946
|
+
addListener: vi.fn(),
|
|
947
|
+
removeListener: vi.fn(),
|
|
948
|
+
addEventListener: vi.fn(),
|
|
949
|
+
removeEventListener: vi.fn(),
|
|
950
|
+
dispatchEvent: vi.fn(),
|
|
951
|
+
})),
|
|
952
|
+
});
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
const createMockStringColumn = () =>
|
|
956
|
+
({
|
|
957
|
+
id: "json_data",
|
|
958
|
+
columnDef: {
|
|
959
|
+
meta: {
|
|
960
|
+
dataType: "string" as const,
|
|
961
|
+
dtype: "str",
|
|
962
|
+
},
|
|
963
|
+
},
|
|
964
|
+
getColumnFormatting: () => undefined,
|
|
965
|
+
getColumnWrapping: () => undefined,
|
|
966
|
+
applyColumnFormatting: (value: unknown) => value,
|
|
967
|
+
}) as unknown as Column<unknown>;
|
|
968
|
+
|
|
969
|
+
const renderCell = (value: string) => {
|
|
970
|
+
const result = renderCellValue({
|
|
971
|
+
column: createMockStringColumn(),
|
|
972
|
+
renderValue: () => value,
|
|
973
|
+
getValue: () => value,
|
|
974
|
+
selectCell: undefined,
|
|
975
|
+
cellStyles: "",
|
|
976
|
+
});
|
|
977
|
+
return render(
|
|
978
|
+
<I18nProvider locale="en-US">
|
|
979
|
+
<TooltipProvider>{result}</TooltipProvider>
|
|
980
|
+
</I18nProvider>,
|
|
981
|
+
);
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
it("opens the JSON viewer for a str cell that holds a JSON object", () => {
|
|
985
|
+
const value =
|
|
986
|
+
'{"vendor":"ACME","attachments":[{"name":"inv.pdf","url":"https://example.com/p/AAA"}]}';
|
|
987
|
+
const { container } = renderCell(value);
|
|
988
|
+
|
|
989
|
+
const trigger = container.querySelector("button");
|
|
990
|
+
expect(trigger).toBeTruthy();
|
|
991
|
+
fireEvent.click(trigger!);
|
|
992
|
+
|
|
993
|
+
// Popover content mounts in a portal, so query the document.
|
|
994
|
+
expect(document.querySelector(".marimo-json-output")).toBeTruthy();
|
|
995
|
+
expect(document.body.textContent).toContain('"vendor":"ACME"');
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it("opens the JSON viewer for a str cell that holds a JSON array", () => {
|
|
999
|
+
const value = '[{"name":"inv.pdf","url":"https://example.com/p/AAA"}]';
|
|
1000
|
+
const { container } = renderCell(value);
|
|
1001
|
+
|
|
1002
|
+
const trigger = container.querySelector("button");
|
|
1003
|
+
expect(trigger).toBeTruthy();
|
|
1004
|
+
fireEvent.click(trigger!);
|
|
1005
|
+
|
|
1006
|
+
expect(document.querySelector(".marimo-json-output")).toBeTruthy();
|
|
1007
|
+
expect(document.body.textContent).toContain('"name":"inv.pdf"');
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it("opens the JSON viewer when the JSON document has edge whitespace", () => {
|
|
1011
|
+
const { container } = renderCell(' {"a": 1} ');
|
|
1012
|
+
|
|
1013
|
+
const trigger = container.querySelector("button");
|
|
1014
|
+
expect(trigger).toBeTruthy();
|
|
1015
|
+
fireEvent.click(trigger!);
|
|
1016
|
+
|
|
1017
|
+
expect(document.querySelector(".marimo-json-output")).toBeTruthy();
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
it("keeps a Python repr as inline text", () => {
|
|
1021
|
+
const value = "{'vendor': 'ACME'}";
|
|
1022
|
+
const { container } = renderCell(value);
|
|
1023
|
+
|
|
1024
|
+
expect(container.querySelector("button")).toBeNull();
|
|
1025
|
+
expect(container.textContent).toBe(value);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("keeps a truncated JSON document as inline text", () => {
|
|
1029
|
+
const value = '{"vendor":"ACME","attachments":';
|
|
1030
|
+
const { container } = renderCell(value);
|
|
1031
|
+
|
|
1032
|
+
expect(container.querySelector("button")).toBeNull();
|
|
1033
|
+
expect(container.textContent).toBe(value);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it("keeps JSON scalar strings as inline text", () => {
|
|
1037
|
+
const value = '"hello"';
|
|
1038
|
+
const { container } = renderCell(value);
|
|
1039
|
+
|
|
1040
|
+
expect(container.querySelector("button")).toBeNull();
|
|
1041
|
+
expect(container.textContent).toBe(value);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it("keeps the text popout for long strings that are not JSON", () => {
|
|
1045
|
+
const value =
|
|
1046
|
+
"This long plain sentence exceeds the fifty character popout threshold.";
|
|
1047
|
+
const { container } = renderCell(value);
|
|
1048
|
+
|
|
1049
|
+
const trigger = container.querySelector("button");
|
|
1050
|
+
expect(trigger).toBeTruthy();
|
|
1051
|
+
fireEvent.click(trigger!);
|
|
1052
|
+
|
|
1053
|
+
expect(document.querySelector(".marimo-json-output")).toBeNull();
|
|
1054
|
+
expect(document.body.textContent).toContain(value);
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
|
|
894
1058
|
describe("renderCellValue with boolean values", () => {
|
|
895
1059
|
const createMockColumn = () =>
|
|
896
1060
|
({
|
|
@@ -7,12 +7,14 @@ const PopoverClose = PopoverPrimitive.Close;
|
|
|
7
7
|
|
|
8
8
|
import type { Column, ColumnDef } from "@tanstack/react-table";
|
|
9
9
|
import { formatDate, isValid } from "date-fns";
|
|
10
|
+
import { useRef } from "react";
|
|
10
11
|
import { useLocale, useNumberFormatter } from "react-aria";
|
|
11
12
|
import { WithLocale } from "@/core/i18n/with-locale";
|
|
12
13
|
import type { DataType } from "@/core/kernel/messages";
|
|
13
14
|
import type { CalculateTopKRows } from "@/plugins/impl/DataTablePlugin";
|
|
14
15
|
import { cn } from "@/utils/cn";
|
|
15
16
|
import { type DateFormat, exactDateTime, getDateFormat } from "@/utils/dates";
|
|
17
|
+
import { Events } from "@/utils/events";
|
|
16
18
|
import { Logger } from "@/utils/Logger";
|
|
17
19
|
import { Maps } from "@/utils/maps";
|
|
18
20
|
import { maxFractionalDigits } from "@/utils/numbers";
|
|
@@ -382,9 +384,10 @@ const PopoutColumn = ({
|
|
|
382
384
|
rawStringValue,
|
|
383
385
|
edges,
|
|
384
386
|
contentClassName,
|
|
385
|
-
|
|
387
|
+
scrollable = false,
|
|
386
388
|
wrapped,
|
|
387
389
|
children,
|
|
390
|
+
buttonText,
|
|
388
391
|
}: {
|
|
389
392
|
cellStyles?: string;
|
|
390
393
|
selectCell?: () => void;
|
|
@@ -393,10 +396,12 @@ const PopoutColumn = ({
|
|
|
393
396
|
// still use `rawStringValue`. Middle is sliced from `rawStringValue`.
|
|
394
397
|
edges?: { leading: string; trailing: string };
|
|
395
398
|
contentClassName?: string;
|
|
396
|
-
|
|
399
|
+
scrollable?: boolean;
|
|
397
400
|
wrapped?: boolean;
|
|
401
|
+
buttonText?: string;
|
|
398
402
|
children: React.ReactNode;
|
|
399
403
|
}) => {
|
|
404
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
400
405
|
const hasEdgeWhitespace =
|
|
401
406
|
edges !== undefined &&
|
|
402
407
|
(edges.leading.length > 0 || edges.trailing.length > 0);
|
|
@@ -412,6 +417,7 @@ const PopoutColumn = ({
|
|
|
412
417
|
<EmotionCacheProvider container={null}>
|
|
413
418
|
<Popover>
|
|
414
419
|
<PopoverTrigger
|
|
420
|
+
ref={triggerRef}
|
|
415
421
|
className={cn(cellStyles, "max-w-fit outline-hidden")}
|
|
416
422
|
onClick={selectCell}
|
|
417
423
|
onMouseDown={(e) => {
|
|
@@ -432,29 +438,77 @@ const PopoutColumn = ({
|
|
|
432
438
|
</span>
|
|
433
439
|
</PopoverTrigger>
|
|
434
440
|
<PopoverContent
|
|
435
|
-
className={
|
|
441
|
+
className={cn(
|
|
442
|
+
contentClassName,
|
|
443
|
+
scrollable && "flex flex-col overflow-hidden",
|
|
444
|
+
)}
|
|
436
445
|
align="start"
|
|
437
446
|
alignOffset={10}
|
|
447
|
+
onInteractOutside={(event) => {
|
|
448
|
+
// Radix sees the shadow host as the target. Resolve the original
|
|
449
|
+
// target so pointer-down does not dismiss before click toggles.
|
|
450
|
+
const target = Events.composedTarget(event.detail.originalEvent);
|
|
451
|
+
if (triggerRef.current?.contains(target)) {
|
|
452
|
+
event.preventDefault();
|
|
453
|
+
}
|
|
454
|
+
}}
|
|
438
455
|
>
|
|
439
|
-
<div
|
|
456
|
+
<div
|
|
457
|
+
className={cn(
|
|
458
|
+
"flex -mt-3 -mr-2",
|
|
459
|
+
scrollable ? "shrink-0 justify-end" : "float-right",
|
|
460
|
+
)}
|
|
461
|
+
>
|
|
440
462
|
<CopyClipboardIcon
|
|
441
463
|
value={rawStringValue}
|
|
442
|
-
className="
|
|
464
|
+
className="size-3 hover:text-link"
|
|
465
|
+
buttonClassName="flex size-5 items-center justify-center"
|
|
443
466
|
tooltip={false}
|
|
444
467
|
/>
|
|
445
|
-
<PopoverClose>
|
|
446
|
-
<Button
|
|
468
|
+
<PopoverClose asChild={true}>
|
|
469
|
+
<Button
|
|
470
|
+
variant="link"
|
|
471
|
+
size="xs"
|
|
472
|
+
className={cn("size-5 p-0", !buttonText && "ml-1.5 mr-1")}
|
|
473
|
+
aria-label="Close"
|
|
474
|
+
>
|
|
447
475
|
{buttonText ?? "Close"}
|
|
448
476
|
</Button>
|
|
449
477
|
</PopoverClose>
|
|
450
478
|
</div>
|
|
451
|
-
{
|
|
479
|
+
{scrollable ? (
|
|
480
|
+
<div className="min-h-0 overflow-auto">{children}</div>
|
|
481
|
+
) : (
|
|
482
|
+
children
|
|
483
|
+
)}
|
|
452
484
|
</PopoverContent>
|
|
453
485
|
</Popover>
|
|
454
486
|
</EmotionCacheProvider>
|
|
455
487
|
);
|
|
456
488
|
};
|
|
457
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Parse a string that holds a JSON object or array document.
|
|
492
|
+
* Returns `undefined` for everything else, including JSON scalars,
|
|
493
|
+
* so ordinary text keeps its plain rendering.
|
|
494
|
+
*/
|
|
495
|
+
function parseJsonDocument(text: string): unknown {
|
|
496
|
+
const trimmedText = text.trim();
|
|
497
|
+
|
|
498
|
+
const first = trimmedText[0];
|
|
499
|
+
const last = trimmedText[trimmedText.length - 1];
|
|
500
|
+
const isBracketPair =
|
|
501
|
+
(first === "{" && last === "}") || (first === "[" && last === "]");
|
|
502
|
+
if (!isBracketPair) {
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
return JSON.parse(trimmedText) as unknown;
|
|
507
|
+
} catch {
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
458
512
|
function isPrimitiveOrNullish(value: unknown): boolean {
|
|
459
513
|
if (value == null) {
|
|
460
514
|
return true;
|
|
@@ -639,6 +693,28 @@ export function renderCellValue<TData, TValue>({
|
|
|
639
693
|
splitLeadingTrailingWhitespace(stringValue);
|
|
640
694
|
const hasEdgeWhitespace = leading.length > 0 || trailing.length > 0;
|
|
641
695
|
|
|
696
|
+
// A str cell that holds a JSON document opens the same JSON viewer as
|
|
697
|
+
// native list/struct cells.
|
|
698
|
+
const jsonDocument = parseJsonDocument(middle);
|
|
699
|
+
if (jsonDocument !== undefined) {
|
|
700
|
+
return (
|
|
701
|
+
<PopoutColumn
|
|
702
|
+
cellStyles={cellStyles}
|
|
703
|
+
selectCell={selectCell}
|
|
704
|
+
rawStringValue={stringValue}
|
|
705
|
+
edges={{ leading, trailing }}
|
|
706
|
+
wrapped={isWrapped}
|
|
707
|
+
>
|
|
708
|
+
<JsonOutput
|
|
709
|
+
data={jsonDocument}
|
|
710
|
+
format="tree"
|
|
711
|
+
valueTypes="json"
|
|
712
|
+
className="max-h-64"
|
|
713
|
+
/>
|
|
714
|
+
</PopoutColumn>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
642
718
|
// Parse only the inner content for URL detection so URLDetector doesn't
|
|
643
719
|
// split on the whitespace padding.
|
|
644
720
|
const parts = parseContent(hasEdgeWhitespace ? middle : stringValue);
|
|
@@ -662,9 +738,10 @@ export function renderCellValue<TData, TValue>({
|
|
|
662
738
|
selectCell={selectCell}
|
|
663
739
|
rawStringValue={stringValue}
|
|
664
740
|
edges={{ leading, trailing }}
|
|
665
|
-
contentClassName="max-h-64
|
|
666
|
-
|
|
741
|
+
contentClassName="max-h-64 whitespace-pre-wrap wrap-break-word text-sm w-96"
|
|
742
|
+
scrollable={true}
|
|
667
743
|
wrapped={isWrapped}
|
|
744
|
+
buttonText="X"
|
|
668
745
|
>
|
|
669
746
|
<MarkdownUrlDetector
|
|
670
747
|
content={stringValue}
|
|
@@ -98,4 +98,30 @@ describe("parseContent", () => {
|
|
|
98
98
|
{ type: "url", url: "https://github.com" },
|
|
99
99
|
]);
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
it("stops at JSON delimiters instead of swallowing them (#10567)", () => {
|
|
103
|
+
const parts = parseContent(
|
|
104
|
+
'[{"name":"inv.pdf","url":"https://example.com/p/AAA"}]',
|
|
105
|
+
);
|
|
106
|
+
expect(parts).toEqual([
|
|
107
|
+
{ type: "text", value: '[{"name":"inv.pdf","url":"' },
|
|
108
|
+
{ type: "url", url: "https://example.com/p/AAA" },
|
|
109
|
+
{ type: "text", value: '"}]' },
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does not include a trailing double-quote in the href", () => {
|
|
114
|
+
const parts = parseContent('"https://example.com/x"');
|
|
115
|
+
expect(parts).toContainEqual({
|
|
116
|
+
type: "url",
|
|
117
|
+
url: "https://example.com/x",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("keeps apostrophes, which are valid in URLs", () => {
|
|
122
|
+
const parts = parseContent("https://example.com/O'Reilly");
|
|
123
|
+
expect(parts).toEqual([
|
|
124
|
+
{ type: "url", url: "https://example.com/O'Reilly" },
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
101
127
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { jsonToMarkdown } from "../json-parser";
|
|
4
|
+
|
|
5
|
+
describe("jsonToMarkdown URL linkification", () => {
|
|
6
|
+
it("links a plain URL", () => {
|
|
7
|
+
const md = jsonToMarkdown([{ link: "https://marimo.io/path" }]);
|
|
8
|
+
expect(md).toContain("[https://marimo.io/path](https://marimo.io/path)");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("stops the link at the JSON delimiter, not the trailing quote (#10567)", () => {
|
|
12
|
+
const md = jsonToMarkdown([
|
|
13
|
+
{ attachments: '[{"url":"https://example.com/p/AAA"}]' },
|
|
14
|
+
]);
|
|
15
|
+
// The link target ends exactly at the URL; the closing `"}]` is left as
|
|
16
|
+
// text rather than pulled into the href.
|
|
17
|
+
expect(md).toContain("(https://example.com/p/AAA)");
|
|
18
|
+
expect(md).not.toContain('https://example.com/p/AAA"');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does not swallow a trailing double-quote into the link", () => {
|
|
22
|
+
const md = jsonToMarkdown([{ v: '"https://example.com/x"' }]);
|
|
23
|
+
expect(md).toContain("(https://example.com/x)");
|
|
24
|
+
expect(md).not.toContain('https://example.com/x"');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("leaves existing markdown links untouched", () => {
|
|
28
|
+
const md = jsonToMarkdown([{ v: "[docs](https://marimo.io/docs)" }]);
|
|
29
|
+
expect(md).toContain("[docs](https://marimo.io/docs)");
|
|
30
|
+
// Must not double-wrap into [[docs](url)](url).
|
|
31
|
+
expect(md).not.toContain("[[docs]");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import { URL_REGEX } from "../url-parser";
|
|
3
4
|
import type { JsonString } from "./base64";
|
|
4
5
|
|
|
6
|
+
const GLOBAL_URL_REGEX = new RegExp(URL_REGEX.source, "g");
|
|
7
|
+
|
|
5
8
|
declare global {
|
|
6
9
|
interface BigInt {
|
|
7
10
|
toJSON(): unknown;
|
|
@@ -126,9 +129,8 @@ function formatValueForMarkdown(value: unknown): string {
|
|
|
126
129
|
return placeholder;
|
|
127
130
|
});
|
|
128
131
|
|
|
129
|
-
// Convert plain URLs to markdown links
|
|
130
|
-
|
|
131
|
-
stringValue = stringValue.replaceAll(urlRegex, "[$1]($1)");
|
|
132
|
+
// Convert plain URLs to markdown links using the shared URL matcher.
|
|
133
|
+
stringValue = stringValue.replaceAll(GLOBAL_URL_REGEX, "[$1]($1)");
|
|
132
134
|
|
|
133
135
|
// Restore markdown links from placeholders
|
|
134
136
|
for (const [placeholder, original] of placeholders) {
|
package/src/utils/url-parser.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/** Matches HTTP(S) URLs without consuming surrounding delimiters. */
|
|
4
|
+
export const URL_REGEX = /(https?:\/\/[^\s"<>`{}|\\^]+)/;
|
|
4
5
|
const imageRegex = /\.(png|jpe?g|gif|webp|svg|ico)(\?.*)?$/i;
|
|
5
6
|
const dataImageRegex = /^data:image\//i;
|
|
6
7
|
const knownImageDomains = ["avatars.githubusercontent.com"];
|
|
@@ -19,9 +20,9 @@ export function parseContent(text: string): ContentPart[] {
|
|
|
19
20
|
return [{ type: "image", url: text }];
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
const parts = text.split(
|
|
23
|
+
const parts = text.split(URL_REGEX).filter((part) => part !== "");
|
|
23
24
|
return parts.map((part) => {
|
|
24
|
-
const isUrl =
|
|
25
|
+
const isUrl = URL_REGEX.test(part);
|
|
25
26
|
if (isUrl) {
|
|
26
27
|
const isImage =
|
|
27
28
|
imageRegex.test(part) ||
|