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