@multiplatform.one/markdown 7.7.0 → 7.7.2

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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * mountDiagramSvg spec (MPO-195). MermaidBlock.spec.tsx mocks this helper
3
+ * out, so until now nothing exercised the sanitizer itself. What it owns:
4
+ * inert parsing, executable-node removal, event-handler and javascript:
5
+ * stripping, the HTML fallback for markup that is not well-formed XML, and a
6
+ * false return that leaves the container alone.
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+ import { mountDiagramSvg } from "./mountDiagramSvg";
11
+
12
+ function host(): HTMLElement {
13
+ const el = document.createElement("div");
14
+ document.body.appendChild(el);
15
+ return el;
16
+ }
17
+
18
+ const PLAIN = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10" /></svg>';
19
+
20
+ describe("mountDiagramSvg", () => {
21
+ it("mounts a well-formed diagram as real SVG nodes and reports true", () => {
22
+ const container = host();
23
+ expect(mountDiagramSvg(container, PLAIN)).toBe(true);
24
+ const svg = container.querySelector("svg");
25
+ expect(svg).toBeTruthy();
26
+ expect(svg!.namespaceURI).toBe("http://www.w3.org/2000/svg");
27
+ expect(container.querySelector("rect")).toBeTruthy();
28
+ });
29
+
30
+ it("replaces previous content instead of appending to it", () => {
31
+ const container = host();
32
+ container.innerHTML = "<p>stale</p>";
33
+ mountDiagramSvg(container, PLAIN);
34
+ expect(container.querySelector("p")).toBeNull();
35
+ expect(container.childElementCount).toBe(1);
36
+ });
37
+
38
+ it("removes script, iframe, object and embed nodes", () => {
39
+ const container = host();
40
+ mountDiagramSvg(
41
+ container,
42
+ '<svg xmlns="http://www.w3.org/2000/svg">' +
43
+ "<script>globalThis.__pwned = true;</script>" +
44
+ '<iframe src="https://example.com"></iframe>' +
45
+ '<object data="x.swf"></object>' +
46
+ '<embed src="x.swf" />' +
47
+ '<rect width="10" height="10" />' +
48
+ "</svg>",
49
+ );
50
+ expect(container.querySelector("script")).toBeNull();
51
+ expect(container.querySelector("iframe")).toBeNull();
52
+ expect(container.querySelector("object")).toBeNull();
53
+ expect(container.querySelector("embed")).toBeNull();
54
+ // the drawing that carried them survives
55
+ expect(container.querySelector("rect")).toBeTruthy();
56
+ });
57
+
58
+ it("strips on* handlers from the root and from descendants", () => {
59
+ const container = host();
60
+ mountDiagramSvg(
61
+ container,
62
+ '<svg xmlns="http://www.w3.org/2000/svg" onload="globalThis.__pwned = true">' +
63
+ '<rect width="10" height="10" onclick="globalThis.__pwned = true" ONMOUSEOVER="x()" />' +
64
+ "</svg>",
65
+ );
66
+ const svg = container.querySelector("svg")!;
67
+ expect(svg.hasAttribute("onload")).toBe(false);
68
+ const rect = container.querySelector("rect")!;
69
+ expect(rect.hasAttribute("onclick")).toBe(false);
70
+ expect(rect.getAttributeNames().some((name) => name.toLowerCase() === "onmouseover")).toBe(
71
+ false,
72
+ );
73
+ });
74
+
75
+ it("strips javascript: hrefs but keeps ordinary ones", () => {
76
+ const container = host();
77
+ mountDiagramSvg(
78
+ container,
79
+ '<svg xmlns="http://www.w3.org/2000/svg">' +
80
+ '<a href=" JavaScript:alert(1)"><text>bad</text></a>' +
81
+ '<a href="https://example.com"><text>good</text></a>' +
82
+ "</svg>",
83
+ );
84
+ const anchors = Array.from(container.querySelectorAll("a"));
85
+ expect(anchors).toHaveLength(2);
86
+ expect(anchors[0].hasAttribute("href")).toBe(false);
87
+ expect(anchors[1].getAttribute("href")).toBe("https://example.com");
88
+ // the defanged anchor's content is still drawn
89
+ expect(container.textContent).toContain("bad");
90
+ });
91
+
92
+ it("falls back to lenient HTML parsing for markup that is not well-formed XML", () => {
93
+ const container = host();
94
+ const mounted = mountDiagramSvg(
95
+ container,
96
+ '<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div>label</foreignObject></svg>',
97
+ );
98
+ expect(mounted).toBe(true);
99
+ expect(container.querySelector("svg")).toBeTruthy();
100
+ expect(container.textContent).toContain("label");
101
+ });
102
+
103
+ it("returns false and leaves the container untouched when there is no svg", () => {
104
+ const container = host();
105
+ container.innerHTML = "<p>kept</p>";
106
+ expect(mountDiagramSvg(container, "not markup at all")).toBe(false);
107
+ expect(mountDiagramSvg(container, "")).toBe(false);
108
+ expect(mountDiagramSvg(container, "<div>html but no svg</div>")).toBe(false);
109
+ expect(container.querySelector("p")?.textContent).toBe("kept");
110
+ });
111
+ });
@@ -9,7 +9,6 @@ vi.mock("mermaid", () => ({
9
9
  }));
10
10
 
11
11
  import { renderMermaidDiagram, resetMermaidForTesting } from "./renderMermaidDiagram";
12
- import { mountDiagramSvg } from "./mountDiagramSvg";
13
12
 
14
13
  beforeEach(() => {
15
14
  resetMermaidForTesting();
@@ -56,38 +55,35 @@ describe("renderMermaidDiagram", () => {
56
55
  const result = await renderMermaidDiagram("graph TD\n A-->B");
57
56
  expect(result).toEqual({ ok: false, error: "boom" });
58
57
  });
59
- });
60
58
 
61
- describe("mountDiagramSvg", () => {
62
- it("mounts the svg element as real nodes", () => {
63
- const container = document.createElement("div");
64
- expect(mountDiagramSvg(container, "<svg><g><text>hi</text></g></svg>")).toBe(true);
65
- expect(container.querySelector("svg g text")?.textContent).toBe("hi");
59
+ it("forwards fontFamily into the theme config and re-inits when it changes", async () => {
60
+ await renderMermaidDiagram("a", { fontFamily: "Courier New, monospace" });
61
+ expect(initialize).toHaveBeenCalledWith(
62
+ expect.objectContaining({ fontFamily: "Courier New, monospace" }),
63
+ );
64
+ await renderMermaidDiagram("b", { fontFamily: "Courier New, monospace" });
65
+ expect(initialize).toHaveBeenCalledTimes(1);
66
+ await renderMermaidDiagram("c", { fontFamily: "Inter" });
67
+ expect(initialize).toHaveBeenCalledTimes(2);
66
68
  });
67
69
 
68
- it("strips executable content and handlers (defense in depth)", () => {
69
- const container = document.createElement("div");
70
- mountDiagramSvg(
71
- container,
72
- '<svg onload="alert(1)"><script>alert(2)</script>' +
73
- '<g onclick="x()"><text>t</text></g>' +
74
- '<image href="javascript:alert(3)"></image></svg>',
75
- );
76
- expect(container.querySelector("script")).toBeNull();
77
- expect(container.querySelector("svg")?.getAttribute("onload")).toBeNull();
78
- expect(container.querySelector("g")?.getAttribute("onclick")).toBeNull();
79
- expect(container.querySelector("image")?.getAttribute("href")).toBeNull();
80
- // the safe content survives
81
- expect(container.querySelector("text")?.textContent).toBe("t");
70
+ it("omits fontFamily entirely when none is given", async () => {
71
+ await renderMermaidDiagram("a");
72
+ expect(initialize.mock.calls[0][0]).not.toHaveProperty("fontFamily");
82
73
  });
83
74
 
84
- it("returns false for non-svg payloads", () => {
85
- const container = document.createElement("div");
86
- expect(mountDiagramSvg(container, "<div>nope</div>")).toBe(false);
87
- expect(container.childNodes.length).toBe(0);
75
+ it("stringifies a non-Error rejection instead of dropping it", async () => {
76
+ parse.mockRejectedValue("plain string failure");
77
+ await expect(renderMermaidDiagram("graph TD\n A--")).resolves.toEqual({
78
+ ok: false,
79
+ error: "plain string failure",
80
+ });
88
81
  });
89
82
  });
90
83
 
84
+ // mountDiagramSvg has its own colocated spec (mountDiagramSvg.spec.ts);
85
+ // its cases used to live here, one module behind another module's name.
86
+
91
87
  // MPO-10: the native tier ships no mermaid import at all. It answers with
92
88
  // the same "requires a DOM" result the web tier gives without a document,
93
89
  // so callers need no platform branch.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Renderer mermaid-fence spec (MPO-195).
3
+ *
4
+ * renderer.spec.tsx covers the node types that map onto plain components and
5
+ * deliberately runs unmocked. The `code` case has a second branch it does not
6
+ * reach: a ```mermaid fence goes to MermaidBlock (GitLab parity), and a
7
+ * custom `code` component still wins over that. Both need MermaidBlock
8
+ * stubbed — the real one lazy-imports the mermaid engine — so they live in
9
+ * their own file rather than putting a module mock over the whole contract
10
+ * suite.
11
+ */
12
+
13
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
14
+ import { describe, expect, it, vi } from "vitest";
15
+
16
+ // The factory is hoisted above every import, so it closes over nothing but
17
+ // JSX — a tamagui element here would be read before its module initialises.
18
+ vi.mock("./mermaid/MermaidBlock", () => ({
19
+ MermaidBlock: ({ children }: { children: string }) => (
20
+ <span data-testid="mermaid-block">{children}</span>
21
+ ),
22
+ }));
23
+
24
+ import { parseMarkdown } from "./parser";
25
+ import { renderMarkdown } from "./renderer";
26
+ import type { MarkdownComponents } from "./types";
27
+
28
+ const MERMAID_DOC = "```mermaid\ngraph TD\n A-->B\n```";
29
+
30
+ function renderDoc(markdown: string, components?: Partial<MarkdownComponents>) {
31
+ return renderWithProviders(<>{renderMarkdown(parseMarkdown(markdown), { components })}</>);
32
+ }
33
+
34
+ describe("renderMarkdown — mermaid fences", () => {
35
+ it("routes a mermaid fence to MermaidBlock, not the code block", () => {
36
+ const { getByTestId } = renderDoc(MERMAID_DOC);
37
+ expect(getByTestId("mermaid-block").textContent).toContain("graph TD");
38
+ });
39
+
40
+ it("leaves every other language on the code block path", () => {
41
+ const { queryByTestId, container } = renderDoc("```typescript\nconst a = 1;\n```");
42
+ expect(queryByTestId("mermaid-block")).toBeNull();
43
+ expect(container.textContent).toContain("const a = 1;");
44
+ });
45
+
46
+ it("treats an unlabelled fence as code, never as a diagram", () => {
47
+ const { queryByTestId } = renderDoc("```\ngraph TD\n A-->B\n```");
48
+ expect(queryByTestId("mermaid-block")).toBeNull();
49
+ });
50
+
51
+ it("gives a custom code component precedence over the mermaid branch", () => {
52
+ const seen: Array<string | null | undefined> = [];
53
+ const components: Partial<MarkdownComponents> = {
54
+ code: ({ children, lang }) => {
55
+ seen.push(lang);
56
+ return <span data-testid="custom-code">{children}</span>;
57
+ },
58
+ };
59
+ const { getByTestId, queryByTestId } = renderDoc(MERMAID_DOC, components);
60
+ expect(queryByTestId("mermaid-block")).toBeNull();
61
+ expect(getByTestId("custom-code").textContent).toContain("graph TD");
62
+ expect(seen).toEqual(["mermaid"]);
63
+ });
64
+
65
+ it("renders an empty document as an empty frame rather than throwing", () => {
66
+ const { container } = renderDoc("");
67
+ expect(container.textContent).toBe("");
68
+ });
69
+ });
@@ -263,6 +263,26 @@ describe("renderNode — unknown node degradation", () => {
263
263
  expect(container.textContent).toContain("still visible");
264
264
  });
265
265
 
266
+ it("wraps a bare text child of an unknown container in Text, never a raw string under the View", () => {
267
+ // A raw string directly under a View is a text node (nodeType 3) and logs
268
+ // "A text node cannot be a child of a <View>" on native and react-native-web
269
+ // (MPO-197, Markdown/Renderer --unknown-nodes); a Text wrapper is an element.
270
+ const { container } = renderWithProviders(
271
+ <>
272
+ {renderNode(
273
+ {
274
+ type: "futureContainer",
275
+ children: [{ type: "text", value: "still visible" }],
276
+ } as any,
277
+ 0,
278
+ )}
279
+ </>,
280
+ );
281
+ const view = container.firstElementChild as HTMLElement;
282
+ expect(view.textContent).toBe("still visible");
283
+ expect(Array.from(view.childNodes).every((n) => n.nodeType === 1)).toBe(true);
284
+ });
285
+
266
286
  it("renders unknown leaf nodes with a string value as text", () => {
267
287
  const { container } = renderWithProviders(
268
288
  <>{renderNode({ type: "futureLeaf", value: "leaf value" } as any, 0)}</>,
package/src/renderer.tsx CHANGED
@@ -311,11 +311,18 @@ export function renderNode(
311
311
  }
312
312
 
313
313
  default:
314
- // Handle unknown nodes - try to render children if they exist
314
+ // Handle unknown nodes - try to render children if they exist. Inline
315
+ // nodes such as `text` render as raw strings, and a View cannot hold a
316
+ // bare string on native or react-native-web ("A text node cannot be a
317
+ // child of a <View>", a console error on every paint), so any string
318
+ // child gets a Text wrapper here.
315
319
  if ("children" in node && Array.isArray(node.children)) {
316
320
  return (
317
321
  <View key={key}>
318
- {node.children.map((child: AnyNode, i: number) => renderNode(child, i, options))}
322
+ {node.children.map((child: AnyNode, i: number) => {
323
+ const rendered = renderNode(child, i, options);
324
+ return typeof rendered === "string" ? <Text key={i}>{rendered}</Text> : rendered;
325
+ })}
319
326
  </View>
320
327
  );
321
328
  }
@@ -1 +1 @@
1
- {"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,OAAO,KAAK,EACV,IAAI,EACJ,OAAO,IAAI,WAAW,EACtB,SAAS,IAAI,aAAa,EAC1B,IAAI,IAAI,QAAQ,EAChB,QAAQ,IAAI,YAAY,EACxB,MAAM,IAAI,UAAU,EACpB,MAAM,IAAI,UAAU,EACpB,UAAU,IAAI,cAAc,EAC5B,IAAI,IAAI,QAAQ,EAChB,IAAI,IAAI,QAAQ,EAChB,KAAK,IAAI,SAAS,EAClB,IAAI,IAAI,QAAQ,EAChB,QAAQ,IAAI,YAAY,EACxB,UAAU,IAAI,cAAc,EAC5B,aAAa,EACb,KAAK,IAAI,SAAS,EAClB,QAAQ,IAAI,YAAY,EACxB,SAAS,IAAI,aAAa,EAC1B,IAAI,IAAI,QAAQ,EAChB,KAAK,IAAI,SAAS,EACnB,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAsB5C,KAAK,OAAO,GACR,IAAI,GACJ,WAAW,GACX,aAAa,GACb,QAAQ,GACR,YAAY,GACZ,UAAU,GACV,UAAU,GACV,cAAc,GACd,QAAQ,GACR,QAAQ,GACR,SAAS,GACT,QAAQ,GACR,YAAY,GACZ,cAAc,GACd,aAAa,GACb,SAAS,GACT,YAAY,GACZ,aAAa,GACb,QAAQ,GACR,SAAS,GACT;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3D,UAAU,aAAa;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACzC,SAAS,CAAC,EAAE,YAAY,CAAC;IACzB,WAAW,CAAC,EAAE;QACZ,OAAO,EAAE,OAAO,CAAC;QACjB,KAAK,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;CACH;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,OAAO,EACb,GAAG,EAAE,MAAM,GAAG,MAAM,EACpB,OAAO,GAAE,aAAkB,GAC1B,SAAS,CA4OX;AAkED;;GAEG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,aAAkB,GAAG,SAAS,CAMjF"}
1
+ {"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,OAAO,KAAK,EACV,IAAI,EACJ,OAAO,IAAI,WAAW,EACtB,SAAS,IAAI,aAAa,EAC1B,IAAI,IAAI,QAAQ,EAChB,QAAQ,IAAI,YAAY,EACxB,MAAM,IAAI,UAAU,EACpB,MAAM,IAAI,UAAU,EACpB,UAAU,IAAI,cAAc,EAC5B,IAAI,IAAI,QAAQ,EAChB,IAAI,IAAI,QAAQ,EAChB,KAAK,IAAI,SAAS,EAClB,IAAI,IAAI,QAAQ,EAChB,QAAQ,IAAI,YAAY,EACxB,UAAU,IAAI,cAAc,EAC5B,aAAa,EACb,KAAK,IAAI,SAAS,EAClB,QAAQ,IAAI,YAAY,EACxB,SAAS,IAAI,aAAa,EAC1B,IAAI,IAAI,QAAQ,EAChB,KAAK,IAAI,SAAS,EACnB,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAsB5C,KAAK,OAAO,GACR,IAAI,GACJ,WAAW,GACX,aAAa,GACb,QAAQ,GACR,YAAY,GACZ,UAAU,GACV,UAAU,GACV,cAAc,GACd,QAAQ,GACR,QAAQ,GACR,SAAS,GACT,QAAQ,GACR,YAAY,GACZ,cAAc,GACd,aAAa,GACb,SAAS,GACT,YAAY,GACZ,aAAa,GACb,QAAQ,GACR,SAAS,GACT;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3D,UAAU,aAAa;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACzC,SAAS,CAAC,EAAE,YAAY,CAAC;IACzB,WAAW,CAAC,EAAE;QACZ,OAAO,EAAE,OAAO,CAAC;QACjB,KAAK,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;CACH;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,OAAO,EACb,GAAG,EAAE,MAAM,GAAG,MAAM,EACpB,OAAO,GAAE,aAAkB,GAC1B,SAAS,CAmPX;AAkED;;GAEG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,aAAkB,GAAG,SAAS,CAMjF"}