`; report which (if either) an element is.
+function mathKind(node: Element): 'inline' | 'display' | null {
+ const className = node.properties?.className;
+ if (Array.isArray(className)) {
+ if (className.includes('math-inline')) {
+ return 'inline';
+ }
+ if (className.includes('math-display')) {
+ return 'display';
+ }
+ }
+ return null;
+}
+
+/**
+ * Routes both math sources into the Math component as `
` elements:
+ * - Markdown `$...$` parsed by remark-math (as `.math-inline`/`.math-display`).
+ * - raw-HTML `$...$`, rewritten in place before rehype-raw runs.
+ *
+ * in markdown: Euler: $e=mc^2$
+ * transformed: Euler: e=mc^2
+ */
+const rehypeDashMath: Plugin<[], Root> = () => tree => {
+ visit(tree, node => {
+ if (node.type === 'raw') {
+ const raw = node as unknown as {value: string};
+ raw.value = rewriteRawMath(raw.value);
+ return;
+ }
+ if (node.type === 'element') {
+ const kind = mathKind(node);
+ if (kind) {
+ node.tagName = 'dashmathjax';
+ node.properties = {
+ inline: kind === 'inline' ? 'true' : 'false',
+ };
+ }
+ }
+ });
+};
+
+export default rehypeDashMath;
diff --git a/components/dash-core-components/src/utils/markdown/plugins/rehypeDccComponents.ts b/components/dash-core-components/src/utils/markdown/plugins/rehypeDccComponents.ts
new file mode 100644
index 0000000000..b4aa4f3d54
--- /dev/null
+++ b/components/dash-core-components/src/utils/markdown/plugins/rehypeDccComponents.ts
@@ -0,0 +1,33 @@
+import type {Plugin} from 'unified';
+import type {Root} from 'hast';
+import {visit} from 'unist-util-visit';
+
+const DCC_ALLOWED_TAGS_LOWERCASE = ['dcclink', 'dccmarkdown'];
+
+/**
+ * Renders supported dash components according to the allowlist.
+ * Slightly obscure because HTML on its own ignores the self-closing `/>` form
+ * of custom tags, so we correct for that here:
+ *
+ * in markdown: Click for info
+ * received here (wrong): Click for info
+ * corrected here: Click for info
+ */
+const rehypeDccComponents: Plugin<[], Root> = () => tree => {
+ visit(tree, 'element', (node, index, parent) => {
+ if (!parent || typeof index !== 'number') {
+ return;
+ }
+
+ const isDccTag = DCC_ALLOWED_TAGS_LOWERCASE.includes(node.tagName);
+ const hasChildrenAttribute = node.properties?.children !== undefined;
+ if (!isDccTag || !hasChildrenAttribute) {
+ return;
+ }
+
+ parent.children.splice(index + 1, 0, ...node.children);
+ node.children = [];
+ });
+};
+
+export default rehypeDccComponents;
diff --git a/components/dash-core-components/src/utils/markdown/plugins/rehypeStripTags.ts b/components/dash-core-components/src/utils/markdown/plugins/rehypeStripTags.ts
new file mode 100644
index 0000000000..13ace40fc5
--- /dev/null
+++ b/components/dash-core-components/src/utils/markdown/plugins/rehypeStripTags.ts
@@ -0,0 +1,23 @@
+import type {Plugin} from 'unified';
+import type {Root} from 'hast';
+import {visit, SKIP} from 'unist-util-visit';
+
+// Tags removed from raw HTML even when `dangerously_allow_html` is True.
+// New additions here may require a major version bump
+const STRIPPED_TAGS = ['script', 'style'];
+
+const rehypeStripTags: Plugin<[], Root> = () => tree => {
+ visit(tree, 'element', (node, index, parent) => {
+ if (
+ STRIPPED_TAGS.includes(node.tagName) &&
+ parent &&
+ typeof index === 'number'
+ ) {
+ parent.children.splice(index, 1);
+ return [SKIP, index];
+ }
+ return undefined;
+ });
+};
+
+export default rehypeStripTags;
diff --git a/components/dash-core-components/src/utils/markdown/text.ts b/components/dash-core-components/src/utils/markdown/text.ts
new file mode 100644
index 0000000000..4d47cad3ae
--- /dev/null
+++ b/components/dash-core-components/src/utils/markdown/text.ts
@@ -0,0 +1,64 @@
+import type {ReactNode} from 'react';
+import type {Content} from 'hast';
+
+// Concatenate the text content of hast nodes, descending into element children.
+export function collectText(nodes: Content[] | undefined): string {
+ if (!nodes) {
+ return '';
+ }
+ return nodes
+ .map(node => {
+ if (node.type === 'text') {
+ return node.value;
+ }
+ if (node.type === 'element') {
+ return collectText(node.children);
+ }
+ return '';
+ })
+ .join('');
+}
+
+export function reactNodeToText(node: ReactNode): string {
+ if (node === null || node === undefined || typeof node === 'boolean') {
+ return '';
+ }
+ if (typeof node === 'string' || typeof node === 'number') {
+ return String(node);
+ }
+ if (Array.isArray(node)) {
+ return node.map(reactNodeToText).join('');
+ }
+ const element = node as {props?: {children?: ReactNode}};
+ return element.props ? reactNodeToText(element.props.children) : '';
+}
+
+export function dedentText(text: string): string {
+ const lines = text.split(/\r\n|\r|\n/);
+ let commonPrefix: string | null = null;
+ for (const line of lines) {
+ const preMatch = line && line.match(/^\s*(?=\S)/);
+ if (preMatch) {
+ const prefix = preMatch[0];
+ if (commonPrefix !== null) {
+ for (let i = 0; i < commonPrefix.length; i++) {
+ // Like Python's textwrap.dedent, we'll remove both space
+ // and tab characters, but only if they match
+ if (prefix[i] !== commonPrefix[i]) {
+ commonPrefix = commonPrefix.substr(0, i);
+ break;
+ }
+ }
+ } else {
+ commonPrefix = prefix;
+ }
+ if (!commonPrefix) {
+ break;
+ }
+ }
+ }
+ const commonLen = commonPrefix ? commonPrefix.length : 0;
+ return lines
+ .map(line => (line.match(/\S/) ? line.substr(commonLen) : ''))
+ .join('\n');
+}
diff --git a/components/dash-core-components/src/utils/mathjax.js b/components/dash-core-components/src/utils/mathjax.js
deleted file mode 100644
index 347a34513c..0000000000
--- a/components/dash-core-components/src/utils/mathjax.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import 'mathjax/es5/tex-svg';
-
-window.MathJax.config.startup.typeset = false;
diff --git a/components/dash-core-components/tests/integration/link/test_link_props.py b/components/dash-core-components/tests/integration/link/test_link_props.py
new file mode 100644
index 0000000000..aac9049ac6
--- /dev/null
+++ b/components/dash-core-components/tests/integration/link/test_link_props.py
@@ -0,0 +1,68 @@
+from multiprocessing import Lock
+
+from dash import Dash, Input, Output, dcc, html
+
+
+def test_lipr001_target(dash_dcc):
+ # The `target` attribute is rendered on the anchor; links with a target
+ # (other than _self) opt out of client-side navigation.
+ app = Dash(__name__)
+ app.layout = html.Div(
+ [
+ dcc.Link("external", id="link1", href="/page-1", target="_blank"),
+ ]
+ )
+ dash_dcc.start_server(app)
+
+ link = dash_dcc.wait_for_element("#link1")
+ assert link.get_attribute("target") == "_blank"
+
+ assert dash_dcc.get_logs() == []
+
+
+def test_lipr002_sanitizes_dangerous_href(dash_dcc):
+ # A dangerous href is passed through clean_url, which rewrites disallowed
+ # protocols to about:blank so the link can't execute script.
+ app = Dash(__name__)
+ app.layout = html.Div(
+ [
+ dcc.Link("click me", id="link1", href="javascript:alert(1)"),
+ ]
+ )
+ dash_dcc.start_server(app)
+
+ link = dash_dcc.wait_for_element("#link1")
+ assert link.get_attribute("href") == "about:blank"
+
+
+def test_lipr003_loading_state(dash_dcc):
+ # While a callback targeting the Link is in flight, the anchor carries
+ # `data-dash-is-loading` (applied via LoadingElement).
+ lock = Lock()
+
+ app = Dash(__name__)
+ app.layout = html.Div(
+ [
+ html.Button(id="btn"),
+ dcc.Link("Page 1", id="link1", href="/page-1"),
+ ]
+ )
+
+ @app.callback(Output("link1", "children"), Input("btn", "n_clicks"))
+ def update_children(n_clicks):
+ with lock:
+ return "Page 1"
+
+ with lock:
+ dash_dcc.start_server(app)
+ dash_dcc.wait_for_element('#link1[data-dash-is-loading="true"]')
+
+ dash_dcc.wait_for_element('#link1:not([data-dash-is-loading="true"])')
+
+ with lock:
+ dash_dcc.wait_for_element("#btn").click()
+ dash_dcc.wait_for_element('#link1[data-dash-is-loading="true"]')
+
+ dash_dcc.wait_for_element('#link1:not([data-dash-is-loading="true"])')
+
+ assert dash_dcc.get_logs() == []
diff --git a/components/dash-core-components/tests/integration/markdown/test_markdown.py b/components/dash-core-components/tests/integration/markdown/test_markdown.py
index 786347cd5f..9c3f4e33e6 100644
--- a/components/dash-core-components/tests/integration/markdown/test_markdown.py
+++ b/components/dash-core-components/tests/integration/markdown/test_markdown.py
@@ -3,6 +3,19 @@
from dash.testing.wait import until
+GRAVITY = "$F=\\frac{Gm_1m_2}{r^2}$"
+
+BLOCK_MATH = """
+ ## h2 tag with MathJax block:
+ $$
+ \\frac{1}{(\\sqrt{\\phi \\sqrt{5}}-\\phi) e^{\\frac25 \\pi}} =
+ 1+\\frac{e^{-2\\pi}} {1+\\frac{e^{-4\\pi}} {1+\\frac{e^{-6\\pi}}
+ {1+\\frac{e^{-8\\pi}} {1+\\ldots} } } }
+ $$
+ ## Next line.
+"""
+
+
def test_mkdw001_img(dash_dcc):
app = Dash(__name__, eager_loading=True, assets_folder="../../assets")
@@ -10,14 +23,24 @@ def test_mkdw001_img(dash_dcc):
[
html.Div("Markdown img"),
dcc.Markdown(
- ['
'], dangerously_allow_html=True
+ ['
'],
+ dangerously_allow_html=True,
+ id="img_html",
),
html.Div("Markdown img - requires dangerously_allow_html"),
- dcc.Markdown(['
']),
+ dcc.Markdown(['
'], id="img_no_html"),
]
)
dash_dcc.start_server(app)
+
+ # With dangerously_allow_html the raw
renders as a real image.
+ img = dash_dcc.wait_for_element("#img_html img")
+ assert img.get_attribute("src").endswith("assets/image.png")
+
+ # Without it, the raw HTML is inert - no
element is created.
+ dash_dcc.wait_for_no_elements("#img_no_html img")
+
dash_dcc.percy_snapshot("mkdw001 - image display")
assert dash_dcc.get_logs() == []
@@ -29,11 +52,12 @@ def test_mkdw002_dcclink(dash_dcc):
app.layout = html.Div(
[
html.Div(["Markdown link"]),
- dcc.Markdown(["[Title](title_crumb)"]),
+ dcc.Markdown(["[Title](title_crumb)"], id="md_link"),
html.Div(["Markdown dccLink"]),
dcc.Markdown(
[''],
dangerously_allow_html=True,
+ id="dcclink_attr",
),
html.Div(["Markdown dccLink - explicit children"]),
dcc.Markdown(
@@ -45,6 +69,7 @@ def test_mkdw002_dcclink(dash_dcc):
"""
],
dangerously_allow_html=True,
+ id="dcclink_explicit",
),
html.Div("Markdown dccLink = inlined"),
dcc.Markdown(
@@ -52,6 +77,7 @@ def test_mkdw002_dcclink(dash_dcc):
'This is an inlined with text on both sides'
],
dangerously_allow_html=True,
+ id="dcclink_inline",
),
html.Div("Markdown dccLink - nested image"),
dcc.Markdown(
@@ -63,6 +89,7 @@ def test_mkdw002_dcclink(dash_dcc):
"""
],
dangerously_allow_html=True,
+ id="dcclink_nested_img",
),
html.Div("Markdown dccLink - nested markdown"),
dcc.Markdown(
@@ -74,6 +101,7 @@ def test_mkdw002_dcclink(dash_dcc):
"""
],
dangerously_allow_html=True,
+ id="dcclink_nested_md",
),
html.Div("Markdown dccLink - nested markdown image"),
dcc.Markdown(
@@ -85,162 +113,156 @@ def test_mkdw002_dcclink(dash_dcc):
"""
],
dangerously_allow_html=True,
+ id="dcclink_nested_md_img",
),
html.Div("Markdown dccLink - requires dangerously_allow_html"),
- dcc.Markdown(['']),
+ dcc.Markdown(
+ [''],
+ id="dcclink_no_html",
+ ),
]
)
dash_dcc.start_server(app)
- assert dash_dcc.get_logs() == []
-
-@pytest.mark.parametrize("is_eager", [True, False])
-def test_mkdw003_without_mathjax(dash_dcc, is_eager):
- app = Dash(__name__, eager_loading=is_eager)
+ # Baseline: a plain Markdown link renders an anchor with the link text.
+ dash_dcc.wait_for_text_to_equal("#md_link a", "Title")
+
+ # The regression from https://github.com/plotly/dash/issues/3951:
+ # a self-closing dccLink whose text is supplied via the `children`
+ # attribute must render "Title", NOT the href. When react-jsx-parser
+ # clobbered the attribute, dcc.Link fell back to rendering the href.
+ dash_dcc.wait_for_text_to_equal("#dcclink_attr a", "Title")
+ assert (
+ dash_dcc.find_element("#dcclink_attr a")
+ .get_attribute("href")
+ .endswith("title_crumb")
+ )
- app.layout = html.Div(
- [
- dcc.Markdown("# No MathJax: Apple: $2, Orange: $3"),
- ]
+ # The nested-content form renders identically.
+ dash_dcc.wait_for_text_to_equal("#dcclink_explicit a", "Title")
+ assert (
+ dash_dcc.find_element("#dcclink_explicit a")
+ .get_attribute("href")
+ .endswith("title_crumb")
)
- dash_dcc.start_server(app)
- dash_dcc.wait_for_text_to_equal("h1", "No MathJax: Apple: $2, Orange: $3")
- assert not dash_dcc.driver.execute_script("return !!window.MathJax")
+ # An inlined dccLink carries the children text on the anchor itself,
+ # with the surrounding prose on the parent element.
+ dash_dcc.wait_for_text_to_equal("#dcclink_inline a", "Title")
+ assert "with text on both sides" in dash_dcc.find_element("#dcclink_inline").text
+
+ # A raw
nested inside the link renders an
, not href text.
+ dash_dcc.wait_for_element("#dcclink_nested_img a img")
+
+ # A nested dccMarkdown renders its markdown (an ) inside the link.
+ dash_dcc.wait_for_text_to_equal("#dcclink_nested_md a h2", "Title")
+
+ # A nested dccMarkdown image renders an
inside the link.
+ dash_dcc.wait_for_element("#dcclink_nested_md_img a img")
+
+ # Without dangerously_allow_html the tag is inert: no anchor is rendered.
+ dash_dcc.wait_for_no_elements("#dcclink_no_html a")
+
assert dash_dcc.get_logs() == []
-@pytest.mark.parametrize("is_eager", [True, False])
-def test_mkdw004_inline_mathjax(dash_dcc, is_eager):
- app = Dash(__name__, eager_loading=is_eager, assets_folder="../../assets")
+def test_mkdw003_without_mathjax(dash_dcc):
+ app = Dash(__name__)
app.layout = html.Div(
[
- dcc.Markdown("# h1 tag with inline MathJax: $E=mc^2$", mathjax=True),
+ dcc.Markdown("# No MathJax: Apple: $2, Orange: $3"),
]
)
dash_dcc.start_server(app)
- dash_dcc.wait_for_element("h1 svg")
+ dash_dcc.wait_for_text_to_equal("h1", "No MathJax: Apple: $2, Orange: $3")
+ assert not dash_dcc.driver.execute_script("return !!window.MathJax")
assert dash_dcc.get_logs() == []
-@pytest.mark.parametrize("is_eager", [True, False])
-def test_mkdw005_block_mathjax(dash_dcc, is_eager):
- app = Dash(__name__, eager_loading=is_eager, assets_folder="../../assets")
+@pytest.mark.parametrize(
+ "markdown",
+ [
+ "# h1 tag with inline MathJax: $E=mc^2$",
+ BLOCK_MATH,
+ ],
+ ids=["inline", "block"],
+)
+def test_mkdw004_mathjax_renders(dash_dcc, markdown):
+ # Both inline ($...$) and block ($$...$$) math render to an