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