xref: /expo/docs/components/base/code.tsx (revision d9bd5b6c)
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
9// @ts-ignore Jest ESM issue https://github.com/facebook/jest/issues/9430
10const { default: testTippy } = tippy;
11
12installLanguages(Prism);
13
14const attributes = {
15  'data-text': true,
16};
17
18const STYLES_CODE_BLOCK = css`
19  ${typography.body.code};
20  color: ${theme.text.default};
21  white-space: inherit;
22  padding: 0;
23  margin: 0;
24
25  .code-annotation {
26    transition: 200ms ease all;
27    transition-property: text-shadow, opacity;
28    text-shadow: ${theme.highlight.emphasis} 0 0 10px, ${theme.highlight.emphasis} 0 0 10px,
29      ${theme.highlight.emphasis} 0 0 10px, ${theme.highlight.emphasis} 0 0 10px;
30  }
31
32  .code-annotation.with-tooltip:hover {
33    cursor: pointer;
34    animation: none;
35    opacity: 0.8;
36  }
37
38  .code-hidden {
39    display: none;
40  }
41
42  .code-placeholder {
43    opacity: 0.5;
44  }
45`;
46
47const STYLES_INLINE_CODE = css`
48  ${typography.body.code};
49  color: ${theme.text.default};
50  white-space: pre-wrap;
51  display: inline;
52  padding: ${spacing[0.5]}px ${spacing[1]}px;
53  max-width: 100%;
54  word-wrap: break-word;
55  background-color: ${theme.background.secondary};
56  border: 1px solid ${theme.border.default};
57  border-radius: ${borderRadius.small}px;
58  vertical-align: middle;
59  overflow-x: auto;
60
61  /* Disable Safari from adding border when used within a (perma)link */
62  a & {
63    border-color: ${theme.border.default};
64  }
65
66  h2 &,
67  h3 &,
68  h4 & {
69    position: relative;
70    top: -2px;
71  }
72`;
73
74const STYLES_CODE_CONTAINER = css`
75  border: 1px solid ${theme.border.default};
76  padding: 16px;
77  margin: 16px 0;
78  white-space: pre;
79  overflow: auto;
80  -webkit-overflow-scrolling: touch;
81  background-color: ${theme.background.secondary};
82  line-height: 120%;
83  border-radius: 4px;
84
85  table &:last-child {
86    margin-bottom: 0;
87  }
88`;
89
90type Props = {
91  className?: string;
92};
93
94export class Code extends React.Component<React.PropsWithChildren<Props>> {
95  componentDidMount() {
96    this.runTippy();
97  }
98
99  componentDidUpdate() {
100    this.runTippy();
101  }
102
103  private runTippy() {
104    const tippyFunc = testTippy || tippy;
105    tippyFunc('.code-annotation.with-tooltip', {
106      allowHTML: true,
107      theme: 'expo',
108      placement: 'top',
109      arrow: roundArrow,
110      interactive: true,
111      offset: [0, 20],
112      appendTo: document.body,
113    });
114  }
115
116  private escapeHtml(text: string) {
117    return text.replace(/"/g, '&quot;');
118  }
119
120  private replaceXmlCommentsWithAnnotations(value: string) {
121    return value
122      .replace(
123        /<span class="token comment">&lt;!-- @info (.*?)--><\/span>\s*/g,
124        (match, content) => {
125          return content
126            ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml(
127                content
128              )}">`
129            : '<span class="code-annotation">';
130        }
131      )
132      .replace(
133        /<span class="token comment">&lt;!-- @hide (.*?)--><\/span>\s*/g,
134        (match, content) => {
135          return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
136            content
137          )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
138        }
139      )
140      .replace(/\s*<span class="token comment">&lt;!-- @end --><\/span>/g, '</span>');
141  }
142
143  private replaceHashCommentsWithAnnotations(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 replaceSlashCommentsWithAnnotations(value: string) {
161    return value
162      .replace(/<span class="token comment">\/\* @info (.*?)\*\/<\/span>\s*/g, (match, content) => {
163        return content
164          ? `<span class="code-annotation with-tooltip" data-tippy-content="${this.escapeHtml(
165              content
166            )}">`
167          : '<span class="code-annotation">';
168      })
169      .replace(/<span class="token comment">\/\* @hide (.*?)\*\/<\/span>\s*/g, (match, content) => {
170        return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
171          content
172        )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
173      })
174      .replace(/\s*<span class="token comment">\/\* @end \*\/<\/span>/g, '</span>');
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 = this.props.className
181      ? this
182      : (React.Children.toArray(this.props.children)[0] as JSX.Element);
183    let html = child?.props?.children?.toString() || '';
184
185    // mdx will add the class `language-foo` to codeblocks with the tag `foo`
186    // if this class is present, we want to slice out `language-`
187    let lang = child.props.className && child.props.className.slice(9).toLowerCase();
188
189    // Allow for code blocks without a language.
190    if (lang) {
191      // sh isn't supported, use sh to match js, and ts
192      if (lang in remapLanguages) {
193        lang = remapLanguages[lang];
194      }
195
196      const grammar = Prism.languages[lang as keyof typeof Prism.languages];
197      if (!grammar) {
198        throw new Error(`docs currently do not support language: ${lang}`);
199      }
200
201      html = Prism.highlight(html, grammar, lang as Language);
202      if (['properties', 'ruby'].includes(lang)) {
203        html = this.replaceHashCommentsWithAnnotations(html);
204      } else if (['xml', 'html'].includes(lang)) {
205        html = this.replaceXmlCommentsWithAnnotations(html);
206      } else {
207        html = this.replaceSlashCommentsWithAnnotations(html);
208      }
209    }
210
211    return (
212      <pre css={STYLES_CODE_CONTAINER} {...attributes}>
213        <code css={STYLES_CODE_BLOCK} dangerouslySetInnerHTML={{ __html: html }} />
214      </pre>
215    );
216  }
217}
218
219const remapLanguages: Record<string, string> = {
220  'objective-c': 'objc',
221  sh: 'bash',
222  rb: 'ruby',
223};
224
225type InlineCodeProps = React.PropsWithChildren<{ className?: string }>;
226
227export const InlineCode = ({ children, className }: InlineCodeProps) => (
228  <code css={STYLES_INLINE_CODE} className={className ? `inline ${className}` : 'inline'}>
229    {children}
230  </code>
231);
232
233const codeBlockContainerStyle = {
234  margin: 0,
235  padding: `3px 6px`,
236};
237
238const codeBlockInlineContainerStyle = {
239  display: 'inline-flex',
240};
241
242type CodeBlockProps = React.PropsWithChildren<{ inline?: boolean }>;
243
244export const CodeBlock = ({ children, inline = false }: CodeBlockProps) => {
245  const Element = inline ? 'span' : 'pre';
246  return (
247    <Element
248      css={[
249        STYLES_CODE_CONTAINER,
250        codeBlockContainerStyle,
251        inline && codeBlockInlineContainerStyle,
252      ]}
253      {...attributes}>
254      <code css={[STYLES_CODE_BLOCK, { fontSize: '80%' }]}>{children}</code>
255    </Element>
256  );
257};
258