xref: /expo/docs/components/base/code.tsx (revision 3ace1283)
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, '&quot;');
108  }
109
110  private replaceXmlCommentsWithAnnotations(value: string) {
111    return value
112      .replace(
113        /<span class="token comment">&lt;!-- @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">&lt;!-- @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">&lt;!-- @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