97 | }
98 |
--------------------------------------------------------------------------------
/app/utils/spotify.server.ts:
--------------------------------------------------------------------------------
1 | import type {SpotifySong} from '~/types'
2 | import type {CachifiedOptions} from './cache.server'
3 | import {cachified} from './cache.server'
4 | import {redisCache} from './redis.server'
5 |
6 | const client_id = process.env.SPOTIFY_CLIENT_ID
7 | const client_secret = process.env.SPOTIFY_CLIENT_SECRET
8 | const refresh_token = process.env.SPOTIFY_REFRESH_TOKEN ?? ''
9 |
10 | const basic = Buffer.from(`${client_id}:${client_secret}`).toString('base64')
11 | const NOW_PLAYING_ENDPOINT = `https://api.spotify.com/v1/me/player/currently-playing`
12 | const TOP_TRACKS_ENDPOINT = `https://api.spotify.com/v1/me/top/tracks?limit=50&time_range=short_term`
13 | const TOKEN_ENDPOINT = `https://accounts.spotify.com/api/token`
14 |
15 | async function getAccessToken() {
16 | const response = await fetch(TOKEN_ENDPOINT, {
17 | method: 'POST',
18 | headers: {
19 | Authorization: `Basic ${basic}`,
20 | 'Content-Type': 'application/x-www-form-urlencoded',
21 | },
22 | body: new URLSearchParams({
23 | grant_type: 'refresh_token',
24 | refresh_token,
25 | }).toString(),
26 | })
27 |
28 | return response.json()
29 | }
30 |
31 | async function getNowPlaying() {
32 | const {access_token} = await getAccessToken()
33 |
34 | return fetch(NOW_PLAYING_ENDPOINT, {
35 | headers: {
36 | Authorization: `Bearer ${access_token}`,
37 | },
38 | })
39 | .then(data => data.json())
40 | .then(
41 | data =>
42 | ({
43 | isPlaying: data.is_playing,
44 | songUrl: data.item?.external_urls?.spotify,
45 | title: data.item?.name,
46 | artist: data.item?.artists
47 | ?.map((artist: {name: string}) => artist.name)
48 | .join(', '),
49 | album: data.item?.album?.name,
50 | albumImageUrl: data.item?.album?.images?.[0]?.url,
51 | } as SpotifySong),
52 | )
53 | .catch(() => null)
54 | }
55 |
56 | async function getTopTracks() {
57 | const {access_token} = await getAccessToken()
58 |
59 | return fetch(TOP_TRACKS_ENDPOINT, {
60 | headers: {
61 | Authorization: `Bearer ${access_token}`,
62 | },
63 | })
64 | .then(data => data.json())
65 | .then(data =>
66 | data.items.map(
67 | (songData: any) =>
68 | ({
69 | songUrl: songData.external_urls?.spotify,
70 | title: songData.name,
71 | artist: songData.artists
72 | ?.map((artist: {name: string}) => artist.name)
73 | .join(', '),
74 | album: songData.album?.name,
75 | albumImageUrl: songData.album?.images?.[0]?.url,
76 | } as SpotifySong),
77 | ),
78 | )
79 | .catch(() => [] as SpotifySong[])
80 | }
81 |
82 | async function getTopTracksCached(options?: CachifiedOptions) {
83 | const maxAge = 11000 * 60 * 60 * 4 // 4 hours
84 |
85 | return cachified({
86 | cache: redisCache,
87 | maxAge,
88 | ...options,
89 | key: `spotify-top-tracks`,
90 | checkValue: (value: unknown) => Array.isArray(value),
91 | getFreshValue: async () => {
92 | try {
93 | const tracks = await getTopTracks()
94 |
95 | return tracks
96 | } catch (e: unknown) {
97 | console.warn(e)
98 | }
99 |
100 | return []
101 | },
102 | })
103 | }
104 |
105 | export {getNowPlaying, getTopTracksCached}
106 |
--------------------------------------------------------------------------------
/app/utils/sitemap.server.ts:
--------------------------------------------------------------------------------
1 | import type {AppHandle, AppSitemapEntry} from '~/types'
2 | import isEqual from 'lodash.isequal'
3 | import {getDomainUrl, removeTrailingSlash, typedBoolean} from '~/utils/misc'
4 | import type {RemixServerProps} from '@remix-run/react'
5 |
6 | async function getSitemapXml(
7 | request: Request,
8 | remixContext: RemixServerProps['context'],
9 | ) {
10 | const domainUrl = getDomainUrl(request)
11 |
12 | function getEntry({route, lastmod, changefreq, priority}: AppSitemapEntry) {
13 | return `
14 |
15 | ${domainUrl}${route}
16 | ${lastmod ? `${lastmod}` : ''}
17 | ${changefreq ? `${changefreq}` : ''}
18 | ${priority ? `${priority}` : ''}
19 |
20 | `.trim()
21 | }
22 |
23 | const rawSitemapEntries = (
24 | await Promise.all(
25 | Object.entries(remixContext.routeModules).map(async ([id, mod]) => {
26 | if (id === 'root') return
27 | if (id.startsWith('routes/_')) return
28 | if (id.startsWith('__test_routes__')) return
29 |
30 | const handle = mod.handle as AppHandle | undefined
31 | if (handle?.getSitemapEntries) {
32 | return handle.getSitemapEntries(request)
33 | }
34 |
35 | const manifestEntry = remixContext.manifest.routes[id]
36 | if (!manifestEntry) {
37 | console.warn(`Could not find a manifest entry for ${id}`)
38 | return
39 | }
40 | let parentId = manifestEntry.parentId
41 | let parent = parentId ? remixContext.manifest.routes[parentId] : null
42 |
43 | let path
44 | if (manifestEntry.path) {
45 | path = removeTrailingSlash(manifestEntry.path)
46 | } else if (manifestEntry.index) {
47 | path = ''
48 | } else {
49 | return
50 | }
51 |
52 | while (parent) {
53 | // the root path is '/', so it messes things up if we add another '/'
54 | const parentPath = parent.path ? removeTrailingSlash(parent.path) : ''
55 | path = `${parentPath}/${path}`
56 | parentId = parent.parentId
57 | parent = parentId ? remixContext.manifest.routes[parentId] : null
58 | }
59 |
60 | // we can't handle dynamic routes, so if the handle doesn't have a
61 | // getSitemapEntries function, we just
62 | if (path.includes(':')) return
63 | if (id === 'root') return
64 |
65 | const entry: AppSitemapEntry = {route: removeTrailingSlash(path)}
66 | return entry
67 | }),
68 | )
69 | )
70 | .flatMap(z => z)
71 | .filter(typedBoolean)
72 |
73 | const sitemapEntries: Array = []
74 | for (const entry of rawSitemapEntries) {
75 | const existingEntryForRoute = sitemapEntries.find(
76 | e => e.route === entry.route,
77 | )
78 | if (existingEntryForRoute) {
79 | if (!isEqual(existingEntryForRoute, entry)) {
80 | console.warn(
81 | `Duplicate route for ${entry.route} with different sitemap data`,
82 | {entry, existingEntryForRoute},
83 | )
84 | }
85 | } else {
86 | sitemapEntries.push(entry)
87 | }
88 | }
89 |
90 | return `
91 |
92 |
97 | ${sitemapEntries.map(entry => getEntry(entry)).join('')}
98 |
99 | `.trim()
100 | }
101 |
102 | export {getSitemapXml}
103 |
--------------------------------------------------------------------------------
/content/blog/patch-an-npm-dependency-with-yarn.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Patch an NPM dependency with yarn
3 | description: |
4 | Fix a bug in a third party dependency without waiting for it to be approved
5 | and published by the maintainers.
6 | date: 2022-05-26
7 | categories:
8 | - javascript
9 | - node
10 | - yarn
11 | meta:
12 | keywords:
13 | - javascript
14 | - node
15 | - yarn
16 | bannerCloudinaryId: bereghici-dev/blog/VjP0l-APtpo_hegnhl
17 | ---
18 |
19 |
23 |
24 | When you have a bug in a third party dependency, usually you have a limited set
25 | of options to fix it.
26 |
27 | You can modify directly the local code of the dependency and make a new build.
28 | This works if it's a critical bug and you have to fix it as soon as possible.
29 | But, this is not the best option, because you'll lose the fix on the next `yarn`
30 | or `npm` install. Also, you won't be able to share the fix with your team.
31 |
32 | Another option is to fork the package, fix the bug and create a pull request. At
33 | this point, you can update your project dependencies and use your fork until the
34 | maintainers of the package approve it and publish a new version.
35 |
36 | ```json
37 | "dependencies": {
38 | "buggy-package": "your_user/buggy-package#bugfix"
39 | }
40 | ```
41 |
42 | This looks like a good option, now your team members will get the fix when they
43 | will update the project dependencies. The downside is that you'll have to
44 | maintain the fork and make sure it's up to date.
45 |
46 | Do we have a better option? Yes, we have. We can use the `yarn patch` command
47 | that was introduced in yarn v2.0.0. This allows you to instantly make and keep
48 | fixes to your dependencies without having to fork the packages.
49 |
50 | Let's take as an example the `remix-run` package. During the development I found
51 | an issue with the session storage. I opened a
52 | [pull request](https://github.com/remix-run/remix/pull/3113) to fix it, then I
53 | patched the package directly in the project using the command:
54 |
55 | ```bash
56 | yarn patch @remix-run/node@npm:1.5.1
57 |
58 | ➤ YN0000: Package @remix-run/node@npm:1.5.1 got extracted with success!
59 | ➤ YN0000: You can now edit the following folder: /private/var/folders/xm/qntd4h_97zn6w88tc95bsvxc0000gp/T/xfs-bfc9a229/user
60 | ➤ YN0000: Once you are done run yarn patch-commit -s /private/var/folders/xm/qntd4h_97zn6w88tc95bsvxc0000gp/T/xfs-bfc9a229/user and Yarn will store a patchfile based on your changes.
61 | ➤ YN0000: Done in 0s 68ms
62 | ```
63 |
64 | Once the package is extracted, we can open the created folder and make our
65 | changes there. One drawback is that you have to modify the production code,
66 | which might be minified and hard to debug. In my case was a simple change to the
67 | `fileStorage` module.
68 |
69 | The next step is to commit the patch using the command displayed earlier in the
70 | console
71 |
72 | ```js
73 | yarn patch-commit -s /private/var/folders/xm/qntd4h_97zn6w88tc95bsvxc0000gp/T/xfs-bfc9a229/user
74 | ```
75 |
76 | At this point we should see the following change in `package.json`
77 |
78 | ```json
79 | "resolutions": {
80 | "@remix-run/node@1.5.1": "patch:@remix-run/node@npm:1.5.1#.yarn/patches/@remix-run-node-npm-1.5.1-51061cf212.patch"
81 | }
82 | ```
83 |
84 | and in `.yarn/patches` you'll find the patch file.
85 |
86 | The benefits of using this approach are that the patch can be reviewed by your
87 | team and it doesn't require additional work to be applied compared to the fork.
88 | This should be used for critical fixes, if you need a new feature I would
89 | suggest to fork it instead. Also, do not forget to open an issue and create a
90 | pull request in the actual package.
91 |
--------------------------------------------------------------------------------
/app/utils/compile-mdx.server.ts:
--------------------------------------------------------------------------------
1 | import {bundleMDX} from 'mdx-bundler'
2 | import type TPQueue from 'p-queue'
3 | import type {ReadTimeResults} from 'reading-time'
4 | import calculateReadingTime from 'reading-time'
5 | import type {GitHubFile} from '~/types'
6 |
7 | async function compileMdx>(
8 | slug: string,
9 | githubFiles: Array,
10 | ): Promise<{
11 | frontmatter: FrontmatterType
12 | code: string
13 | readTime: ReadTimeResults
14 | } | null> {
15 | const indexFile = githubFiles.find(
16 | ({path}) =>
17 | path.includes(`${slug}/index.mdx`) || path.includes(`${slug}/index.md`),
18 | )
19 |
20 | if (!indexFile) {
21 | return null
22 | }
23 |
24 | const rootDir = indexFile.path.replace(/index.mdx?$/, '')
25 | const relativeFiles: Array = githubFiles.map(
26 | ({path, content}) => ({
27 | path: path.replace(rootDir, './'),
28 | content,
29 | }),
30 | )
31 |
32 | const files = arrayToObj(relativeFiles, {
33 | keyName: 'path',
34 | valueName: 'content',
35 | })
36 |
37 | try {
38 | const {default: remarkGfm} = await import('remark-gfm')
39 | const {default: rehypeSlug} = await import('rehype-slug')
40 | const {default: rehypeCodeTitles} = await import('rehype-code-titles')
41 | const {default: rehypeAutolinkHeadings} = await import(
42 | 'rehype-autolink-headings'
43 | )
44 | const {default: rehypePrism} = await import('rehype-prism-plus')
45 |
46 | const {frontmatter, code} = await bundleMDX({
47 | source: indexFile.content,
48 | files,
49 | xdmOptions(options) {
50 | options.remarkPlugins = [...(options.remarkPlugins ?? []), remarkGfm]
51 | options.rehypePlugins = [
52 | ...(options.rehypePlugins ?? []),
53 | rehypeSlug,
54 | rehypeCodeTitles,
55 | rehypePrism,
56 | [
57 | rehypeAutolinkHeadings,
58 | {
59 | properties: {
60 | className: ['anchor'],
61 | },
62 | },
63 | ],
64 | ]
65 | return options
66 | },
67 | })
68 |
69 | const readTime = calculateReadingTime(indexFile.content)
70 |
71 | return {
72 | code,
73 | readTime,
74 | frontmatter: frontmatter as FrontmatterType,
75 | }
76 | } catch (error: unknown) {
77 | console.error(`Compilation error for slug: `, slug)
78 | throw error
79 | }
80 | }
81 |
82 | function arrayToObj>(
83 | array: Array,
84 | {keyName, valueName}: {keyName: keyof ItemType; valueName: keyof ItemType},
85 | ) {
86 | const obj: Record = {}
87 | for (const item of array) {
88 | const key = item[keyName]
89 | if (typeof key !== 'string') {
90 | throw new Error(`${keyName} of item must be a string`)
91 | }
92 | const value = item[valueName]
93 | obj[key] = value
94 | }
95 | return obj
96 | }
97 |
98 | let _queue: TPQueue | null = null
99 | async function getQueue() {
100 | const {default: PQueue} = await import('p-queue')
101 | if (_queue) return _queue
102 |
103 | _queue = new PQueue({concurrency: 1})
104 | return _queue
105 | }
106 |
107 | // We have to use a queue because we can't run more than one of these at a time
108 | // or we'll hit an out of memory error because esbuild uses a lot of memory...
109 | async function queuedCompileMdx<
110 | FrontmatterType extends Record,
111 | >(...args: Parameters) {
112 | const queue = await getQueue()
113 | const result = await queue.add(() => compileMdx(...args))
114 | return result
115 | }
116 |
117 | export {queuedCompileMdx as compileMdx}
118 |
--------------------------------------------------------------------------------
/content/blog/cheat-sheets-for-web-developers.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Cheat Sheets for Web Developers
3 | description: Cheat Sheets that always save my time during web development 🚀
4 | date: 2021-12-24
5 | categories:
6 | - development
7 | - cheatsheets
8 | meta:
9 | keywords:
10 | - cheat sheet
11 | - cheatsheet
12 | - cheatsheets
13 | - web development
14 | - web development cheatsheet
15 | - shortcuts
16 | - javascript
17 | - git
18 | - css
19 | - html
20 | - css
21 | - typescript
22 | - accessibility
23 | - design patterns
24 | bannerCloudinaryId: bereghici-dev/blog/cheat-sheets-for-developers_azxvcj
25 | ---
26 |
27 |
31 |
32 | The front-end development evolves with incredible speed. Now it's a challenge to
33 | remember all the syntax, methods, or commands of a programming language,
34 | framework, or library. Here is where cheat sheets come in. They are great,
35 | intuitive resources that help you quickly find what you need.
36 |
37 | In this post, I want to share some of the most useful cheat sheets or references
38 | I've found it, and I use it daily.
39 |
40 | #### General
41 |
42 | [https://goalkicker.com](https://goalkicker.com/) - Programming Notes for
43 | Professionals books. It includes a lot of frameworks / programming languages.
44 | Probably the best and concise cheat sheets I've found.
45 |
46 | [https://devdocs.io](https://devdocs.io) - multiple API documentations in a
47 | fast, organized, and searchable interface.
48 |
49 | #### Accessibility
50 |
51 | [https://learn-the-web.algonquindesign.ca/topics/accessibility-cheat-sheet](https://learn-the-web.algonquindesign.ca/topics/accessibility-cheat-sheet/)
52 |
53 | [https://lab.abhinayrathore.com/aria-cheatsheet](https://lab.abhinayrathore.com/aria-cheatsheet/)
54 |
55 | [https://www.w3.org/TR/wai-aria-practices](https://www.w3.org/TR/wai-aria-practices/)
56 |
57 | ### HTML
58 |
59 | [https://digital.com/tools/html-cheatsheet](https://digital.com/tools/html-cheatsheet/)
60 |
61 | [https://htmlreference.io](https://htmlreference.io/)
62 |
63 | [https://quickref.me/html](https://quickref.me/html)
64 |
65 | [https://dev.w3.org/html5/html-author](https://dev.w3.org/html5/html-author/)
66 |
67 | ### CSS
68 |
69 | [https://cssreference.io](https://cssreference.io/)
70 |
71 | [https://quickref.me/css](https://quickref.me/css)
72 |
73 | [https://devdocs.io/css](https://devdocs.io/css/)
74 |
75 | [CSS Snippets](https://www.30secondsofcode.org/css/p/1)
76 |
77 | [Grid - A simple visual cheatsheet for CSS Grid Layout](https://grid.malven.co/)
78 |
79 | [Flex - A simple visual cheatsheet for flexbox](https://flexbox.malven.co/)
80 |
81 | ### Javascript
82 |
83 | [JavaScript Snippets](https://www.30secondsofcode.org/)
84 |
85 | [https://javascript.info](https://javascript.info/)
86 |
87 | [https://www.javascripttutorial.net/es-next](https://www.javascripttutorial.net/es-next/)
88 |
89 | [https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference)
90 |
91 | ### Typescript
92 |
93 | [https://rmolinamir.github.io/typescript-cheatsheet](https://rmolinamir.github.io/typescript-cheatsheet/)
94 |
95 | [https://github.com/sindresorhus/type-fest](https://github.com/sindresorhus/type-fest)
96 |
97 | [https://www.freecodecamp.org/news/advanced-typescript-types-cheat-sheet-with-examples](https://www.freecodecamp.org/news/advanced-typescript-types-cheat-sheet-with-examples/)
98 |
99 | [https://www.sitepen.com/blog/typescript-cheat-sheet](https://www.sitepen.com/blog/typescript-cheat-sheet)
100 |
101 | ### Git
102 |
103 | [Git Command Explorer](https://gitexplorer.com/)
104 |
105 | [Git Snippets](https://www.30secondsofcode.org/git/p/1)
106 |
107 | ### Design Patterns
108 |
109 | [https://www.patterns.dev/posts](https://www.patterns.dev/posts/)
110 |
111 | [https://refactoring.guru/design-patterns](https://refactoring.guru/design-patterns)
112 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const defaultTheme = require('tailwindcss/defaultTheme')
3 | const fromRoot = p => path.join(__dirname, p)
4 |
5 | module.exports = {
6 | darkMode: 'class',
7 | theme: {
8 | colors: {
9 | transparent: 'transparent',
10 | current: 'currentColor',
11 | white: '#fff',
12 | black: '#000',
13 | gray: {
14 | 50: '#F9FAFB',
15 | 100: '#fafafa',
16 | 200: '#eaeaea',
17 | 300: ' #999999',
18 | 400: '#888888',
19 | 500: '#666666',
20 | 600: '#444444',
21 | 700: '#333333',
22 | 800: '#222222',
23 | 900: '#111111',
24 | },
25 | yellow: {
26 | 500: '#F59E0B',
27 | },
28 | blue: {
29 | 100: '#e8f2ff',
30 | 200: '#bee3f8',
31 | 300: '#93C5FD',
32 | 400: '#60a5fa',
33 | 500: '#4b96ff',
34 | 600: '#2563eb',
35 | 700: '#2b6cb0',
36 | },
37 | red: {
38 | 500: '#eb5656',
39 | },
40 | green: {
41 | 100: '#ECFDF5',
42 | 500: '#10B981',
43 | 600: '#059669',
44 | },
45 | purple: {
46 | 500: '#8B5CF6',
47 | },
48 | pink: {
49 | 500: '#EC4899',
50 | },
51 | },
52 | extend: {
53 | fontFamily: {
54 | sans: ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
55 | },
56 | maxHeight: {
57 | '75vh': '75vh',
58 | },
59 | spacing: {
60 | '5vw': '5vw',
61 | },
62 | animation: {
63 | 'fade-in-stroke': 'fadeInStroke 0.5s ease-in-out',
64 | },
65 | keyframes: theme => ({
66 | fadeInStroke: {
67 | '0%': {stroke: theme('colors.transparent')},
68 | '100%': {stroke: theme('colors.current')},
69 | },
70 | }),
71 | typography: theme => ({
72 | DEFAULT: {
73 | css: {
74 | color: theme('colors.gray.700'),
75 | a: {
76 | color: theme('colors.blue.500'),
77 | '&:hover': {
78 | color: theme('colors.blue.700'),
79 | },
80 | code: {color: theme('colors.blue.400')},
81 | },
82 | 'h2,h3,h4': {
83 | 'scroll-margin-top': defaultTheme.spacing[32],
84 | },
85 | thead: {
86 | borderBottomColor: theme('colors.gray.200'),
87 | },
88 | code: {color: theme('colors.pink.500')},
89 | 'blockquote p:first-of-type::before': false,
90 | 'blockquote p:last-of-type::after': false,
91 | },
92 | },
93 | dark: {
94 | css: {
95 | color: theme('colors.gray.200'),
96 | a: {
97 | color: theme('colors.blue.400'),
98 | '&:hover': {
99 | color: theme('colors.blue.600'),
100 | },
101 | code: {color: theme('colors.blue.400')},
102 | },
103 | blockquote: {
104 | borderLeftColor: theme('colors.gray.700'),
105 | color: theme('colors.gray.300'),
106 | },
107 | 'h2,h3,h4': {
108 | color: theme('colors.gray.100'),
109 | 'scroll-margin-top': defaultTheme.spacing[32],
110 | },
111 | hr: {borderColor: theme('colors.gray.700')},
112 | ol: {
113 | li: {
114 | '&:before': {color: theme('colors.gray.500')},
115 | },
116 | },
117 | ul: {
118 | li: {
119 | '&:before': {backgroundColor: theme('colors.gray.500')},
120 | },
121 | },
122 | strong: {color: theme('colors.gray.100')},
123 | thead: {
124 | color: theme('colors.gray.100'),
125 | borderBottomColor: theme('colors.gray.600'),
126 | },
127 | tbody: {
128 | tr: {
129 | borderBottomColor: theme('colors.gray.700'),
130 | },
131 | },
132 | },
133 | },
134 | }),
135 | },
136 | },
137 | content: [fromRoot('./app/**/*.+(js|ts|tsx|mdx|md)')],
138 | plugins: [
139 | require('@tailwindcss/typography'),
140 | require('@tailwindcss/line-clamp'),
141 | ],
142 | }
143 |
--------------------------------------------------------------------------------
/app/utils/blog.server.tsx:
--------------------------------------------------------------------------------
1 | import type {Post as PrismaPost} from '@prisma/client'
2 | import type {MdxPage, MdxListItem, Post, PostItem, GithubUser} from '~/types'
3 | import {getMdxPage, getBlogMdxListItems} from './mdx'
4 | import type {Timings} from './metrics.server'
5 | import {typedBoolean} from './misc'
6 | import {prisma} from './prisma.server'
7 |
8 | function toPost(page: MdxPage, post: PrismaPost): Post {
9 | return {
10 | ...page,
11 | ...post,
12 | }
13 | }
14 |
15 | function toPostItem(page: MdxListItem, post: PrismaPost): PostItem {
16 | return {
17 | ...page,
18 | ...post,
19 | }
20 | }
21 |
22 | async function getAllPostViewsCount() {
23 | try {
24 | const allViews = await prisma.post.aggregate({
25 | _sum: {
26 | views: true,
27 | },
28 | })
29 |
30 | return Number(allViews._sum.views)
31 | } catch (error: unknown) {
32 | console.log(error)
33 | return 0
34 | }
35 | }
36 |
37 | async function getPostBySlug(slug: string) {
38 | return prisma.post.findFirst({
39 | where: {
40 | slug: {
41 | equals: slug,
42 | },
43 | },
44 | include: {
45 | comments: {
46 | orderBy: {
47 | createdAt: 'desc',
48 | },
49 | },
50 | },
51 | })
52 | }
53 |
54 | async function getPostsBySlugs(slugs: Array) {
55 | try {
56 | return prisma.post.findMany({
57 | where: {
58 | slug: {
59 | in: slugs,
60 | },
61 | },
62 | })
63 | } catch (error: unknown) {
64 | console.log(error)
65 | return []
66 | }
67 | }
68 |
69 | async function addPostRead(slug: string) {
70 | try {
71 | return await prisma.post.upsert({
72 | where: {slug},
73 | create: {
74 | slug,
75 | views: 1,
76 | },
77 | update: {
78 | views: {
79 | increment: 1,
80 | },
81 | },
82 | })
83 | } catch (error: unknown) {
84 | console.error(error)
85 | }
86 | }
87 |
88 | async function getAllPosts({
89 | limit,
90 |
91 | request,
92 | timings,
93 | }: {
94 | limit?: number
95 |
96 | request: Request
97 | timings?: Timings
98 | }): Promise> {
99 | let posts = await getBlogMdxListItems({
100 | request,
101 | timings,
102 | })
103 |
104 | if (limit) {
105 | posts = posts.slice(0, limit)
106 | }
107 |
108 | const dbPosts = await getPostsBySlugs(posts.map(p => p.slug))
109 |
110 | const postsWithViews = posts
111 | .map(async post => {
112 | const currentDbPost =
113 | dbPosts.find(view => view.slug === post.slug) ||
114 | (await createPost(post.slug))
115 |
116 | return {
117 | ...toPostItem(post, currentDbPost),
118 | }
119 | })
120 | .filter(typedBoolean)
121 |
122 | return Promise.all(postsWithViews)
123 | }
124 |
125 | async function createPost(slug: string) {
126 | return prisma.post.create({
127 | data: {
128 | slug,
129 | views: 0,
130 | },
131 | })
132 | }
133 |
134 | async function getPost({
135 | slug,
136 | request,
137 | timings,
138 | }: {
139 | slug: string
140 | request: Request
141 | timings?: Timings
142 | }): Promise {
143 | const page = await getMdxPage(
144 | {
145 | slug,
146 | contentDir: 'blog',
147 | },
148 | {request, timings},
149 | )
150 |
151 | if (!page) {
152 | return null
153 | }
154 |
155 | const post = (await getPostBySlug(slug)) || (await createPost(slug))
156 |
157 | return toPost(page, post)
158 | }
159 |
160 | async function createPostComment({
161 | postId,
162 | body,
163 | user,
164 | }: {
165 | body: string
166 | postId: string
167 | user: GithubUser
168 | }) {
169 | const authorName = user.name.givenName ?? user.displayName
170 |
171 | return prisma.comment.create({
172 | data: {
173 | body,
174 | authorName,
175 | authorAvatarUrl: user.photos?.[0]?.value,
176 | postId,
177 | },
178 | })
179 | }
180 |
181 | async function deletePostComment(commentId: string) {
182 | return prisma.comment.delete({
183 | where: {
184 | id: commentId,
185 | },
186 | })
187 | }
188 |
189 | export {
190 | getAllPosts,
191 | getPost,
192 | createPost,
193 | getPostsBySlugs,
194 | getPostBySlug,
195 | getAllPostViewsCount,
196 | addPostRead,
197 | createPostComment,
198 | deletePostComment,
199 | }
200 |
--------------------------------------------------------------------------------
/server/index.ts:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import onFinished from 'on-finished'
3 | import express from 'express'
4 | import compression from 'compression'
5 | import morgan from 'morgan'
6 | import * as Sentry from '@sentry/node'
7 | import {createRequestHandler} from '@remix-run/express'
8 | // eslint-disable-next-line import/no-extraneous-dependencies
9 | import {installGlobals} from '@remix-run/node'
10 |
11 | installGlobals()
12 |
13 | const here = (...d: Array) => path.join(__dirname, ...d)
14 |
15 | if (process.env.FLY) {
16 | Sentry.init({
17 | dsn: process.env.SENTRY_DSN,
18 | tracesSampleRate: 0.3,
19 | environment: process.env.NODE_ENV,
20 | })
21 | Sentry.setContext('region', {name: process.env.FLY_REGION ?? 'unknown'})
22 | }
23 |
24 | const MODE = process.env.NODE_ENV
25 | const BUILD_DIR = path.join(process.cwd(), 'build')
26 |
27 | const app = express()
28 |
29 | app.use((req, res, next) => {
30 | res.set('X-Powered-By', 'bereghici.dev')
31 | res.set('X-Fly-Region', process.env.FLY_REGION ?? 'unknown')
32 | // if they connect once with HTTPS, then they'll connect with HTTPS for the next hundred years
33 | res.set('Strict-Transport-Security', `max-age=${60 * 60 * 24 * 365 * 100}`)
34 | next()
35 | })
36 |
37 | app.use((req, res, next) => {
38 | const proto = req.get('X-Forwarded-Proto')
39 | const host = req.get('X-Forwarded-Host') ?? req.get('host')
40 | if (proto === 'http') {
41 | res.set('X-Forwarded-Proto', 'https')
42 | res.redirect(`https://${host}${req.originalUrl}`)
43 | return
44 | }
45 | next()
46 | })
47 |
48 | app.use((req, res, next) => {
49 | if (req.path.endsWith('/') && req.path.length > 1) {
50 | const query = req.url.slice(req.path.length)
51 | const safepath = req.path.slice(0, -1).replace(/\/+/g, '/')
52 | res.redirect(301, safepath + query)
53 | } else {
54 | next()
55 | }
56 | })
57 |
58 | app.use(compression())
59 |
60 | const publicAbsolutePath = here('../public')
61 |
62 | app.use(
63 | express.static(publicAbsolutePath, {
64 | maxAge: '1w',
65 | setHeaders(res, resourcePath) {
66 | const relativePath = resourcePath.replace(`${publicAbsolutePath}/`, '')
67 | if (relativePath.startsWith('build/info.json')) {
68 | res.setHeader('cache-control', 'no-cache')
69 | return
70 | }
71 | // If we ever change our font (which we quite possibly never will)
72 | // then we'll just want to change the filename or something...
73 | // Remix fingerprints its assets so we can cache forever
74 | if (
75 | relativePath.startsWith('fonts') ||
76 | relativePath.startsWith('build')
77 | ) {
78 | res.setHeader('cache-control', 'public, max-age=31536000, immutable')
79 | }
80 | },
81 | }),
82 | )
83 |
84 | app.use(morgan('tiny'))
85 |
86 | // log the referrer for 404s
87 | app.use((req, res, next) => {
88 | onFinished(res, () => {
89 | const referrer = req.get('referer')
90 | if (res.statusCode === 404 && referrer) {
91 | console.info(
92 | `👻 404 on ${req.method} ${req.path} referred by: ${referrer}`,
93 | )
94 | }
95 | })
96 | next()
97 | })
98 |
99 | app.all(
100 | '*',
101 | MODE === 'production'
102 | ? createRequestHandler({build: require('../build')})
103 | : (req, res, next) => {
104 | purgeRequireCache()
105 | return createRequestHandler({build: require('../build'), mode: MODE})(
106 | req,
107 | res,
108 | next,
109 | )
110 | },
111 | )
112 |
113 | const port = process.env.PORT ?? 3000
114 | app.listen(port, () => {
115 | // preload the build so we're ready for the first request
116 | // we want the server to start accepting requests asap, so we wait until now
117 | // to preload the build
118 | require('../build')
119 | console.log(`Express server listening on port ${port}`)
120 | })
121 |
122 | ////////////////////////////////////////////////////////////////////////////////
123 | function purgeRequireCache() {
124 | // purge require cache on requests for "server side HMR" this won't const
125 | // you have in-memory objects between requests in development,
126 | // alternatively you can set up nodemon/pm2-dev to restart the server on
127 | // file changes, we prefer the DX of this though, so we've included it
128 | // for you by default
129 | for (const key in require.cache) {
130 | if (key.startsWith(BUILD_DIR)) {
131 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
132 | delete require.cache[key]
133 | }
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/content/blog/headings-and-accessibility.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Headings & Accessibility
3 | description:
4 | How to use headings in web pages to help users get a sense of the page’s
5 | organization and structure without breaking accessibility.
6 | date: 2021-12-28
7 | categories:
8 | - react
9 | - accessibility
10 | meta:
11 | keywords:
12 | - react
13 | - accessibility
14 | - headings
15 | - web
16 | bannerCloudinaryId: bereghici-dev/blog/heading_accessibility_v31ihj
17 | ---
18 |
19 |
23 |
24 | Headings are used to organize content in a web page by separating it into
25 | meaningful sections. Well-written headings help users to scan quickly through
26 | the page and get a sense of whether the page contains the information they are
27 | looking for. Headings are critical for accessibility, the adaptive technology
28 | users rely on formatted headings to understand and navigate through the page.
29 | Without a good heading, the screen reader software read the entire content as a
30 | single section.
31 |
32 | Generally, it's considered bad practice to skip heading levels, for example
33 | having a `
` without a `
`. You can not only confuse screen readers but
34 | all readers when you don't follow a consistent pattern for your content.
35 |
36 | From practice, I've noticed that the developers are using the wrong element just
37 | because of style. You should NOT use different heading tags just for styling
38 | purposes.
39 |
40 | Also, in React it's very easy to end up with a wrong structure, especially when
41 | you move components with headings around. You have to check the levels if still
42 | make sense and adjust them if needed, so most of the time developers ends up
43 | with using only a `
` element or with a wrong structure.
44 |
45 | A very interesting solution for this problem I found in
46 | [baseweb](https://github.com/uber/baseweb), a component library created by
47 | **Uber.**
48 |
49 | Instead of worrying about what element you have to use, you can have a React
50 | Context that handles the document outline algorithm for you. Here is the
51 | `HeadingLevel` component that's used to track the heading levels.
52 |
53 | ```jsx
54 | export const HeadingLevel = ({children}: Props) => {
55 | const level = React.useContext(LevelContext)
56 |
57 | return (
58 | {children}
59 | )
60 | }
61 | ```
62 |
63 | Now the `Heading` component can consume the level and render the correct
64 | element. It contains validations to make sure you cannot have more than 6 levels
65 | deep. Also, it solves the styling problem. If you need to have a `
` element
66 | but styled as a `
`, you can use the `styleLevel` prop to specify it.
67 |
68 | ```jsx
69 | import { LevelContext } from "./heading-level";
70 |
71 | interface Props {
72 | styleLevel?: number;
73 | children: React.ReactNode;
74 | }
75 |
76 | const STYLES = ["", "h1", "h2", "h3", "h4", "h5", "h6"];
77 |
78 | const Heading = ({ styleLevel, children }: Props) => {
79 | const level = React.useContext(LevelContext);
80 |
81 | if (level === 0) {
82 | throw new Error(
83 | "Heading component must be a descendant of HeadingLevel component."
84 | );
85 | }
86 | if (level > 6) {
87 | throw new Error(
88 | `HeadingLevel cannot be nested ${level} times. The maximum is 6 levels.`
89 | );
90 | }
91 |
92 | if (typeof styleLevel !== "undefined" && (styleLevel < 1 || styleLevel > 6)) {
93 | throw new Error(`styleLevel = ${styleLevel} is out of 1-6 range.`);
94 | }
95 |
96 | const Element = `h${level}` as React.ElementType;
97 |
98 | const classes = styleLevel ? STYLES[styleLevel] : STYLES[level];
99 |
100 | return {children};
101 | };
102 | ```
103 |
104 | It might look a bit verbose, but now you don't have to worry about what element
105 | you should use, you just care about the levels. If you want to play around with
106 | this solution, you can use the sandbox below.
107 |
108 |
121 |
--------------------------------------------------------------------------------
/content/pages/cv/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Curriculum Vitae
3 | description: Curriculum Vitae
4 | meta:
5 | keywords:
6 | - bereghici
7 | - alexandru
8 | - about
9 | - information
10 | - cv
11 | - resume
12 | - bio
13 | bannerCloudinaryId: bereghici-dev/blog/avatar_bwdhvv
14 | ---
15 |
16 | ## 🎓 My education
17 |
18 | #### Technical University of Moldova
19 |
20 | 📅 2012 - 2016
21 |
22 | Bachelor's Degree in Information Technology, French section - Faculty of
23 | Computers, Informatics and Microelectronics
24 |
25 | ## 🎩 My experience
26 |
27 | #### 🌀 World-renowned travel web platform - React Developer / Team Leader
28 |
29 | 📅 February 2019 - Present
30 |
31 | - Building reusable components and front-end libraries for future use.
32 | Translating designs and wireframes into high-quality code.
33 |
34 | - Optimizing components for maximum performance across a vast array of
35 | web-capable devices and browsers.
36 |
37 | - Providing help to the maintenance of code quality, organization and
38 | automatization.
39 |
40 | - Acting as a Team Leader for the front-end development team.
41 |
42 | - Collaboration in the Agile environment.
43 |
44 | - Implementing project management tools using Jira and Confluence.
45 |
46 | 🛠️ **Technologies used:** Javascript, Typescript, NodeJS, React, Redux, Flow,
47 | GraphQL, Webpack, Rollup, Babel, CSS Modules, CSS-in-JS, Git, Jest, Enzyme,
48 | react-testing-library, Storybook, RushJS
49 |
50 | ---
51 |
52 | #### 🌀 Provider of day-to-day services - Web Developer / Mobile Developer / Team Leader.
53 |
54 | ##### 📅 June 2016 - January 2019
55 |
56 | - Developing new functionalities for two mobile applications (created with Ionic
57 | Framework).
58 | - Application deployment (on Apple Store and Google Play).
59 | - Development of two websites using Angular (a website for the clients and a
60 | dashboard for project management purposes).
61 | - Development of a Cordova plugin for iOS and Android to integrate with a
62 | thermal printer.
63 | - Acting as a Team Leader for the front-end development team.
64 | - Backlog refinement with the Product Owner and Scrum Master on front-end tasks.
65 |
66 | 🛠️ **Technologies used:** Javascript, Typescript, Angular, Ionic, Cordova,
67 | Webpack, SASS, Git, REST API
68 |
69 | ---
70 |
71 | #### 🌀 Pentalog - iOS Developer
72 |
73 | 📅 February 2016 - June 2016
74 |
75 | iOS Developer within a project focusing on developing an internal application
76 | called "Zimbra Shared Calendars". It is a mobile application running on iOS
77 | devices whose main aim is to facilitate the process of booking meeting rooms or
78 | creating meeting events. The application includes several features allowing
79 | users to:
80 |
81 | - Add one or more Zimbra accounts (e-mail server, web client used by Pentalog);
82 | - Create groups of calendars (people or resources);
83 | - View the availability of each calendar on a certain date;
84 | - View, create, edit, delete appointments. Analysis of the current business
85 | needs. Design, development and implementation of the software modules.
86 | Application testing and bug fixing to ensure proper functioning.
87 |
88 | 🛠️ **Technologies used:** Swift, Objective-C, SOAP API, XCode, Git
89 |
90 | ---
91 |
92 | #### 🌀 Trainee with the Pentalog Group
93 |
94 | 📅 June 2015 - August 2015
95 |
96 | Participation in a training session on mobile development. Study of mobile
97 | development concepts and techniques associated to Xamarin platform. Development
98 | of several applications implementing the acquired knowledge:
99 |
100 | - An application for controlling castle gates opening (Pentalog Orleans agency)
101 | by means of mobile phones and iBeacon tags; implementation of iBeacons for
102 | Android and iOS (Portable Xamarin Cross Platform Project).
103 |
104 | - A mobile application for managing DK-Fetes payments (coffee, sweets etc).
105 | Xamarin Cross Plaform integration with Portable projects (the UI and business
106 | layer are developed as shared project, while other platform-dependent elements
107 | are implemented using native libraries) and Shared projects (the business
108 | layer is developed as a shared project, whereas the UI is implemented with
109 | native libraries).
110 |
111 | 🛠️ **Technologies used:** C#, Xamarin, NodeJS, iBeacons, Python, Django
112 |
113 | ---
114 |
115 | #### 🌀 Frank Emerald - iOS Developer
116 |
117 | 📅 January 2015 - June 2015
118 |
119 | iOS Developer within a company providing development solutions and services for
120 | web and mobile platforms: applications for iPhone, iPad and Apple Watch,
121 | applications for all Android devices, web design, SEO, SMM, cyber security,
122 | consulting.
123 |
124 | 🛠️ **Technologies used:** Objective-C, XCode
125 |
--------------------------------------------------------------------------------
/app/components/speech-post.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {motion} from 'framer-motion'
3 | import {Paragraph} from './typography'
4 | import {ClientOnly} from './client-only'
5 |
6 | type Props = {
7 | contentRef: React.RefObject
8 | }
9 |
10 | function SpeechPost({contentRef}: Props) {
11 | const [playing, setPlaying] = React.useState(false)
12 | const [started, setStarted] = React.useState(false)
13 |
14 | const utteranceRef = React.useRef(null)
15 |
16 | const onUtteranceEnd = React.useCallback(() => {
17 | setStarted(false)
18 | }, [])
19 |
20 | React.useEffect(() => {
21 | const utterance = utteranceRef.current
22 | return () => {
23 | window.speechSynthesis.cancel()
24 | utterance?.removeEventListener('end', onUtteranceEnd)
25 | }
26 | }, [onUtteranceEnd])
27 |
28 | function play() {
29 | if (started) {
30 | window.speechSynthesis.resume()
31 | setPlaying(true)
32 | } else {
33 | if (contentRef.current?.textContent) {
34 | let utterance = utteranceRef.current
35 |
36 | utterance = new SpeechSynthesisUtterance(contentRef.current.textContent)
37 | utterance.rate = 1
38 | utterance.pitch = 0.7
39 | utterance.volume = 0.8
40 |
41 | window.speechSynthesis.cancel()
42 | window.speechSynthesis.speak(utterance)
43 |
44 | utterance.addEventListener('end', onUtteranceEnd)
45 |
46 | setStarted(true)
47 | setPlaying(true)
48 | }
49 | }
50 | }
51 |
52 | function pause() {
53 | setPlaying(false)
54 | window.speechSynthesis.pause()
55 | }
56 |
57 | function onClick() {
58 | if (playing) {
59 | pause()
60 | } else {
61 | play()
62 | }
63 | }
64 |
65 | return (
66 |
67 |
71 | {playing ? : }
72 |
73 |
74 |
75 | Would you prefer to have this post read to you?
76 |
77 |
83 | SpeechSynthesis is still experimental. This could be buggy
84 |
85 |
86 |
87 | )
88 | }
89 |
90 | const transition = {
91 | type: 'spring',
92 | stiffness: 200,
93 | damping: 10,
94 | }
95 |
96 | const variants = {
97 | initial: {rotate: 45},
98 | animate: {rotate: 0, transition},
99 | }
100 |
101 | function PlayIcon() {
102 | return (
103 |
123 | )
124 | }
125 |
126 | function PauseIcon() {
127 | return (
128 |
144 | )
145 | }
146 |
147 | export default function SpeechPostWrapper(props: Props) {
148 | const speechSupported =
149 | typeof window !== 'undefined' && 'speechSynthesis' in window
150 | return (
151 |
152 | {() => (speechSupported ? : null)}
153 |
154 | )
155 | }
156 |
--------------------------------------------------------------------------------
/content/blog/keeping-your-development-resources-organized-with-notion.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Keeping your development resources organized with Notion
3 | description:
4 | Keep your development resources in one place and organize them efficiently
5 | with Notion.
6 | date: 2022-10-03
7 | categories:
8 | - productivity
9 | meta:
10 | keywords:
11 | - notion
12 | - development
13 | - organize
14 | - productivity
15 | - programming
16 | - resources
17 | - rss feed
18 | - save to notion
19 | bannerCloudinaryId: bereghici-dev/blog/using_notion_as_developer_mauyax
20 | ---
21 |
22 |
26 |
27 | As a developer, I have a lot of learning resources and I quickly realized that
28 | keeping stuff in my browser's bookmark doesn't work for me. I needed a tool to
29 | keep everything in one place. I started to use [Notion](https://www.notion.so/)
30 | and it didn't disappoint me. In this article I want to share how I structured
31 | all my resources using this tool.
32 |
33 | ## Structure
34 |
35 | This is what my notion "homepage" looks like. I divided the articles and
36 | tutorials into different sections.
37 |
38 |
42 |
43 | Each section is divided into subsections. This allows me to keep the resources
44 | related to one topic and it helps me find things faster.
45 |
46 |
50 |
51 | ## Bookmarks
52 |
53 | Each subsection has a database where I store the bookmarks related to this
54 | specific topic. I'm using
55 | [Save to Notion](https://chrome.google.com/webstore/detail/save-to-notion/ldmmifpegigmeammaeckplhnjbbpccmm?hl=en)
56 | chrome extension to bookmark my links. The benefits of keeping the bookmarks in
57 | Notion are that you can add tags or notes, and you can filter or sort them by
58 | different criteria.
59 |
60 |
64 |
65 | ## Task List
66 |
67 | The importance of practice in programming cannot be ignored. "Practice makes a
68 | man perfect", they said. Often, just reading a tutorial or a book is not enough,
69 | you have to get your hands dirty with that specific technology / pattern /
70 | language / whatever you learned. In the task list I define small side-projects
71 | or things I need to practice. This is a great way to assess the progress.
72 |
73 |
77 |
78 | ## Reading list
79 |
80 | The Reading list is my collection of books. I found that taking notes while
81 | reading a book helps me process the information better. Also, I like being able
82 | to go back and search my notes and quickly find the most essential information
83 | from books I've read.
84 |
85 |
89 |
90 | ## Saved Tweets
91 |
92 | A majority of the most valuable information I consume online comes from tweets
93 | and threads. The goal is to keep everything in one place and hopefully there is
94 | a bot named [Save To Notion](https://twitter.com/SaveToNotion) that can save the
95 | tweets or threads directly in your notion by tagging the bot on a specific
96 | tweet.
97 |
98 |
102 |
103 | ## RSS Feed
104 |
105 | I bookmarked many useful blogs, but it was annoying to open each link manually
106 | to see if there is new content. A common way to follow the new content is using
107 | RSS feeds. Unfortunately, Notion doesn't provide this functionality. I solved
108 | this problem by creating a small application with Rust that allows you to manage
109 | the RSS sources in a separate notion page and daily reads the new content from
110 | your sources and saves them in a notion feed. The project and the setup
111 | instructions can be found here:
112 | [https://github.com/abereghici/notion-feed.rs](https://github.com/abereghici/notion-feed.rs)
113 |
114 | This is how looks the RSS sources:
115 |
116 |
120 |
121 | This is the RSS feed:
122 |
123 |
127 |
128 | ## Conclusion
129 |
130 | Notion is a great and flexible tool that can increase your productivity. It
131 | comes with a lot of templates that can cover all your needs. If you find other
132 | useful use cases, share them with us in the comments.
133 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix-bereghici-dev",
3 | "private": true,
4 | "description": "",
5 | "license": "",
6 | "sideEffects": false,
7 | "scripts": {
8 | "prebuild": "npm run clean && echo All clean ✨",
9 | "build": "npm run build:css:prod && npm run build:remix && npm run build:server && node ./other/generate-build-info",
10 | "build:css": "postcss styles/**/*.css --base styles --dir app/styles",
11 | "build:css:prod": "npm run build:css -- --env production",
12 | "build:remix": "cross-env NODE_ENV=production dotenv -e .env remix build --sourcemap",
13 | "build:server": "node ./other/build-server.js",
14 | "clean": "rimraf ./node_modules/.cache ./server/dist ./build ./public/build \"./app/styles/**/*.css\"",
15 | "css:watch": "npm run build:css -- --w",
16 | "cy:open": "cypress open",
17 | "cy:run": "cypress run",
18 | "dev": "pm2-dev ./other/pm2.config.js",
19 | "format": "prettier --write \"**/*.+(js|jsx|json|yml|yaml|css|less|scss|ts|tsx|md|gql|graphql|mdx|vue)\"",
20 | "lint": "eslint --cache --cache-location ./node_modules/.cache/.eslintcache --ext js,jsx,ts,tsx .",
21 | "prepare": "husky install",
22 | "setup": "npm install && docker compose up -d",
23 | "start": "cross-env NODE_ENV=production node --require ./node_modules/dotenv/config ./index.js",
24 | "start:mocks": "cross-env NODE_ENV=production node --require ./mocks --require ./node_modules/dotenv/config ./index.js",
25 | "test": "jest --passWithNoTests",
26 | "test:e2e:dev": "cross-env RUNNING_E2E=true ENABLE_TEST_ROUTES=true start-server-and-test dev http-get://localhost:3000/build/info.json cy:open",
27 | "test:e2e:run": "cross-env RUNNING_E2E=true PORT=8811 start-server-and-test start:mocks http-get://localhost:8811/build/info.json cy:run",
28 | "typecheck": "tsc -b && tsc -b cypress",
29 | "validate": "./other/validate"
30 | },
31 | "eslintIgnore": [
32 | "node_modules",
33 | "coverage",
34 | "server-build",
35 | "build",
36 | "public/build",
37 | "*.ignored/",
38 | "*.ignored.*"
39 | ],
40 | "dependencies": {
41 | "@octokit/plugin-throttling": "^3.5.2",
42 | "@octokit/rest": "^18.12.0",
43 | "@prisma/client": "^3.6.0",
44 | "@remix-run/express": "1.12.0",
45 | "@remix-run/react": "1.12.0",
46 | "@remix-run/node": "1.12.0",
47 | "@remix-run/server-runtime": "1.12.0",
48 | "@sentry/browser": "^6.16.1",
49 | "@sentry/node": "^6.16.1",
50 | "@sentry/tracing": "^6.16.1",
51 | "@tailwindcss/line-clamp": "^0.3.1",
52 | "@tailwindcss/typography": "^0.5.0",
53 | "cloudinary-build-url": "^0.2.1",
54 | "clsx": "^1.1.1",
55 | "compression": "^1.7.4",
56 | "cross-env": "^7.0.3",
57 | "date-fns": "^2.27.0",
58 | "dotenv": "^10.0.0",
59 | "error-stack-parser": "^2.0.6",
60 | "esbuild": "^0.14.5",
61 | "express": "^4.17.2",
62 | "framer-motion": "^5.6.0",
63 | "fs-extra": "^10.0.0",
64 | "glob": "^7.2.0",
65 | "lodash.isequal": "^4.5.0",
66 | "mdx-bundler": "^8.0.1",
67 | "morgan": "^1.10.0",
68 | "on-finished": "^2.3.0",
69 | "p-queue": "^7.1.0",
70 | "pm2": "^5.1.2",
71 | "prisma": "^3.6.0",
72 | "react": "^17.0.2",
73 | "react-dom": "^17.0.2",
74 | "react-textarea-autosize": "^8.3.3",
75 | "reading-time": "^1.5.0",
76 | "redis": "^3.1.2",
77 | "rehype-autolink-headings": "^6.1.0",
78 | "rehype-code-titles": "^1.0.3",
79 | "rehype-prism-plus": "^1.1.3",
80 | "rehype-slug": "^5.0.0",
81 | "remark-gfm": "^3.0.1",
82 | "remix-auth": "^3.2.1",
83 | "remix-auth-github": "^1.0.0",
84 | "remix-themes": "^1.4.0"
85 | },
86 | "devDependencies": {
87 | "@cld-apis/types": "^0.1.3",
88 | "@remix-run/dev": "1.12.0",
89 | "@remix-run/eslint-config": "1.12.0",
90 | "@testing-library/cypress": "^8.0.2",
91 | "@testing-library/jest-dom": "^5.16.1",
92 | "@testing-library/react": "^12.1.2",
93 | "@testing-library/react-hooks": "^7.0.2",
94 | "@testing-library/user-event": "^13.5.0",
95 | "@types/compression": "^1.7.2",
96 | "@types/cors": "^2.8.12",
97 | "@types/express": "^4.17.13",
98 | "@types/lodash.isequal": "^4.5.5",
99 | "@types/morgan": "^1.9.3",
100 | "@types/on-finished": "^2.3.1",
101 | "@types/react": "^17.0.37",
102 | "@types/react-dom": "^17.0.11",
103 | "@types/redis": "^2.8.32",
104 | "autoprefixer": "^10.4.0",
105 | "concurrently": "^6.5.1",
106 | "cssnano": "^5.0.15",
107 | "cypress": "^9.2.0",
108 | "dotenv-cli": "^4.1.1",
109 | "esbuild-jest": "^0.5.0",
110 | "esbuild-register": "^3.2.1",
111 | "eslint": "^8.5.0",
112 | "eslint-config-prettier": "^8.3.0",
113 | "eslint-plugin-prettier": "^4.0.0",
114 | "husky": "^7.0.4",
115 | "jest": "^27.4.5",
116 | "jest-watch-typeahead": "^1.0.0",
117 | "msw": "^0.36.3",
118 | "postcss": "^8.4.5",
119 | "postcss-cli": "^9.1.0",
120 | "postcss-import": "^14.0.2",
121 | "prettier": "^2.5.1",
122 | "rimraf": "^3.0.2",
123 | "start-server-and-test": "^1.14.0",
124 | "tailwindcss": "^3.0.7",
125 | "typescript": "4.6.2"
126 | },
127 | "engines": {
128 | "node": "16",
129 | "npm": "8"
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/content/blog/build-a-scalable-front-end-with-rush-monorepo-and-react--vscode.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: |
3 | Build a scalable front-end with Rush monorepo and React — VSCode
4 | description: |
5 | This is the 5th part of the blog series "Build a scalable front-end with Rush monorepo and React".
6 | In this post we'll add VSCode configurations to have a better development experience with monorepo.
7 | date: 2021-08-20
8 | categories:
9 | - react
10 | - monorepo
11 | meta:
12 | keywords:
13 | - react
14 | - monorepo
15 | - rushstack
16 | bannerCloudinaryId: bereghici-dev/blog/build-a-scalable-front-end-with-rush-monorepo-and-react-part5_wgbjfi
17 | ---
18 |
19 |
23 |
24 | This is the 5th part of the blog series "Build a scalable front-end with Rush
25 | monorepo and React"
26 |
27 | - [Part 1](/blog/build-a-scalable-front-end-with-rush-monorepo-and-react--repo-setup+import-projects+prettier):
28 | Monorepo setup, import projects with preserving git history, add Prettier
29 |
30 | - [Part 2](/blog/build-a-scalable-front-end-with-rush-monorepo-and-react--webpack+jest):
31 | Create build tools package with Webpack and Jest
32 |
33 | - [Part 3](/blog/build-a-scalable-front-end-with-rush-monorepo-and-react--eslint+lint-staged):
34 | Add shared ESLint configuration and use it with lint-staged
35 |
36 | - [Part 4](/blog/build-a-scalable-front-end-with-rush-monorepo-and-react--github-actions+netlify):
37 | Setup a deployment workflow with Github Actions and Netlify.
38 |
39 | - [Part 5](/blog/build-a-scalable-front-end-with-rush-monorepo-and-react--vscode):
40 | Add VSCode configurations for a better development experience.
41 |
42 | ---
43 |
44 | #### TL;DR
45 |
46 | If you're interested in just see the code, you can find it here:
47 | [https://github.com/abereghici/rush-monorepo-boilerplate](https://github.com/abereghici/rush-monorepo-boilerplate)
48 |
49 | If you want to see an example with Rush used in a real, large project, you can
50 | look at [ITwin.js](https://github.com/imodeljs/imodeljs), an open-source project
51 | developed by Bentley Systems.
52 |
53 | ---
54 |
55 | In previous posts, we added `prettier` and `eslint` to format our code and
56 | enforce a consistent code style across our projects. We can save time by
57 | automatically formatting pasted code, or fix `lint` errors while writing code,
58 | without running lint command to see all the errors.
59 |
60 | VSCode provides two different types of settings:
61 |
62 | - User Settings - applied to all VSCode instances
63 | - Workspace Settings - applied to the current project only.
64 |
65 | We'll use Workspace Settings and few extensions to improve our development
66 | experience in VSCode.
67 |
68 | #### Install extensions
69 |
70 | Let's add Prettier Formatter for VSCode. Launch VS Code Quick Open (Ctrl+P),
71 | paste the following command, and press enter.
72 |
73 | ```bash
74 | ext install esbenp.prettier-vscode
75 | ```
76 |
77 | or you can open
78 | [https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
79 | and install it manually.
80 |
81 | In the same manner, let's install VSCode ESLint extension:
82 |
83 | ```bash
84 | ext install dbaeumer.vscode-eslint
85 | ```
86 |
87 | or install manually from
88 | [https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
89 |
90 | #### Add settings
91 |
92 | Create a new file `.vscode/settings.json` in the root of our monorepo and let's
93 | add the following settings:
94 |
95 | ```json
96 | {
97 | "editor.defaultFormatter": "esbenp.prettier-vscode",
98 | "editor.tabSize": 2,
99 | "editor.insertSpaces": true,
100 | "editor.formatOnSave": true,
101 | "search.exclude": {
102 | "**/node_modules": true,
103 | "**/.nyc_output": true,
104 | "**/.rush": true
105 | },
106 | "files.exclude": {
107 | "**/.nyc_output": true,
108 | "**/.rush": true,
109 | "**/*.build.log": true,
110 | "**/*.build.error.log": true,
111 | "**/generated-docs": true,
112 | "**/package-deps.json": true,
113 | "**/test-apps/**/build": true
114 | },
115 | "files.trimTrailingWhitespace": true,
116 | "eslint.validate": [
117 | "javascript",
118 | "javascriptreact",
119 | "typescript",
120 | "typescriptreact"
121 | ],
122 |
123 | "eslint.workingDirectories": [
124 | {
125 | "mode": "auto"
126 | }
127 | ],
128 | "eslint.nodePath": "common/temp/node_modules",
129 | "eslint.trace.server": "verbose",
130 | "eslint.options": {
131 | "resolvePluginsRelativeTo": "node_modules/@monorepo/eslint-config"
132 | },
133 | "eslint.format.enable": true,
134 | "eslint.lintTask.enable": true,
135 | "editor.codeActionsOnSave": {
136 | "editor.action.fixAll": true,
137 | "source.fixAll.eslint": true
138 | }
139 | }
140 | ```
141 |
142 | In these settings we:
143 |
144 | - set Prettier as default formatter
145 | - exclude from search some irrelevant folders like `node_modules` and
146 | `.nyc_output`
147 | - exclude from VSCode file explorer irrelevant files
148 | - provide a nodePath for ESLint. We're not using `eslint` directly (we're using
149 | `lint` script from `react-scripts`) so we're helping the extension to find the
150 | `eslint` binary.
151 | - provide a path to `eslint` plugins. We're helping ESLint extension to pick up
152 | the right rules for each project.
153 |
154 | I hope you'll find these settings useful.
155 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {json} from '@remix-run/node'
3 | import type {LoaderFunction, MetaFunction} from '@remix-run/node'
4 | import {
5 | Links,
6 | Meta,
7 | Scripts,
8 | Outlet,
9 | LiveReload,
10 | useLoaderData,
11 | ScrollRestoration,
12 | useCatch,
13 | } from '@remix-run/react'
14 | import type {LinksFunction} from '@remix-run/node'
15 | import type {Theme} from 'remix-themes'
16 | import {ThemeProvider, useTheme, PreventFlashOnWrongTheme} from 'remix-themes'
17 | import {getDomainUrl, getDisplayUrl} from '~/utils/misc'
18 | import type {Timings} from '~/utils/metrics.server'
19 | import {getServerTimeHeader} from '~/utils/metrics.server'
20 | import {getSocialMetas} from './utils/seo'
21 | import {getEnv} from '~/utils/env.server'
22 | import {themeSessionResolver} from './utils/theme.server'
23 | import {getNowPlaying} from '~/utils/spotify.server'
24 | import {pathedRoutes} from '~/other-routes.server'
25 | import Navbar from '~/components/navbar'
26 | import Footer from '~/components/footer'
27 | import {FourOhFour, ServerError} from '~/components/errors'
28 | import type {SpotifySong} from '~/types'
29 |
30 | import tailwindStyles from './styles/tailwind.css'
31 | import proseStyles from './styles/prose.css'
32 | import globalStyles from './styles/global.css'
33 |
34 | export const links: LinksFunction = () => {
35 | return [
36 | {
37 | rel: 'preload',
38 | as: 'font',
39 | href: '/fonts/ibm-plex-sans-var.woff2',
40 | type: 'font/woff2',
41 | crossOrigin: 'anonymous',
42 | },
43 | {
44 | rel: 'preload',
45 | as: 'font',
46 | href: '/fonts/ibm-plex-sans-var-italic.woff2',
47 | type: 'font/woff2',
48 | crossOrigin: 'anonymous',
49 | },
50 | {rel: 'icon', href: '/favicon.ico'},
51 | {rel: 'preload', as: 'style', href: tailwindStyles},
52 | {rel: 'preload', as: 'style', href: proseStyles},
53 | {rel: 'preload', as: 'style', href: globalStyles},
54 | {rel: 'stylesheet', href: tailwindStyles},
55 | {rel: 'stylesheet', href: proseStyles},
56 | {rel: 'stylesheet', href: globalStyles},
57 | ]
58 | }
59 |
60 | export const meta: MetaFunction = ({data}) => {
61 | const requestInfo = (data as LoaderData | undefined)?.requestInfo
62 |
63 | const title = 'Alexandru Bereghici · bereghici.dev'
64 | const description = 'Software engineer specializing in JavaScript ecosystem'
65 |
66 | return {
67 | viewport: 'width=device-width,initial-scale=1,viewport-fit=cover',
68 | 'theme-color': '#111111',
69 | robots: 'index,follow',
70 | ...getSocialMetas({
71 | keywords: 'alexandru, bereghici, frontend, react, javascript, typescript',
72 | url: getDisplayUrl(requestInfo),
73 | image: 'bereghici-dev/blog/avatar_bwdhvv',
74 | title,
75 | description,
76 | }),
77 | }
78 | }
79 |
80 | export type LoaderData = {
81 | ENV: ReturnType
82 | nowPlayingSong: SpotifySong | null
83 | requestInfo: {
84 | origin: string
85 | path: string
86 | session: {
87 | theme: Theme | null
88 | }
89 | }
90 | }
91 |
92 | export const loader: LoaderFunction = async ({request}) => {
93 | // because this is called for every route, we'll do an early return for anything
94 | // that has a other route setup. The response will be handled there.
95 | if (pathedRoutes[new URL(request.url).pathname]) {
96 | return new Response()
97 | }
98 |
99 | const timings: Timings = {}
100 | const {getTheme} = await themeSessionResolver(request)
101 | const nowPlayingSong = await getNowPlaying()
102 |
103 | const data: LoaderData = {
104 | ENV: getEnv(),
105 | nowPlayingSong,
106 | requestInfo: {
107 | origin: getDomainUrl(request),
108 | path: new URL(request.url).pathname,
109 | session: {
110 | theme: getTheme(),
111 | },
112 | },
113 | }
114 |
115 | const headers: HeadersInit = new Headers()
116 | headers.append('Server-Timing', getServerTimeHeader(timings))
117 |
118 | return json(data, {headers})
119 | }
120 |
121 | function App() {
122 | const data = useLoaderData()
123 | const [theme] = useTheme()
124 |
125 | return (
126 |
127 |
128 |
129 |
130 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
148 |
152 |
166 |
167 |
168 | {process.env.NODE_ENV === 'development' ? : null}
169 |
170 |
171 | )
172 | }
173 |
174 | export default function AppWithProviders() {
175 | const data = useLoaderData()
176 |
177 | return (
178 |
182 |
183 |
184 | )
185 | }
186 |
187 | // best effort, last ditch error boundary. This should only catch root errors
188 | // all other errors should be caught by the index route which will include
189 | // the footer and stuff, which is much better.
190 | export function ErrorBoundary({error}: {error: Error}) {
191 | console.error(error)
192 | return (
193 |
194 |
195 | Oh no...
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 | )
205 | }
206 |
207 | export function CatchBoundary() {
208 | const caught = useCatch()
209 | console.error('CatchBoundary', caught)
210 | if (caught.status === 404) {
211 | return (
212 |
213 |
214 | Oh no...
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 | )
224 | }
225 | throw new Error(`Unhandled error: ${caught.status}`)
226 | }
227 |
--------------------------------------------------------------------------------
/content/blog/how-to-test-your-github-pull-requests-with-codesandbox-ci.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: How to test your GitHub Pull Requests with CodeSandbox CI
3 | description: |
4 | If you're an open source project maintainer or you plan to create one,
5 | you should consider using CodeSandbox CI in your project configuration.
6 | CodeSandbox CI it's an awesome GitHub application that auto-builds your
7 | open source project from pull requests. This can save a lot of time and
8 | effort to test and approve the changes.
9 | date: 2021-11-10
10 | categories:
11 | - react
12 | meta:
13 | keywords:
14 | - react
15 | - code-sandbox
16 | - code-sandbox-ci
17 | bannerCloudinaryId: bereghici-dev/blog/how-to-test-your-github-pull-requests-with-codesandbox-ci_ntggsx
18 | ---
19 |
20 |
24 |
25 | If you're an open source project maintainer or you plan to create one, you
26 | should consider using CodeSandbox CI in your project configuration. CodeSandbox
27 | CI it's an awesome GitHub application that auto-builds your open source project
28 | from pull requests. This can save a lot of time and effort to test and approve
29 | the changes.
30 |
31 | #### How it works?
32 |
33 | Whenever someone opens a new pull request, CodeSandbox CI builds a new version
34 | of your project. Those builds get posted to CodeSandbox registry, so you can
35 | test it in there or locally, and all without having to publish the build to
36 | npm.
37 |
38 | #### How do I set this up?
39 |
40 | Let's create a demo project to see CodeSandbox CI in action. For that, create a
41 | new project on GitHub and name it, for example, `codesandbox-ci-test`. Clone it
42 | locally and add a `package.json` file with the following content:
43 |
44 | ```json
45 | {
46 | "name": "codesandbox-ci-test",
47 | "version": "1.0.0",
48 | "main": "dist/index.js",
49 | "engines": {
50 | "node": ">=12"
51 | },
52 | "scripts": {
53 | "build": "kcd-scripts build"
54 | },
55 | "peerDependencies": {
56 | "react": "^17.0.2"
57 | },
58 | "devDependencies": {
59 | "kcd-scripts": "^11.2.2",
60 | "react": "^17.0.2"
61 | },
62 | "dependencies": {
63 | "@babel/runtime": "^7.16.0"
64 | }
65 | }
66 | ```
67 |
68 | This is a standard package.json file for a JavaScript project. We'll be using
69 | `kcd-scripts` to build our project, and we'll be using `react` to create a small
70 | reusable component for this demo. `@babel/runtime` is required by `kcd-scripts`,
71 | otherwise it won't build the project.
72 |
73 | In `src/index.js` create a simple Counter component:
74 |
75 | ```jsx
76 | import * as React from 'react'
77 |
78 | export default function Counter() {
79 | const [count, setCount] = React.useState(0)
80 |
81 | return (
82 |
83 |
You clicked {count} times!!!
84 |
85 |
86 | )
87 | }
88 | ```
89 |
90 | Install the CodeSandbox Github application from
91 | [https://github.com/apps/codesandbox](https://github.com/apps/codesandbox) in
92 | our new repository.
93 |
94 | Create a file called `ci.json` in a folder called `.codesandbox` in the root of
95 | the repository and add:
96 |
97 | ```json
98 | {
99 | "buildCommand": "build",
100 | "node": "12",
101 | "sandboxes": ["/cra-template"]
102 | }
103 | ```
104 |
105 | - `buildCommand` indicates which script in `package.json` should run to build
106 | the project.
107 | - `node` is the Node.js version to use for building the PR.
108 | - `sandboxes` is the list of sandboxes that we want to be generated. The default
109 | value is `vanilla`.
110 |
111 | We don't want to use a default sandbox, because we'll have to modify manually
112 | the sandbox code, to import and display the Counter component. Instead, we'll
113 | create a custom template, named `cra-template`.
114 |
115 | Create a new folder named `cra-template`, inside of this folder create a
116 | `package.json`:
117 |
118 | ```json
119 | {
120 | "name": "react-starter-example",
121 | "version": "1.0.0",
122 | "description": "React example starter project",
123 | "main": "src/index.js",
124 | "dependencies": {
125 | "react": "17.0.2",
126 | "react-dom": "17.0.2",
127 | "react-scripts": "4.0.0"
128 | },
129 | "devDependencies": {
130 | "@babel/runtime": "7.13.8",
131 | "typescript": "4.1.3"
132 | },
133 | "scripts": {
134 | "start": "react-scripts start",
135 | "build": "react-scripts build",
136 | "test": "react-scripts test --env=jsdom",
137 | "eject": "react-scripts eject"
138 | },
139 | "browserslist": [">0.2%", "not dead", "not ie <= 11", "not op_mini all"]
140 | }
141 | ```
142 |
143 | Create a `src` folder and a `index.js` file with:
144 |
145 | ```jsx
146 | import {StrictMode} from 'react'
147 | import ReactDOM from 'react-dom'
148 | import Counter from 'codesandbox-ci-test'
149 |
150 | const rootElement = document.getElementById('root')
151 | ReactDOM.render(
152 |
153 |
154 | ,
155 | rootElement,
156 | )
157 | ```
158 |
159 | Create a `public` folder with a `index.html` file with:
160 |
161 | ```html
162 |
163 |
164 |
165 |
166 |
170 |
171 |
172 |
173 | React App
174 |
175 |
176 |
177 |
178 |
179 |
180 | ```
181 |
182 | At this point we can create a new pull request and see our configuration in
183 | action. The CodeSandbox CI app will build the project and will leave a comment
184 | on the pull request.
185 |
186 |
190 |
191 | You can checkout the following links to see the result:
192 |
193 | CodeSandbox CI dashboard for PRs:
194 | [https://ci.codesandbox.io/status/abereghici/codesandbox-ci-test/pr/1/builds/186555](https://ci.codesandbox.io/status/abereghici/codesandbox-ci-test/pr/1/builds/186555)
195 |
196 | CodeSandbox app:
197 | [https://codesandbox.io/s/react-zmd24](https://codesandbox.io/s/react-zmd24)
198 |
199 | #### Useful Links & Documentation
200 |
201 | If you encountered any issues along the way, please check the Github repository:
202 | [https://github.com/abereghici/codesandbox-ci-test](https://github.com/abereghici/codesandbox-ci-test)
203 | with the code from this article.
204 |
205 | If you're interested in using CodeSandbox CI in a mono-repo project, you can
206 | check out the Design System project from Twilio
207 | [https://github.com/twilio-labs/paste](https://github.com/twilio-labs/paste) to
208 | see their configuration.
209 |
210 | For more information about CodeSandbox CI, please check out the
211 | [documentation](https://codesandbox.io/docs/ci).
212 |
--------------------------------------------------------------------------------
/app/utils/github.server.ts:
--------------------------------------------------------------------------------
1 | import nodePath from 'path'
2 | import {Octokit as createOctokit} from '@octokit/rest'
3 | import {throttling} from '@octokit/plugin-throttling'
4 | import type {GitHubFile, GitHubRepo} from '~/types'
5 |
6 | const Octokit = createOctokit.plugin(throttling)
7 |
8 | type ThrottleOptions = {
9 | method: string
10 | url: string
11 | request: {retryCount: number}
12 | }
13 | const octokit = new Octokit({
14 | auth: process.env.GITHUB_TOKEN,
15 | throttle: {
16 | onRateLimit: (retryAfter: number, options: ThrottleOptions) => {
17 | console.warn(
18 | `Request quota exhausted for request ${options.method} ${options.url}. Retrying after ${retryAfter} seconds.`,
19 | )
20 | return true
21 | },
22 | onAbuseLimit: (retryAfter: number, options: ThrottleOptions) => {
23 | // does not retry, only logs a warning
24 | octokit.log.warn(
25 | `Abuse detected for request ${options.method} ${options.url}`,
26 | )
27 | },
28 | },
29 | })
30 |
31 | async function downloadFirstMdxFile(
32 | list: Array<{name: string; type: string; path: string; sha: string}>,
33 | ) {
34 | const filesOnly = list.filter(({type}) => type === 'file')
35 | for (const extension of ['.mdx', '.md']) {
36 | const file = filesOnly.find(({name}) => name.endsWith(extension))
37 | if (file) return downloadFileBySha(file.sha)
38 | }
39 | return null
40 | }
41 |
42 | /**
43 | *
44 | * @param relativeMdxFileOrDirectory the path to the content. For example:
45 | * content/blog/first-post.mdx (pass "blog/first-post")
46 | * @returns A promise that resolves to an Array of GitHubFiles for the necessary files
47 | */
48 | async function downloadMdxFileOrDirectory(
49 | relativeMdxFileOrDirectory: string,
50 | ): Promise<{entry: string; files: Array}> {
51 | const mdxFileOrDirectory = `content/${relativeMdxFileOrDirectory}`
52 |
53 | const parentDir = nodePath.dirname(mdxFileOrDirectory)
54 | const dirList = await downloadDirList(parentDir)
55 |
56 | const basename = nodePath.basename(mdxFileOrDirectory)
57 | const mdxFileWithoutExt = nodePath.parse(mdxFileOrDirectory).name
58 | const potentials = dirList.filter(({name}) => name.startsWith(basename))
59 | const exactMatch = potentials.find(
60 | ({name}) => nodePath.parse(name).name === mdxFileWithoutExt,
61 | )
62 | const dirPotential = potentials.find(({type}) => type === 'dir')
63 |
64 | const content = await downloadFirstMdxFile(
65 | exactMatch ? [exactMatch] : potentials,
66 | )
67 | let files: Array = []
68 | let entry = mdxFileOrDirectory
69 | if (content) {
70 | // technically you can get the blog post by adding .mdx at the end... Weird
71 | // but may as well handle it since that's easy...
72 | entry = mdxFileOrDirectory.endsWith('.mdx')
73 | ? mdxFileOrDirectory
74 | : `${mdxFileOrDirectory}.mdx`
75 | // /content/about.mdx => entry is about.mdx, but compileMdx needs
76 | // the entry to be called "/content/index.mdx" so we'll set it to that
77 | // because this is the entry for this path
78 | files = [{path: nodePath.join(mdxFileOrDirectory, 'index.mdx'), content}]
79 | } else if (dirPotential) {
80 | entry = dirPotential.path
81 | files = await downloadDirectory(mdxFileOrDirectory)
82 | }
83 |
84 | return {entry, files}
85 | }
86 |
87 | /**
88 | *
89 | * @param dir the directory to download.
90 | * This will recursively download all content at the given path.
91 | * @returns An array of file paths with their content
92 | */
93 | async function downloadDirectory(dir: string): Promise> {
94 | const dirList = await downloadDirList(dir)
95 |
96 | const result = await Promise.all(
97 | dirList.map(async ({path: fileDir, type, sha}) => {
98 | switch (type) {
99 | case 'file': {
100 | const content = await downloadFileBySha(sha)
101 | return {path: fileDir, content}
102 | }
103 | case 'dir': {
104 | return downloadDirectory(fileDir)
105 | }
106 | default: {
107 | throw new Error(`Unexpected repo file type: ${type}`)
108 | }
109 | }
110 | }),
111 | )
112 |
113 | return result.flat()
114 | }
115 |
116 | /**
117 | *
118 | * @param sha the hash for the file (retrieved via `downloadDirList`)
119 | * @returns a promise that resolves to a string of the contents of the file
120 | */
121 | async function downloadFileBySha(sha: string) {
122 | const {data} = await octokit.request(
123 | 'GET /repos/{owner}/{repo}/git/blobs/{file_sha}',
124 | {
125 | owner: 'abereghici',
126 | repo: 'remix-bereghici-dev',
127 | file_sha: sha,
128 | },
129 | )
130 | const encoding = data.encoding as Parameters['1']
131 | return Buffer.from(data.content, encoding).toString()
132 | }
133 |
134 | async function downloadFile(path: string) {
135 | const {data} = (await octokit.request(
136 | 'GET /repos/{owner}/{repo}/contents/{path}',
137 | {
138 | owner: 'abereghici',
139 | repo: 'remix-bereghici-dev',
140 | path,
141 | },
142 | )) as {data: {content?: string; encoding?: string}}
143 |
144 | if (!data.content || !data.encoding) {
145 | console.error(data)
146 | throw new Error(
147 | `Tried to get ${path} but got back something that was unexpected. It doesn't have a content or encoding property`,
148 | )
149 | }
150 |
151 | const encoding = data.encoding as Parameters['1']
152 | return Buffer.from(data.content, encoding).toString()
153 | }
154 |
155 | /**
156 | *
157 | * @param path the full path to list
158 | * @returns a promise that resolves to a file ListItem of the files/directories in the given directory (not recursive)
159 | */
160 | async function downloadDirList(path: string) {
161 | const resp = await octokit.repos.getContent({
162 | owner: 'abereghici',
163 | repo: 'remix-bereghici-dev',
164 | path,
165 | })
166 | const data = resp.data
167 |
168 | if (!Array.isArray(data)) {
169 | throw new Error(
170 | `Tried to download content from ${path}. GitHub did not return an array of files. This should never happen...`,
171 | )
172 | }
173 |
174 | return data
175 | }
176 |
177 | interface RepoResponseData {
178 | user: {
179 | repositoriesContributedTo: {
180 | nodes: GitHubRepo[]
181 | }
182 | }
183 | }
184 |
185 | /**
186 | *
187 | * @returns a promise that resolves to an array of GitHubRepo repositories that the user has contributed to
188 | */
189 | async function getRepositoriesContributedTo() {
190 | const limit = 100
191 | const query = `
192 | query repositoriesContributedTo($username: String! $limit: Int!) {
193 | user (login: $username) {
194 | repositoriesContributedTo(last: $limit, privacy: PUBLIC, includeUserRepositories: false, contributionTypes: [COMMIT, PULL_REQUEST, REPOSITORY]) {
195 | nodes {
196 | id
197 | name
198 | url
199 | description
200 | owner {
201 | login
202 | }
203 | }
204 | }
205 | }
206 | }`
207 |
208 | const data = await octokit.graphql(query, {
209 | username: 'abereghici',
210 | limit,
211 | })
212 |
213 | return {
214 | contributedRepos: data.user.repositoriesContributedTo.nodes,
215 | }
216 | }
217 |
218 | export {
219 | downloadMdxFileOrDirectory,
220 | downloadDirList,
221 | downloadFile,
222 | getRepositoriesContributedTo,
223 | }
224 |
--------------------------------------------------------------------------------