Skip to content

Commit

Permalink
Feature: st.html (streamlit#8366)
Browse files Browse the repository at this point in the history
This PR adds a new command st.html which allows for insertion of arbitrary HTML into a Streamlit app. The goal of this feature is a nicer interface for adding simple HTML / CSS in-line.

Please note this HTML is sanitized via dompurify (ex: no <script>, tags) and is similar to using the current st.markdown with unsafe_allow_html.
  • Loading branch information
mayagbarnes authored Mar 29, 2024
1 parent b573f80 commit 4c55b35
Show file tree
Hide file tree
Showing 45 changed files with 1,089 additions and 207 deletions.
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ repos:
|^vendor/
|^component-lib/declarations/apache-arrow
|^frontend/app/src/assets/css/variables\.scss
|^lib/tests/streamlit/elements/test_html\.js
- id: insert-license
name: Add license for all Proto files
files: \.proto$
Expand Down
572 changes: 572 additions & 0 deletions NOTICES

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 60 additions & 0 deletions e2e_playwright/st_html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2024)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import streamlit as st

# Test that we can render HTML with in-line styles
st.html(
"""
<div style="font-family: 'Comic Sans MS'; color: orange">
This is a div with some inline styles.
</div>
"""
)

# Test that script tags are sanitized
st.html(
"""
<i> This is a i tag </i>
<script>
alert('BEWARE - the script tag is scripting');
</script>
<strong> This is a strong tag </strong>
"""
)

# Test that style tags are applied
st.html(
"""
<style>
#corgi {
color:blue;
}
</style>
<div id="corgi">This text should be blue</div>
"""
)

# Test that non-rendered HTML doesn't cause extra spacing
st.write("Before tag:")
st.html(
"""
<style>
#random {
color:blue;
}
</style>
"""
)
st.write("After tag")
65 changes: 65 additions & 0 deletions e2e_playwright/st_html_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2024)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from playwright.sync_api import Page, expect

from e2e_playwright.conftest import ImageCompareFunction


def test_html_in_line_styles(themed_app: Page, assert_snapshot: ImageCompareFunction):
"""Test that html renders correctly using snapshot testing."""
html_elements = themed_app.get_by_test_id("stHtml")
expect(html_elements).to_have_count(4)
first_html = html_elements.nth(0)

expect(first_html).to_have_text("This is a div with some inline styles.")

styled_div = first_html.locator("div")
expect(styled_div).to_have_css("color", "rgb(255, 165, 0)")
assert_snapshot(first_html, name="st_html-inline_styles")


def test_html_sanitization(themed_app: Page, assert_snapshot: ImageCompareFunction):
"""Test that html sanitizes script tags correctly."""
html_elements = themed_app.get_by_test_id("stHtml")
expect(html_elements).to_have_count(4)
second_html = html_elements.nth(1)

expect(second_html).to_contain_text("This is a i tag")
expect(second_html).to_contain_text("This is a strong tag")
expect(second_html.locator("script")).to_have_count(0)
assert_snapshot(second_html, name="st_html-script_tags")


def test_html_style_tags(themed_app: Page, assert_snapshot: ImageCompareFunction):
"""Test that html style tags are applied correctly."""
html_elements = themed_app.get_by_test_id("stHtml")
expect(html_elements).to_have_count(4)
third_html = html_elements.nth(2)

expect(third_html).to_have_text("This text should be blue")
expect(third_html.locator("div")).to_have_css("color", "rgb(0, 0, 255)")
assert_snapshot(third_html, name="st_html-style_tags")


def test_html_style_tag_spacing(
themed_app: Page, assert_snapshot: ImageCompareFunction
):
"""Test that non-rendered html doesn't cause unnecessary spacing."""
html_elements = themed_app.get_by_test_id("stHtml")
expect(html_elements).to_have_count(4)

assert_snapshot(
themed_app.get_by_test_id("stVerticalBlock"), name="st_html-style_tag_spacing"
)
2 changes: 2 additions & 0 deletions frontend/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"date-fns-tz": "^2.0.0",
"decamelize": "^6.0.0",
"deck.gl": "^8.8.23",
"dompurify": "^3.0.9",
"fzy.js": "^0.4.1",
"hoist-non-react-statics": "^3.3.2",
"immer": "^9.0.19",
Expand Down Expand Up @@ -127,6 +128,7 @@
"@testing-library/user-event": "^14.4.3",
"@types/d3": "^7.4.0",
"@types/d3-graphviz": "^2.6.7",
"@types/dompurify": "^3.0.5",
"@types/jest": "^27.4.3",
"@types/node-emoji": "^1.8.2",
"@types/plotly.js": "^2.12.13",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
ComponentInstance as ComponentInstanceProto,
DateInput as DateInputProto,
FileUploader as FileUploaderProto,
Html as HtmlProto,
MultiSelect as MultiSelectProto,
NumberInput as NumberInputProto,
Radio as RadioProto,
Expand Down Expand Up @@ -174,6 +175,9 @@ const ColorPicker = React.lazy(
const DateInput = React.lazy(
() => import("@streamlit/lib/src/components/widgets/DateInput")
)
const Html = React.lazy(
() => import("@streamlit/lib/src/components/elements/Html")
)
const Multiselect = React.lazy(
() => import("@streamlit/lib/src/components/widgets/Multiselect")
)
Expand Down Expand Up @@ -386,6 +390,11 @@ const RawElementNodeRenderer = (
case "metric":
return <Metric element={node.element.metric as MetricProto} />

case "html":
return (
<Html element={node.element.html as HtmlProto} {...elementProps} />
)

case "pageLink": {
const pageLinkProto = node.element.pageLink as PageLinkProto
const isDisabled = widgetProps.disabled || pageLinkProto.disabled
Expand Down
4 changes: 4 additions & 0 deletions frontend/lib/src/components/core/Block/styled-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export const StyledElementContainer = styled.div<StyledElementContainerProps>(
overflow: "visible",
},

":is(.empty-html)": {
display: "none",
},

":has(> .cacheSpinner)": {
height: 0,
overflow: "visible",
Expand Down
79 changes: 79 additions & 0 deletions frontend/lib/src/components/elements/Html/Html.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2024)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React from "react"
import "@testing-library/jest-dom"
import { screen } from "@testing-library/react"
import { render } from "@streamlit/lib/src/test_util"
import { Html as HtmlProto } from "@streamlit/lib/src/proto"
import Html, { HtmlProps } from "./Html"

const getProps = (elementProps: Partial<HtmlProto> = {}): HtmlProps => ({
element: HtmlProto.create({
body: "<div>Test Html</div>",
...elementProps,
}),
width: 100,
})

describe("HTML element", () => {
it("renders the element as expected", () => {
const props = getProps()
render(<Html {...props} />)
const html = screen.getByTestId("stHtml")
expect(html).toHaveTextContent("Test Html")
expect(html).toHaveStyle("width: 100px")
})

it("handles <style> tags - applies style", () => {
const props = getProps({
body: `
<style>
#random { color: orange; }
</style>
<div id="random">Test Html</div>
`,
})
render(<Html {...props} />)
const html = screen.getByTestId("stHtml")
expect(html).toHaveTextContent("Test Html")
// Check that the style tag is applied to the div
expect(screen.getByText("Test Html")).toHaveStyle("color: orange")
// Check that the unnecessary spacing handling by hiding parent
// eslint-disable-next-line testing-library/no-node-access
expect(html.parentElement).toHaveClass("empty-html")
})

it("sanitizes <script> tags", () => {
const props = getProps({
body: `<script> alert('BEWARE - the script tag is scripting'); </script>`,
})
render(<Html {...props} />)
expect(screen.queryByTestId("stHtml")).not.toBeInTheDocument()
})

it("sanitizes <svg> tags", () => {
const props = getProps({
body: `
<svg width="100" height="100">
<circle cx="50" cy="50" r="40" stroke="green" stroke-width="4" fill="yellow" />
</svg>
`,
})
render(<Html {...props} />)
expect(screen.getByTestId("stHtml")).toHaveTextContent("")
})
})
75 changes: 75 additions & 0 deletions frontend/lib/src/components/elements/Html/Html.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2024)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React, { ReactElement, useState, useEffect, useRef } from "react"
import DOMPurify from "dompurify"

import { Html as HtmlProto } from "@streamlit/lib/src/proto"

export interface HtmlProps {
width: number
element: HtmlProto
}

const sanitizeString = (html: string): string => {
const sanitizationOptions = {
// Default to permit HTML, SVG and MathML, this limits to HTML only
USE_PROFILES: { html: true },
// glue elements like style, script or others to document.body and prevent unintuitive browser behavior in several edge-cases
FORCE_BODY: true,
}
return DOMPurify.sanitize(html, sanitizationOptions)
}

/**
* HTML code to insert into the page.
*/
export default function Html({ element, width }: HtmlProps): ReactElement {
const { body } = element
const [sanitizedHtml, setSanitizedHtml] = useState(sanitizeString(body))
const htmlRef = useRef<HTMLDivElement | null>(null)

useEffect(() => {
if (sanitizeString(body) !== sanitizedHtml) {
setSanitizedHtml(sanitizeString(body))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [body])

useEffect(() => {
if (
htmlRef.current?.clientHeight === 0 &&
htmlRef.current.parentElement?.childElementCount === 1
) {
// div has no rendered content - hide to avoid unnecessary spacing
htmlRef.current.parentElement.classList.add("empty-html")
}
})

return (
<>
{sanitizedHtml && (
<div
className="stHtml"
data-testid="stHtml"
ref={htmlRef}
style={{ width: width }}
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
)}
</>
)
}
17 changes: 17 additions & 0 deletions frontend/lib/src/components/elements/Html/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2024)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export { default } from "./Html"
Loading

0 comments on commit 4c55b35

Please sign in to comment.