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