xref: /expo/docs/components/base/code.tsx (revision d4cd4dfc)
1import { css } from '@emotion/react';
2import { borderRadius, spacing, 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
9import { Snippet } from '~/ui/components/Snippet/Snippet';
10import { SnippetContent } from '~/ui/components/Snippet/SnippetContent';
11import { SnippetHeader } from '~/ui/components/Snippet/SnippetHeader';
12import { CopyAction } from '~/ui/components/Snippet/actions/CopyAction';
13
14// @ts-ignore Jest ESM issue https://github.com/facebook/jest/issues/9430
15const { default: testTippy } = tippy;
16
17installLanguages(Prism);
18
19const attributes = {
20  'data-text': true,
21};
22
23const STYLES_CODE_BLOCK = css`
24  ${typography.body.code};
25  color: ${theme.text.default};
26  white-space: inherit;
27  padding: 0;
28  margin: 0;
29
30  .code-annotation {
31    transition: 200ms ease all;
32    transition-property: text-shadow, opacity;
33    text-shadow: ${theme.highlight.emphasis} 0 0 10px, ${theme.highlight.emphasis} 0 0 10px,
34      ${theme.highlight.emphasis} 0 0 10px, ${theme.highlight.emphasis} 0 0 10px;
35  }
36
37  .code-annotation.with-tooltip:hover {
38    cursor: pointer;
39    animation: none;
40    opacity: 0.8;
41  }
42
43  .code-hidden {
44    display: none;
45  }
46
47  .code-placeholder {
48    opacity: 0.5;
49  }
50`;
51
52const STYLES_CODE_CONTAINER_BLOCK = css`
53  border: 1px solid ${theme.border.default};
54  padding: 16px;
55  margin: 16px 0;
56  background-color: ${theme.background.secondary};
57`;
58
59const STYLES_CODE_CONTAINER = css`
60  white-space: pre;
61  overflow: auto;
62  -webkit-overflow-scrolling: touch;
63  line-height: 120%;
64  border-radius: ${borderRadius.small}px;
65  padding: ${spacing[4]}px;
66
67  table &:last-child {
68    margin-bottom: 0;
69  }
70`;
71
72type Props = {
73  className?: string;
74};
75
76export class Code extends React.Component<React.PropsWithChildren<Props>> {
77  componentDidMount() {
78    this.runTippy();
79  }
80
81  componentDidUpdate() {
82    this.runTippy();
83  }
84
85  private runTippy() {
86    const tippyFunc = testTippy || tippy;
87    tippyFunc('.code-annotation.with-tooltip', {
88      allowHTML: true,
89      theme: 'expo',
90      placement: 'top',
91      arrow: roundArrow,
92      interactive: true,
93      offset: [0, 20],
94      appendTo: document.body,
95    });
96  }
97
98  private escapeHtml(text: string) {
99    return text.replace(/"/g, '&quot;');
100  }
101
102  private replaceXmlCommentsWithAnnotations(value: string) {
103    return value
104      .replace(
105        /<span class="token comment">&lt;!-- @info (.*?)--><\/span>\s*/g,
106        (match, content) => {
107          return content
108            ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml(
109                content
110              )}">`
111            : '<span class="code-annotation">';
112        }
113      )
114      .replace(
115        /<span class="token comment">&lt;!-- @hide (.*?)--><\/span>\s*/g,
116        (match, content) => {
117          return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
118            content
119          )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
120        }
121      )
122      .replace(/\s*<span class="token comment">&lt;!-- @end --><\/span>/g, '</span>');
123  }
124
125  private replaceHashCommentsWithAnnotations(value: string) {
126    return value
127      .replace(/<span class="token comment"># @info (.*?)#<\/span>\s*/g, (match, content) => {
128        return content
129          ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml(
130              content
131            )}">`
132          : '<span class="code-annotation">';
133      })
134      .replace(/<span class="token comment"># @hide (.*?)#<\/span>\s*/g, (match, content) => {
135        return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
136          content
137        )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
138      })
139      .replace(/\s*<span class="token comment"># @end #<\/span>/g, '</span>');
140  }
141
142  private replaceSlashCommentsWithAnnotations(value: string) {
143    return value
144      .replace(/<span class="token comment">\/\* @info (.*?)\*\/<\/span>\s*/g, (match, content) => {
145        return content
146          ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml(
147              content
148            )}">`
149          : '<span class="code-annotation">';
150      })
151      .replace(/<span class="token comment">\/\* @hide (.*?)\*\/<\/span>\s*/g, (match, content) => {
152        return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
153          content
154        )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
155      })
156      .replace(/\s*<span class="token comment">\/\* @end \*\/<\/span>/g, '</span>');
157  }
158
159  private parseValue(value: string) {
160    if (value.startsWith('@@@')) {
161      const valueChunks = value.split('@@@');
162      return {
163        title: valueChunks[1],
164        value: valueChunks[2],
165      };
166    }
167    return {
168      value,
169    };
170  }
171
172  private cleanCopyValue(value: string) {
173    return value.replace(/ *(\/\*|#|<!--)+\s@.+(\*\/|-->|#)\r?\n/g, '');
174  }
175
176  render() {
177    // note(simek): MDX dropped `inlineCode` pseudo-tag, and we need to relay on `pre` and `code` now,
178    // which results in this nesting mess, we should fix it in the future
179    const child =
180      this.props.className && this.props.className.startsWith('language')
181        ? this
182        : (React.Children.toArray(this.props.children)[0] as JSX.Element);
183
184    const value = this.parseValue(child?.props?.children?.toString() || '');
185    let html = value.value;
186
187    // mdx will add the class `language-foo` to codeblocks with the tag `foo`
188    // if this class is present, we want to slice out `language-`
189    let lang = child.props.className && child.props.className.slice(9).toLowerCase();
190
191    // Allow for code blocks without a language.
192    if (lang) {
193      // sh isn't supported, use sh to match js, and ts
194      if (lang in remapLanguages) {
195        lang = remapLanguages[lang];
196      }
197
198      const grammar = Prism.languages[lang as keyof typeof Prism.languages];
199      if (!grammar) {
200        throw new Error(`docs currently do not support language: ${lang}`);
201      }
202
203      html = Prism.highlight(html, grammar, lang as Language);
204      if (['properties', 'ruby', 'bash'].includes(lang)) {
205        html = this.replaceHashCommentsWithAnnotations(html);
206      } else if (['xml', 'html'].includes(lang)) {
207        html = this.replaceXmlCommentsWithAnnotations(html);
208      } else {
209        html = this.replaceSlashCommentsWithAnnotations(html);
210      }
211    }
212
213    return value?.title ? (
214      <Snippet>
215        <SnippetHeader title={value.title}>
216          <CopyAction text={this.cleanCopyValue(value.value)} />
217        </SnippetHeader>
218        <SnippetContent skipPadding>
219          <pre css={STYLES_CODE_CONTAINER} {...attributes}>
220            <code
221              css={STYLES_CODE_BLOCK}
222              dangerouslySetInnerHTML={{ __html: html.replace(/^@@@.+@@@/g, '') }}
223            />
224          </pre>
225        </SnippetContent>
226      </Snippet>
227    ) : (
228      <pre css={[STYLES_CODE_CONTAINER, STYLES_CODE_CONTAINER_BLOCK]} {...attributes}>
229        <code css={STYLES_CODE_BLOCK} dangerouslySetInnerHTML={{ __html: html }} />
230      </pre>
231    );
232  }
233}
234
235const remapLanguages: Record<string, string> = {
236  'objective-c': 'objc',
237  sh: 'bash',
238  rb: 'ruby',
239};
240
241const codeBlockContainerStyle = {
242  margin: 0,
243  padding: `3px 6px`,
244};
245
246const codeBlockInlineContainerStyle = {
247  display: 'inline-flex',
248};
249
250type CodeBlockProps = React.PropsWithChildren<{ inline?: boolean }>;
251
252export const CodeBlock = ({ children, inline = false }: CodeBlockProps) => {
253  const Element = inline ? 'span' : 'pre';
254  return (
255    <Element
256      css={[
257        STYLES_CODE_CONTAINER,
258        codeBlockContainerStyle,
259        inline && codeBlockInlineContainerStyle,
260      ]}
261      {...attributes}>
262      <code css={[STYLES_CODE_BLOCK, { fontSize: '80%' }]}>{children}</code>
263    </Element>
264  );
265};
266