82 | {MONTHS[parsedDate.getMonth()]} {parsedDate.getDate()}{' '} 83 | {parsedDate.getFullYear()} 84 |
85 | ) : null} 86 | {timeToRead ?/ {timeToRead} min read /
: null} 87 |{subtitle}
124 | )} 125 |Test); 9 | expect(component.baseElement).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/components/MDX/Blockquote/__tests__/__snapshots__/Blockquote.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Blockquote can render a Blockquote 1`] = ` 4 | .emotion-0 { 5 | -webkit-transition: 0.5s; 6 | transition: 0.5s; 7 | margin: 30px 0px; 8 | color: var(--maximeheckel-colors-typeface-0); 9 | font-style: italic; 10 | position: relative; 11 | width: 100vw; 12 | left: calc(-50vw + 50%); 13 | padding-top: 40px; 14 | padding-bottom: 40px; 15 | background: var(--maximeheckel-colors-emphasis); 16 | -webkit-backdrop-filter: blur(6px); 17 | backdrop-filter: blur(6px); 18 | } 19 | 20 | .emotion-0 > p { 21 | max-width: 880px !important; 22 | padding-left: 50px; 23 | padding-bottom: 0; 24 | width: 100%; 25 | margin: 0 auto; 26 | font-size: 27px; 27 | line-height: 1.6818; 28 | font-weight: 400; 29 | } 30 | 31 | 32 |
36 | Test 37 |38 |
', () => {
35 | const preProps = {
36 | children: {
37 | props: {
38 | children: 'some code to render',
39 | mdxType: 'code',
40 | metastring: 'javascript',
41 | },
42 | },
43 | };
44 | expect(preToCodeBlock(preProps)).toMatchSnapshot();
45 |
46 | const prePropsWithNoCode = {
47 | children: {
48 | props: {
49 | children: 'some inline code to render',
50 | metastring: 'javascript',
51 | },
52 | },
53 | };
54 |
55 | expect(preToCodeBlock(prePropsWithNoCode)).toBeUndefined();
56 | });
57 |
58 | it('Renders a Codeblock component when the proper preProps are passed', () => {
59 | const { container, getByTestId } = render(
60 |
61 |
62 | var hello="world"
63 |
64 |
65 | );
66 |
67 | expect(container.querySelector('pre[class="prism-code"]')).toBeDefined();
68 | expect(getByTestId('number-line')).toBeDefined();
69 |
70 | expect(getByTestId('number-line')).toHaveTextContent(1);
71 | });
72 |
73 | it('Renders a Codeblock with title when the proper preProps are passed', () => {
74 | const { container, getByTestId } = render(
75 |
76 |
77 | some code to render
78 |
79 |
80 | );
81 |
82 | expect(
83 | container.querySelector('p[data-testid="codesnippet-title"]')
84 | ).toHaveTextContent('test');
85 | expect(container.querySelector('pre[class="prism-code"]')).toBeDefined();
86 | expect(getByTestId('number-line')).toBeDefined();
87 | expect(getByTestId('number-line')).toHaveTextContent(1);
88 | expect(container.querySelector('button')).toBeInTheDocument();
89 | });
90 |
91 | it('Renders a Codeblock with title and line highlight when the proper preProps are passed', () => {
92 | const { container, getAllByTestId, debug } = render(
93 |
94 |
95 | {`some code to render
96 | some code to render 2
97 | some code to render 3
98 | some code to render 4`}
99 |
100 |
101 | );
102 |
103 | expect(
104 | container.querySelector('p[data-testid="codesnippet-title"]')
105 | ).toHaveTextContent('test');
106 | expect(getAllByTestId('number-line')).toHaveLength(4);
107 | expect(getAllByTestId('highlight-line')).toHaveLength(3);
108 | expect(container.querySelector('button')).toBeInTheDocument();
109 | });
110 |
111 | it('Renders a when there are no proper preProps passed', () => {
112 | const { container } = render(
113 |
114 | var hello="world"
115 |
116 | );
117 |
118 | expect(container.querySelector('pre')).toBeDefined();
119 | expect(container.querySelector('pre[class="prism-code"]')).toBeNull();
120 | expect(container.querySelector('pre').firstElementChild).toHaveTextContent(
121 | 'var hello="world"'
122 | );
123 | });
124 | });
125 |
--------------------------------------------------------------------------------
/src/components/MDX/Code/__tests__/__snapshots__/Code.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Code preToCodeBlock returns the proper set of props for our code block components if the children are of type 1`] = `
4 | Object {
5 | "className": "",
6 | "codeString": "some code to render",
7 | "language": "",
8 | "mdxType": "code",
9 | "metastring": "javascript",
10 | }
11 | `;
12 |
--------------------------------------------------------------------------------
/src/components/MDX/Code/index.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/core';
2 | import styled from '@emotion/styled';
3 | import {
4 | motion,
5 | useAnimation,
6 | useMotionValue,
7 | useTransform,
8 | AnimatePresence,
9 | AnimateSharedLayout,
10 | } from 'framer-motion';
11 | import Highlight, {
12 | Prism,
13 | defaultProps,
14 | Language,
15 | PrismTheme,
16 | } from 'prism-react-renderer';
17 | import React from 'react';
18 | import { LiveProvider, LiveEditor, LiveError, LivePreview } from 'react-live';
19 | import * as Recharts from 'recharts';
20 | import Button, { CopyToClipboardButton } from '../../Button';
21 | import { LinkButton } from '../../Button/LinkButton';
22 | import { useTheme } from '../../../context/ThemeContext';
23 | import Pill from '../Pill';
24 |
25 | // @ts-ignore
26 | (typeof global !== 'undefined' ? global : window).Prism = Prism;
27 |
28 | /**
29 | * This imports the syntax highlighting style for the Swift language
30 | */
31 | require('prismjs/components/prism-swift');
32 |
33 | type PrePropsType = {
34 | props: {
35 | live?: boolean;
36 | render?: boolean;
37 | };
38 | children: {
39 | props: {
40 | metastring: string;
41 | mdxType?: string;
42 | className?: string;
43 | children: string;
44 | };
45 | };
46 | };
47 |
48 | export const preToCodeBlock = (
49 | preProps: PrePropsType
50 | ):
51 | | {
52 | live?: boolean;
53 | render?: boolean;
54 | className: string;
55 | codeString: string;
56 | language: Language;
57 | metastring: string;
58 | }
59 | | undefined => {
60 | if (
61 | preProps.children &&
62 | preProps.children.props &&
63 | preProps.children.props.mdxType === 'code'
64 | ) {
65 | const {
66 | children: codeString,
67 | className = '',
68 | ...props
69 | } = preProps.children.props;
70 |
71 | const matches = className.match(/language-(?.*)/);
72 | return {
73 | className,
74 | codeString: codeString.trim(),
75 | language:
76 | matches && matches.groups && matches.groups.lang
77 | ? (matches.groups.lang as Language)
78 | : ('' as Language),
79 | ...props,
80 | };
81 | }
82 | };
83 |
84 | const RE = /{([\d,-]+)}/;
85 |
86 | export const calculateLinesToHighlight = (metastring: string | null) => {
87 | if (!metastring || !RE.test(metastring)) {
88 | return () => false;
89 | } else {
90 | const lineNumbers = RE.exec(metastring)![1]
91 | .split(',')
92 | .map((v) => v.split('-').map((val) => parseInt(val, 10)));
93 | return (index: number) => {
94 | const lineNumber = index + 1;
95 | const inRange = lineNumbers.some(([start, end]) =>
96 | end ? lineNumber >= start && lineNumber <= end : lineNumber === start
97 | );
98 | return inRange;
99 | };
100 | }
101 | };
102 |
103 | const RETitle = /title=[A-Za-z](.+)/;
104 |
105 | export const hasTitle = (metastring: string | null) => {
106 | if (!metastring || !RETitle.test(metastring)) {
107 | return '';
108 | } else {
109 | return RETitle.exec(metastring)![0].split('title=')[1];
110 | }
111 | };
112 |
113 | interface IInlineCodeProps {
114 | children: React.ReactNode;
115 | }
116 |
117 | export const InlineCode: React.FC = (props) => {
118 | return {props.children} ;
119 | };
120 |
121 | export const LiveCodeBlock: React.FC = (props) => {
122 | const { codeString, live, render } = props;
123 |
124 | const { dark } = useTheme();
125 |
126 | const scope = {
127 | motion,
128 | useAnimation,
129 | useMotionValue,
130 | useTransform,
131 | AnimatePresence,
132 | AnimateSharedLayout,
133 | styled,
134 | Button,
135 | LinkButton,
136 | React,
137 | Pill: Pill,
138 | Recharts: { ...Recharts },
139 | };
140 |
141 | const baseTheme = dark ? prismDark : prismLight;
142 |
143 | const customTheme = {
144 | ...baseTheme,
145 | plain: {
146 | ...baseTheme.plain,
147 | fontFamily: 'Fira Code',
148 | fontSize: '14px',
149 | lineHeight: '26px',
150 | overflowWrap: 'normal',
151 | position: 'relative',
152 | overflow: 'auto',
153 | },
154 | } as PrismTheme;
155 |
156 | if (live) {
157 | return (
158 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 | );
177 | }
178 |
179 | if (render) {
180 | return (
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 | );
189 | }
190 |
191 | return null;
192 | };
193 |
194 | interface ICodeBlockProps {
195 | codeString: string;
196 | language: Language;
197 | metastring: string | null;
198 | live?: boolean;
199 | render?: boolean;
200 | }
201 |
202 | export const CodeBlock: React.FC = (props) => {
203 | const { codeString, language, metastring } = props;
204 |
205 | const { dark } = useTheme();
206 |
207 | const baseTheme = dark ? prismDark : prismLight;
208 |
209 | const customTheme = {
210 | ...baseTheme,
211 | plain: {
212 | ...baseTheme.plain,
213 | fontFamily: 'Fira Code',
214 | fontSize: '14px',
215 | },
216 | } as PrismTheme;
217 |
218 | const shouldHighlightLine = calculateLinesToHighlight(metastring);
219 | const title = hasTitle(metastring);
220 | return (
221 |
222 | {title ? (
223 |
233 |
234 | {title}
235 |
236 |
237 |
238 | ) : null}
239 |
245 | {({ className, style, tokens, getLineProps, getTokenProps }) => (
246 | <>
247 |
248 | {tokens.map((line, index) => {
249 | const { className: lineClassName } = getLineProps({
250 | className: shouldHighlightLine(index) ? 'highlight-line' : '',
251 | key: index,
252 | line,
253 | });
254 |
255 | return (
256 |
263 | {index + 1}
264 |
265 | {line.map((token, key) => {
266 | return (
267 |
275 | );
276 | })}
277 |
278 |
279 | );
280 | })}
281 |
282 | >
283 | )}
284 |
285 |
286 | );
287 | };
288 |
289 | export const Code: React.FC = (preProps) => {
290 | const props = preToCodeBlock(preProps);
291 |
292 | if (props) {
293 | return props.live || props.render ? (
294 |
295 | ) : (
296 |
297 | );
298 | } else {
299 | return ;
300 | }
301 | };
302 |
303 | const Pre = styled.pre<{ title?: string }>`
304 | text-align: left;
305 | padding: 8px 0px;
306 | overflow: auto;
307 | border-bottom-left-radius: var(--border-radius-2);
308 | border-bottom-right-radius: var(--border-radius-2);
309 |
310 | ${(p) =>
311 | p.title
312 | ? ''
313 | : `
314 | border-top-left-radius: var(--border-radius-2);
315 | border-top-right-radius: var(--border-radius-2);
316 | `}
317 | `;
318 |
319 | const Line = styled.div`
320 | display: table;
321 | border-collapse: collapse;
322 | padding: 0px 14px;
323 | border-left: 3px solid transparent;
324 | &.highlight-line {
325 | background: var(--maximeheckel-colors-emphasis);
326 | border-color: var(--maximeheckel-colors-brand);
327 | }
328 |
329 | &:hover {
330 | background-color: var(--maximeheckel-colors-emphasis);
331 | }
332 | `;
333 |
334 | const LineNo = styled.div`
335 | width: 45px;
336 | padding: 0 12px;
337 | user-select: none;
338 | opacity: 0.5;
339 | `;
340 |
341 | const LineContent = styled.span`
342 | display: table-cell;
343 | width: 100%;
344 | `;
345 |
346 | const InlineCodeWrapper = styled('code')`
347 | border-radius: var(--border-radius-1);
348 | background-color: var(--maximeheckel-colors-emphasis);
349 | color: var(--maximeheckel-colors-brand);
350 | padding-top: 2px;
351 | padding-bottom: 2px;
352 | padding-left: 6px;
353 | padding-right: 6px;
354 | font-size: 16px;
355 | font-weight: 400 !important;
356 | `;
357 |
358 | const CodeSnippetTitle = styled('p')`
359 | font-size: 14px;
360 | margin-bottom: 0px;
361 | color: var(--maximeheckel-colors-typeface-2);
362 | font-weight: 500;
363 | `;
364 |
365 | const CodeSnippetHeader = styled('div')`
366 | @media (max-width: 500px) {
367 | border-radius: 0px;
368 | padding: 0px 8px;
369 | }
370 |
371 | display: flex;
372 | justify-content: space-between;
373 | align-items: center;
374 | border-top-left-radius: var(--border-radius-2);
375 | border-top-right-radius: var(--border-radius-2);
376 | min-height: 45px;
377 | padding: 0px 14px;
378 | `;
379 |
380 | const fullWidthSnipperStyle = () => css`
381 | position: relative;
382 | width: 100vw;
383 | left: calc(-50vw + 50%);
384 | border-radius: 0px;
385 | max-width: 1100px;
386 | `;
387 |
388 | const CodeSnippetWrapper = styled('div')`
389 | @media (max-width: 600px) {
390 | ${fullWidthSnipperStyle}
391 | }
392 | width: 100%;
393 | border-radius: var(--border-radius-2);
394 | margin: 40px 0px;
395 | position: relative;
396 | `;
397 |
398 | interface CodeSnippetWrapperProps {
399 | fullWidth?: boolean;
400 | }
401 |
402 | const StyledLiveCodeWrapper = styled('div')`
403 | @media (max-width: 750px) {
404 | display: block;
405 | }
406 |
407 | @media (max-width: 1100px) {
408 | ${(p) => (p.fullWidth ? fullWidthSnipperStyle : '')}
409 | }
410 |
411 | @media (min-width: 1101px) {
412 | ${(p) =>
413 | p.fullWidth
414 | ? `
415 | width: 1100px;
416 | transform: translateX(-200px);
417 | `
418 | : ''}
419 | }
420 |
421 | backdrop-filter: blur(6px);
422 | border-radius: var(--border-radius-2);
423 | display: flex;
424 | align-items: center;
425 | margin: 40px 0px;
426 | `;
427 |
428 | const StyledEditorWrapper = styled('div')`
429 | flex: 60 1 0%;
430 | height: 100%;
431 | max-height: 600px;
432 | overflow: auto;
433 | margin: 0;
434 | border-top-right-radius: var(--border-radius-2);
435 | border-bottom-right-radius: var(--border-radius-2);
436 |
437 | * > textarea:focus {
438 | outline: none;
439 | }
440 | `;
441 |
442 | const StyledPreviewWrapper = styled('div')<{
443 | height?: number;
444 | withEditor?: boolean;
445 | }>`
446 | max-height: 600px;
447 | min-height: ${(p) => p.height || 300}px;
448 | flex: 40 1 0%;
449 | display: flex;
450 | align-items: center;
451 | justify-content: center;
452 | background-color: var(--maximeheckel-colors-emphasis);
453 | ${(p) =>
454 | p.withEditor
455 | ? `
456 | border-top-left-radius: var(--border-radius-2);
457 | border-bottom-left-radius: var(--border-radius-2);
458 | `
459 | : `
460 | border-radius: var(--border-radius-2);
461 | `}
462 | `;
463 |
464 | const StyledErrorWrapper = styled('div')`
465 | color: var(--maximeheckel-colors-typeface-1);
466 |
467 | pre {
468 | padding: 15px;
469 | margin-bottom: 0px;
470 | }
471 | `;
472 |
473 | const prismLight = {
474 | plain: {
475 | color: '#403f53',
476 | backgroundColor: 'var(--maximeheckel-colors-foreground)',
477 | },
478 | styles: [
479 | {
480 | types: ['changed'],
481 | style: {
482 | color: 'rgb(162, 191, 252)',
483 | fontStyle: 'italic',
484 | },
485 | },
486 | {
487 | types: ['deleted'],
488 | style: {
489 | color: 'rgba(239, 83, 80, 0.56)',
490 | fontStyle: 'italic',
491 | },
492 | },
493 | {
494 | types: ['inserted', 'attr-name'],
495 | style: {
496 | color: 'rgb(72, 118, 214)',
497 | fontStyle: 'italic',
498 | },
499 | },
500 | {
501 | types: ['comment'],
502 | style: {
503 | color: 'rgb(152, 159, 177)',
504 | fontStyle: 'italic',
505 | },
506 | },
507 | {
508 | types: ['string', 'builtin', 'char', 'constant', 'url'],
509 | style: {
510 | color: 'rgb(72, 118, 214)',
511 | },
512 | },
513 | {
514 | types: ['variable'],
515 | style: {
516 | color: 'rgb(201, 103, 101)',
517 | },
518 | },
519 | {
520 | types: ['number'],
521 | style: {
522 | color: 'rgb(170, 9, 130)',
523 | },
524 | },
525 | {
526 | // This was manually added after the auto-generation
527 | // so that punctuations are not italicised
528 | types: ['punctuation'],
529 | style: {
530 | color: 'rgb(153, 76, 195)',
531 | },
532 | },
533 | {
534 | types: ['function', 'selector', 'doctype'],
535 | style: {
536 | color: 'rgb(153, 76, 195)',
537 | fontStyle: 'italic',
538 | },
539 | },
540 | {
541 | types: ['class-name'],
542 | style: {
543 | color: 'rgb(17, 17, 17)',
544 | },
545 | },
546 | {
547 | types: ['tag'],
548 | style: {
549 | color: 'rgb(153, 76, 195)',
550 | },
551 | },
552 | {
553 | types: ['operator', 'property', 'keyword', 'namespace'],
554 | style: {
555 | color: 'rgb(12, 150, 155)',
556 | },
557 | },
558 | {
559 | types: ['boolean'],
560 | style: {
561 | color: 'rgb(188, 84, 84)',
562 | },
563 | },
564 | ],
565 | };
566 |
567 | const prismDark = {
568 | plain: {
569 | color: '#d6deeb',
570 | backgroundColor: 'var(--maximeheckel-colors-foreground)',
571 | },
572 | styles: [
573 | {
574 | types: ['changed'],
575 | style: {
576 | color: 'rgb(162, 191, 252)',
577 | fontStyle: 'italic',
578 | },
579 | },
580 | {
581 | types: ['deleted'],
582 | style: {
583 | color: 'rgba(239, 83, 80, 0.56)',
584 | fontStyle: 'italic',
585 | },
586 | },
587 | {
588 | types: ['inserted', 'attr-name'],
589 | style: {
590 | color: 'rgb(173, 219, 103)',
591 | fontStyle: 'italic',
592 | },
593 | },
594 | {
595 | types: ['comment'],
596 | style: {
597 | color: 'rgb(99, 119, 119)',
598 | fontStyle: 'italic',
599 | },
600 | },
601 | {
602 | types: ['string', 'url'],
603 | style: {
604 | color: 'rgb(173, 219, 103)',
605 | },
606 | },
607 | {
608 | types: ['variable'],
609 | style: {
610 | color: 'rgb(214, 222, 235)',
611 | },
612 | },
613 | {
614 | types: ['number'],
615 | style: {
616 | color: 'rgb(247, 140, 108)',
617 | },
618 | },
619 | {
620 | types: ['builtin', 'char', 'constant', 'function'],
621 | style: {
622 | color: 'rgb(130, 170, 255)',
623 | },
624 | },
625 | {
626 | // This was manually added after the auto-generation
627 | // so that punctuations are not italicised
628 | types: ['punctuation'],
629 | style: {
630 | color: 'rgb(199, 146, 234)',
631 | },
632 | },
633 | {
634 | types: ['selector', 'doctype'],
635 | style: {
636 | color: 'rgb(199, 146, 234)',
637 | fontStyle: 'italic',
638 | },
639 | },
640 | {
641 | types: ['class-name'],
642 | style: {
643 | color: 'rgb(255, 203, 139)',
644 | },
645 | },
646 | {
647 | types: ['tag', 'operator', 'keyword'],
648 | style: {
649 | color: 'rgb(127, 219, 202)',
650 | },
651 | },
652 | {
653 | types: ['boolean'],
654 | style: {
655 | color: 'rgb(255, 88, 116)',
656 | },
657 | },
658 | {
659 | types: ['property'],
660 | style: {
661 | color: 'rgb(128, 203, 196)',
662 | },
663 | },
664 | {
665 | types: ['namespace'],
666 | style: {
667 | color: 'rgb(178, 204, 214)',
668 | },
669 | },
670 | ],
671 | };
672 |
--------------------------------------------------------------------------------
/src/components/MDX/MDX.tsx:
--------------------------------------------------------------------------------
1 | import { MDXProvider } from '@mdx-js/react';
2 | import React from 'react';
3 | import styled from '@emotion/styled';
4 | import Callout from '../Callout';
5 | import Pill from '../Pill';
6 | import { Blockquote } from './Blockquote';
7 | import Button from '../Button';
8 | import { Code, InlineCode } from './Code';
9 |
10 | const ArrowSVG = () => (
11 |
31 | );
32 |
33 | const ListItem = (props: HTMLLIElement) => {
34 | return (
35 |
36 |
37 |
38 |
39 | {props.children}
40 |
41 | );
42 | };
43 |
44 | const components = {
45 | Button,
46 | blockquote: Blockquote,
47 | Callout,
48 | inlineCode: InlineCode,
49 | Pill,
50 | pre: Code,
51 | li: ListItem,
52 | };
53 |
54 | interface IMDXProps {
55 | children: React.ReactNode;
56 | maxWidth?: number;
57 | }
58 |
59 | const MDX = React.forwardRef(
60 | ({ children, ...props }: IMDXProps, ref: React.Ref) => {
61 | return (
62 |
63 |
64 | {children}
65 |
66 |
67 | );
68 | }
69 | );
70 |
71 | MDX.displayName = 'MDX';
72 |
73 | export { MDX };
74 |
75 | export const toKebabCase = (str: string): string | null => {
76 | const match = str.match(
77 | /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g
78 | );
79 |
80 | return match && match.map((x) => x.toLowerCase()).join('-');
81 | };
82 |
83 | type MDXBody = {
84 | children: React.ReactNode;
85 | ref: React.Ref;
86 | maxWidth?: number;
87 | };
88 |
89 | const MDXBody = styled.div`
90 | margin: 0 auto;
91 | max-width: ${(p) => `${p.maxWidth || 700}px`};
92 | padding: 20px 0px 20px 0px;
93 | color: var(--maximeheckel-colors-typeface-1);
94 |
95 | figcaption {
96 | font-size: 14px;
97 | text-align: left;
98 | line-height: 1.5;
99 | font-weight: 500;
100 | color: var(--maximeheckel-colors-typeface-2);
101 | padding-top: 10px;
102 | }
103 |
104 | h1 {
105 | color: var(--maximeheckel-colors-typeface-0);
106 | }
107 |
108 | h2 {
109 | color: var(--maximeheckel-colors-typeface-0);
110 | margin-top: 2em;
111 | }
112 |
113 | h3 {
114 | color: var(--maximeheckel-colors-typeface-0);
115 | margin-top: 2em;
116 | }
117 |
118 | strong {
119 | color: var(--maximeheckel-colors-typeface-0);
120 | }
121 |
122 | hr {
123 | height: 2px;
124 | width: 40%;
125 | margin: 50px auto;
126 | background-color: var(--maximeheckel-colors-typeface-0);
127 | }
128 |
129 | ul {
130 | margin-left: 0px;
131 | li {
132 | list-style: none;
133 | display: flex;
134 | span {
135 | display: inline-block;
136 | padding-right: 16px;
137 | padding-top: 2px;
138 | transform: translateY(-2px);
139 | svg {
140 | stroke: var(--maximeheckel-colors-brand);
141 | }
142 | }
143 | }
144 | }
145 |
146 | ol {
147 | margin-left: 0px;
148 | list-style: none;
149 | li {
150 | counter-increment: li;
151 | display: flex;
152 |
153 | &:before {
154 | content: counters(li, '.') '. ';
155 | color: var(--maximeheckel-colors-brand);
156 | padding-right: 12px;
157 | }
158 | }
159 | }
160 |
161 | a {
162 | color: var(--maximeheckel-colors-brand);
163 | }
164 |
165 | twitter-widget {
166 | margin: 0 auto;
167 | }
168 | `;
169 |
--------------------------------------------------------------------------------
/src/components/MDX/Pill/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from '@emotion/styled';
3 | import { useTheme } from '../../../context/ThemeContext';
4 |
5 | export enum PillVariant {
6 | INFO = 'info',
7 | SUCCESS = 'success',
8 | WARNING = 'warning',
9 | DANGER = 'danger',
10 | }
11 |
12 | interface PillProps {
13 | variant: PillVariant;
14 | }
15 |
16 | const PillWrapper = styled('span')`
17 | font-size: 14px;
18 | border-radius: var(--border-radius-1);
19 | height: 28px;
20 | display: inline-flex !important;
21 | align-items: center;
22 | justify-content: center;
23 | padding: 5px 8px 5px 8px !important;
24 | min-width: 40px;
25 | font-weight: 500;
26 | cursor: default;
27 | user-select: none;
28 | ${(p) =>
29 | p.dark
30 | ? `
31 | ${
32 | p.variant === PillVariant.INFO
33 | ? `
34 | background: var(--maximeheckel-colors-emphasis);
35 | color: var(--maximeheckel-colors-brand);`
36 | : ''
37 | }
38 |
39 | ${
40 | p.variant === PillVariant.SUCCESS
41 | ? `
42 | background: var(--maximeheckel-colors-success-emphasis);
43 | color: var(--maximeheckel-colors-success);`
44 | : ''
45 | }
46 |
47 | ${
48 | p.variant === PillVariant.WARNING
49 | ? `
50 | background: var(--maximeheckel-colors-warning-emphasis);
51 | color: var(--maximeheckel-colors-warning);`
52 | : ''
53 | }
54 |
55 | ${
56 | p.variant === PillVariant.DANGER
57 | ? `
58 | background: var(--maximeheckel-colors-danger-emphasis);
59 | color: var(--maximeheckel-colors-danger);`
60 | : ''
61 | }
62 | `
63 | : `
64 | ${
65 | p.variant === PillVariant.INFO
66 | ? `
67 | background: var(--maximeheckel-colors-emphasis);
68 | color: var(--maximeheckel-colors-brand);`
69 | : ''
70 | }
71 |
72 | ${
73 | p.variant === PillVariant.SUCCESS
74 | ? `
75 | background: var(--maximeheckel-colors-success-emphasis);
76 | color: hsl(var(--palette-green-80));`
77 | : ''
78 | }
79 |
80 | ${
81 | p.variant === PillVariant.WARNING
82 | ? `
83 | background: var(--maximeheckel-colors-warning-emphasis);
84 | color: hsl(var(--palette-orange-80));`
85 | : ''
86 | }
87 |
88 | ${
89 | p.variant === PillVariant.DANGER
90 | ? `
91 | background: var(--maximeheckel-colors-danger-emphasis);
92 | color: var(--maximeheckel-colors-danger);`
93 | : ''
94 | }
95 | `}
96 | `;
97 |
98 | const Pill: React.FC = (props) => {
99 | const theme = useTheme();
100 | const { children, variant } = props;
101 | return (
102 |
103 | {children}
104 |
105 | );
106 | };
107 |
108 | export default Pill;
109 |
--------------------------------------------------------------------------------
/src/components/MDX/__tests__/MDX.spec.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 | import React from 'react';
3 | import MDX, { toKebabCase } from '../';
4 |
5 | afterEach(() => {
6 | cleanup();
7 | });
8 |
9 | describe('MDX', () => {
10 | it('Transform a given string to kebab case', () => {
11 | expect(toKebabCase('helloWorld')).toBe('hello-world');
12 | expect(toKebabCase('HelloWorld')).toBe('hello-world');
13 | expect(toKebabCase('Helloworld')).toBe('helloworld');
14 | });
15 |
16 | it('Renders the MDX component accordingly', () => {
17 | const { asFragment } = render(
18 |
19 | Test
20 |
21 | );
22 |
23 | expect(asFragment()).toMatchSnapshot();
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/components/MDX/__tests__/__snapshots__/MDX.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`MDX Renders the MDX component accordingly 1`] = `
4 |
5 | .emotion-0 {
6 | margin: 0 auto;
7 | max-width: 700px;
8 | padding: 20px 0px 20px 0px;
9 | color: var(--maximeheckel-colors-typeface-1);
10 | }
11 |
12 | .emotion-0 figcaption {
13 | font-size: 14px;
14 | text-align: left;
15 | line-height: 1.5;
16 | font-weight: 500;
17 | color: var(--maximeheckel-colors-typeface-2);
18 | padding-top: 10px;
19 | }
20 |
21 | .emotion-0 h1 {
22 | color: var(--maximeheckel-colors-typeface-0);
23 | }
24 |
25 | .emotion-0 h2 {
26 | color: var(--maximeheckel-colors-typeface-0);
27 | margin-top: 2em;
28 | }
29 |
30 | .emotion-0 h3 {
31 | color: var(--maximeheckel-colors-typeface-0);
32 | margin-top: 2em;
33 | }
34 |
35 | .emotion-0 strong {
36 | color: var(--maximeheckel-colors-typeface-0);
37 | }
38 |
39 | .emotion-0 hr {
40 | height: 2px;
41 | width: 40%;
42 | margin: 50px auto;
43 | background-color: var(--maximeheckel-colors-typeface-0);
44 | }
45 |
46 | .emotion-0 ul {
47 | margin-left: 0px;
48 | }
49 |
50 | .emotion-0 ul li {
51 | list-style: none;
52 | display: -webkit-box;
53 | display: -webkit-flex;
54 | display: -ms-flexbox;
55 | display: flex;
56 | }
57 |
58 | .emotion-0 ul li span {
59 | display: inline-block;
60 | padding-right: 16px;
61 | padding-top: 2px;
62 | -webkit-transform: translateY(-2px);
63 | -ms-transform: translateY(-2px);
64 | transform: translateY(-2px);
65 | }
66 |
67 | .emotion-0 ul li span svg {
68 | stroke: var(--maximeheckel-colors-brand);
69 | }
70 |
71 | .emotion-0 ol {
72 | margin-left: 0px;
73 | list-style: none;
74 | }
75 |
76 | .emotion-0 ol li {
77 | counter-increment: li;
78 | display: -webkit-box;
79 | display: -webkit-flex;
80 | display: -ms-flexbox;
81 | display: flex;
82 | }
83 |
84 | .emotion-0 ol li:before {
85 | content: counters(li,'.') '. ';
86 | color: var(--maximeheckel-colors-brand);
87 | padding-right: 12px;
88 | }
89 |
90 | .emotion-0 a {
91 | color: var(--maximeheckel-colors-brand);
92 | }
93 |
94 | .emotion-0 twitter-widget {
95 | margin: 0 auto;
96 | }
97 |
98 |
101 |
102 | Test
103 |
104 |
105 |
106 | `;
107 |
--------------------------------------------------------------------------------
/src/components/MDX/index.tsx:
--------------------------------------------------------------------------------
1 | import { Blockquote } from './Blockquote';
2 | import { Code } from './Code';
3 | import { MDX, toKebabCase } from './MDX';
4 |
5 | export default MDX;
6 | export { toKebabCase, Code, Blockquote };
7 |
--------------------------------------------------------------------------------
/src/components/Pill/Pill.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from '@emotion/styled';
3 |
4 | interface PillWrapperProps {
5 | color: string;
6 | }
7 |
8 | const PillWrapper = styled('span')`
9 | font-size: 14px;
10 | background: ${(p) => p.color};
11 | border-radius: var(--border-radius-1);
12 | height: 22px;
13 | display: inline-flex;
14 | align-items: center;
15 | justify-content: center;
16 | padding: 4px 8px 5px 8px;
17 | margin-left: 8px;
18 | color: #2b2d3e;
19 | min-width: 40px;
20 | `;
21 |
22 | interface Props extends PillWrapperProps {
23 | text: string;
24 | }
25 |
26 | const Pill: React.FC = (props) => (
27 | {props.text}
28 | );
29 |
30 | export { Pill };
31 |
--------------------------------------------------------------------------------
/src/components/Pill/__tests__/Pill.spec.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 | import React from 'react';
3 | import Pill from '../';
4 |
5 | afterEach(() => {
6 | cleanup();
7 | });
8 |
9 | describe('Pill', () => {
10 | it('Renders the pill component properly', () => {
11 | const { getByText, asFragment } = render( );
12 | expect(getByText('test')).toBeDefined();
13 | expect(asFragment()).toMatchSnapshot();
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/Pill/__tests__/__snapshots__/Pill.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Pill Renders the pill component properly 1`] = `
4 |
5 | .emotion-0 {
6 | font-size: 14px;
7 | background: blue;
8 | border-radius: var(--border-radius-1);
9 | height: 22px;
10 | display: -webkit-inline-box;
11 | display: -webkit-inline-flex;
12 | display: -ms-inline-flexbox;
13 | display: inline-flex;
14 | -webkit-align-items: center;
15 | -webkit-box-align: center;
16 | -ms-flex-align: center;
17 | align-items: center;
18 | -webkit-box-pack: center;
19 | -webkit-justify-content: center;
20 | -ms-flex-pack: center;
21 | justify-content: center;
22 | padding: 4px 8px 5px 8px;
23 | margin-left: 8px;
24 | color: #2b2d3e;
25 | min-width: 40px;
26 | }
27 |
28 |
32 | test
33 |
34 |
35 | `;
36 |
--------------------------------------------------------------------------------
/src/components/Pill/index.tsx:
--------------------------------------------------------------------------------
1 | import { Pill } from './Pill';
2 | export default Pill;
3 |
--------------------------------------------------------------------------------
/src/components/ProgressBar/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import AnchorLink from 'react-anchor-link-smooth-scroll';
3 | import Scrollspy from 'react-scrollspy';
4 | import styled from '@emotion/styled';
5 | import { useReducedMotion, motion, useViewportScroll } from 'framer-motion';
6 |
7 | const ProgressBar = styled(motion.div)`
8 | width: 1px;
9 | background-color: var(--maximeheckel-colors-typeface-1);
10 | height: 100%;
11 | `;
12 |
13 | const ProgressBarWrapper = styled(motion.div)`
14 | height: calc(88vh - 40px);
15 | max-height: 425px;
16 | width: 1px;
17 | background-color: rgba(8, 8, 11, 0.3);
18 | `;
19 |
20 | type WrapperProps = {
21 | showTableOfContents: boolean;
22 | slim?: boolean;
23 | };
24 |
25 | const Wrapper = styled('div')`
26 | @media (max-width: 1100px) {
27 | left: 10px;
28 | }
29 | position: fixed;
30 | top: 266px;
31 | display: flex;
32 | left: 30px;
33 |
34 | ${(p) =>
35 | !p.showTableOfContents
36 | ? `
37 | ul {
38 | display: none;
39 | }
40 | `
41 | : ''}
42 |
43 | ul {
44 | @media (max-width: 1250px) {
45 | display: none;
46 | }
47 |
48 | max-width: ${(p) => (p.slim ? '150px' : '200px')};
49 | display: flex;
50 | flex-direction: column;
51 |
52 | li {
53 | list-style: none;
54 | font-size: 14px;
55 | font-weight: 500;
56 | line-height: 1.5;
57 | margin-bottom: 22px;
58 | a {
59 | ${(p) =>
60 | !p.showTableOfContents ? `cursor: none; pointer-events: none;` : ''}
61 | color: var(--maximeheckel-colors-typeface-1);
62 | text-decoration: none;
63 | }
64 |
65 | &:focus:not(:focus-visible) {
66 | outline: 0;
67 | }
68 |
69 | &:focus-visible {
70 | outline: 2px solid var(--maximeheckel-colors-brand);
71 | opacity: 1 !important;
72 | }
73 | }
74 | }
75 | `;
76 |
77 | type TableOfContentItemType = {
78 | url: string;
79 | title: string;
80 | };
81 |
82 | export type TableOfContentType = {
83 | items: TableOfContentItemType[];
84 | };
85 |
86 | interface ReadingProgressProps {
87 | tableOfContents?: TableOfContentType;
88 | target: React.RefObject;
89 | slim?: boolean;
90 | }
91 |
92 | const ReadingProgress: React.FC = ({
93 | tableOfContents,
94 | target,
95 | slim,
96 | }) => {
97 | const shouldReduceMotion = useReducedMotion();
98 | const [readingProgress, setReadingProgress] = React.useState(0);
99 |
100 | const shouldShowTableOfContent = readingProgress > 7 && readingProgress < 100;
101 | const shouldHideProgressBar = readingProgress >= 99;
102 |
103 | const scrollListener = () => {
104 | if (!target || !target.current) {
105 | return;
106 | }
107 |
108 | const element = target.current;
109 | const totalHeight = element.clientHeight;
110 | const windowScrollTop =
111 | window.pageYOffset ||
112 | document.documentElement.scrollTop ||
113 | document.body.scrollTop ||
114 | 0;
115 |
116 | if (windowScrollTop === 0) {
117 | return setReadingProgress(0);
118 | }
119 |
120 | if (windowScrollTop > totalHeight) {
121 | return setReadingProgress(100);
122 | }
123 |
124 | setReadingProgress((windowScrollTop / totalHeight) * 100);
125 | };
126 |
127 | React.useEffect(() => {
128 | window.addEventListener('scroll', scrollListener);
129 | return () => window.removeEventListener('scroll', scrollListener);
130 | });
131 |
132 | const variants = {
133 | hide: {
134 | opacity: shouldReduceMotion ? 1 : 0,
135 | },
136 | show: { opacity: 0.7 },
137 | emphasis: { opacity: 1 },
138 | };
139 |
140 | const { scrollYProgress } = useViewportScroll();
141 |
142 | return (
143 |
144 |
150 |
155 |
156 | {tableOfContents && tableOfContents.items.length > 0 ? (
157 | `${item.url.replace('#', '')}-section`
160 | )}
161 | currentClassName="isCurrent"
162 | offset={-175}
163 | >
164 | {tableOfContents.items.map((item) => {
165 | return (
166 |
174 |
175 | {item.title}
176 |
177 |
178 | );
179 | })}
180 |
181 | ) : null}
182 |
183 | );
184 | };
185 |
186 | export default ReadingProgress;
187 |
--------------------------------------------------------------------------------
/src/components/SearchBox/__tests__/SearchBox.spec.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 | import React from 'react';
3 | import SearchBox from '../';
4 |
5 | afterEach(() => {
6 | cleanup();
7 | });
8 |
9 | describe('SearchBox', () => {
10 | it('Renders the SearchBox component properly', () => {
11 | const location = { search: '' };
12 |
13 | const { container } = render(
14 |
15 | );
16 |
17 | expect(container.querySelector('input[name="search"]')).toBeDefined();
18 | expect(
19 | container.querySelector('li[data-testid="portfolio-link"]')
20 | ).toBeDefined();
21 | expect(
22 | container.querySelector('li[data-testid="twitter-link"]')
23 | ).toBeDefined();
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/components/SearchBox/index.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/core';
2 | import FocusTrap from 'focus-trap-react';
3 | import { motion } from 'framer-motion';
4 | import { Link } from 'gatsby';
5 | import Mousetrap from 'mousetrap';
6 | import React from 'react';
7 | import ReactDOM from 'react-dom';
8 | import styled from '@emotion/styled';
9 | import useDebouncedValue from '../../hooks/useDebouncedValue';
10 | import { useTheme } from '../../context/ThemeContext';
11 | import VisuallyHidden from '../VisuallyHidden';
12 |
13 | const TwitterIcon = () => (
14 |
29 | );
30 |
31 | const Portfolio = () => (
32 |
47 | );
48 |
49 | const Contact = () => (
50 |
71 | );
72 |
73 | const RSS = () => (
74 |
101 | );
102 |
103 | declare global {
104 | interface Window {
105 | __LUNR__: any;
106 | }
107 | }
108 |
109 | type Result = {
110 | date: string;
111 | slug: string;
112 | title: string;
113 | };
114 |
115 | interface Props {
116 | onClose?: () => void;
117 | showOverride?: boolean;
118 | }
119 |
120 | const toggleLockScroll = () =>
121 | document.documentElement.classList.toggle('lock-scroll');
122 |
123 | const SearchBox: React.FC = (props) => {
124 | const { onClose, showOverride } = props;
125 |
126 | // Local state to track the input value
127 | const [searchQuery, setSearchQuery] = React.useState('');
128 | const debouncedSearchQuery = useDebouncedValue(searchQuery, 50);
129 |
130 | const inputRef = React.useRef(null);
131 | const searchBoxRef = React.useRef(null);
132 | const [show, setShow] = React.useState(showOverride);
133 | const [results, setResults] = React.useState([]);
134 |
135 | const close = React.useCallback(() => {
136 | toggleLockScroll();
137 | onClose && onClose();
138 | return setShow(false);
139 | // eslint-disable-next-line
140 | }, []);
141 |
142 | const clickAway = (e: React.BaseSyntheticEvent) => {
143 | if (
144 | searchBoxRef &&
145 | searchBoxRef.current &&
146 | searchBoxRef.current.contains(e.target)
147 | ) {
148 | return null;
149 | }
150 | return close();
151 | };
152 |
153 | const onEnterKey = (event: KeyboardEvent) => {
154 | if (event.keyCode === 13) {
155 | toggleLockScroll();
156 | }
157 | };
158 |
159 | React.useEffect(() => {
160 | Mousetrap.bind(['ctrl+k'], () => setShow((prevState) => !prevState));
161 | return () => {
162 | Mousetrap.unbind(['ctrl+k']);
163 | };
164 | }, []);
165 |
166 | React.useEffect(() => {
167 | setShow(showOverride);
168 | }, [showOverride]);
169 |
170 | React.useEffect(() => {
171 | if (show) {
172 | toggleLockScroll();
173 | inputRef && inputRef.current && inputRef.current.focus();
174 | }
175 | }, [show]);
176 |
177 | React.useEffect(() => {
178 | const keyPressHandler = (e: KeyboardEvent): void => {
179 | if (show) {
180 | switch (e.keyCode) {
181 | case 27:
182 | return close();
183 | default:
184 | return;
185 | }
186 | }
187 | };
188 |
189 | document.addEventListener('keydown', keyPressHandler);
190 |
191 | return () => {
192 | document.removeEventListener('keydown', keyPressHandler);
193 | };
194 | // eslint-disable-next-line
195 | }, [show]);
196 |
197 | React.useEffect(() => {
198 | if (
199 | debouncedSearchQuery &&
200 | debouncedSearchQuery !== '' &&
201 | window.__LUNR__
202 | ) {
203 | window.__LUNR__.__loaded.then(
204 | (lunr: {
205 | en: {
206 | index: { search: (arg0: string) => { ref: string }[] };
207 | store: { [x: string]: any };
208 | };
209 | }) => {
210 | const refs: { ref: string }[] = lunr.en.index.search(
211 | debouncedSearchQuery
212 | );
213 | const posts = refs.map(({ ref }) => lunr.en.store[ref]);
214 | setResults(posts);
215 | }
216 | );
217 | }
218 |
219 | if (debouncedSearchQuery === '') {
220 | setResults([]);
221 | }
222 | }, [debouncedSearchQuery]);
223 |
224 | const { dark } = useTheme();
225 |
226 | if (!show) {
227 | return null;
228 | }
229 |
230 | return ReactDOM.createPortal(
231 |
232 |
395 | ,
396 | document.body
397 | );
398 | };
399 |
400 | export default SearchBox;
401 |
402 | const NoResultsWrapper = styled('div')`
403 | display: flex;
404 | align-items: center;
405 | justify-content: center;
406 | height: 65px;
407 | color: var(--maximeheckel-colors-typeface-1);
408 | font-weight: 500;
409 | `;
410 |
411 | const ShortcutKey = styled('span')`
412 | color: var(--maximeheckel-colors-brand);
413 | font-size: 14px;
414 | border-radius: var(--border-radius-1);
415 | padding: 8px 8px;
416 | background: var(--maximeheckel-colors-emphasis);
417 | &:not(:last-child) {
418 | margin-right: 16px;
419 | }
420 | `;
421 |
422 | const Item = styled('li')`
423 | height: 65px;
424 | margin-bottom: 0px;
425 | transition: 0.25s;
426 | list-style: none;
427 | color: var(--maximeheckel-colors-typeface-1);
428 |
429 | > *:not(svg) {
430 | height: inherit;
431 | display: flex;
432 | flex-direction: row;
433 | align-items: center;
434 | padding: 10px 25px;
435 | font-size: 16px;
436 | width: 100%;
437 | }
438 |
439 | a {
440 | color: unset;
441 | white-space: nowrap;
442 | overflow: hidden;
443 | text-overflow: ellipsis;
444 | }
445 |
446 | div {
447 | justify-content: space-between;
448 | }
449 |
450 | &:hover {
451 | a {
452 | background-color: var(--maximeheckel-colors-foreground);
453 | color: var(--maximeheckel-colors-brand);
454 | }
455 |
456 | svg {
457 | stroke: var(--maximeheckel-colors-brand);
458 | }
459 | }
460 | `;
461 |
462 | const Separator = styled('li')`
463 | height: 30px;
464 | width: 100%;
465 | font-size: 14px;
466 | background-color: var(--maximeheckel-colors-foreground);
467 | color: var(--maximeheckel-colors-typeface-1);
468 | display: flex;
469 | align-items: center;
470 | padding-left: 25px;
471 | padding-right: 25px;
472 | margin-bottom: 0;
473 | `;
474 |
475 | const SearchResults = styled('ul')<{ results: number; searchQuery: string }>`
476 | @media (max-width: 700px) {
477 | max-height: 385px;
478 | }
479 |
480 | max-height: 500px;
481 | overflow: auto;
482 | margin: 0px;
483 | transition: height 0.6s ease;
484 | will-change: height;
485 |
486 | height: ${(p) =>
487 | p.results > 0 ? p.results * 65 : p.searchQuery ? 65 : 450}px;
488 | }
489 |
490 | `;
491 |
492 | const SearchBoxWrapper = styled(motion.div)<{}>`
493 | @media (max-width: 700px) {
494 | width: 100%;
495 | top: 0;
496 | border-radius: 0px;
497 | }
498 |
499 | position: fixed;
500 | overflow: hidden;
501 | background: var(--maximeheckel-colors-body);
502 | width: 600px;
503 | top: 20%;
504 | left: 50%;
505 | border-radius: var(--border-radius-2);
506 | box-shadow: var(--maximeheckel-shadow-1);
507 |
508 | form {
509 | margin: 0px;
510 | }
511 |
512 | input {
513 | background: transparent;
514 | border: none;
515 | font-weight: 300;
516 | height: 55px;
517 | padding: 0px 25px;
518 | width: 100%;
519 | outline: none;
520 | color: var(--maximeheckel-colors-typeface-0);
521 | ::placeholder,
522 | ::-webkit-input-placeholder {
523 | color: var(--maximeheckel-colors-typeface-1);
524 | }
525 | :-ms-input-placeholder {
526 | color: var(--maximeheckel-colors-typeface-1);
527 | }
528 |
529 | ::-webkit-autofill {
530 | background: transparent;
531 | color: var(--maximeheckel-colors-typeface-0);
532 | font-size: 14px;
533 | }
534 | }
535 | `;
536 |
537 | const SearchBoxOverlay = styled(motion.div)<{}>`
538 | position: fixed;
539 | top: 0;
540 | left: 0;
541 | width: 100%;
542 | height: 100%;
543 | z-index: 999;
544 | outline: none;
545 | `;
546 |
--------------------------------------------------------------------------------
/src/components/Seo/SchemaOrg.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Helmet from 'react-helmet';
3 |
4 | interface Props {
5 | author: {
6 | name: string;
7 | };
8 | canonicalUrl: string;
9 | datePublished: string;
10 | defaultTitle: string;
11 | description: string;
12 | image: string;
13 | isBlogPost: boolean;
14 | title: string;
15 | url: string;
16 | }
17 |
18 | const SchemaOrg = React.memo(
19 | ({
20 | author,
21 | canonicalUrl,
22 | datePublished,
23 | defaultTitle,
24 | description,
25 | image,
26 | isBlogPost,
27 | title,
28 | url,
29 | }: Props) => {
30 | const baseSchema = [
31 | {
32 | '@context': 'http://schema.org',
33 | '@type': 'WebSite',
34 | url,
35 | name: title,
36 | alternateName: defaultTitle,
37 | },
38 | ];
39 |
40 | const schema = isBlogPost
41 | ? [
42 | ...baseSchema,
43 | {
44 | '@context': 'http://schema.org',
45 | '@type': 'BreadcrumbList',
46 | itemListElement: [
47 | {
48 | '@type': 'ListItem',
49 | position: 1,
50 | item: {
51 | '@id': url,
52 | name: title,
53 | image,
54 | },
55 | },
56 | ],
57 | },
58 | {
59 | '@context': 'http://schema.org',
60 | '@type': 'BlogPosting',
61 | url,
62 | name: title,
63 | alternateName: defaultTitle,
64 | headline: title,
65 | image: {
66 | '@type': 'ImageObject',
67 | url: image,
68 | },
69 | description,
70 | author: {
71 | '@type': 'Person',
72 | name: author,
73 | },
74 | mainEntityOfPage: {
75 | '@type': 'WebSite',
76 | '@id': canonicalUrl,
77 | },
78 | datePublished,
79 | },
80 | ]
81 | : baseSchema;
82 |
83 | return (
84 |
85 | {/* Schema.org tags */}
86 |
87 |
88 | );
89 | }
90 | );
91 |
92 | export default SchemaOrg;
93 |
--------------------------------------------------------------------------------
/src/components/Seo/index.tsx:
--------------------------------------------------------------------------------
1 | import { graphql, StaticQuery } from 'gatsby';
2 | import React from 'react';
3 | import Helmet from 'react-helmet';
4 | import SchemaOrg from './SchemaOrg';
5 |
6 | const query = graphql`
7 | query SEO {
8 | site {
9 | buildTime(formatString: "YYYY-MM-DD")
10 | siteMetadata {
11 | defaultTitle: title
12 | titleAlt
13 | shortName
14 | author
15 | keywords
16 | siteUrl: url
17 | defaultDescription: description
18 | twitter
19 | }
20 | }
21 | }
22 | `;
23 |
24 | interface ISEOProps {
25 | article?: boolean;
26 | banner?: string | undefined;
27 | desc?: string | undefined;
28 | pathname?: string | undefined;
29 | title?: string | undefined;
30 | date?: string | undefined;
31 | }
32 |
33 | const SEO: React.FC = ({
34 | title,
35 | desc,
36 | banner,
37 | pathname,
38 | article,
39 | date,
40 | }) => (
41 | {
55 | const seo = {
56 | description: desc || defaultDescription,
57 | image: banner ? `${siteUrl}${banner}` : '',
58 | date: date ? date : '',
59 | title: title || defaultTitle,
60 | url: `${siteUrl}/${
61 | pathname ? `${article ? 'posts' : 'projects'}/${pathname}` : ''
62 | }`,
63 | };
64 |
65 | return (
66 | <>
67 |
68 |
69 |
70 |
71 | {seo.url && }
72 | {seo.url && }
73 | {(article ? true : null) && (
74 |
75 | )}
76 | 0 ? keywords.join(`, `) : ''
80 | }
81 | />
82 | {seo.title && }
83 | {seo.description && (
84 |
85 | )}
86 | {seo.image && }
87 |
88 | {twitter && }
89 | {twitter && }
90 | {seo.title && }
91 | {seo.description && (
92 |
93 | )}
94 | {seo.image && }
95 |
96 |
107 | >
108 | );
109 | }}
110 | />
111 | );
112 |
113 | SEO.defaultProps = {
114 | article: false,
115 | };
116 |
117 | export default SEO;
118 |
--------------------------------------------------------------------------------
/src/components/Signature/Signature.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from '@emotion/styled';
3 | import { WebmentionReplies } from '../Webmentions';
4 |
5 | const ColoredBlockWrapper = styled('div')`
6 | background: var(--maximeheckel-colors-emphasis);
7 | color: var(--maximeheckel-colors-typeface-0);
8 | position: relative;
9 | width: 100vw;
10 | padding-bottom: 50px;
11 | padding-top: 50px;
12 | left: calc(-50vw + 50%);
13 |
14 | > div {
15 | @media (max-width: 700px) {
16 | padding-left: 20px;
17 | padding-right: 20px;
18 | }
19 | margin: 0 auto;
20 | max-width: 700px;
21 | }
22 | `;
23 |
24 | const Signature: React.FC<{ title: string; url: string }> = ({
25 | title,
26 | url,
27 | }) => {
28 | return (
29 |
30 |
31 |
32 |
33 | Do you have any questions, comments or simply wish to contact me
34 | privately? Don’t hesitate to shoot me a DM on{' '}
35 |
40 | Twitter
41 |
42 | .
43 |
44 |
45 |
46 | Have a wonderful day.
47 | Maxime
48 |
49 |
50 |
51 | );
52 | };
53 |
54 | export { Signature };
55 |
--------------------------------------------------------------------------------
/src/components/Signature/index.tsx:
--------------------------------------------------------------------------------
1 | import { Signature } from './Signature';
2 | export default Signature;
3 |
--------------------------------------------------------------------------------
/src/components/VisuallyHidden/VisuallyHidden.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/core';
2 | import React from 'react';
3 |
4 | interface Props {
5 | as: React.ElementType;
6 | children?: React.ReactNode;
7 | id?: string;
8 | }
9 |
10 | const VisuallyHidden = ({ as: Component, ...props }: Props) => (
11 |
27 | {props.children}
28 |
29 | );
30 |
31 | export { VisuallyHidden };
32 |
--------------------------------------------------------------------------------
/src/components/VisuallyHidden/index.tsx:
--------------------------------------------------------------------------------
1 | export { VisuallyHidden as default } from './VisuallyHidden';
2 |
--------------------------------------------------------------------------------
/src/components/Webmentions/WebmentionCount.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Flex from '../Flex';
3 | import styled from '@emotion/styled';
4 |
5 | const initialCounts = {
6 | count: 0,
7 | type: {
8 | like: 0,
9 | mention: 0,
10 | reply: 0,
11 | repost: 0,
12 | },
13 | };
14 |
15 | const fetchCounts = async (target: string) =>
16 | fetch(
17 | `https://webmention.io/api/count.json?target=${target}`
18 | ).then((response) => (response.json ? response.json() : response));
19 |
20 | const WebmentionCount = ({ target }: { target: string }) => {
21 | const [counts, setCounts] = React.useState(initialCounts);
22 |
23 | // Get counts on `target` change.
24 | React.useEffect(() => {
25 | async function getCounts() {
26 | const responseCounts = await fetchCounts(target);
27 | setCounts((previousCounts) => {
28 | return {
29 | ...previousCounts,
30 | ...responseCounts,
31 | type: {
32 | ...previousCounts.type,
33 | ...responseCounts.type,
34 | },
35 | };
36 | });
37 | }
38 |
39 | getCounts();
40 | }, [target]);
41 |
42 | return (
43 |
44 | {counts === undefined && (
45 | Failed to load counts 😞
46 | )}
47 | {counts && (
48 | <>
49 |
50 | {counts.type.like || 0}
51 | {' Likes '}•
52 |
53 |
54 | {' '}
55 | {counts.type.reply || 0}
56 | {' Replies '}•
57 |
58 |
59 | {' '}
60 | {counts.type.repost || 0}
61 | {' Reposts'}
62 |
63 | >
64 | )}
65 |
66 | );
67 | };
68 |
69 | const CountWrapper = styled(Flex)`
70 | p {
71 | color: var(--maximeheckel-colors-brand) !important;
72 | font-size: 14px;
73 | font-weight: 500;
74 | }
75 | `;
76 |
77 | export { WebmentionCount };
78 |
--------------------------------------------------------------------------------
/src/components/Webmentions/WebmentionReplies.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import { OutboundLink } from 'gatsby-plugin-google-analytics';
3 | import React from 'react';
4 | import { useInView } from 'react-intersection-observer';
5 | import ReactTooltip from 'react-tooltip';
6 | import styled from '@emotion/styled';
7 |
8 | const RepliesList = styled(motion.ul)`
9 | display: flex;
10 | flex-wrap: wrap;
11 | margin-left: 0px;
12 | margin-bottom: 8px;
13 | margin-top: 15px;
14 | li {
15 | margin-right: -10px;
16 | }
17 | `;
18 |
19 | const Head = styled(motion.li)`
20 | list-style: none;
21 |
22 | img {
23 | border-radius: 50%;
24 | border: 3px solid var(--maximeheckel-colors-brand);
25 | }
26 | `;
27 |
28 | type Reply = {
29 | source: URL;
30 | target: URL;
31 | verified: boolean;
32 | verified_date: string;
33 | id: number;
34 | private: boolean;
35 | activity: {
36 | type: string;
37 | sentence: string;
38 | sentence_html: string;
39 | };
40 | data: {
41 | author: {
42 | name: string;
43 | url: string;
44 | photo: string;
45 | };
46 | url: string;
47 | };
48 | };
49 |
50 | interface RepliesProps {
51 | replies: Reply[];
52 | }
53 |
54 | const list = {
55 | visible: {
56 | opacity: 1,
57 | transition: {
58 | when: 'beforeChildren',
59 | staggerChildren: 0.1,
60 | },
61 | },
62 | hidden: {
63 | opacity: 0,
64 | transition: {
65 | when: 'afterChildren',
66 | },
67 | },
68 | };
69 |
70 | const item = {
71 | visible: { opacity: 1, x: 0 },
72 | hidden: { opacity: 0, x: -10 },
73 | };
74 |
75 | const Replies = ({ replies }: RepliesProps) => {
76 | const sanitizedReplies = replies
77 | .filter((reply) => reply.data.url.includes('https://twitter.com'))
78 | .reduce((acc: Record, item: Reply) => {
79 | if (item.data?.author?.url && !acc[item.data.author.url]) {
80 | acc[item.data.author.url] = item;
81 | return acc;
82 | }
83 |
84 | return acc;
85 | }, {});
86 |
87 | return (
88 | <>
89 | {Object.values(sanitizedReplies) &&
90 | Object.values(sanitizedReplies).length ? (
91 |
92 | {Object.values(sanitizedReplies)
93 | .filter((link) => link.data.author)
94 | .map((link) => (
95 |
105 |
109 |
115 |
116 |
117 | ))}
118 |
119 |
120 | ) : null}
121 | >
122 | );
123 | };
124 |
125 | interface Props {
126 | title: string;
127 | url: string;
128 | }
129 |
130 | const WebmentionReplies = ({ title, url }: Props) => {
131 | const [ref, inView] = useInView();
132 | const [page, setPage] = React.useState(0);
133 | const [fetchState, setFetchState] = React.useState('fetching');
134 |
135 | const mergeReplies = (oldReplies: Reply[], newReplies: Reply[]) => [
136 | ...oldReplies,
137 | ...newReplies,
138 | ];
139 | const [replies, setReplies] = React.useReducer(mergeReplies, []);
140 | const perPage = 500;
141 | const text = `${title} by @MaximeHeckel ${url}`;
142 |
143 | const getMentions = React.useCallback(
144 | () =>
145 | fetch(
146 | `https://webmention.io/api/mentions?page=${page}&per-page=${perPage}&target=${url}`
147 | ).then((response) => (response.json ? response.json() : response)),
148 | [page, url]
149 | );
150 | const incrementPage = () => setPage((previousPage) => previousPage + 1);
151 | // const fetchMore = () =>
152 | // getMentions()
153 | // .then((newReplies) => {
154 | // if (newReplies.length) {
155 | // setReplies(newReplies);
156 | // } else {
157 | // setFetchState('nomore');
158 | // }
159 | // })
160 | // .then(incrementPage);
161 |
162 | React.useEffect(() => {
163 | getMentions()
164 | .then((newReplies) => {
165 | setReplies(newReplies.links);
166 | setFetchState('done');
167 | })
168 | .then(incrementPage);
169 | // eslint-disable-next-line react-hooks/exhaustive-deps
170 | }, []);
171 |
172 | if (fetchState === 'fetching') {
173 | return Fetching Replies...
;
174 | }
175 |
176 | const distinctFans = [
177 | // @ts-ignore
178 | ...new Set(
179 | replies
180 | .filter((reply) => reply.data.author)
181 | .map((reply) => reply.data.author.url)
182 | ),
183 | ];
184 |
185 | const heightRow = 77;
186 | const numberOfRow = Math.ceil(replies.length / 17);
187 |
188 | return (
189 |
190 |
191 |
192 | {replies.length > 0
193 | ? `Already ${
194 | distinctFans.length > 1
195 | ? `${distinctFans.length} awesome people`
196 | : 'one awesome person'
197 | } liked, shared or talked about this article:`
198 | : 'Be the first one to share this article!'}
199 |
200 |
201 |
202 | {inView ? (
203 |
204 | ) : (
205 |
206 | )}
207 |
208 |
213 | Tweet about this post
214 | {' '}
215 | and it will show up here! Or,{' '}
216 |
221 | click here to leave a comment
222 | {' '}
223 | and discuss about it on Twitter.
224 |
225 |
226 | );
227 | };
228 |
229 | export { WebmentionReplies };
230 |
--------------------------------------------------------------------------------
/src/components/Webmentions/__tests__/WebmentionCount.spec.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render, waitFor } from '@testing-library/react';
2 | import React from 'react';
3 | import { WebmentionCount } from '../WebmentionCount';
4 |
5 | beforeEach(() => {
6 | global.fetch = jest.fn().mockImplementation(() => {
7 | const p = new Promise((resolve, reject) => {
8 | resolve({
9 | count: 100,
10 | type: {
11 | like: 50,
12 | repost: 25,
13 | reply: 25,
14 | },
15 | });
16 | });
17 |
18 | return p;
19 | });
20 | });
21 |
22 | afterEach(() => {
23 | cleanup();
24 | });
25 |
26 | describe('Webmention', () => {
27 | it('renders the webmention counts', async () => {
28 | const { getByTestId } = render( );
29 |
30 | await waitFor(() => {
31 | expect(getByTestId('likes')).toBeDefined();
32 | expect(getByTestId('replies')).toBeDefined();
33 | expect(getByTestId('reposts')).toBeDefined();
34 | expect(getByTestId('likes')).toHaveTextContent('50 Likes •');
35 | expect(getByTestId('replies')).toHaveTextContent('25 Replies •');
36 | expect(getByTestId('reposts')).toHaveTextContent('25 Reposts');
37 | });
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/src/components/Webmentions/__tests__/WebmentionReplies.spec.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render, waitFor } from '@testing-library/react';
2 | import { mockAllIsIntersecting } from 'react-intersection-observer/test-utils';
3 | import React from 'react';
4 | import { WebmentionReplies } from '../WebmentionReplies';
5 |
6 | beforeEach(() => {
7 | global.fetch = jest.fn().mockImplementation(() => {
8 | const p = new Promise((resolve, reject) => {
9 | resolve({
10 | links: [
11 | {
12 | source:
13 | 'https://brid-gy.appspot.com/repost/twitter/MaximeHeckel/1244993113669742593/1245014714028978176',
14 | verified: true,
15 | verified_date: '2020-04-21T05:02:10+00:00',
16 | id: 785123,
17 | private: false,
18 | data: {
19 | author: {
20 | name: 'eli 🤠',
21 | url: 'https://twitter.com/_seemethere',
22 | photo:
23 | 'https://webmention.io/avatar/pbs.twimg.com/adc02883d6e8c838df8f2f2fc1ddc56f701b28bf91a7b152c82c21439371f3a9.jpg',
24 | },
25 | url: 'https://twitter.com/_seemethere/status/1245014714028978176',
26 | name: null,
27 | content:
28 | 'Good morning friends! 👋 I just published "How to fix NPM link duplicate dependencies issues"\n\nblog.maximeheckel.com/posts/duplicat…',
29 | published: '2020-03-31T15:46:53+00:00',
30 | published_ts: 1585669613,
31 | },
32 | activity: {
33 | type: 'repost',
34 | sentence:
35 | 'eli 🤠 retweeted a tweet https://blog.maximeheckel.com/posts/duplicate-dependencies-npm-link/',
36 | sentence_html:
37 | 'eli 🤠 retweeted a tweet https://blog.maximeheckel.com/posts/duplicate-dependencies-npm-link/',
38 | },
39 | target:
40 | 'https://blog.maximeheckel.com/posts/duplicate-dependencies-npm-link/',
41 | },
42 | ],
43 | });
44 | });
45 |
46 | return p;
47 | });
48 | });
49 |
50 | afterEach(() => {
51 | cleanup();
52 | });
53 |
54 | describe('Webmention', () => {
55 | it('renders the webmention replies', async () => {
56 | const { container, getByTestId } = render(
57 |
58 | );
59 |
60 | mockAllIsIntersecting(true);
61 |
62 | await waitFor(() => {
63 | expect(getByTestId('main-text')).toBeDefined();
64 | expect(getByTestId('share-text')).toBeDefined();
65 | expect(getByTestId('main-text')).toHaveTextContent(
66 | 'Already one awesome person liked, shared or talked about this article:'
67 | );
68 | expect(
69 | container.querySelector(
70 | "[data-tip='eli 🤠 retweeted a tweet https://blog.maximeheckel.com/posts/duplicate-dependencies-npm-link/']"
71 | )
72 | ).toBeDefined();
73 | });
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/src/components/Webmentions/index.tsx:
--------------------------------------------------------------------------------
1 | import { WebmentionCount } from './WebmentionCount';
2 | import { WebmentionReplies } from './WebmentionReplies';
3 |
4 | export { WebmentionCount, WebmentionReplies };
5 |
--------------------------------------------------------------------------------
/src/constants/constants.tsx:
--------------------------------------------------------------------------------
1 | export const MONTHS = [
2 | 'Jan',
3 | 'Feb',
4 | 'Mar',
5 | 'Apr',
6 | 'May',
7 | 'Jun',
8 | 'Jul',
9 | 'Aug',
10 | 'Sep',
11 | 'Oct',
12 | 'Nov',
13 | 'Dec',
14 | ];
15 |
--------------------------------------------------------------------------------
/src/constants/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './constants';
2 |
--------------------------------------------------------------------------------
/src/context/ThemeContext.tsx:
--------------------------------------------------------------------------------
1 | import Mousetrap from 'mousetrap';
2 | import React, { Dispatch, ReactNode, SetStateAction } from 'react';
3 |
4 | enum Theme {
5 | LIGHT = 'light',
6 | DARK = 'dark',
7 | }
8 |
9 | const KEY = 'mode';
10 |
11 | const defaultContextData = {
12 | dark: false,
13 | toggleDark: () => {},
14 | };
15 |
16 | export const ThemeContext = React.createContext(defaultContextData);
17 |
18 | const useTheme = () => React.useContext(ThemeContext);
19 |
20 | const storage = {
21 | get: (init?: Theme) => window.localStorage.getItem(KEY) || init,
22 | set: (value: Theme) => window.localStorage.setItem(KEY, value),
23 | };
24 |
25 | const supportsDarkMode = () =>
26 | window.matchMedia('(prefers-color-scheme: dark)').matches === true;
27 |
28 | const useDarkMode = (): [Theme, Dispatch>] => {
29 | const [themeState, setThemeState] = React.useState(Theme.LIGHT);
30 |
31 | const setThemeStateEnhanced = () => {
32 | setThemeState((prevState) => {
33 | const nextState = prevState === Theme.LIGHT ? Theme.DARK : Theme.LIGHT;
34 | document.body.classList.remove('maximeheckel-' + prevState);
35 |
36 | document.body.classList.add('maximeheckel-' + nextState);
37 | storage.set(nextState);
38 |
39 | return nextState;
40 | });
41 | };
42 |
43 | React.useEffect(() => {
44 | const storedMode = storage.get();
45 | if (!storedMode && supportsDarkMode()) {
46 | return setThemeStateEnhanced();
47 | }
48 |
49 | if (!storedMode || storedMode === themeState) {
50 | return;
51 | }
52 | setThemeStateEnhanced();
53 | }, [themeState]);
54 |
55 | return [themeState, setThemeStateEnhanced];
56 | };
57 |
58 | const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
59 | const [themeState, setThemeStateEnhanced] = useDarkMode();
60 | const toggleDark = () => {
61 | setThemeStateEnhanced(
62 | themeState === Theme.LIGHT ? Theme.DARK : Theme.LIGHT
63 | );
64 | };
65 |
66 | React.useEffect(() => {
67 | Mousetrap.bind(['ctrl+t'], () => toggleDark());
68 | return () => {
69 | Mousetrap.unbind(['ctrl+t']);
70 | };
71 | }, []);
72 |
73 | return (
74 |
80 | {children}
81 |
82 | );
83 | };
84 |
85 | export { ThemeProvider, useTheme };
86 |
--------------------------------------------------------------------------------
/src/context/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import GlobalStyles from '../components/GlobalStyles';
3 | import { ThemeProvider } from './ThemeContext';
4 |
5 | interface IRootWrapperProps {
6 | children: React.ReactNode;
7 | }
8 |
9 | const RootWrapper: React.FC = ({ children }) => (
10 |
11 |
12 | {children}
13 |
14 | );
15 |
16 | export default RootWrapper;
17 |
--------------------------------------------------------------------------------
/src/hooks/useDebouncedValue.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const useDebounce = (value: any, delay: number) => {
4 | // State and setters for debounced value
5 | const [debouncedValue, setDebouncedValue] = React.useState(value);
6 |
7 | React.useEffect(
8 | () => {
9 | // Update debounced value after delay
10 | const handler = setTimeout(() => {
11 | setDebouncedValue(value);
12 | }, delay);
13 |
14 | // Cancel the timeout if value changes (also on delay change or unmount)
15 | // This is how we prevent debounced value from updating if value is changed ...
16 | // .. within the delay period. Timeout gets cleared and restarted.
17 | return () => {
18 | clearTimeout(handler);
19 | };
20 | },
21 | [value, delay] // Only re-call effect if value or delay changes
22 | );
23 |
24 | return debouncedValue;
25 | };
26 |
27 | export default useDebounce;
28 |
--------------------------------------------------------------------------------
/src/hooks/useScrollCounter.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const useScrollCounter = (offset: number) => {
4 | const [reached, setReached] = React.useState(false);
5 | React.useEffect(() => {
6 | const showTitle = () => setReached(window.scrollY > offset);
7 | window.addEventListener('scroll', showTitle);
8 | return () => {
9 | window.removeEventListener('scroll', showTitle);
10 | };
11 | }, [offset]);
12 |
13 | return reached;
14 | };
15 |
16 | export default useScrollCounter;
17 |
--------------------------------------------------------------------------------
/src/layouts/MainLayout.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { graphql, useStaticQuery } from 'gatsby';
3 | import React from 'react';
4 | import Footer from '../components/Footer';
5 | import { DefaultHeader, MainHeaderProps } from '../components/Header';
6 | import { useTheme } from '../context/ThemeContext';
7 | import 'plyr/dist/plyr.css';
8 |
9 | interface LayoutProps {
10 | footer?: boolean;
11 | header?: boolean;
12 | headerProps?: MainHeaderProps;
13 | }
14 |
15 | const Layout: React.FC = (props) => {
16 | const data = useStaticQuery(graphql`
17 | query HeaderQuery {
18 | site {
19 | siteMetadata {
20 | author
21 | title
22 | url
23 | }
24 | }
25 | }
26 | `);
27 |
28 | const { header, footer, headerProps, ...rest } = props;
29 | const { site } = data;
30 |
31 | const theme = useTheme();
32 |
33 | React.useEffect(() => {
34 | if (typeof window !== 'undefined' && typeof document !== 'undefined') {
35 | const Plyr = require('plyr');
36 | Array.from(document.querySelectorAll('#videoplayer-maximeheckel')).map(
37 | (p) => new Plyr(p)
38 | );
39 | }
40 | }, []);
41 |
42 | return (
43 |
48 | {header ? (
49 |
54 | ) : null}
55 |
56 | {(props.children as React.ReactElement) &&
57 | // @ts-ignore TODO: Need to figure out if there's a better way to handle children in gatsby layout
58 | props.children({ ...props, site })}
59 |
60 | {footer ? : null}
61 |
62 | );
63 | };
64 |
65 | export { Layout };
66 |
67 | const Wrapper = styled.div`
68 | transition: 0.5s;
69 | background: var(--maximeheckel-colors-body);
70 |
71 | &:focus:not(:focus-visible) {
72 | outline: 0;
73 | }
74 |
75 | &:focus-visible {
76 | outline: 2px solid var(--maximeheckel-colors-brand);
77 | background-color: var(--maximeheckel-colors-foreground);
78 | }
79 | `;
80 |
81 | const Content = styled.div`
82 | @media (max-width: 700px) {
83 | padding: 0px 20px 0px 20px;
84 | }
85 | margin: 0 auto;
86 | max-width: 1020px;
87 | padding: 0px 70px 0px 70px;
88 | color: var(--maximeheckel-colors-typeface-0);
89 | `;
90 |
--------------------------------------------------------------------------------
/src/layouts/index.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from './MainLayout';
2 |
3 | export interface LayoutChildrenProps {
4 | site: {
5 | siteMetadata: {
6 | author: string;
7 | title: string;
8 | url: string;
9 | };
10 | };
11 | }
12 |
13 | export default Layout;
14 |
--------------------------------------------------------------------------------
/src/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Link } from 'gatsby';
3 | import React from 'react';
4 | import Button from '../components/Button';
5 | import Seo from '../components/Seo';
6 | import Layout, { LayoutChildrenProps } from '../layouts';
7 |
8 | const NotFoundPage = () => (
9 |
10 | {(layoutProps: LayoutChildrenProps) => {
11 | return (
12 | <>
13 |
16 |
17 |
18 | Oh no! You just got lost 😱
19 |
20 | {/* eslint-disable-next-line react/no-unescaped-entities */}
21 | Don't worry I got you!{' '}
22 |
23 |
24 | {' '}
25 | to go back home.
26 |
27 |
28 |
29 | >
30 | );
31 | }}
32 |
33 | );
34 |
35 | export default NotFoundPage;
36 |
37 | const Wrapper = styled.div`
38 | margin: 0 auto;
39 | max-width: 1430px;
40 | display: flex;
41 | height: calc(100vh);
42 | align-items: center;
43 | color: var(--maximeheckel-colors-typeface-0);
44 | padding: 0px 70px;
45 |
46 | @media (max-width: 700px) {
47 | padding: 0px 30px;
48 | }
49 |
50 | h2 {
51 | line-height: 34px;
52 | }
53 |
54 | * > a {
55 | color: inherit;
56 | text-decoration: none;
57 | }
58 | `;
59 |
--------------------------------------------------------------------------------
/src/templates/BlogPost.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/core';
2 | import slugify from '@sindresorhus/slugify';
3 | import { FluidObject } from 'gatsby-image';
4 | import React from 'react';
5 | import { MONTHS } from '../constants';
6 | import Layout, { LayoutChildrenProps } from '../layouts';
7 | import Hero from '../components/Hero';
8 | import Flex from '../components/Flex';
9 | import MDX from '../components/MDX';
10 | import ProgressBar, { TableOfContentType } from '../components/ProgressBar';
11 | import Seo from '../components/Seo';
12 | import { WebmentionCount } from '../components/Webmentions';
13 | import Signature from '../components/Signature';
14 | import sectionize from '../utils/sectionize';
15 |
16 | interface BlogPostProps {
17 | pageContext: {
18 | frontmatter: {
19 | title: string;
20 | subtitle?: string;
21 | date: string;
22 | slug: string;
23 | };
24 | tableOfContents?: TableOfContentType;
25 | timeToRead: number;
26 | cover: {
27 | childImageSharp: {
28 | fluid: FluidObject;
29 | };
30 | };
31 | };
32 |
33 | site: any;
34 | }
35 |
36 | const BlogPost: React.FC = (props) => {
37 | const { pageContext } = props;
38 | const { frontmatter, cover, tableOfContents, timeToRead } = pageContext;
39 | const { date, slug, subtitle, title } = frontmatter;
40 |
41 | const childrenWithProps = sectionize(
42 | props.children as React.ReactElement[]
43 | );
44 |
45 | const headerProps = {
46 | title,
47 | sticky: true,
48 | collapsableOnScroll: true,
49 | search: true,
50 | rss: true,
51 | };
52 |
53 | return (
54 |
55 | {(layoutProps: LayoutChildrenProps) => {
56 | const { site } = layoutProps;
57 | const progressBarTarget = React.createRef();
58 | const parsedDate = new Date(Date.parse(date));
59 | const SeoBanner = `/opengraph-images/${slugify(title)}.png`;
60 | const postUrl = `${site.siteMetadata.url}/posts/${slug}/`;
61 | return (
62 |
63 |
71 |
72 |
76 | {title}
77 |
78 |
79 |
80 | {date ? (
81 |
82 | {MONTHS[parsedDate.getMonth()]} {parsedDate.getDate()}{' '}
83 | {parsedDate.getFullYear()}
84 |
85 | ) : null}
86 | {timeToRead ? / {timeToRead} min read /
: null}
87 |
88 |
89 |
90 | {cover ? (
91 |
98 |
102 |
103 | ) : null}
104 |
105 |
109 | {childrenWithProps}
110 |
111 |
118 |
122 | {subtitle && (
123 | {subtitle}
124 | )}
125 |
126 | );
127 | }}
128 |
129 | );
130 | };
131 |
132 | export default BlogPost;
133 |
--------------------------------------------------------------------------------
/src/templates/PortfolioProject.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/core';
2 | import { FluidObject } from 'gatsby-image';
3 | import React from 'react';
4 | import Layout, { LayoutChildrenProps } from '../layouts';
5 | import Hero from '../components/Hero';
6 | import MDX from '../components/MDX';
7 | import ProgressBar, { TableOfContentType } from '../components/ProgressBar';
8 | import Seo from '../components/Seo';
9 | import sectionize from '../utils/sectionize';
10 |
11 | interface ProjectPortfolioProps {
12 | pageContext: {
13 | frontmatter: {
14 | title: string;
15 | subtitle?: string;
16 | slug: string;
17 | background: string;
18 | };
19 | tableOfContents?: TableOfContentType;
20 |
21 | cover: {
22 | childImageSharp: {
23 | fluid: FluidObject;
24 | };
25 | };
26 | };
27 | site: any;
28 | }
29 |
30 | const PortfolioProject: React.FC = (props) => {
31 | const { pageContext } = props;
32 | const { frontmatter, cover, tableOfContents } = pageContext;
33 | const { slug, subtitle, title, background } = frontmatter;
34 |
35 | const childrenWithProps = sectionize(
36 | props.children as React.ReactElement[]
37 | );
38 |
39 | const headerProps = {
40 | title,
41 | sticky: true,
42 | collapsableOnScroll: true,
43 | };
44 |
45 | return (
46 |
47 | {(layoutProps: LayoutChildrenProps) => {
48 | const { site } = layoutProps;
49 | const progressBarTarget = React.createRef();
50 | const SeoBanner = cover.childImageSharp.fluid.src;
51 |
52 | return (
53 |
54 |
61 |
62 |
63 | {title}
64 |
65 | {subtitle ? {subtitle} : null}
66 |
67 | {cover ? (
68 |
80 |
87 |
88 | ) : null}
89 |
94 |
95 | {childrenWithProps}
96 |
97 |
98 | );
99 | }}
100 |
101 | );
102 | };
103 |
104 | export default PortfolioProject;
105 |
--------------------------------------------------------------------------------
/src/templates/Snippet.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import React from 'react';
3 | import Layout, { LayoutChildrenProps } from '../layouts';
4 | import MDX from '../components/MDX';
5 | import Seo from '../components/Seo';
6 | import { FluidObject } from 'gatsby-image';
7 | import { MONTHS } from '../constants';
8 | import Flex from '../components/Flex';
9 | import Pill from '../components/Pill';
10 |
11 | interface SnippetProps {
12 | pageContext: {
13 | frontmatter: {
14 | title: string;
15 | description?: string;
16 | created: string;
17 | slug: string;
18 | language: string;
19 | };
20 | snippetImage: {
21 | childImageSharp: {
22 | fluid: FluidObject;
23 | };
24 | };
25 | };
26 | site: any;
27 | }
28 |
29 | const Snippet: React.FC = (props) => {
30 | const { pageContext } = props;
31 | const { frontmatter, snippetImage } = pageContext;
32 | const { created, slug, title, description, language } = frontmatter;
33 | const ref = React.createRef();
34 |
35 | return (
36 |
41 | {(layoutProps: LayoutChildrenProps) => {
42 | const { site } = layoutProps;
43 | const parsedDate = new Date(created);
44 |
45 | return (
46 |
47 |
55 |
56 | {title}
57 |
58 |
59 | Created {MONTHS[parsedDate.getMonth()]} {parsedDate.getDate()}{' '}
60 | {parsedDate.getFullYear()}
61 |
62 |
63 |
64 |
65 |
66 | {props.children}
67 |
68 |
69 |
70 |
71 | );
72 | }}
73 |
74 | );
75 | };
76 |
77 | const FixMargin = styled('div')`
78 | margin-top: -30px;
79 | `;
80 |
81 | const FixPadding = styled('div')`
82 | padding-top: 35px;
83 |
84 | p {
85 | color: var(--maximeheckel-colors-typeface-2);
86 | font-size: 14px;
87 | font-weight: 500;
88 | }
89 | `;
90 |
91 | export default Snippet;
92 |
--------------------------------------------------------------------------------
/src/utils/sectionize.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const sectionize = (children: React.ReactElement[]) =>
4 | React.Children.map(children, (child: React.ReactElement) => {
5 | return child && child.props.mdxType === 'section'
6 | ? React.cloneElement(child, {
7 | id: `${child.props.children[0].props.id}-section`,
8 | })
9 | : child;
10 | });
11 |
12 | export default sectionize;
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": false,
3 | "compilerOptions": {
4 | /* Basic Options */
5 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */,
6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
7 | "lib": ["dom", "es2015", "es2017"], // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */
8 | /* Specify library files to be included in the compilation. */
9 |
10 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */
12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
13 | "sourceMap": false /* Generates corresponding '.map' file. */,
14 | // "outFile": "./", /* Concatenate and emit output to single file. */
15 | // "outDir": "./", /* Redirect output structure to the directory. */
16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
17 | // "composite": true, /* Enable project compilation */
18 | // "removeComments": true, /* Do not emit comments to output. */
19 | // "noEmit": true, /* Do not emit outputs. */
20 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
21 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
22 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
23 |
24 | /* Strict Type-Checking Options */
25 | "strict": true /* Enable all strict type-checking options. */,
26 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
27 | // "strictNullChecks": true, /* Enable strict null checks. */
28 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
32 |
33 | /* Additional Checks */
34 | "noUnusedLocals": true /* Report errors on unused locals. */,
35 | "noUnusedParameters": true /* Report errors on unused parameters. */,
36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
37 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
38 |
39 | /* Module Resolution Options */
40 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
44 | // "typeRoots": [], /* List of folders to include type definitions from. */
45 | // "types": [], /* Type declaration files to be included in compilation. */
46 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
47 | // "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
48 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
49 |
50 | /* Source Map Options */
51 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
52 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
55 |
56 | /* Experimental Options */
57 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
58 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
59 | },
60 | "include": ["./src/**/*"],
61 | "exclude": ["src/**/*.spec.tsx", "src/**/*.spec.ts"]
62 | }
63 |
--------------------------------------------------------------------------------