1import { css } from '@emotion/react'; 2import { 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 color: ${theme.text.default}; 17 font-family: ${typography.fontFaces.mono}; 18 font-size: 13px; 19 line-height: 20px; 20 white-space: inherit; 21 padding: 0; 22 margin: 0; 23 24 .code-annotation { 25 transition: 200ms ease all; 26 transition-property: text-shadow, opacity; 27 text-shadow: ${theme.highlight.emphasis} 0 0 10px, ${theme.highlight.emphasis} 0 0 10px, 28 ${theme.highlight.emphasis} 0 0 10px, ${theme.highlight.emphasis} 0 0 10px; 29 } 30 31 .code-annotation.with-tooltip:hover { 32 cursor: pointer; 33 animation: none; 34 opacity: 0.8; 35 } 36 37 .code-hidden { 38 display: none; 39 } 40 41 .code-placeholder { 42 opacity: 0.5; 43 } 44`; 45 46const STYLES_INLINE_CODE = css` 47 color: ${theme.text.default}; 48 font-family: ${typography.fontFaces.mono}; 49 font-size: 0.825em; 50 white-space: pre-wrap; 51 display: inline; 52 padding: 2px 4px; 53 line-height: 170%; 54 max-width: 100%; 55 56 word-wrap: break-word; 57 background-color: ${theme.background.secondary}; 58 border: 1px solid ${theme.border.default}; 59 border-radius: 4px; 60 vertical-align: middle; 61 overflow-x: auto; 62 63 /* Disable Safari from adding border when used within a (perma)link */ 64 a & { 65 border-color: ${theme.border.default}; 66 } 67`; 68 69const STYLES_CODE_CONTAINER = css` 70 border: 1px solid ${theme.border.default}; 71 padding: 16px; 72 margin: 16px 0; 73 white-space: pre; 74 overflow: auto; 75 -webkit-overflow-scrolling: touch; 76 background-color: ${theme.background.secondary}; 77 line-height: 120%; 78 border-radius: 4px; 79`; 80 81type Props = { 82 className?: string; 83}; 84 85export class Code extends React.Component<React.PropsWithChildren<Props>> { 86 componentDidMount() { 87 this.runTippy(); 88 } 89 90 componentDidUpdate() { 91 this.runTippy(); 92 } 93 94 private runTippy() { 95 tippy('.code-annotation.with-tooltip', { 96 allowHTML: true, 97 theme: 'expo', 98 placement: 'top', 99 arrow: roundArrow, 100 interactive: true, 101 offset: [0, 20], 102 appendTo: document.body, 103 }); 104 } 105 106 private escapeHtml(text: string) { 107 return text.replace(/"/g, '"'); 108 } 109 110 private replaceXmlCommentsWithAnnotations(value: string) { 111 return value 112 .replace( 113 /<span class="token comment"><!-- @info (.*?)--><\/span>\s*/g, 114 (match, content) => { 115 return content 116 ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml( 117 content 118 )}">` 119 : '<span class="code-annotation">'; 120 } 121 ) 122 .replace( 123 /<span class="token comment"><!-- @hide (.*?)--><\/span>\s*/g, 124 (match, content) => { 125 return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml( 126 content 127 )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`; 128 } 129 ) 130 .replace(/<span class="token comment"><!-- @end --><\/span>(\n *)?/g, '</span></span>'); 131 } 132 133 private replaceHashCommentsWithAnnotations(value: string) { 134 return value 135 .replace(/<span class="token comment"># @info (.*?)#<\/span>\s*/g, (match, content) => { 136 return content 137 ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml( 138 content 139 )}">` 140 : '<span class="code-annotation">'; 141 }) 142 .replace(/<span class="token comment"># @hide (.*?)#<\/span>\s*/g, (match, content) => { 143 return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml( 144 content 145 )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`; 146 }) 147 .replace(/<span class="token comment"># @end #<\/span>(\n *)?/g, '</span></span>'); 148 } 149 150 private replaceSlashCommentsWithAnnotations(value: string) { 151 return value 152 .replace(/<span class="token comment">\/\* @info (.*?)\*\/<\/span>\s*/g, (match, content) => { 153 return content 154 ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml( 155 content 156 )}">` 157 : '<span class="code-annotation">'; 158 }) 159 .replace(/<span class="token comment">\/\* @hide (.*?)\*\/<\/span>\s*/g, (match, content) => { 160 return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml( 161 content 162 )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`; 163 }) 164 .replace(/<span class="token comment">\/\* @end \*\/<\/span>(\n *)?/g, '</span></span>'); 165 } 166 167 render() { 168 let html = this.props.children?.toString() || ''; 169 // mdx will add the class `language-foo` to codeblocks with the tag `foo` 170 // if this class is present, we want to slice out `language-` 171 let lang = this.props.className && this.props.className.slice(9).toLowerCase(); 172 173 // Allow for code blocks without a language. 174 if (lang) { 175 // sh isn't supported, use sh to match js, and ts 176 if (lang in remapLanguages) { 177 lang = remapLanguages[lang]; 178 } 179 180 const grammar = Prism.languages[lang as keyof typeof Prism.languages]; 181 if (!grammar) { 182 throw new Error(`docs currently do not support language: ${lang}`); 183 } 184 185 html = Prism.highlight(html, grammar, lang as Language); 186 if (['properties', 'ruby'].includes(lang)) { 187 html = this.replaceHashCommentsWithAnnotations(html); 188 } else if (['xml', 'html'].includes(lang)) { 189 html = this.replaceXmlCommentsWithAnnotations(html); 190 } else { 191 html = this.replaceSlashCommentsWithAnnotations(html); 192 } 193 } 194 195 // Remove leading newline if it exists (because inside <pre> all whitespace is displayed as is by the browser, and 196 // sometimes, Prism adds a newline before the code) 197 if (html.startsWith('\n')) { 198 html = html.replace('\n', ''); 199 } 200 201 return ( 202 <pre css={STYLES_CODE_CONTAINER} {...attributes}> 203 <code css={STYLES_CODE_BLOCK} dangerouslySetInnerHTML={{ __html: html }} /> 204 </pre> 205 ); 206 } 207} 208 209const remapLanguages: Record<string, string> = { 210 'objective-c': 'objc', 211 sh: 'bash', 212 rb: 'ruby', 213}; 214 215type InlineCodeProps = React.PropsWithChildren<{ className?: string }>; 216 217export const InlineCode = ({ children, className }: InlineCodeProps) => ( 218 <code css={STYLES_INLINE_CODE} className={className ? `inline ${className}` : 'inline'}> 219 {children} 220 </code> 221); 222 223const codeBlockContainerStyle = { 224 margin: 0, 225 padding: `3px 6px`, 226}; 227 228const codeBlockInlineContainerStyle = { 229 display: 'inline-flex', 230}; 231 232type CodeBlockProps = React.PropsWithChildren<{ inline?: boolean }>; 233 234export const CodeBlock = ({ children, inline = false }: CodeBlockProps) => { 235 const Element = inline ? 'span' : 'pre'; 236 return ( 237 <Element 238 css={[ 239 STYLES_CODE_CONTAINER, 240 codeBlockContainerStyle, 241 inline && codeBlockInlineContainerStyle, 242 ]} 243 {...attributes}> 244 <code css={[STYLES_CODE_BLOCK, { fontSize: '80%' }]}>{children}</code> 245 </Element> 246 ); 247}; 248