xref: /expo/docs/components/base/code.tsx (revision 75f652f0)
1import { css } from '@emotion/react';
2import { theme } 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 * as Constants from '~/constants/theme';
10
11installLanguages(Prism);
12
13const attributes = {
14  'data-text': true,
15};
16
17const STYLES_CODE_BLOCK = css`
18  color: ${theme.text.default};
19  font-family: ${Constants.fontFamilies.mono};
20  font-size: 13px;
21  line-height: 20px;
22  white-space: inherit;
23  padding: 0px;
24  margin: 0px;
25
26  .code-annotation {
27    transition: 200ms ease all;
28    transition-property: text-shadow, opacity;
29    text-shadow: ${theme.highlight.emphasis} 0px 0px 10px, ${theme.highlight.emphasis} 0px 0px 10px,
30      ${theme.highlight.emphasis} 0px 0px 10px, ${theme.highlight.emphasis} 0px 0px 10px;
31  }
32
33  .code-annotation:hover {
34    cursor: pointer;
35    animation: none;
36    opacity: 0.8;
37  }
38
39  .code-hidden {
40    display: none;
41  }
42
43  .code-placeholder {
44    opacity: 0.5;
45  }
46`;
47
48const STYLES_INLINE_CODE = css`
49  color: ${theme.text.default};
50  font-family: ${Constants.fontFamilies.mono};
51  font-size: 0.825em;
52  white-space: pre-wrap;
53  display: inline;
54  padding: 2px 4px;
55  line-height: 170%;
56  max-width: 100%;
57
58  word-wrap: break-word;
59  background-color: ${theme.background.secondary};
60  border: 1px solid ${theme.border.default};
61  border-radius: 4px;
62  vertical-align: middle;
63  overflow-x: auto;
64
65  /* Disable Safari from adding border when used within a (perma)link */
66  a & {
67    border-color: ${theme.border.default};
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<Props> {
88  componentDidMount() {
89    this.runTippy();
90  }
91
92  componentDidUpdate() {
93    this.runTippy();
94  }
95
96  private runTippy() {
97    tippy('.code-annotation', {
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 `<span class="code-annotation" data-tippy-content="${this.escapeHtml(content)}">`;
118        }
119      )
120      .replace(
121        /<span class="token comment">&lt;!-- @hide (.*?)--><\/span>\s*/g,
122        (match, content) => {
123          return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
124            content
125          )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
126        }
127      )
128      .replace(/<span class="token comment">&lt;!-- @end --><\/span>(\n *)?/g, '</span></span>');
129  }
130
131  private replaceHashCommentsWithAnnotations(value: string) {
132    return value
133      .replace(/<span class="token comment"># @info (.*?)#<\/span>\s*/g, (match, content) => {
134        return `<span class="code-annotation" data-tippy-content="${this.escapeHtml(content)}">`;
135      })
136      .replace(/<span class="token comment"># @hide (.*?)#<\/span>\s*/g, (match, content) => {
137        return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
138          content
139        )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
140      })
141      .replace(/<span class="token comment"># @end #<\/span>(\n *)?/g, '</span></span>');
142  }
143
144  private replaceSlashCommentsWithAnnotations(value: string) {
145    return value
146      .replace(/<span class="token comment">\/\* @info (.*?)\*\/<\/span>\s*/g, (match, content) => {
147        return `<span class="code-annotation" data-tippy-content="${this.escapeHtml(content)}">`;
148      })
149      .replace(/<span class="token comment">\/\* @hide (.*?)\*\/<\/span>\s*/g, (match, content) => {
150        return `<span><span class="code-hidden">%%placeholder-start%%</span><span class="code-placeholder">${this.escapeHtml(
151          content
152        )}</span><span class="code-hidden">%%placeholder-end%%</span><span class="code-hidden">`;
153      })
154      .replace(/<span class="token comment">\/\* @end \*\/<\/span>(\n *)?/g, '</span></span>');
155  }
156
157  render() {
158    let html = this.props.children?.toString() || '';
159    // mdx will add the class `language-foo` to codeblocks with the tag `foo`
160    // if this class is present, we want to slice out `language-`
161    let lang = this.props.className && this.props.className.slice(9).toLowerCase();
162
163    // Allow for code blocks without a language.
164    if (lang) {
165      // sh isn't supported, use Use sh to match js, and ts
166      if (lang in remapLanguages) {
167        lang = remapLanguages[lang];
168      }
169
170      const grammar = Prism.languages[lang as keyof typeof Prism.languages];
171      if (!grammar) {
172        throw new Error(`docs currently do not support language: ${lang}`);
173      }
174
175      html = Prism.highlight(html, grammar, lang as Language);
176      if (['properties', 'ruby'].includes(lang)) {
177        html = this.replaceHashCommentsWithAnnotations(html);
178      } else if (['xml', 'html'].includes(lang)) {
179        html = this.replaceXmlCommentsWithAnnotations(html);
180      } else {
181        html = this.replaceSlashCommentsWithAnnotations(html);
182      }
183    }
184
185    // Remove leading newline if it exists (because inside <pre> all whitespace is dislayed as is by the browser, and
186    // sometimes, Prism adds a newline before the code)
187    if (html.startsWith('\n')) {
188      html = html.replace('\n', '');
189    }
190
191    return (
192      <pre css={STYLES_CODE_CONTAINER} {...attributes}>
193        <code css={STYLES_CODE_BLOCK} dangerouslySetInnerHTML={{ __html: html }} />
194      </pre>
195    );
196  }
197}
198
199const remapLanguages: Record<string, string> = {
200  'objective-c': 'objc',
201  sh: 'bash',
202  rb: 'ruby',
203};
204
205export const InlineCode: React.FC = ({ children }) => (
206  <code css={STYLES_INLINE_CODE} className="inline">
207    {children}
208  </code>
209);
210