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