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