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