xref: /expo/docs/components/base/code.tsx (revision 2101e951)
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 function cleanCopyValue(value: string) {
79  return value
80    .replace(/\/\*\s?@(info[^*]+|end|hide[^*]+).?\*\//g, '')
81    .replace(/#\s?@(info[^#]+|end|hide[^#]+).?#/g, '')
82    .replace(/<!--\s?@(info[^<>]+|end|hide[^<>]+).?-->/g, '')
83    .replace(/^ +\r?\n|\n +\r?$/gm, '');
84}
85
86export class Code extends React.Component<React.PropsWithChildren<Props>> {
87  componentDidMount() {
88    this.runTippy();
89  }
90
91  componentDidUpdate() {
92    this.runTippy();
93  }
94
95  private runTippy() {
96    const tippyFunc = testTippy || tippy;
97    tippyFunc('.code-annotation.with-tooltip', {
98      allowHTML: true,
99      theme: 'expo',
100      placement: 'top',
101      arrow: roundArrow,
102      interactive: true,
103      offset: [0, 20],
104      appendTo: document.body,
105    });
106  }
107
108  private escapeHtml(text: string) {
109    return text.replace(/"/g, '&quot;');
110  }
111
112  private replaceXmlCommentsWithAnnotations(value: string) {
113    return value
114      .replace(
115        /<span class="token comment">&lt;!-- @info (.*?)--><\/span>\s*/g,
116        (match, content) => {
117          return content
118            ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml(
119                content
120              )}">`
121            : '<span class="code-annotation">';
122        }
123      )
124      .replace(
125        /<span class="token comment">&lt;!-- @hide (.*?)--><\/span>\s*/g,
126        (match, content) => {
127          return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
128            content
129          )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
130        }
131      )
132      .replace(/\s*<span class="token comment">&lt;!-- @end --><\/span>/g, '</span>');
133  }
134
135  private replaceHashCommentsWithAnnotations(value: string) {
136    return value
137      .replace(/<span class="token comment"># @info (.*?)#<\/span>\s*/g, (match, content) => {
138        return content
139          ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml(
140              content
141            )}">`
142          : '<span class="code-annotation">';
143      })
144      .replace(/<span class="token comment"># @hide (.*?)#<\/span>\s*/g, (match, content) => {
145        return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
146          content
147        )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
148      })
149      .replace(/\s*<span class="token comment"># @end #<\/span>/g, '</span>');
150  }
151
152  private replaceSlashCommentsWithAnnotations(value: string) {
153    return value
154      .replace(/<span class="token comment">\/\* @info (.*?)\*\/<\/span>\s*/g, (match, content) => {
155        return content
156          ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml(
157              content
158            )}">`
159          : '<span class="code-annotation">';
160      })
161      .replace(/<span class="token comment">\/\* @hide (.*?)\*\/<\/span>\s*/g, (match, content) => {
162        return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
163          content
164        )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
165      })
166      .replace(/\s*<span class="token comment">\/\* @end \*\/<\/span>/g, '</span>');
167  }
168
169  private parseValue(value: string) {
170    if (value.startsWith('@@@')) {
171      const valueChunks = value.split('@@@');
172      return {
173        title: valueChunks[1],
174        value: valueChunks[2],
175      };
176    }
177    return {
178      value,
179    };
180  }
181
182  render() {
183    // note(simek): MDX dropped `inlineCode` pseudo-tag, and we need to relay on `pre` and `code` now,
184    // which results in this nesting mess, we should fix it in the future
185    const child =
186      this.props.className && this.props.className.startsWith('language')
187        ? this
188        : (React.Children.toArray(this.props.children)[0] as JSX.Element);
189
190    const value = this.parseValue(child?.props?.children?.toString() || '');
191    let html = value.value;
192
193    // mdx will add the class `language-foo` to codeblocks with the tag `foo`
194    // if this class is present, we want to slice out `language-`
195    let lang = child.props.className && child.props.className.slice(9).toLowerCase();
196
197    // Allow for code blocks without a language.
198    if (lang) {
199      // sh isn't supported, use sh to match js, and ts
200      if (lang in remapLanguages) {
201        lang = remapLanguages[lang];
202      }
203
204      const grammar = Prism.languages[lang as keyof typeof Prism.languages];
205      if (!grammar) {
206        throw new Error(`docs currently do not support language: ${lang}`);
207      }
208
209      html = Prism.highlight(html, grammar, lang as Language);
210      if (['properties', 'ruby', 'bash'].includes(lang)) {
211        html = this.replaceHashCommentsWithAnnotations(html);
212      } else if (['xml', 'html'].includes(lang)) {
213        html = this.replaceXmlCommentsWithAnnotations(html);
214      } else {
215        html = this.replaceSlashCommentsWithAnnotations(html);
216      }
217    }
218
219    return value?.title ? (
220      <Snippet>
221        <SnippetHeader title={value.title}>
222          <CopyAction text={cleanCopyValue(value.value)} />
223        </SnippetHeader>
224        <SnippetContent skipPadding>
225          <pre css={STYLES_CODE_CONTAINER} {...attributes}>
226            <code
227              css={STYLES_CODE_BLOCK}
228              dangerouslySetInnerHTML={{ __html: html.replace(/^@@@.+@@@/g, '') }}
229            />
230          </pre>
231        </SnippetContent>
232      </Snippet>
233    ) : (
234      <pre css={[STYLES_CODE_CONTAINER, STYLES_CODE_CONTAINER_BLOCK]} {...attributes}>
235        <code css={STYLES_CODE_BLOCK} dangerouslySetInnerHTML={{ __html: html }} />
236      </pre>
237    );
238  }
239}
240
241const remapLanguages: Record<string, string> = {
242  'objective-c': 'objc',
243  sh: 'bash',
244  rb: 'ruby',
245};
246
247const codeBlockContainerStyle = {
248  margin: 0,
249  padding: `3px 6px`,
250};
251
252const codeBlockInlineStyle = {
253  padding: 4,
254};
255
256const codeBlockInlineContainerStyle = {
257  display: 'inline-flex',
258  padding: 0,
259};
260
261type CodeBlockProps = React.PropsWithChildren<{ inline?: boolean }>;
262
263export const CodeBlock = ({ children, inline = false }: CodeBlockProps) => {
264  const Element = inline ? 'span' : 'pre';
265  return (
266    <Element
267      css={[
268        STYLES_CODE_CONTAINER,
269        codeBlockContainerStyle,
270        inline && codeBlockInlineContainerStyle,
271      ]}
272      {...attributes}>
273      <CODE css={[STYLES_CODE_BLOCK, inline && codeBlockInlineStyle, { fontSize: '80%' }]}>
274        {children}
275      </CODE>
276    </Element>
277  );
278};
279