UNPKG

@liveblocks/react-ui

Version:

A set of React pre-built components for the Liveblocks products. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.

662 lines (659 loc) 24.3 kB
import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; import { assertNever, sanitizeUrl, isUrl } from '@liveblocks/core'; import { Slot } from '@radix-ui/react-slot'; import { Lexer } from 'marked'; import { forwardRef, useMemo, memo } from 'react'; const LIST_ITEM_CHECKBOX_REGEX = /^\[\s?(x)?\]?$/i; const PARTIAL_LINK_IMAGE_REGEX = /(?<!\\)(?<image>!)?\[(?!\^)(?<text>[^\]]*)(?:\](?:\((?<url>[^)]*)?)?)?$/; const PARTIAL_TABLE_HEADER_REGEX = /^\s*\|(?:[^|\n]+(?:\|[^|\n]+)*?)?\|?\s*(?:\n\s*\|\s*[-:|\s]*\s*)?$/; const PARTIAL_EMOJI_REGEX = /(?:\u200D|\uFE0F|\u20E3|\p{Regional_Indicator}|\p{Emoji_Presentation}|\p{Emoji_Modifier_Base}|\p{Emoji_Modifier})+$/u; const TRAILING_NON_WHITESPACE_REGEX = /^\S*/; const WHITESPACE_REGEX = /\s/; const NEWLINE_REGEX = /\r\n?/g; const BUFFERED_CHARACTERS_REGEX = /(?<!\\)((\*+|_+|~+|`+|\++|-{0,2}|={0,2}|\\|!|<\/?)\s*)$/; const SINGLE_CHARACTER_REGEX = /^\s*(\S\s*)$/; const LEFT_ANGLE_BRACKET_REGEX = /</g; const RIGHT_ANGLE_BRACKET_REGEX = />/g; const AMPERSAND_REGEX = /&(?!#?[0-9A-Za-z]+;)/g; const DEFAULT_PARTIAL_LINK_URL = "#"; const defaultComponents = { Paragraph: ({ children }) => { return /* @__PURE__ */ jsx("p", { children }); }, Inline: ({ type, children }) => { switch (type) { case "strong": return /* @__PURE__ */ jsx("strong", { children }); case "em": return /* @__PURE__ */ jsx("em", { children }); case "code": return /* @__PURE__ */ jsx("code", { children }); case "del": return /* @__PURE__ */ jsx("del", { children }); default: assertNever(type, "Unknown inline type"); } }, CodeBlock: ({ language, code }) => { return /* @__PURE__ */ jsx("pre", { "data-language": language ?? void 0, children: /* @__PURE__ */ jsx("code", { children: code }) }); }, Link: ({ href, title, children }) => { return /* @__PURE__ */ jsx("a", { href, title, target: "_blank", rel: "noopener noreferrer", children }); }, Heading: ({ level, children }) => { const Heading = `h${level}`; return /* @__PURE__ */ jsx(Heading, { children }); }, Image: ({ src, alt, title }) => { return /* @__PURE__ */ jsx("img", { src, alt, title }); }, Blockquote: ({ children }) => { return /* @__PURE__ */ jsx("blockquote", { children }); }, Table: ({ headings, rows }) => { return /* @__PURE__ */ jsxs("table", { children: [ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { children: headings.map((heading, index) => { return /* @__PURE__ */ jsx("th", { align: heading.align, children: heading.children }, index); }) }) }), /* @__PURE__ */ jsx("tbody", { children: rows.map((row, index) => { return /* @__PURE__ */ jsx("tr", { children: row.map((cell, index2) => { return /* @__PURE__ */ jsx("td", { align: cell.align, children: cell.children }, index2); }) }, index); }) }) ] }); }, List: ({ type, items, start }) => { const List = type === "ordered" ? "ol" : "ul"; return /* @__PURE__ */ jsx(List, { start: start === 1 ? void 0 : start, children: items.map((item, index) => /* @__PURE__ */ jsx("li", { children: item.children }, index)) }); }, Separator: () => { return /* @__PURE__ */ jsx("hr", {}); } }; const Markdown = forwardRef( ({ content, partial, components, asChild, ...props }, forwardedRef) => { const Component = asChild ? Slot : "div"; const tokens = useMemo(() => { if (!content) { return []; } return partial ? tokenizePartial(content) : tokenize(content); }, [content, partial]); return /* @__PURE__ */ jsx(Component, { ...props, ref: forwardedRef, children: tokens.map((token, index) => { return /* @__PURE__ */ jsx( MemoizedMarkdownToken, { token, components, partial }, index ); }) }); } ); const MemoizedMarkdownToken = memo( ({ token, components }) => { return /* @__PURE__ */ jsx(MarkdownToken, { token, components }); }, (previousProps, nextProps) => { const previousToken = previousProps.token; const nextToken = nextProps.token; if (previousToken.type !== nextToken.type || previousProps.partial !== nextProps.partial) { return false; } let previousContent = previousToken.raw; let nextContent = nextToken.raw; if ("text" in previousToken && "text" in nextToken) { previousContent = previousToken.text; nextContent = nextToken.text; } if (previousContent.length !== nextContent.length) { return false; } return previousContent === nextContent; } ); function MarkdownToken({ token, components }) { switch (token.type) { case "escape": { return token.text; } case "space": { return null; } case "text": { if (token.tokens !== void 0) { return /* @__PURE__ */ jsx(MarkdownTokens, { tokens: token.tokens, components }); } else { return parseHtmlEntities(token.text); } } case "br": { return /* @__PURE__ */ jsx("br", {}); } case "paragraph": { const Paragraph = components?.Paragraph ?? defaultComponents.Paragraph; return /* @__PURE__ */ jsx(Paragraph, { children: /* @__PURE__ */ jsx(MarkdownTokens, { tokens: token.tokens, components }) }); } case "heading": { const Heading = components?.Heading ?? defaultComponents.Heading; return /* @__PURE__ */ jsx(Heading, { level: clampHeadingLevel(token.depth), children: /* @__PURE__ */ jsx(MarkdownTokens, { tokens: token.tokens, components }) }); } case "strong": { const Inline = components?.Inline ?? defaultComponents.Inline; return /* @__PURE__ */ jsx(Inline, { type: "strong", children: /* @__PURE__ */ jsx(MarkdownTokens, { tokens: token.tokens, components }) }); } case "em": { const Inline = components?.Inline ?? defaultComponents.Inline; return /* @__PURE__ */ jsx(Inline, { type: "em", children: /* @__PURE__ */ jsx(MarkdownTokens, { tokens: token.tokens, components }) }); } case "codespan": { const Inline = components?.Inline ?? defaultComponents.Inline; return /* @__PURE__ */ jsx(Inline, { type: "code", children: parseHtmlEntities(token.text) }); } case "del": { const Inline = components?.Inline ?? defaultComponents.Inline; return /* @__PURE__ */ jsx(Inline, { type: "del", children: /* @__PURE__ */ jsx(MarkdownTokens, { tokens: token.tokens, components }) }); } case "link": { const href = sanitizeUrl(token.href); if (href === null) { return /* @__PURE__ */ jsx(MarkdownTokens, { tokens: token.tokens, components }); } const Link = components?.Link ?? defaultComponents.Link; return /* @__PURE__ */ jsx(Link, { href, title: token.title ?? void 0, children: /* @__PURE__ */ jsx(MarkdownTokens, { tokens: token.tokens, components }) }); } case "code": { let language = void 0; if (token.lang !== void 0) { language = token.lang.match(TRAILING_NON_WHITESPACE_REGEX)?.[0] ?? void 0; } const CodeBlock = components?.CodeBlock ?? defaultComponents.CodeBlock; return /* @__PURE__ */ jsx(CodeBlock, { language, code: token.text || " " }); } case "blockquote": { const Blockquote = components?.Blockquote ?? defaultComponents.Blockquote; return /* @__PURE__ */ jsx(Blockquote, { children: /* @__PURE__ */ jsx( MarkdownTokens, { tokens: token.tokens, components, normalizeToBlockTokens: true } ) }); } case "list": { const List = components?.List ?? defaultComponents.List; const items = token.items.map((item) => { let tokens = item.tokens; if (item.task) { tokens = [ { type: "checkbox", checked: Boolean(item.checked), raw: `[${item.checked ? "x" : " "}]` }, ...tokens ]; } return { checked: item.task ? item.checked : void 0, children: /* @__PURE__ */ jsx( MarkdownTokens, { tokens, components, normalizeToBlockTokens: ( // A non-loose list item doesn't need to be wrapped in block tokens. item.tokens.length > 0 ? item.loose : false ) } ) }; }); const props = token.ordered ? { type: "ordered", items, start: token.start || 1 } : { type: "unordered", items }; return /* @__PURE__ */ jsx(List, { ...props }); } case "checkbox": { return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx("input", { type: "checkbox", disabled: true, checked: token.checked }), " " ] }); } case "table": { const Table = components?.Table ?? defaultComponents.Table; const headings = token.header.map( (cell) => ({ align: cell.align ?? void 0, children: /* @__PURE__ */ jsx(MarkdownTokens, { tokens: cell.tokens, components }) }) ); const rows = token.rows.map( (row) => row.map((cell) => ({ align: cell.align ?? void 0, children: /* @__PURE__ */ jsx(MarkdownTokens, { tokens: cell.tokens, components }) })) ); return /* @__PURE__ */ jsx(Table, { headings, rows }); } case "image": { const href = sanitizeUrl(token.href); if (href === null) { return token.text; } const Image = components?.Image ?? defaultComponents.Image; return /* @__PURE__ */ jsx(Image, { src: href, alt: token.text, title: token.title ?? void 0 }); } case "hr": { const Separator = components?.Separator ?? defaultComponents.Separator; return /* @__PURE__ */ jsx(Separator, {}); } case "html": { return parseHtmlEntities(token.text); } default: { return null; } } } function MarkdownTokens({ tokens, components, normalizeToBlockTokens = false }) { assertTokens(tokens); let normalizedTokens = []; if (normalizeToBlockTokens) { let leadingCheckboxToken = tokens[0]?.type === "checkbox" ? tokens[0] : null; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; switch (token.type) { case "text": { const paragraphTextTokens = [token]; while (i + 1 < tokens.length && tokens[i + 1].type === "text") { i++; paragraphTextTokens.push(tokens[i]); } const paragraphRaw = paragraphTextTokens.map((text) => text.raw).join(""); const paragraphText = paragraphTextTokens.map((text) => text.text).join(""); normalizedTokens.push({ type: "paragraph", tokens: leadingCheckboxToken ? [leadingCheckboxToken, ...paragraphTextTokens] : paragraphTextTokens, raw: paragraphRaw, text: paragraphText }); leadingCheckboxToken = null; break; } case "checkbox": break; default: { normalizedTokens.push(token); } } } } else { normalizedTokens = tokens; } return normalizedTokens.map((token, index) => /* @__PURE__ */ jsx(MarkdownToken, { token, components }, index)); } function assertTokens(_) { } function isBlockToken(token) { return token.type === "paragraph" || token.type === "heading" || token.type === "blockquote" || token.type === "list_item"; } function tokenize(markdown) { return new Lexer().lex(markdown); } function tokenizePartial(markdown) { const preprocessedContent = trimPartialMarkdown(normalizeNewlines(markdown)); const tokens = tokenize(preprocessedContent); try { return completePartialTokens(tokens); } catch { return tokens; } } function findPotentiallyPartialToken(tokens, parentToken) { if (tokens.length === 0) { return parentToken; } assertTokens(tokens); const lastIndex = tokens.length - 1; let lastToken = tokens[lastIndex]; if (lastToken.type === "space") { const penultimateToken = tokens[lastIndex - 1]; if (!penultimateToken) { return parentToken; } lastToken = penultimateToken; } if (lastToken.type === "list") { const listToken = lastToken; const lastListItem = listToken.items[listToken.items.length - 1]; if (!lastListItem) { return parentToken; } const lastListItemTokens = lastListItem.tokens; if (lastListItemTokens.some((token) => token.type === "space") && lastListItemTokens.length > 0) { const lastListItemLastToken = lastListItemTokens[lastListItemTokens.length - 1]; if (lastListItemLastToken) { if (lastListItemLastToken.type === "text") { return lastListItemLastToken; } if (isBlockToken(lastListItemLastToken)) { return findPotentiallyPartialToken( lastListItemLastToken.tokens, lastListItemLastToken ); } return void 0; } } return findPotentiallyPartialToken(lastListItem.tokens, lastListItem); } if (lastToken.type === "table") { const tableToken = lastToken; const lastTableRow = tableToken.rows[tableToken.rows.length - 1]; if (!lastTableRow) { return parentToken; } const firstEmptyTableCellIndex = lastTableRow.findIndex( (cell) => cell.tokens.length === 0 ); const lastNonEmptyTableCell = firstEmptyTableCellIndex === -1 ? void 0 : firstEmptyTableCellIndex === 0 ? lastTableRow[firstEmptyTableCellIndex] : lastTableRow[firstEmptyTableCellIndex - 1]; if (!lastNonEmptyTableCell) { return parentToken; } return findPotentiallyPartialToken( lastNonEmptyTableCell.tokens, lastNonEmptyTableCell ); } if (isBlockToken(lastToken)) { return findPotentiallyPartialToken(lastToken.tokens, lastToken); } return parentToken; } function normalizeNewlines(string) { return string.replace(NEWLINE_REGEX, "\n"); } function trimPartialMarkdown(markdown) { const lines = markdown.split("\n"); if (lines.length === 0) { return markdown; } const [singleCharacterMatch] = lines[lines.length - 1].match(SINGLE_CHARACTER_REGEX) ?? []; if (singleCharacterMatch) { lines[lines.length - 1] = lines[lines.length - 1].slice( 0, -singleCharacterMatch.length ); return lines.join("\n"); } const [bufferedCharactersMatch] = lines[lines.length - 1].match(BUFFERED_CHARACTERS_REGEX) ?? []; if (bufferedCharactersMatch) { lines[lines.length - 1] = lines[lines.length - 1].slice( 0, -bufferedCharactersMatch.length ); return lines.join("\n"); } return markdown; } function completePartialInlineMarkdown(markdown, options = {}) { const stack = []; const allowLinksImages = options.allowLinksImages ?? true; let completedMarkdown = markdown; const partialEmojiMatch = completedMarkdown.match(PARTIAL_EMOJI_REGEX); if (partialEmojiMatch) { const partialEmoji = partialEmojiMatch[0]; completedMarkdown = completedMarkdown.slice(0, -partialEmoji.length); if (partialEmoji.includes("\uFE0F") || partialEmoji.includes("\u20E3")) { const codepoints = Array.from(completedMarkdown); if (codepoints.length > 0) { completedMarkdown = codepoints.slice(0, -1).join(""); } } } for (let i = 0; i < completedMarkdown.length; i++) { const character = completedMarkdown[i]; const isEscaped = i > 0 ? completedMarkdown[i - 1] === "\\" : false; if (isEscaped) { continue; } if (character === "`") { const lastDelimiter = stack[stack.length - 1]; const isClosingPreviousDelimiter = lastDelimiter?.string === "`" && i > lastDelimiter.index; if (isClosingPreviousDelimiter) { stack.pop(); } else { const characterAfterDelimiter = completedMarkdown[i + 1]; if (characterAfterDelimiter && !WHITESPACE_REGEX.test(characterAfterDelimiter)) { stack.push({ string: "`", length: 1, index: i }); } } continue; } if (character === "*" || character === "_" || character === "~") { const isInsideInlineCode = stack[stack.length - 1]?.string === "`"; let j = i; while (j < completedMarkdown.length && completedMarkdown[j] === character) { j++; } const consecutiveDelimiterCharacters = j - i; if (isInsideInlineCode) { i += consecutiveDelimiterCharacters - 1; continue; } let remainingConsecutiveDelimiterCharacters = consecutiveDelimiterCharacters; let consecutiveDelimiterCharacterIndex = 0; while (remainingConsecutiveDelimiterCharacters > 0) { const lastDelimiter = stack[stack.length - 1]; if (!lastDelimiter || lastDelimiter.string[0] !== character) { break; } if (remainingConsecutiveDelimiterCharacters >= lastDelimiter.length) { stack.pop(); remainingConsecutiveDelimiterCharacters -= lastDelimiter.length; consecutiveDelimiterCharacterIndex += lastDelimiter.length; continue; } break; } if (remainingConsecutiveDelimiterCharacters > 0) { if (i + consecutiveDelimiterCharacters >= completedMarkdown.length) { completedMarkdown = completedMarkdown.slice( 0, completedMarkdown.length - remainingConsecutiveDelimiterCharacters ); break; } const characterAfterDelimiters = completedMarkdown[i + consecutiveDelimiterCharacters]; if (characterAfterDelimiters && !WHITESPACE_REGEX.test(characterAfterDelimiters)) { let delimiterStartIndex = i + consecutiveDelimiterCharacterIndex; if (remainingConsecutiveDelimiterCharacters % 2 === 1) { stack.push({ string: character, length: 1, index: delimiterStartIndex }); delimiterStartIndex += 1; remainingConsecutiveDelimiterCharacters -= 1; } while (remainingConsecutiveDelimiterCharacters >= 2) { stack.push({ string: character + character, length: 2, index: delimiterStartIndex }); delimiterStartIndex += 2; remainingConsecutiveDelimiterCharacters -= 2; } } } i += consecutiveDelimiterCharacters - 1; continue; } } if (allowLinksImages) { const partialLinkImageMatch = completedMarkdown.match( PARTIAL_LINK_IMAGE_REGEX ); if (partialLinkImageMatch) { const linkImageStartIndex = partialLinkImageMatch.index; const linkImageEndIndex = linkImageStartIndex + partialLinkImageMatch[0].length; const isInsideInlineCode = stack.some( (delimiter) => delimiter.string === "`" && delimiter.index < linkImageStartIndex ); if (!isInsideInlineCode) { const partialLinkImageContent = partialLinkImageMatch[0]; const { text: partialLinkText, url: partialLinkUrl, image: isImage } = partialLinkImageMatch.groups; if (isImage) { completedMarkdown = completedMarkdown.slice( 0, -partialLinkImageContent.length ); } else { for (let i = stack.length - 1; i >= 0; i--) { const delimiter = stack[i]; if (delimiter.index >= linkImageStartIndex && delimiter.index < linkImageEndIndex) { stack.splice(i, 1); } } const completedLinkText = partialLinkText ? partialLinkUrl ? ( // If there's a partial URL, the text is already completed. partialLinkText ) : ( // Otherwise, we complete the text and its potential nested elements. completePartialInlineMarkdown(partialLinkText, { // Links/images cannot be nested. allowLinksImages: false }) ) : ""; const completedLinkUrl = partialLinkUrl && !WHITESPACE_REGEX.test(partialLinkUrl) && isUrl(partialLinkUrl) ? ( // We only use the partial URL if it's valid. partialLinkUrl ) : DEFAULT_PARTIAL_LINK_URL; const completedLink = `[${completedLinkText}](${completedLinkUrl})`; completedMarkdown = completedMarkdown.slice( 0, -partialLinkImageContent.length ); completedMarkdown += completedLink; } } } } for (let i = stack.length - 1; i >= 0; i--) { const delimiter = stack[i]; if (delimiter.index + delimiter.length >= completedMarkdown.length) { completedMarkdown = completedMarkdown.slice(0, delimiter.index); continue; } if (delimiter.string !== "`") { completedMarkdown = completedMarkdown.trimEnd(); } completedMarkdown += delimiter.string; } return completedMarkdown; } function completePartialTableMarkdown(markdown) { const tableLines = markdown.split("\n"); if (tableLines.length === 0) { return void 0; } const tableHeader = tableLines[0]; if (tableHeader === "|") { return void 0; } const tableHeadings = tableHeader.split("|").map((cell) => cell.trim()).filter((cell) => cell !== ""); if (tableHeadings.length === 0) { return void 0; } if (!tableHeader.endsWith("|")) { const lastTableHeading = tableHeadings[tableHeadings.length - 1]; const completedLastTableHeading = completePartialInlineMarkdown(lastTableHeading); tableHeadings[tableHeadings.length - 1] = completedLastTableHeading; } return `| ${tableHeadings.join(" | ")} | | ${tableHeadings.map(() => "---").join(" | ")} |`; } function completePartialTokens(tokens) { const potentiallyPartialToken = findPotentiallyPartialToken(tokens); if (!potentiallyPartialToken) { return tokens; } if (potentiallyPartialToken.type === "paragraph" || potentiallyPartialToken.type === "text") { if (PARTIAL_TABLE_HEADER_REGEX.test(potentiallyPartialToken.raw)) { const completedTableMarkdown = completePartialTableMarkdown( potentiallyPartialToken.raw ); if (completedTableMarkdown) { const completedTable = tokenize(completedTableMarkdown)[0]; if (completedTable) { const table = potentiallyPartialToken; table.type = "table"; table.header = completedTable.header; table.align = completedTable.align; table.rows = completedTable.rows; return tokens; } } else { potentiallyPartialToken.text = ""; potentiallyPartialToken.tokens = []; } } } if (potentiallyPartialToken.type === "list_item") { const listItem = potentiallyPartialToken; const listItemTokens = listItem.tokens; if (!listItem.task && listItemTokens.length === 1 && listItemTokens[0].type === "text") { const listItemText = listItemTokens[0]; const checkboxMatch = listItemText.text.match(LIST_ITEM_CHECKBOX_REGEX); if (checkboxMatch) { listItem.task = true; if (checkboxMatch[1] === "x") { listItem.checked = true; } else { listItem.checked = false; } listItem.text = ""; listItem.tokens = []; } } } if (potentiallyPartialToken.text.length === 0) { return tokens; } const completedMarkdown = completePartialInlineMarkdown( potentiallyPartialToken.text ); const completedMarkdownTokens = tokenize(completedMarkdown)[0]?.tokens ?? []; potentiallyPartialToken.text = completedMarkdown; potentiallyPartialToken.tokens = completedMarkdownTokens; return tokens; } function parseHtmlEntities(input) { const document = new DOMParser().parseFromString( `<!doctype html><body>${input.replace(AMPERSAND_REGEX, "&amp;").replace(LEFT_ANGLE_BRACKET_REGEX, "&lt;").replace(RIGHT_ANGLE_BRACKET_REGEX, "&gt;")}`, "text/html" ); return document.body.textContent; } function clampHeadingLevel(level) { return Math.max(1, Math.min(6, level)); } export { Markdown, MarkdownToken }; //# sourceMappingURL=Markdown.js.map