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