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