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: 4px; 92 93 table &:last-child { 94 margin-bottom: 0; 95 } 96`; 97 98type Props = { 99 className?: string; 100}; 101 102export class Code extends React.Component<React.PropsWithChildren<Props>> { 103 componentDidMount() { 104 this.runTippy(); 105 } 106 107 componentDidUpdate() { 108 this.runTippy(); 109 } 110 111 private runTippy() { 112 const tippyFunc = testTippy || tippy; 113 tippyFunc('.code-annotation.with-tooltip', { 114 allowHTML: true, 115 theme: 'expo', 116 placement: 'top', 117 arrow: roundArrow, 118 interactive: true, 119 offset: [0, 20], 120 appendTo: document.body, 121 }); 122 } 123 124 private escapeHtml(text: string) { 125 return text.replace(/"/g, '"'); 126 } 127 128 private replaceXmlCommentsWithAnnotations(value: string) { 129 return value 130 .replace( 131 /<span class="token comment"><!-- @info (.*?)--><\/span>\s*/g, 132 (match, content) => { 133 return content 134 ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml( 135 content 136 )}">` 137 : '<span class="code-annotation">'; 138 } 139 ) 140 .replace( 141 /<span class="token comment"><!-- @hide (.*?)--><\/span>\s*/g, 142 (match, content) => { 143 return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml( 144 content 145 )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`; 146 } 147 ) 148 .replace(/\s*<span class="token comment"><!-- @end --><\/span>/g, '</span>'); 149 } 150 151 private replaceHashCommentsWithAnnotations(value: string) { 152 return value 153 .replace(/<span class="token comment"># @info (.*?)#<\/span>\s*/g, (match, content) => { 154 return content 155 ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml( 156 content 157 )}">` 158 : '<span class="code-annotation">'; 159 }) 160 .replace(/<span class="token comment"># @hide (.*?)#<\/span>\s*/g, (match, content) => { 161 return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml( 162 content 163 )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`; 164 }) 165 .replace(/\s*<span class="token comment"># @end #<\/span>/g, '</span>'); 166 } 167 168 private replaceSlashCommentsWithAnnotations(value: string) { 169 return value 170 .replace(/<span class="token comment">\/\* @info (.*?)\*\/<\/span>\s*/g, (match, content) => { 171 return content 172 ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml( 173 content 174 )}">` 175 : '<span class="code-annotation">'; 176 }) 177 .replace(/<span class="token comment">\/\* @hide (.*?)\*\/<\/span>\s*/g, (match, content) => { 178 return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml( 179 content 180 )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`; 181 }) 182 .replace(/\s*<span class="token comment">\/\* @end \*\/<\/span>/g, '</span>'); 183 } 184 185 private parseValue(value: string) { 186 if (value.startsWith('@@@')) { 187 const valueChunks = value.split('@@@'); 188 return { 189 title: valueChunks[1], 190 value: valueChunks[2], 191 }; 192 } 193 return { 194 value, 195 }; 196 } 197 198 private cleanCopyValue(value: string) { 199 return value.replace(/ *(\/\*|#|<!--)+\s@.+(\*\/|-->)\r?\n/g, ''); 200 } 201 202 render() { 203 // note(simek): MDX dropped `inlineCode` pseudo-tag, and we need to relay on `pre` and `code` now, 204 // which results in this nesting mess, we should fix it in the future 205 const child = this.props.className 206 ? this 207 : (React.Children.toArray(this.props.children)[0] as JSX.Element); 208 209 const value = this.parseValue(child?.props?.children?.toString() || ''); 210 let html = value.value; 211 212 // mdx will add the class `language-foo` to codeblocks with the tag `foo` 213 // if this class is present, we want to slice out `language-` 214 let lang = child.props.className && child.props.className.slice(9).toLowerCase(); 215 216 // Allow for code blocks without a language. 217 if (lang) { 218 // sh isn't supported, use sh to match js, and ts 219 if (lang in remapLanguages) { 220 lang = remapLanguages[lang]; 221 } 222 223 const grammar = Prism.languages[lang as keyof typeof Prism.languages]; 224 if (!grammar) { 225 throw new Error(`docs currently do not support language: ${lang}`); 226 } 227 228 html = Prism.highlight(html, grammar, lang as Language); 229 if (['properties', 'ruby'].includes(lang)) { 230 html = this.replaceHashCommentsWithAnnotations(html); 231 } else if (['xml', 'html'].includes(lang)) { 232 html = this.replaceXmlCommentsWithAnnotations(html); 233 } else { 234 html = this.replaceSlashCommentsWithAnnotations(html); 235 } 236 } 237 238 return value?.title ? ( 239 <Snippet> 240 <SnippetHeader title={value.title}> 241 <CopyAction text={this.cleanCopyValue(value.value)} /> 242 </SnippetHeader> 243 <SnippetContent> 244 <pre css={STYLES_CODE_CONTAINER} {...attributes}> 245 <code 246 css={STYLES_CODE_BLOCK} 247 dangerouslySetInnerHTML={{ __html: html.replace(/^@@@.+@@@/g, '') }} 248 /> 249 </pre> 250 </SnippetContent> 251 </Snippet> 252 ) : ( 253 <pre css={[STYLES_CODE_CONTAINER, STYLES_CODE_CONTAINER_BLOCK]} {...attributes}> 254 <code css={STYLES_CODE_BLOCK} dangerouslySetInnerHTML={{ __html: html }} /> 255 </pre> 256 ); 257 } 258} 259 260const remapLanguages: Record<string, string> = { 261 'objective-c': 'objc', 262 sh: 'bash', 263 rb: 'ruby', 264}; 265 266type InlineCodeProps = React.PropsWithChildren<{ className?: string }>; 267 268export const InlineCode = ({ children, className }: InlineCodeProps) => ( 269 <code css={STYLES_INLINE_CODE} className={className ? `inline ${className}` : 'inline'}> 270 {children} 271 </code> 272); 273 274const codeBlockContainerStyle = { 275 margin: 0, 276 padding: `3px 6px`, 277}; 278 279const codeBlockInlineContainerStyle = { 280 display: 'inline-flex', 281}; 282 283type CodeBlockProps = React.PropsWithChildren<{ inline?: boolean }>; 284 285export const CodeBlock = ({ children, inline = false }: CodeBlockProps) => { 286 const Element = inline ? 'span' : 'pre'; 287 return ( 288 <Element 289 css={[ 290 STYLES_CODE_CONTAINER, 291 codeBlockContainerStyle, 292 inline && codeBlockInlineContainerStyle, 293 ]} 294 {...attributes}> 295 <code css={[STYLES_CODE_BLOCK, { fontSize: '80%' }]}>{children}</code> 296 </Element> 297 ); 298}; 299