Files
upstream-tldraw/apps/docs/utils/parse-markdown.tsx
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

277 lines
6.4 KiB
TypeScript

import { assert, assertExists } from '@tldraw/utils'
import GithubSlugger from 'github-slugger'
import { Nodes, Root } from 'mdast'
import { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx'
import { remark } from 'remark'
import remarkMdx from 'remark-mdx'
import { TABLE_OF_CONTENTS_CLASSNAME } from './config'
const BREAK = '\n\n'
// Add nodes to mdast content.
declare module 'mdast' {
interface BlockContentMap {
/**
* MDX JSX element node, occurring in flow (block).
*/
mdxJsxFlowElement: MdxJsxFlowElement
}
interface PhrasingContentMap {
/**
* MDX JSX element node, occurring in text (phrasing).
*/
mdxJsxTextElement: MdxJsxTextElement
}
interface RootContentMap {
/**
* MDX JSX element node, occurring in flow (block).
*/
mdxJsxFlowElement: MdxJsxFlowElement
/**
* MDX JSX element node, occurring in text (phrasing).
*/
mdxJsxTextElement: MdxJsxTextElement
}
}
export interface ParsedHeading {
level: number
title: string
slug: string
contentText: string
isInherited: boolean
}
/**
* Walk the MDX AST of `content` and extract headings & their (plain text) content as we go.
*
* This returns an array of heading objects containing the heading info, and the text below it
* (before the next heading). It also returns the initial content text before the first heading.
*
* Heading extraction should be reliable, but content extraction is pretty best-effort. It's useful
* for a search index (which is what we use it for!), but might not be a perfect representation of
* everything on the page.
*/
export function parseMarkdown(
content: string,
debugContext: string
): {
headings: ParsedHeading[]
allContentText: string
initialContentText: string
} {
// the headings found so far. we start with a dummy heading at the beginning to hold the initial
// content text, but we'll remove it at the end.
const headings: Omit<ParsedHeading, 'slug'>[] = [
{
level: 0,
title: '',
contentText: '',
isInherited: false,
},
]
// all the text we've seen so far, including headings
let allContentText = ''
interface State {
isInHeading: boolean
isInherited: boolean
}
function visitChildren(node: Nodes, state: State) {
if ('children' in node) {
for (const child of node.children) {
visit(child, state)
}
}
}
function currentHeading() {
const currentHeading = headings[headings.length - 1]
return currentHeading
}
function addText(text: string, state: State) {
if (state.isInHeading) {
currentHeading().title += text
} else {
currentHeading().contentText += text
}
allContentText += text
}
// add a paragraph break if needed:
function breakText(state: State) {
assert(!state.isInHeading)
if (!currentHeading().contentText.endsWith(BREAK)) {
currentHeading().contentText += BREAK
}
if (!allContentText.endsWith(BREAK)) {
allContentText += BREAK
}
}
function visit(node: Nodes, state: State) {
switch (node.type) {
case 'heading': {
const { depth } = node
allContentText += BREAK
headings.push({
level: depth,
title: '',
contentText: '',
isInherited: state.isInherited,
})
visitChildren(node, { ...state, isInHeading: true })
break
}
case 'text':
case 'inlineCode':
addText(node.value, state)
break
case 'blockquote':
case 'paragraph':
breakText(state)
visitChildren(node, state)
break
case 'code':
// skip these entirely
break
case 'mdxJsxFlowElement':
case 'mdxJsxTextElement': {
const { name, attributes } = node
if (node.type === 'mdxJsxFlowElement') {
breakText(state)
}
if (
// Remove table of contents
name === 'details' &&
attributes.some(
(attr) =>
'name' in attr &&
attr.name === 'className' &&
attr.value === TABLE_OF_CONTENTS_CLASSNAME
)
) {
break
}
if (name === 'ApiMemberTitle') {
// each ApiMemberTitle, which is used for the title of everything in the API
// reference docs, should have an `inherited` attribute:
const inheritedAttr = attributes.find(
(attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'inherited'
)
if (!inheritedAttr) {
throw new Error(`ApiMemberTitle missing inherited attribute (in ${debugContext})`)
}
if (
!inheritedAttr.value ||
typeof inheritedAttr.value !== 'object' ||
inheritedAttr.type !== 'mdxJsxAttribute'
) {
throw new Error(
`Unknown format for ApiMemberTitle inherited attribute (in ${debugContext})`
)
}
// if it doesn't have the value `null`, then it's inherited. otherwise, we can
// visit its children normally.
if (inheritedAttr.value.value !== 'null') {
visitChildren(node, { ...state, isInherited: true })
break
}
}
if (name === 'ParametersTableDescription') {
addText('(', state)
}
visitChildren(node, state)
if (name === 'ApiHeading') {
addText(':', state)
breakText(state)
}
if (name === 'ParametersTableDescription') {
addText(')', state)
}
if (name === 'ParametersTableRow') {
addText(',', state)
}
if (name === 'ParametersTable') {
breakText(state)
addText('.', state)
}
break
}
default:
visitChildren(node, state)
}
}
remark()
.use(remarkMdx)
.use(() => (tree: Root) => {
visit(tree, { isInHeading: false, isInherited: false })
})
.processSync(content)
const initial = assertExists(headings.shift())
const slugs = new GithubSlugger()
return {
initialContentText: postprocessText(initial.contentText),
allContentText: postprocessText(allContentText),
headings: headings.map((heading) => ({
...heading,
slug: slugs.slug(heading.title, true),
contentText: postprocessText(heading.contentText),
})),
}
}
function postprocessText(text: string) {
return (
text
// remove trailing/leading whitespace in each line:
.replace(/^\s+/gm, '')
.replace(/\s+$/gm, '')
// remove empty lines:
.replace(/^\s*$/gm, '')
// place on a single line:
.replace(/\s+/g, ' ')
// empty brackets:
.replace(/\( \)/g, '')
// bracket spacing:
.replace(/\( /g, '(')
.replace(/ \)/g, ')')
// comma leading space:
.replace(/\s+,/g, ',')
// comma + period:
.replace(/,\s*\./g, '.')
// blank Returns:
.replace(/Returns:$/, '')
// weird punctuation:
.replace(/\.\),/g, '),')
)
}