1import { css } from '@emotion/react'; 2import { borderRadius, spacing, theme, typography } from '@expo/styleguide'; 3import { Language, Prism } from 'prism-react-renderer'; 4import * as React from 'react'; 5import tippy, { roundArrow } from 'tippy.js'; 6 7import { installLanguages } from './languages'; 8 9// @ts-ignore Jest ESM issue https://github.com/facebook/jest/issues/9430 10const { default: testTippy } = tippy; 11 12installLanguages(Prism); 13 14const attributes = { 15 'data-text': true, 16}; 17 18const STYLES_CODE_BLOCK = css` 19 ${typography.body.code}; 20 color: ${theme.text.default}; 21 white-space: inherit; 22 padding: 0; 23 margin: 0; 24 25 .code-annotation { 26 transition: 200ms ease all; 27 transition-property: text-shadow, opacity; 28 text-shadow: ${theme.highlight.emphasis} 0 0 10px, ${theme.highlight.emphasis} 0 0 10px, 29 ${theme.highlight.emphasis} 0 0 10px, ${theme.highlight.emphasis} 0 0 10px; 30 } 31 32 .code-annotation.with-tooltip:hover { 33 cursor: pointer; 34 animation: none; 35 opacity: 0.8; 36 } 37 38 .code-hidden { 39 display: none; 40 } 41 42 .code-placeholder { 43 opacity: 0.5; 44 } 45`; 46 47const STYLES_INLINE_CODE = css` 48 ${typography.body.code}; 49 color: ${theme.text.default}; 50 white-space: pre-wrap; 51 display: inline; 52 padding: ${spacing[0.5]}px ${spacing[1]}px; 53 max-width: 100%; 54 word-wrap: break-word; 55 background-color: ${theme.background.secondary}; 56 border: 1px solid ${theme.border.default}; 57 border-radius: ${borderRadius.small}px; 58 vertical-align: middle; 59 overflow-x: auto; 60 61 /* Disable Safari from adding border when used within a (perma)link */ 62 a & { 63 border-color: ${theme.border.default}; 64 } 65 66 h2 &, 67 h3 &, 68 h4 & { 69 position: relative; 70 top: -2px; 71 } 72`; 73 74const STYLES_CODE_CONTAINER = css` 75 border: 1px solid ${theme.border.default}; 76 padding: 16px; 77 margin: 16px 0; 78 white-space: pre; 79 overflow: auto; 80 -webkit-overflow-scrolling: touch; 81 background-color: ${theme.background.secondary}; 82 line-height: 120%; 83 border-radius: 4px; 84`; 85 86type Props = { 87 className?: string; 88}; 89 90export class Code extends React.Component<React.PropsWithChildren<Props>> { 91 componentDidMount() { 92 this.runTippy(); 93 } 94 95 componentDidUpdate() { 96 this.runTippy(); 97 } 98 99 private runTippy() { 100 const tippyFunc = testTippy || tippy; 101 tippyFunc('.code-annotation.with-tooltip', { 102 allowHTML: true, 103 theme: 'expo', 104 placement: 'top', 105 arrow: roundArrow, 106 interactive: true, 107 offset: [0, 20], 108 appendTo: document.body, 109 }); 110 } 111 112 private escapeHtml(text: string) { 113 return text.replace(/"/g, '"'); 114 } 115 116 private replaceXmlCommentsWithAnnotations(value: string) { 117 return value 118 .replace( 119 /<span class="token comment"><!-- @info (.*?)--><\/span>\s*/g, 120 (match, content) => { 121 return content 122 ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml( 123 content 124 )}">` 125 : '<span class="code-annotation">'; 126 } 127 ) 128 .replace( 129 /<span class="token comment"><!-- @hide (.*?)--><\/span>\s*/g, 130 (match, content) => { 131 return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml( 132 content 133 )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`; 134 } 135 ) 136 .replace(/\s*<span class="token comment"><!-- @end --><\/span>/g, '</span>'); 137 } 138 139 private replaceHashCommentsWithAnnotations(value: string) { 140 return value 141 .replace(/<span class="token comment"># @info (.*?)#<\/span>\s*/g, (match, content) => { 142 return content 143 ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml( 144 content 145 )}">` 146 : '<span class="code-annotation">'; 147 }) 148 .replace(/<span class="token comment"># @hide (.*?)#<\/span>\s*/g, (match, content) => { 149 return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml( 150 content 151 )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`; 152 }) 153 .replace(/\s*<span class="token comment"># @end #<\/span>/g, '</span>'); 154 } 155 156 private replaceSlashCommentsWithAnnotations(value: string) { 157 return value 158 .replace(/<span class="token comment">\/\* @info (.*?)\*\/<\/span>\s*/g, (match, content) => { 159 return content 160 ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml( 161 content 162 )}">` 163 : '<span class="code-annotation">'; 164 }) 165 .replace(/<span class="token comment">\/\* @hide (.*?)\*\/<\/span>\s*/g, (match, content) => { 166 return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml( 167 content 168 )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`; 169 }) 170 .replace(/\s*<span class="token comment">\/\* @end \*\/<\/span>/g, '</span>'); 171 } 172 173 render() { 174 // note(simek): MDX dropped `inlineCode` pseudo-tag, and we need to relay on `pre` and `code` now, 175 // which results in this nesting mess, we should fix it in the future 176 const child = this.props.className 177 ? this 178 : (React.Children.toArray(this.props.children)[0] as JSX.Element); 179 let html = child?.props?.children?.toString() || ''; 180 181 // mdx will add the class `language-foo` to codeblocks with the tag `foo` 182 // if this class is present, we want to slice out `language-` 183 let lang = child.props.className && child.props.className.slice(9).toLowerCase(); 184 185 // Allow for code blocks without a language. 186 if (lang) { 187 // sh isn't supported, use sh to match js, and ts 188 if (lang in remapLanguages) { 189 lang = remapLanguages[lang]; 190 } 191 192 const grammar = Prism.languages[lang as keyof typeof Prism.languages]; 193 if (!grammar) { 194 throw new Error(`docs currently do not support language: ${lang}`); 195 } 196 197 html = Prism.highlight(html, grammar, lang as Language); 198 if (['properties', 'ruby'].includes(lang)) { 199 html = this.replaceHashCommentsWithAnnotations(html); 200 } else if (['xml', 'html'].includes(lang)) { 201 html = this.replaceXmlCommentsWithAnnotations(html); 202 } else { 203 html = this.replaceSlashCommentsWithAnnotations(html); 204 } 205 } 206 207 return ( 208 <pre css={STYLES_CODE_CONTAINER} {...attributes}> 209 <code css={STYLES_CODE_BLOCK} dangerouslySetInnerHTML={{ __html: html }} /> 210 </pre> 211 ); 212 } 213} 214 215const remapLanguages: Record<string, string> = { 216 'objective-c': 'objc', 217 sh: 'bash', 218 rb: 'ruby', 219}; 220 221type InlineCodeProps = React.PropsWithChildren<{ className?: string }>; 222 223export const InlineCode = ({ children, className }: InlineCodeProps) => ( 224 <code css={STYLES_INLINE_CODE} className={className ? `inline ${className}` : 'inline'}> 225 {children} 226 </code> 227); 228 229const codeBlockContainerStyle = { 230 margin: 0, 231 padding: `3px 6px`, 232}; 233 234const codeBlockInlineContainerStyle = { 235 display: 'inline-flex', 236}; 237 238type CodeBlockProps = React.PropsWithChildren<{ inline?: boolean }>; 239 240export const CodeBlock = ({ children, inline = false }: CodeBlockProps) => { 241 const Element = inline ? 'span' : 'pre'; 242 return ( 243 <Element 244 css={[ 245 STYLES_CODE_CONTAINER, 246 codeBlockContainerStyle, 247 inline && codeBlockInlineContainerStyle, 248 ]} 249 {...attributes}> 250 <code css={[STYLES_CODE_BLOCK, { fontSize: '80%' }]}>{children}</code> 251 </Element> 252 ); 253}; 254