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