Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add anchors to Markdown headers #2513

Merged
32 commits merged into from Jan 12, 2021
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/scripts/st_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
import streamlit as st

st.header("This header is awesome!")
st.header("This header is awesome too!", anchor="awesome-header")
4 changes: 4 additions & 0 deletions e2e/scripts/st_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@
$$
"""
)

st.markdown("# Some header 1")
st.markdown("## Some header 2")
st.markdown("### Some header 3")
1 change: 1 addition & 0 deletions e2e/scripts/st_subheader.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
import streamlit as st

st.subheader("This subheader is awesome!")
st.subheader("This subheader is awesome too!", anchor="awesome-subheader")
1 change: 1 addition & 0 deletions e2e/scripts/st_title.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
import streamlit as st

st.title("This title is awesome!")
st.title("This title is awesome too!", anchor="awesome-title")
17 changes: 13 additions & 4 deletions e2e/specs/st_header.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,18 @@ describe("st.header", () => {
});

it("displays a header", () => {
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown h2").should(
"contain",
"This header is awesome!"
);
cy.get(".element-container .stMarkdown h2").should("have.length", 2);
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown h2").then(els => {
expect(els[0].textContent).to.eq("This header is awesome!");
expect(els[1].textContent).to.eq("This header is awesome too!");
});
});

it("displays headers with anchors", () => {
cy.get(".element-container .stMarkdown h2").should("have.length", 2);
cy.get(".element-container .stMarkdown h2").then(els => {
cy.wrap(els[0]).should("have.attr", "id", "this-header-is-awesome");
cy.wrap(els[1]).should("have.attr", "id", "awesome-header");
});
});
});
21 changes: 19 additions & 2 deletions e2e/specs/st_markdown.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe("st.markdown", () => {
});

it("displays markdown", () => {
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown").should("have.length", 8);
cy.get(".element-container .stMarkdown").should("have.length", 11);
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown").then(els => {
expect(els[0].textContent).to.eq("This markdown is awesome! 😎");
expect(els[1].textContent).to.eq("This <b>HTML tag</b> is escaped!");
Expand All @@ -33,14 +33,31 @@ describe("st.markdown", () => {
expect(els[7].textContent).to.eq(
"ax2+bx+c=0ax^2 + bx + c = 0ax2+bx+c=0"
);
expect(els[8].textContent).to.eq("Some header 1");
expect(els[9].textContent).to.eq("Some header 2");
expect(els[10].textContent).to.eq("Some header 3");

cy.wrap(els[3])
.find("a")
.should("not.exist");

cy.wrap(els[4])
.find("a")
.should("have.attr", "href");
});
});

it("displays headers with anchors", () => {
cy.get(".element-container .stMarkdown").should("have.length", 11);
cy.get(".element-container .stMarkdown").then(els => {
cy.wrap(els[8])
.find("h1")
.should("have.attr", "id", "some-header-1");
cy.wrap(els[9])
.find("h2")
.should("have.attr", "id", "some-header-2");
cy.wrap(els[10])
.find("h3")
.should("have.attr", "id", "some-header-3");
});
});
});
17 changes: 13 additions & 4 deletions e2e/specs/st_subheader.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,18 @@ describe("st.subheader", () => {
});

it("displays a subheader", () => {
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown h3").should(
"contain",
"This subheader is awesome!"
);
cy.get(".element-container .stMarkdown h3").should("have.length", 2);
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown h3").then(els => {
expect(els[0].textContent).to.eq("This subheader is awesome!");
expect(els[1].textContent).to.eq("This subheader is awesome too!");
});
});

it("displays subheaders with anchors", () => {
cy.get(".element-container .stMarkdown h3").should("have.length", 2);
cy.get(".element-container .stMarkdown h3").then(els => {
cy.wrap(els[0]).should("have.attr", "id", "this-subheader-is-awesome");
cy.wrap(els[1]).should("have.attr", "id", "awesome-subheader");
});
});
});
17 changes: 13 additions & 4 deletions e2e/specs/st_title.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,18 @@ describe("st.title", () => {
});

it("displays a title", () => {
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown h1").should(
"contain",
"This title is awesome!"
);
cy.get(".element-container .stMarkdown h1").should("have.length", 2);
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown h1").then(els => {
expect(els[0].textContent).to.eq("This title is awesome!");
expect(els[1].textContent).to.eq("This title is awesome too!");
});
});

it("displays title with anchors", () => {
cy.get(".element-container .stMarkdown h1").should("have.length", 2);
cy.get(".element-container .stMarkdown h1").then(els => {
cy.wrap(els[0]).should("have.attr", "id", "this-title-is-awesome");
cy.wrap(els[1]).should("have.attr", "id", "awesome-title");
});
});
});
22 changes: 22 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
IGitInfo,
GitInfo,
} from "autogen/proto"
import { without, concat } from "lodash"

import { RERUN_PROMPT_MODAL_DIALOG } from "lib/baseconsts"
import { SessionInfo } from "lib/SessionInfo"
Expand Down Expand Up @@ -102,6 +103,7 @@ interface State {
layout: PageConfig.Layout
initialSidebarState: PageConfig.SidebarState
allowRunOnSave: boolean
reportFinishedHandlers: (() => void)[]
gitInfo?: IGitInfo | null
}

Expand Down Expand Up @@ -159,6 +161,7 @@ export class App extends PureComponent<Props, State> {
layout: PageConfig.Layout.CENTERED,
initialSidebarState: PageConfig.SidebarState.AUTO,
allowRunOnSave: true,
reportFinishedHandlers: [],
gitInfo: null,
}

Expand Down Expand Up @@ -572,6 +575,11 @@ export class App extends PureComponent<Props, State> {
*/
handleReportFinished(status: ForwardMsg.ReportFinishedStatus): void {
if (status === ForwardMsg.ReportFinishedStatus.FINISHED_SUCCESSFULLY) {
// Notify any subscribers of this event (and do it next event loop)
This conversation was marked as resolved.
Show resolved Hide resolved
window.setTimeout(() => {
this.state.reportFinishedHandlers.map(handler => handler())
}, 0)

// Clear any stale elements left over from the previous run.
// (We don't do this if our script had a compilation error and didn't
// finish successfully.)
Expand Down Expand Up @@ -924,6 +932,18 @@ export class App extends PureComponent<Props, State> {
this.setState({ isFullScreen })
}

addReportFinishedHandler = (func: () => void): void => {
this.setState({
reportFinishedHandlers: concat(this.state.reportFinishedHandlers, func),
})
}

removeReportFinishedHandler = (func: () => void): void => {
this.setState({
reportFinishedHandlers: without(this.state.reportFinishedHandlers, func),
})
}

render(): JSX.Element {
const {
allowRunOnSave,
Expand Down Expand Up @@ -960,6 +980,8 @@ export class App extends PureComponent<Props, State> {
embedded: isEmbeddedInIFrame(),
isFullScreen,
setFullScreen: this.handleFullScreen,
addReportFinishedHandler: this.addReportFinishedHandler,
removeReportFinishedHandler: this.removeReportFinishedHandler,
}}
>
<GlobalHotKeys keyMap={this.keyMap} handlers={this.keyHandlers}>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/core/PageLayoutContext/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ export default React.createContext({
embedded: false,
isFullScreen: false,
setFullScreen: (value: boolean) => {},
addReportFinishedHandler: (func: () => void) => {},
removeReportFinishedHandler: (func: () => void) => {},
})
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { mount } from "enzyme"
import {
linkWithTargetBlank,
linkReferenceHasParens,
createAnchorFromText,
} from "./StreamlitMarkdown"

// Fixture Generator
Expand All @@ -33,6 +34,20 @@ const getMarkdownElement = (body: string): ReactElement => {
return <ReactMarkdown source={body} renderers={renderers} />
}

describe("createAnchorFromText", () => {
it("generates slugs correctly", () => {
const cases = [
["some header", "some-header"],
["some -24$35-9824 header", "some-24-35-9824-header"],
["blah___blah___blah", "blah-blah-blah"],
]

cases.forEach(([s, want]) => {
expect(createAnchorFromText(s)).toEqual(want)
})
})
})

describe("linkReference", () => {
it("renders a link with _blank target", () => {
const body = "Some random URL like [Streamlit](https://streamlit.io/)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import React, { ReactElement, ReactNode, Fragment, PureComponent } from "react"
import ReactMarkdown from "react-markdown"
import { once } from "lodash"
// @ts-ignore
import htmlParser from "react-markdown/plugins/html-parser"
// @ts-ignore
Expand All @@ -25,6 +26,7 @@ import { BlockMath, InlineMath } from "react-katex"
import RemarkMathPlugin from "remark-math"
// @ts-ignore
import RemarkEmoji from "remark-emoji"
import PageLayoutContext from "components/core/PageLayoutContext"
import CodeBlock from "components/elements/CodeBlock/"
import { StyledStreamlitMarkdown } from "./styled-components"

Expand All @@ -43,6 +45,117 @@ export interface Props {
allowHTML: boolean
}

/**
* Creates a slug suitable for use as an anchor given a string.
* Splits the string on non-alphanumeric characters, and joins with a dash.
*/
export function createAnchorFromText(text: string | null): string {
const newAnchor = text
?.toLowerCase()
.split(/[^A-Za-z0-9]/)
.filter(Boolean)
.join("-")
return newAnchor || ""
}

// wrapping in `once` ensures we only scroll once
const scrollNodeIntoView = once((node: HTMLElement): void => {
node.scrollIntoView(true)
})

interface HeadingWithAnchorProps {
tag: string
anchor?: string
children: [ReactElement]
}

interface CustomHeadingProps {
level: string | number
children: [ReactElement]
}

interface CustomParsedHtmlProps {
type: ReactElement
element: {
type: string
props: {
"data-anchor": string
children: [ReactElement]
}
}
}

function HeadingWithAnchor({
tag,
anchor: propsAnchor,
children,
}: HeadingWithAnchorProps): ReactElement {
const [elementId, setElementId] = React.useState(propsAnchor)
const [target, setTarget] = React.useState<HTMLElement | null>(null)

const {
addReportFinishedHandler,
removeReportFinishedHandler,
} = React.useContext(PageLayoutContext)

const onReportFinished = React.useCallback(() => {
if (target !== null) {
// wait a bit for everything on page to finish loading
window.setTimeout(() => {
scrollNodeIntoView(target)
}, 300)
}
}, [target])

React.useEffect(() => {
addReportFinishedHandler(onReportFinished)
return () => {
removeReportFinishedHandler(onReportFinished)
}
}, [addReportFinishedHandler, removeReportFinishedHandler, onReportFinished])

const ref = React.useCallback(
node => {
if (node === null) {
return
}

const anchor = propsAnchor || createAnchorFromText(node.textContent)
setElementId(anchor)
if (window.location.hash.slice(1) === anchor) {
setTarget(node)
}
},
[propsAnchor]
)

return React.createElement(tag, { ref, id: elementId }, children)
}

function CustomHeading({ level, children }: CustomHeadingProps): ReactElement {
return <HeadingWithAnchor tag={`h${level}`}>{children}</HeadingWithAnchor>
}

function CustomParsedHtml(props: CustomParsedHtmlProps): ReactElement {
const {
element: { type, props: elementProps },
} = props

const headingElements = ["h1", "h2", "h3", "h4", "h5", "h6"]
if (!headingElements.includes(type)) {
// casting to any because ReactMarkdown's types are funky
// but this just means "call the original renderer provided by ReactMarkdown"
return (ReactMarkdown.renderers.parsedHtml as any)(props)
kantuni marked this conversation as resolved.
Show resolved Hide resolved
}

const { "data-anchor": anchor, children } = elementProps
return (
<HeadingWithAnchor tag={type} anchor={anchor}>
{children}
</HeadingWithAnchor>
)
}

/**
* Wraps the <ReactMarkdown> component to include our standard
* renderers and AST plugins (for syntax highlighting, HTML support, etc).
Expand Down Expand Up @@ -71,6 +184,8 @@ class StreamlitMarkdown extends PureComponent<Props> {
math: (props: { value: string }): ReactElement => (
<BlockMath>{props.value}</BlockMath>
),
heading: CustomHeading,
parsedHtml: CustomParsedHtml,
}

const plugins = [RemarkMathPlugin, RemarkEmoji]
Expand Down Expand Up @@ -105,8 +220,13 @@ interface LinkReferenceProps {
// Using target="_blank" without rel="noopener noreferrer" is a security risk:
// see https://mathiasbynens.github.io/rel-noopener
export function linkWithTargetBlank(props: LinkProps): ReactElement {
const { href, title, children } = props
// if it's a #hash link, don't open in new tab
if (props.href.startsWith("#")) {
const { children, ...rest } = props
return <a {...rest}>{children}</a>
}

const { href, title, children } = props
return (
<a href={href} title={title} target="_blank" rel="noopener noreferrer">
{children}
Expand Down