xref: /expo/docs/components/base/code.tsx (revision 023bc8ea)
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_INLINE_CODE = css`
53  ${typography.body.code};
54  color: ${theme.text.default};
55  white-space: pre-wrap;
56  display: inline;
57  padding: ${spacing[0.5]}px ${spacing[1]}px;
58  max-width: 100%;
59  word-wrap: break-word;
60  background-color: ${theme.background.secondary};
61  border: 1px solid ${theme.border.default};
62  border-radius: ${borderRadius.small}px;
63  vertical-align: middle;
64  overflow-x: auto;
65
66  /* Disable Safari from adding border when used within a (perma)link */
67  a & {
68    border-color: ${theme.border.default};
69  }
70
71  h2 &,
72  h3 &,
73  h4 & {
74    position: relative;
75    top: -2px;
76  }
77`;
78
79const STYLES_CODE_CONTAINER_BLOCK = css`
80  border: 1px solid ${theme.border.default};
81  padding: 16px;
82  margin: 16px 0;
83  background-color: ${theme.background.secondary};
84`;
85
86const STYLES_CODE_CONTAINER = css`
87  white-space: pre;
88  overflow: auto;
89  -webkit-overflow-scrolling: touch;
90  line-height: 120%;
91  border-radius: ${borderRadius.small}px;
92  padding: ${spacing[4]}px;
93
94  table &:last-child {
95    margin-bottom: 0;
96  }
97`;
98
99type Props = {
100  className?: string;
101};
102
103export class Code extends React.Component<React.PropsWithChildren<Props>> {
104  componentDidMount() {
105    this.runTippy();
106  }
107
108  componentDidUpdate() {
109    this.runTippy();
110  }
111
112  private runTippy() {
113    const tippyFunc = testTippy || tippy;
114    tippyFunc('.code-annotation.with-tooltip', {
115      allowHTML: true,
116      theme: 'expo',
117      placement: 'top',
118      arrow: roundArrow,
119      interactive: true,
120      offset: [0, 20],
121      appendTo: document.body,
122    });
123  }
124
125  private escapeHtml(text: string) {
126    return text.replace(/"/g, '&quot;');
127  }
128
129  private replaceXmlCommentsWithAnnotations(value: string) {
130    return value
131      .replace(
132        /<span class="token comment">&lt;!-- @info (.*?)--><\/span>\s*/g,
133        (match, content) => {
134          return content
135            ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml(
136                content
137              )}">`
138            : '<span class="code-annotation">';
139        }
140      )
141      .replace(
142        /<span class="token comment">&lt;!-- @hide (.*?)--><\/span>\s*/g,
143        (match, content) => {
144          return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
145            content
146          )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
147        }
148      )
149      .replace(/\s*<span class="token comment">&lt;!-- @end --><\/span>/g, '</span>');
150  }
151
152  private replaceHashCommentsWithAnnotations(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 replaceSlashCommentsWithAnnotations(value: string) {
170    return value
171      .replace(/<span class="token comment">\/\* @info (.*?)\*\/<\/span>\s*/g, (match, content) => {
172        return content
173          ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml(
174              content
175            )}">`
176          : '<span class="code-annotation">';
177      })
178      .replace(/<span class="token comment">\/\* @hide (.*?)\*\/<\/span>\s*/g, (match, content) => {
179        return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
180          content
181        )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
182      })
183      .replace(/\s*<span class="token comment">\/\* @end \*\/<\/span>/g, '</span>');
184  }
185
186  private parseValue(value: string) {
187    if (value.startsWith('@@@')) {
188      const valueChunks = value.split('@@@');
189      return {
190        title: valueChunks[1],
191        value: valueChunks[2],
192      };
193    }
194    return {
195      value,
196    };
197  }
198
199  private cleanCopyValue(value: string) {
200    return value.replace(/ *(\/\*|#|<!--)+\s@.+(\*\/|-->)\r?\n/g, '');
201  }
202
203  render() {
204    // note(simek): MDX dropped `inlineCode` pseudo-tag, and we need to relay on `pre` and `code` now,
205    // which results in this nesting mess, we should fix it in the future
206    const child = this.props.className
207      ? this
208      : (React.Children.toArray(this.props.children)[0] as JSX.Element);
209
210    const value = this.parseValue(child?.props?.children?.toString() || '');
211    let html = value.value;
212
213    // mdx will add the class `language-foo` to codeblocks with the tag `foo`
214    // if this class is present, we want to slice out `language-`
215    let lang = child.props.className && child.props.className.slice(9).toLowerCase();
216
217    // Allow for code blocks without a language.
218    if (lang) {
219      // sh isn't supported, use sh to match js, and ts
220      if (lang in remapLanguages) {
221        lang = remapLanguages[lang];
222      }
223
224      const grammar = Prism.languages[lang as keyof typeof Prism.languages];
225      if (!grammar) {
226        throw new Error(`docs currently do not support language: ${lang}`);
227      }
228
229      html = Prism.highlight(html, grammar, lang as Language);
230      if (['properties', 'ruby'].includes(lang)) {
231        html = this.replaceHashCommentsWithAnnotations(html);
232      } else if (['xml', 'html'].includes(lang)) {
233        html = this.replaceXmlCommentsWithAnnotations(html);
234      } else {
235        html = this.replaceSlashCommentsWithAnnotations(html);
236      }
237    }
238
239    return value?.title ? (
240      <Snippet>
241        <SnippetHeader title={value.title}>
242          <CopyAction text={this.cleanCopyValue(value.value)} />
243        </SnippetHeader>
244        <SnippetContent skipPadding>
245          <pre css={STYLES_CODE_CONTAINER} {...attributes}>
246            <code
247              css={STYLES_CODE_BLOCK}
248              dangerouslySetInnerHTML={{ __html: html.replace(/^@@@.+@@@/g, '') }}
249            />
250          </pre>
251        </SnippetContent>
252      </Snippet>
253    ) : (
254      <pre css={[STYLES_CODE_CONTAINER, STYLES_CODE_CONTAINER_BLOCK]} {...attributes}>
255        <code css={STYLES_CODE_BLOCK} dangerouslySetInnerHTML={{ __html: html }} />
256      </pre>
257    );
258  }
259}
260
261const remapLanguages: Record<string, string> = {
262  'objective-c': 'objc',
263  sh: 'bash',
264  rb: 'ruby',
265};
266
267type InlineCodeProps = React.PropsWithChildren<{ className?: string }>;
268
269export const InlineCode = ({ children, className }: InlineCodeProps) => (
270  <code css={STYLES_INLINE_CODE} className={className ? `inline ${className}` : 'inline'}>
271    {children}
272  </code>
273);
274
275const codeBlockContainerStyle = {
276  margin: 0,
277  padding: `3px 6px`,
278};
279
280const codeBlockInlineContainerStyle = {
281  display: 'inline-flex',
282};
283
284type CodeBlockProps = React.PropsWithChildren<{ inline?: boolean }>;
285
286export const CodeBlock = ({ children, inline = false }: CodeBlockProps) => {
287  const Element = inline ? 'span' : 'pre';
288  return (
289    <Element
290      css={[
291        STYLES_CODE_CONTAINER,
292        codeBlockContainerStyle,
293        inline && codeBlockInlineContainerStyle,
294      ]}
295      {...attributes}>
296      <code css={[STYLES_CODE_BLOCK, { fontSize: '80%' }]}>{children}</code>
297    </Element>
298  );
299};
300