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