Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions frontend/src/assets/css/write.scss
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,34 @@
text-container table tr:nth-child(2n) {
background: $gray-faint;
}

// (Demo) TODO:
// Determine symbol / image to use
// Properly position and size elements
// Determine functionality (<a> tag encompasses heading or exists after?)
.heading-anchor {
cursor: pointer;
color: inherit;
text-decoration: none;
display: flex;
align-items: center;
position: relative;
left: -4ex;

&::before {
content: "#";
color: $primary;
font-weight: bold;
font-size: 3ex;
transform: translateY(0.27ex);
padding-right: 1.25ex;
visibility: hidden;
}

&:hover::before {
visibility: visible;
}
}
}

pre.error {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/elements/Markdown/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import "assets/css/write.scss"
export interface MarkdownProps {
width: number
element: ImmutableMap<string, any>
anchor?: string
}

/**
Expand All @@ -34,12 +35,13 @@ export default function Markdown({
element,
}: MarkdownProps): ReactElement {
const body = element.get("body")
const anchor = element.get("anchor")
const styleProp = { width }

const allowHTML = element.get("allowHtml")
return (
<div className="markdown-text-container stMarkdown" style={styleProp}>
<StreamlitMarkdown source={body} allowHTML={allowHTML} />
<StreamlitMarkdown source={body} allowHTML={allowHTML} anchor={anchor} />
</div>
)
}
39 changes: 38 additions & 1 deletion frontend/src/components/shared/StreamlitMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import RemarkMathPlugin from "remark-math"
import RemarkEmoji from "remark-emoji"
import CodeBlock from "components/elements/CodeBlock/"

import { slugify, findTheHeading } from "../../lib/utils"

import "katex/dist/katex.min.css"

export interface Props {
Expand All @@ -40,6 +42,20 @@ export interface Props {
* any HTML will be escaped in the output.
*/
allowHTML: boolean

/**
* Anchor tag and Element "id" attribute for
* title, header, and subheader elements.
* Default: set to "slugified" (a b c -> a-b-c) heading text.
* Optional: set to anchor parameter (second argument).
*/
anchor?: string
}

interface HeadingProps {
children: [ReactElement]
level: number
anchor: string
}

/**
Expand All @@ -58,12 +74,33 @@ export class StreamlitMarkdown extends PureComponent<Props> {
}

public render = (): ReactNode => {
const { source, allowHTML } = this.props
const { source, allowHTML, anchor } = this.props
const usedHeadings: string[] = []

const headingWithAnchorTag = (props: HeadingProps): ReactElement => {
if (props.level > 3) {
return React.createElement(`h${props.level}`, {}, props.children)
}
// TODO: add better/robust functionality for st.write(markdown)
const cleanedAnchor =
slugify(anchor) || slugify(findTheHeading(source, usedHeadings))

return (
<a className="heading-anchor" href={`#${cleanedAnchor}`}>
{React.createElement(
`h${props.level}`,
{ id: cleanedAnchor },
props.children
)}
</a>
)
}

const renderers = {
code: CodeBlock,
link: linkWithTargetBlank,
linkReference: linkReferenceHasParens,
heading: headingWithAnchorTag,
inlineMath: (props: { value: string }): ReactElement => (
<InlineMath>{props.value}</InlineMath>
),
Expand Down
36 changes: 35 additions & 1 deletion frontend/src/lib/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import { BlockElement } from "lib/DeltaParser"
import { List, Set as ImmutableSet, Map as ImmutableMap } from "immutable"
import { getCookie, flattenElements, setCookie } from "./utils"
import { getCookie, flattenElements, setCookie, slugify } from "./utils"

describe("flattenElements", () => {
const simpleElement1 = ImmutableMap({ key1: "value1", key2: "value2" })
Expand Down Expand Up @@ -133,3 +133,37 @@ describe("setCookie", () => {
expect(document.cookie).toEqual("")
})
})

describe("slugify", () => {
it("expects an existing string of length > 0", () => {
// TODO:
// Account for undefined params and return value -- waiting on clarification
expect(slugify("streamlit")).not.toEqual("")
expect(typeof slugify("42") === "string").toBeTruthy()
})

it("removes spaces and connects words with dashes", () => {
expect(slugify("s t r e a m l i t")).toEqual("s-t-r-e-a-m-l-i-t")
expect(slugify(" space exploration ")).toEqual("space-exploration")
})

it("lowercases all letters", () => {
expect(slugify("COOKING INSTRUCTIONS")).toEqual("cooking-instructions")
expect(slugify("StReAmLiT")).toEqual("streamlit")
})

it("removes non-acceptable characters from string", () => {
expect(slugify("The fastest way to build data apps.")).toEqual(
"the-fastest-way-to-build-data-apps"
)
expect(slugify("Real-time rendering")).toEqual("real-time-rendering")
expect(slugify("They're there now with their friends")).toEqual(
"theyre-there-now-with-their-friends"
)
expect(slugify("sl-----ug")).toEqual("sl-ug")
expect(slugify(".. stre@mlit .. ,, !! @@")).toEqual("stremlit")
expect(slugify("3 types: emdash (—), endash (–), and dash (-)")).toEqual(
"3-types-emdash-endash-and-dash"
)
})
})
37 changes: 37 additions & 0 deletions frontend/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,40 @@ export function setCookie(
: ""
document.cookie = `${name}=${value};${expirationStr}path=/`
}

/**
* Turns regular text into "text slug"
* 'a b c' -> 'a-b-c'
*/
export function slugify(text: string | undefined): string | undefined {
if (!text) return ""

return text
.toString()
.toLowerCase()
.replace(/\s+/g, "-") // Replace spaces with -
.replace(/[^\w-]+/g, "") // Remove all non-word chars
.replace(/-+/g, "-") // Replace multiple - with single -
.replace(/^-+/, "") // Trim - from start of text
.replace(/-+$/, "") // Trim - from end of text
}

/**
* Find which headings have anchor tags from st.write(markdown)
* and have not already been used / have anchor tags applied already.
* Quick feature support for st.write() and heading anchors.
*/
export function findTheHeading(
source: string,
usedHeadings: string[]
): string {
const heading = source
.split("\n")
.filter(line => line.includes("#"))
.find(heading => !usedHeadings.includes(heading))
if (heading) {
usedHeadings.push(heading)
return heading
}
return ""
}
1 change: 1 addition & 0 deletions lib/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ tornado = ">=5.0"
tzlocal = "*"
validators = "*"
watchdog = "*"
streamlit = "*"
39 changes: 36 additions & 3 deletions lib/streamlit/elements/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,45 +61,67 @@ def markdown(dg, body, unsafe_allow_html=False):

return dg._enqueue("markdown", markdown_proto) # type: ignore

def header(dg, body):
def header(dg, body, anchor=None):
"""Display text in header formatting.

Parameters
----------
body : str
The text to display.

anchor : str
Displayed as anchor tag next to header.
Set to body text by default or passed value.

Example
-------
>>> st.header('This is a header')
... st.header('This is a header', 'This is its custom anchor value')

.. output::
https://share.streamlit.io/0.25.0-2JkNY/index.html?id=AnfQVFgSCQtGv6yMUMUYjj
height: 100px

"""
header_proto = MarkdownProto()

if anchor is None:
header_proto.anchor = body
else:
header_proto.anchor = anchor

header_proto.body = "## %s" % _clean_text(body)
return dg._enqueue("markdown", header_proto) # type: ignore

def subheader(dg, body):
def subheader(dg, body, anchor=None):
"""Display text in subheader formatting.

Parameters
----------
body : str
The text to display.

anchor : str
Displayed as anchor tag next to subheader.
Set to body text by default or passed value.

Example
-------
>>> st.subheader('This is a subheader')
... st.subheader('This is a subheader', 'This is its custom anchor value')

.. output::
https://share.streamlit.io/0.25.0-2JkNY/index.html?id=LBKJTfFUwudrbWENSHV6cJ
height: 100px

"""
subheader_proto = MarkdownProto()

if anchor is None:
subheader_proto.anchor = body
else:
subheader_proto.anchor = anchor

subheader_proto.body = "### %s" % _clean_text(body)
return dg._enqueue("markdown", subheader_proto) # type: ignore

Expand Down Expand Up @@ -136,7 +158,7 @@ def code(dg, body, language="python"):
code_proto.body = _clean_text(markdown)
return dg._enqueue("markdown", code_proto) # type: ignore

def title(dg, body):
def title(dg, body, anchor=None):
"""Display text in title formatting.

Each document should have a single `st.title()`, although this is not
Expand All @@ -147,16 +169,27 @@ def title(dg, body):
body : str
The text to display.

anchor : str
Displayed as anchor tag next to title.
Set to body text by default or passed value.

Example
-------
>>> st.title('This is a title')
... st.title('This is a title', 'This is its custom anchor value')

.. output::
https://share.streamlit.io/0.25.0-2JkNY/index.html?id=SFcBGANWd8kWXF28XnaEZj
height: 100px

"""
title_proto = MarkdownProto()

if anchor is None:
title_proto.anchor = body
else:
title_proto.anchor = anchor

title_proto.body = "# %s" % _clean_text(body)
return dg._enqueue("markdown", title_proto) # type: ignore

Expand Down
6 changes: 6 additions & 0 deletions lib/streamlit/hello/hello.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ def run():

if demo_name == "—":
show_code = False
st.title('Streamlit Title')
st.title('Streamlit Title With Passed Anchor', 'Here is the Title anchor tag')
st.header('Streamlit Header')
st.header('Streamlit Header With Passed Anchor', 'Here is the Header anchor tag')
st.subheader('Streamlit Subheader')
st.subheader('Streamlit Subheader With Passed Anchor', 'Here is the Subheader anchor tag')
st.write("# Welcome to Streamlit! 👋")
else:
show_code = st.sidebar.checkbox("Show code", True)
Expand Down
2 changes: 1 addition & 1 deletion lib/tests/streamlit/streamlit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ def test_st_help(self):
el.doc_string.doc_string.startswith("Display text in header formatting.")
)
self.assertEqual(el.doc_string.type, "<class 'method'>")
self.assertEqual(el.doc_string.signature, "(body)")
self.assertEqual(el.doc_string.signature, "(body, anchor=None)")

def test_st_image_PIL_image(self):
"""Test st.image with PIL image."""
Expand Down
4 changes: 3 additions & 1 deletion proto/streamlit/proto/Markdown.proto
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ syntax = "proto3";
message Markdown {
// Content to display.
string body = 1;

bool allow_html = 2;

// Anchor tag for heading elements
string anchor = 16;
}