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