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