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