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 9installLanguages(Prism); 10 11const attributes = { 12 'data-text': true, 13}; 14 15const STYLES_CODE_BLOCK = css` 16 ${typography.body.code}; 17 color: ${theme.text.default}; 18 white-space: inherit; 19 padding: 0; 20 margin: 0; 21 22 .code-annotation { 23 transition: 200ms ease all; 24 transition-property: text-shadow, opacity; 25 text-shadow: ${theme.highlight.emphasis} 0 0 10px, ${theme.highlight.emphasis} 0 0 10px, 26 ${theme.highlight.emphasis} 0 0 10px, ${theme.highlight.emphasis} 0 0 10px; 27 } 28 29 .code-annotation.with-tooltip:hover { 30 cursor: pointer; 31 animation: none; 32 opacity: 0.8; 33 } 34 35 .code-hidden { 36 display: none; 37 } 38 39 .code-placeholder { 40 opacity: 0.5; 41 } 42`; 43 44const STYLES_INLINE_CODE = css` 45 ${typography.body.code}; 46 color: ${theme.text.default}; 47 white-space: pre-wrap; 48 display: inline; 49 padding: ${spacing[0.5]}px ${spacing[1]}px; 50 max-width: 100%; 51 word-wrap: break-word; 52 background-color: ${theme.background.secondary}; 53 border: 1px solid ${theme.border.default}; 54 border-radius: ${borderRadius.small}px; 55 vertical-align: middle; 56 overflow-x: auto; 57 58 /* Disable Safari from adding border when used within a (perma)link */ 59 a & { 60 border-color: ${theme.border.default}; 61 } 62 63 h2 &, 64 h3 &, 65 h4 & { 66 position: relative; 67 top: -2px; 68 } 69`; 70 71const STYLES_CODE_CONTAINER = css` 72 border: 1px solid ${theme.border.default}; 73 padding: 16px; 74 margin: 16px 0; 75 white-space: pre; 76 overflow: auto; 77 -webkit-overflow-scrolling: touch; 78 background-color: ${theme.background.secondary}; 79 line-height: 120%; 80 border-radius: 4px; 81`; 82 83type Props = { 84 className?: string; 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 tippy('.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(/<span class="token comment"><!-- @end --><\/span>(\n *)?/g, '</span></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(/<span class="token comment"># @end #<\/span>(\n *)?/g, '</span></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(/<span class="token comment">\/\* @end \*\/<\/span>(\n *)?/g, '</span></span>'); 167 } 168 169 render() { 170 let html = this.props.children?.toString() || ''; 171 // mdx will add the class `language-foo` to codeblocks with the tag `foo` 172 // if this class is present, we want to slice out `language-` 173 let lang = this.props.className && this.props.className.slice(9).toLowerCase(); 174 175 // Allow for code blocks without a language. 176 if (lang) { 177 // sh isn't supported, use sh to match js, and ts 178 if (lang in remapLanguages) { 179 lang = remapLanguages[lang]; 180 } 181 182 const grammar = Prism.languages[lang as keyof typeof Prism.languages]; 183 if (!grammar) { 184 throw new Error(`docs currently do not support language: ${lang}`); 185 } 186 187 html = Prism.highlight(html, grammar, lang as Language); 188 if (['properties', 'ruby'].includes(lang)) { 189 html = this.replaceHashCommentsWithAnnotations(html); 190 } else if (['xml', 'html'].includes(lang)) { 191 html = this.replaceXmlCommentsWithAnnotations(html); 192 } else { 193 html = this.replaceSlashCommentsWithAnnotations(html); 194 } 195 } 196 197 // Remove leading newline if it exists (because inside <pre> all whitespace is displayed as is by the browser, and 198 // sometimes, Prism adds a newline before the code) 199 if (html.startsWith('\n')) { 200 html = html.replace('\n', ''); 201 } 202 203 return ( 204 <pre css={STYLES_CODE_CONTAINER} {...attributes}> 205 <code css={STYLES_CODE_BLOCK} dangerouslySetInnerHTML={{ __html: html }} /> 206 </pre> 207 ); 208 } 209} 210 211const remapLanguages: Record<string, string> = { 212 'objective-c': 'objc', 213 sh: 'bash', 214 rb: 'ruby', 215}; 216 217type InlineCodeProps = React.PropsWithChildren<{ className?: string }>; 218 219export const InlineCode = ({ children, className }: InlineCodeProps) => ( 220 <code css={STYLES_INLINE_CODE} className={className ? `inline ${className}` : 'inline'}> 221 {children} 222 </code> 223); 224 225const codeBlockContainerStyle = { 226 margin: 0, 227 padding: `3px 6px`, 228}; 229 230const codeBlockInlineContainerStyle = { 231 display: 'inline-flex', 232}; 233 234type CodeBlockProps = React.PropsWithChildren<{ inline?: boolean }>; 235 236export const CodeBlock = ({ children, inline = false }: CodeBlockProps) => { 237 const Element = inline ? 'span' : 'pre'; 238 return ( 239 <Element 240 css={[ 241 STYLES_CODE_CONTAINER, 242 codeBlockContainerStyle, 243 inline && codeBlockInlineContainerStyle, 244 ]} 245 {...attributes}> 246 <code css={[STYLES_CODE_BLOCK, { fontSize: '80%' }]}>{children}</code> 247 </Element> 248 ); 249}; 250