xref: /expo/docs/components/base/code.tsx (revision fb146470)
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
9installLanguages(Prism);
10
11const attributes = {
12  'data-text': true,
13};
14
15const STYLES_CODE_BLOCK = css`
16  ${typography.body.code};
17  color: ${theme.text.default};
18  white-space: inherit;
19  padding: 0;
20  margin: 0;
21
22  .code-annotation {
23    transition: 200ms ease all;
24    transition-property: text-shadow, opacity;
25    text-shadow: ${theme.highlight.emphasis} 0 0 10px, ${theme.highlight.emphasis} 0 0 10px,
26      ${theme.highlight.emphasis} 0 0 10px, ${theme.highlight.emphasis} 0 0 10px;
27  }
28
29  .code-annotation.with-tooltip:hover {
30    cursor: pointer;
31    animation: none;
32    opacity: 0.8;
33  }
34
35  .code-hidden {
36    display: none;
37  }
38
39  .code-placeholder {
40    opacity: 0.5;
41  }
42`;
43
44const STYLES_INLINE_CODE = css`
45  ${typography.body.code};
46  color: ${theme.text.default};
47  white-space: pre-wrap;
48  display: inline;
49  padding: ${spacing[0.5]}px ${spacing[1]}px;
50  max-width: 100%;
51  word-wrap: break-word;
52  background-color: ${theme.background.secondary};
53  border: 1px solid ${theme.border.default};
54  border-radius: ${borderRadius.small}px;
55  vertical-align: middle;
56  overflow-x: auto;
57
58  /* Disable Safari from adding border when used within a (perma)link */
59  a & {
60    border-color: ${theme.border.default};
61  }
62
63  h2 &,
64  h3 &,
65  h4 & {
66    position: relative;
67    top: -2px;
68  }
69`;
70
71const STYLES_CODE_CONTAINER = css`
72  border: 1px solid ${theme.border.default};
73  padding: 16px;
74  margin: 16px 0;
75  white-space: pre;
76  overflow: auto;
77  -webkit-overflow-scrolling: touch;
78  background-color: ${theme.background.secondary};
79  line-height: 120%;
80  border-radius: 4px;
81`;
82
83type Props = {
84  className?: string;
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    tippy('.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(/<span class="token comment">&lt;!-- @end --><\/span>(\n *)?/g, '</span></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(/<span class="token comment"># @end #<\/span>(\n *)?/g, '</span></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(/<span class="token comment">\/\* @end \*\/<\/span>(\n *)?/g, '</span></span>');
167  }
168
169  render() {
170    let html = this.props.children?.toString() || '';
171    // mdx will add the class `language-foo` to codeblocks with the tag `foo`
172    // if this class is present, we want to slice out `language-`
173    let lang = this.props.className && this.props.className.slice(9).toLowerCase();
174
175    // Allow for code blocks without a language.
176    if (lang) {
177      // sh isn't supported, use sh to match js, and ts
178      if (lang in remapLanguages) {
179        lang = remapLanguages[lang];
180      }
181
182      const grammar = Prism.languages[lang as keyof typeof Prism.languages];
183      if (!grammar) {
184        throw new Error(`docs currently do not support language: ${lang}`);
185      }
186
187      html = Prism.highlight(html, grammar, lang as Language);
188      if (['properties', 'ruby'].includes(lang)) {
189        html = this.replaceHashCommentsWithAnnotations(html);
190      } else if (['xml', 'html'].includes(lang)) {
191        html = this.replaceXmlCommentsWithAnnotations(html);
192      } else {
193        html = this.replaceSlashCommentsWithAnnotations(html);
194      }
195    }
196
197    // Remove leading newline if it exists (because inside <pre> all whitespace is displayed as is by the browser, and
198    // sometimes, Prism adds a newline before the code)
199    if (html.startsWith('\n')) {
200      html = html.replace('\n', '');
201    }
202
203    return (
204      <pre css={STYLES_CODE_CONTAINER} {...attributes}>
205        <code css={STYLES_CODE_BLOCK} dangerouslySetInnerHTML={{ __html: html }} />
206      </pre>
207    );
208  }
209}
210
211const remapLanguages: Record<string, string> = {
212  'objective-c': 'objc',
213  sh: 'bash',
214  rb: 'ruby',
215};
216
217type InlineCodeProps = React.PropsWithChildren<{ className?: string }>;
218
219export const InlineCode = ({ children, className }: InlineCodeProps) => (
220  <code css={STYLES_INLINE_CODE} className={className ? `inline ${className}` : 'inline'}>
221    {children}
222  </code>
223);
224
225const codeBlockContainerStyle = {
226  margin: 0,
227  padding: `3px 6px`,
228};
229
230const codeBlockInlineContainerStyle = {
231  display: 'inline-flex',
232};
233
234type CodeBlockProps = React.PropsWithChildren<{ inline?: boolean }>;
235
236export const CodeBlock = ({ children, inline = false }: CodeBlockProps) => {
237  const Element = inline ? 'span' : 'pre';
238  return (
239    <Element
240      css={[
241        STYLES_CODE_CONTAINER,
242        codeBlockContainerStyle,
243        inline && codeBlockInlineContainerStyle,
244      ]}
245      {...attributes}>
246      <code css={[STYLES_CODE_BLOCK, { fontSize: '80%' }]}>{children}</code>
247    </Element>
248  );
249};
250