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