├── .nvmrc ├── .gitattributes ├── cypress ├── fixtures │ └── example.json ├── support │ ├── index.ts │ └── commands.ts ├── .eslintrc.js ├── e2e │ ├── smoke.ts │ └── comments.ts ├── tsconfig.json └── plugins │ └── index.ts ├── .dockerignore ├── app ├── types.ts ├── components │ ├── navbar │ │ ├── index.ts │ │ ├── links.ts │ │ ├── skip-nav.tsx │ │ ├── navlink.tsx │ │ ├── menu-item.tsx │ │ ├── navbar.tsx │ │ ├── mobile-menu.tsx │ │ ├── menu-toggle.tsx │ │ └── theme-toggle.tsx │ ├── use-hydrated.ts │ ├── client-only.tsx │ ├── tag.tsx │ ├── button.tsx │ ├── responsive-container.tsx │ ├── github-repo-card.tsx │ ├── hero.tsx │ ├── link.tsx │ ├── blog-post-comment-authenticate.tsx │ ├── track-item.tsx │ ├── blog-post-card.tsx │ ├── blog-post-comment-input.tsx │ ├── blog-post.tsx │ ├── blog-post-comment.tsx │ ├── use-on-read.tsx │ ├── footer.tsx │ ├── typography.tsx │ ├── now-playing.tsx │ ├── errors.tsx │ └── speech-post.tsx ├── tsconfig.json ├── .eslintrc.js ├── routes │ ├── action │ │ ├── set-theme.tsx │ │ └── refresh-cache.tsx │ ├── auth.github.callback.tsx │ ├── healthcheck.tsx │ ├── refresh-commit-sha[.]json.tsx │ ├── top-tracks.tsx │ ├── about.tsx │ ├── cv.tsx │ ├── uses.tsx │ ├── blog │ │ └── index.tsx │ ├── github-activity.tsx │ └── index.tsx ├── entry.client.tsx ├── utils │ ├── env.server.ts │ ├── theme.server.ts │ ├── session.server.ts │ ├── auth.server.ts │ ├── seo.ts │ ├── metrics.server.ts │ ├── blog-rss-feed.server.ts │ ├── images.tsx │ ├── misc.tsx │ ├── prisma.server.tsx │ ├── homepage.server.ts │ ├── redis.server.ts │ ├── spotify.server.ts │ ├── sitemap.server.ts │ ├── compile-mdx.server.ts │ ├── blog.server.tsx │ └── github.server.ts ├── __test_routes__ │ └── github │ │ └── authenticate.tsx ├── other-routes.server.ts ├── entry.server.tsx └── root.tsx ├── cypress.json ├── .husky └── pre-commit ├── mocks ├── index.js ├── utils.ts ├── tsconfig.json ├── .eslintrc.js └── start.ts ├── public ├── icon.png ├── avatar.jpeg ├── favicon.ico ├── icon-512x512.png └── fonts │ ├── ibm-plex-sans-var.woff2 │ └── ibm-plex-sans-var-italic.woff2 ├── types ├── tsconfig.json ├── .eslintrc.js └── index.d.ts ├── server ├── .eslintrc.js ├── tsconfig.json └── index.ts ├── .prettierignore ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20220111130314_init │ │ └── migration.sql └── schema.prisma ├── index.js ├── postcss.config.js ├── .vscode └── settings.json ├── .gitignore ├── styles ├── global.css ├── tailwind.css ├── prose.css └── code-highlight.css ├── .eslintrc.js ├── other ├── validate ├── build-server.js ├── is-deployable.js ├── generate-build-info.js ├── refresh-on-content-changes.js ├── refresh-changed-content.js ├── pm2.config.js ├── utils.js └── get-changed-files.js ├── README.md ├── docker-compose.yml ├── prettier.config.js ├── .github └── workflows │ ├── refresh-content.yml │ └── nightly.yml ├── tsconfig.json ├── jest.config.js ├── content ├── pages │ ├── about │ │ └── index.mdx │ ├── uses │ │ └── index.mdx │ └── cv │ │ └── index.mdx └── blog │ ├── aws-ssm-node.mdx │ ├── patch-an-npm-dependency-with-yarn.mdx │ ├── cheat-sheets-for-web-developers.mdx │ ├── headings-and-accessibility.mdx │ ├── keeping-your-development-resources-organized-with-notion.mdx │ ├── build-a-scalable-front-end-with-rush-monorepo-and-react--vscode.mdx │ └── how-to-test-your-github-pull-requests-with-codesandbox-ci.mdx ├── .env.example ├── remix.config.js ├── fly.toml ├── Dockerfile ├── tailwind.config.js └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.13.1 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | Dockerfile -------------------------------------------------------------------------------- /app/types.ts: -------------------------------------------------------------------------------- 1 | export * from '../types' 2 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectId": "ygt3g8" 3 | } 4 | -------------------------------------------------------------------------------- /app/components/navbar/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './navbar' 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run validate -------------------------------------------------------------------------------- /mocks/index.js: -------------------------------------------------------------------------------- 1 | require('esbuild-register/dist/node').register() 2 | require('./start') 3 | -------------------------------------------------------------------------------- /mocks/utils.ts: -------------------------------------------------------------------------------- 1 | const isE2E = process.env.RUNNING_E2E === 'true' 2 | 3 | export {isE2E} 4 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["./**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /mocks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["./**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abereghici/remix-bereghici-dev/HEAD/public/icon.png -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["./**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/cypress/add-commands' 2 | import './commands' 3 | -------------------------------------------------------------------------------- /public/avatar.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abereghici/remix-bereghici-dev/HEAD/public/avatar.jpeg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abereghici/remix-bereghici-dev/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abereghici/remix-bereghici-dev/HEAD/public/icon-512x512.png -------------------------------------------------------------------------------- /public/fonts/ibm-plex-sans-var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abereghici/remix-bereghici-dev/HEAD/public/fonts/ibm-plex-sans-var.woff2 -------------------------------------------------------------------------------- /app/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | tsconfigRootDir: __dirname, 4 | project: './tsconfig.json', 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /cypress/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | tsconfigRootDir: __dirname, 4 | project: './tsconfig.json', 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /mocks/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | tsconfigRootDir: __dirname, 4 | project: './tsconfig.json', 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/fonts/ibm-plex-sans-var-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abereghici/remix-bereghici-dev/HEAD/public/fonts/ibm-plex-sans-var-italic.woff2 -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | tsconfigRootDir: __dirname, 4 | project: './tsconfig.json', 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /types/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | tsconfigRootDir: __dirname, 4 | project: './tsconfig.json', 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .DS_Store 3 | node_modules/ 4 | 5 | /.cache 6 | /build 7 | /server-build 8 | /public/build 9 | /coverage 10 | 11 | app/styles -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /app/components/navbar/links.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | {name: '🏠 Home', to: '/'}, 3 | {name: '📰 Blog', to: '/blog'}, 4 | {name: '🧑‍💻 About', to: '/about'}, 5 | ] 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === 'production') { 2 | require('./server-build') 3 | } else { 4 | require('esbuild-register/dist/node').register() 5 | require('./server') 6 | } 7 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["./**/*"], 4 | "compilerOptions": { 5 | "paths": { 6 | "~/*": ["../app/*"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import'), 4 | require('tailwindcss'), 5 | require('autoprefixer'), 6 | ...(process.env.NODE_ENV === 'production' ? [require('cssnano')] : []), 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Alexandru", 4 | "bereghici", 5 | "cloudinary", 6 | "clsx", 7 | "frontmatter", 8 | "pathed", 9 | "tailwindcss" 10 | ], 11 | "svn.ignoreMissingSvnWarning": true, 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | /.cache 5 | /build 6 | /server-build 7 | /public/build 8 | /coverage 9 | tsconfig.tsbuildinfo 10 | 11 | /app/styles/**/*.css 12 | other/postcss.ignored 13 | 14 | *.local.* 15 | 16 | .env 17 | .env.production 18 | .envrc 19 | 20 | *.ignored.* -------------------------------------------------------------------------------- /styles/global.css: -------------------------------------------------------------------------------- 1 | ::selection { 2 | background-color: #47a3f3; 3 | color: #fefefe; 4 | } 5 | 6 | /* Remove Safari input shadow on mobile */ 7 | input[type='text'], 8 | input[type='email'] { 9 | -webkit-appearance: none; 10 | -moz-appearance: none; 11 | appearance: none; 12 | } 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@remix-run/eslint-config', 4 | '@remix-run/eslint-config/jest', 5 | 'plugin:prettier/recommended', 6 | ], 7 | parserOptions: { 8 | tsconfigRootDir: __dirname, 9 | project: './tsconfig.json', 10 | }, 11 | rules: { 12 | 'no-console': 'off', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /app/routes/action/set-theme.tsx: -------------------------------------------------------------------------------- 1 | import {createThemeAction} from 'remix-themes' 2 | import {themeSessionResolver} from '~/utils/theme.server' 3 | import type {AppHandle} from '~/types' 4 | 5 | export const handle: AppHandle = { 6 | getSitemapEntries: () => null, 7 | } 8 | 9 | export const action = createThemeAction(themeSessionResolver) 10 | -------------------------------------------------------------------------------- /app/components/use-hydrated.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react' 2 | 3 | let hydrating = true 4 | 5 | export function useHydrated() { 6 | let [hydrated, setHydrated] = useState(() => !hydrating) 7 | 8 | useEffect(function hydrate() { 9 | hydrating = false 10 | setHydrated(true) 11 | }, []) 12 | 13 | return hydrated 14 | } 15 | -------------------------------------------------------------------------------- /app/components/client-only.tsx: -------------------------------------------------------------------------------- 1 | import type {ReactNode} from 'react' 2 | import React from 'react' 3 | import {useHydrated} from './use-hydrated' 4 | 5 | type Props = { 6 | children(): ReactNode 7 | fallback?: ReactNode 8 | } 9 | 10 | export function ClientOnly({children, fallback = null}: Props) { 11 | return useHydrated() ? <>{children()} : <>{fallback} 12 | } 13 | -------------------------------------------------------------------------------- /app/components/navbar/skip-nav.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export default function SkipNav() { 4 | return ( 5 | 9 | Skip to content 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /other/validate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npx concurrently \ 4 | --kill-others-on-fail \ 5 | --prefix "[{name}]" \ 6 | --names "test,lint,typecheck,build" \ 7 | --prefix-colors "bgRed.bold.white,bgGreen.bold.white,bgBlue.bold.white,bgMagenta.bold.white" \ 8 | "npm run test --silent -- --watch=false" \ 9 | "npm run lint --silent" \ 10 | "npm run typecheck --silent" \ 11 | "npm run build --silent" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bereghici.dev (Remix version) 2 | 3 | [![Build Status][build-badge]][build] 4 | 5 | 6 | [build-badge]: https://img.shields.io/github/workflow/status/abereghici/remix-bereghici-dev/%F0%9F%9A%80%20Deploy?logo=github&style=flat-square 7 | [build]: https://github.com/abereghici/remix-bereghici-dev/actions?query=workflow%3A%22%F0%9F%9A%80+Deploy%22++ 8 | 9 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import {RemixBrowser as Remix} from '@remix-run/react' 4 | import * as Sentry from '@sentry/browser' 5 | import {Integrations} from '@sentry/tracing' 6 | 7 | Sentry.init({ 8 | dsn: window.ENV.SENTRY_DSN, 9 | release: 'bereghici.dev@1.0.0', 10 | integrations: [new Integrations.BrowserTracing()], 11 | tracesSampleRate: 1.0, 12 | }) 13 | 14 | ReactDOM.hydrate(, document) 15 | -------------------------------------------------------------------------------- /app/components/tag.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import * as React from 'react' 3 | 4 | export default function Tag({ 5 | category, 6 | className, 7 | }: { 8 | category: string 9 | className?: string 10 | }) { 11 | const classes = clsx( 12 | 'text-xs font-bold leading-sm px-2 py-1 bg-blue-200 text-blue-700 rounded-full', 13 | className, 14 | ) 15 | return ( 16 | 17 | #{category} 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | redis: 4 | image: 'redis:alpine' 5 | command: redis-server --requirepass alex_rocks 6 | expose: 7 | - '6379' 8 | volumes: 9 | - ./.cache/redis:/data 10 | ports: 11 | - '6379:6379' 12 | database: 13 | image: 'postgres:13' 14 | environment: 15 | - POSTGRES_USER=alex 16 | - POSTGRES_PASSWORD=alex_rocks 17 | - POSTGRES_DB=bereghici_dev_db 18 | ports: 19 | - '5432:5432' 20 | -------------------------------------------------------------------------------- /mocks/start.ts: -------------------------------------------------------------------------------- 1 | import {setupServer} from 'msw/node' 2 | import {githubHandlers} from './github' 3 | import {spotifyHandlers} from './spotify' 4 | import {isE2E} from './utils' 5 | 6 | const server = setupServer(...githubHandlers, ...spotifyHandlers) 7 | 8 | server.listen({onUnhandledRequest: 'warn'}) 9 | console.info('🔶 Mock server installed') 10 | if (isE2E) console.info('running in E2E mode') 11 | 12 | process.once('SIGINT', () => server.close()) 13 | process.once('SIGTERM', () => server.close()) 14 | -------------------------------------------------------------------------------- /app/components/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | type Props = JSX.IntrinsicElements['button'] 4 | 5 | const classes = 6 | 'flex items-center justify-center font-medium h-8 bg-green-600 hover:bg-green-500 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-100 rounded w-28' 7 | 8 | export default function Button({children, className, ...rest}: Props) { 9 | return ( 10 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/utils/env.server.ts: -------------------------------------------------------------------------------- 1 | function getEnv() { 2 | return { 3 | FLY: process.env.FLY, 4 | NODE_ENV: process.env.NODE_ENV, 5 | PRIMARY_REGION: process.env.PRIMARY_REGION, 6 | GA_TRACKING_ID: process.env.GA_TRACKING_ID, 7 | SENTRY_DSN: process.env.SENTRY_DSN, 8 | } 9 | } 10 | 11 | type ENV = ReturnType 12 | 13 | // App puts these on 14 | declare global { 15 | // eslint-disable-next-line 16 | var ENV: ENV 17 | interface Window { 18 | ENV: ENV 19 | } 20 | } 21 | 22 | export {getEnv} 23 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | process.env.RUNNING_PRETTIER = 'true' 2 | module.exports = { 3 | arrowParens: 'avoid', 4 | bracketSpacing: false, 5 | embeddedLanguageFormatting: 'auto', 6 | endOfLine: 'lf', 7 | htmlWhitespaceSensitivity: 'css', 8 | insertPragma: false, 9 | jsxSingleQuote: false, 10 | printWidth: 80, 11 | proseWrap: 'always', 12 | quoteProps: 'as-needed', 13 | requirePragma: false, 14 | semi: false, 15 | singleQuote: true, 16 | tabWidth: 2, 17 | trailingComma: 'all', 18 | useTabs: false, 19 | } 20 | -------------------------------------------------------------------------------- /app/utils/theme.server.ts: -------------------------------------------------------------------------------- 1 | import {createCookieSessionStorage} from '@remix-run/node' 2 | import {createThemeSessionResolver} from 'remix-themes' 3 | import {getRequiredServerEnvVar} from './misc' 4 | 5 | export const themeSessionResolver = createThemeSessionResolver( 6 | createCookieSessionStorage({ 7 | cookie: { 8 | name: 'bereghici_dev_theme', 9 | secure: true, 10 | sameSite: 'lax', 11 | secrets: [getRequiredServerEnvVar('SESSION_SECRET')], 12 | path: '/', 13 | expires: new Date('2100-08-14'), 14 | httpOnly: true, 15 | }, 16 | }), 17 | ) 18 | -------------------------------------------------------------------------------- /app/routes/auth.github.callback.tsx: -------------------------------------------------------------------------------- 1 | import type {LoaderFunction} from '@remix-run/node' 2 | import type {AppHandle} from '~/types' 3 | import {authenticator} from '~/utils/auth.server' 4 | import {getSession} from '~/utils/session.server' 5 | 6 | export const handle: AppHandle = { 7 | getSitemapEntries: () => null, 8 | } 9 | 10 | export let loader: LoaderFunction = async ({request}) => { 11 | let session = await getSession(request) 12 | const redirectUrl = session.get('redirectUrl') ?? '/' 13 | 14 | return await authenticator.authenticate('github', request, { 15 | successRedirect: redirectUrl, 16 | failureRedirect: redirectUrl, 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/refresh-content.yml: -------------------------------------------------------------------------------- 1 | name: 🥬 Refresh Content 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | refresh: 9 | name: 🥬 Refresh Content 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: ⬇️ Checkout repo 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: '50' 16 | 17 | - name: ⎔ Setup node 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 16 21 | 22 | - name: 🥬 Refresh Content 23 | run: node ./other/refresh-changed-content.js ${{ github.sha }} 24 | env: 25 | REFRESH_CACHE_SECRET: ${{ secrets.REFRESH_CACHE_SECRET }} 26 | -------------------------------------------------------------------------------- /app/routes/healthcheck.tsx: -------------------------------------------------------------------------------- 1 | import type {LoaderFunction} from '@remix-run/node' 2 | import {getAllPosts} from '~/utils/blog.server' 3 | 4 | export const loader: LoaderFunction = async ({request}) => { 5 | const host = 6 | request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') 7 | 8 | try { 9 | await Promise.all([ 10 | getAllPosts({limit: 1, request}), 11 | fetch(`http://${host}`, {method: 'HEAD'}).then(r => { 12 | if (!r.ok) return Promise.reject(r) 13 | }), 14 | ]) 15 | return new Response('OK') 16 | } catch (error: unknown) { 17 | console.log('healthcheck ❌', {error}) 18 | return new Response('ERROR', {status: 500}) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace Cypress { 3 | interface Chainable { 4 | /** 5 | * authenticate a test user with github 6 | * 7 | * @returns {typeof authenticate} 8 | * @memberof Chainable 9 | * @example 10 | * cy.authenticate() 11 | */ 12 | authenticate: typeof authenticate 13 | } 14 | } 15 | } 16 | 17 | export function authenticate({redirectUrl}: {redirectUrl: string}) { 18 | const query = new URLSearchParams() 19 | query.set('redirectUrl', redirectUrl) 20 | 21 | return cy.visit(`/__tests/github/authenticate?${query.toString()}`) 22 | } 23 | 24 | Cypress.Commands.add('authenticate', authenticate) 25 | -------------------------------------------------------------------------------- /app/routes/refresh-commit-sha[.]json.tsx: -------------------------------------------------------------------------------- 1 | import type {LoaderFunction} from '@remix-run/node' 2 | import type {AppHandle} from '~/types' 3 | import {redisCache} from '~/utils/redis.server' 4 | import {commitShaKey as refreshCacheCommitShaKey} from './action/refresh-cache' 5 | 6 | export const handle: AppHandle = { 7 | getSitemapEntries: () => null, 8 | } 9 | 10 | export const loader: LoaderFunction = async () => { 11 | const shaInfo = await redisCache.get(refreshCacheCommitShaKey) 12 | const data = JSON.stringify(shaInfo) 13 | return new Response(data, { 14 | headers: { 15 | 'Content-Type': 'application/json', 16 | 'Content-Length': String(Buffer.byteLength(data)), 17 | }, 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /cypress/e2e/smoke.ts: -------------------------------------------------------------------------------- 1 | describe('smoke', () => { 2 | it('should allow a typical user flow', () => { 3 | cy.visit('/') 4 | 5 | cy.findByRole('navigation').within(() => { 6 | cy.findByRole('link', {name: /📰 blog/i}).click() 7 | }) 8 | 9 | cy.location('pathname', {timeout: 10000}).should('include', '/blog') 10 | 11 | cy.findByRole('heading', { 12 | name: /headings & accessibility/i, 13 | }) 14 | .should('be.visible') 15 | .click({force: true}) 16 | 17 | cy.get('html').should('have.class', 'light') 18 | 19 | cy.findByRole('button', { 20 | name: /toggle theme/i, 21 | }).click() 22 | 23 | cy.get('html').should('have.class', 'dark') 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /app/components/responsive-container.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import clsx from 'clsx' 3 | 4 | interface Props { 5 | as?: React.ElementType | string 6 | className?: string 7 | children?: React.ReactNode 8 | } 9 | const ResponsiveContainer = React.forwardRef( 10 | function ResponsiveContainer( 11 | {children, as: Component = 'div', className}, 12 | ref, 13 | ) { 14 | return ( 15 | 22 | {children} 23 | 24 | ) 25 | }, 26 | ) 27 | 28 | export default ResponsiveContainer 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["./cypress"], 3 | "include": ["**/*.ts", "**/*.tsx"], 4 | "compilerOptions": { 5 | "lib": ["ES2021", "DOM", "DOM.Iterable"], 6 | "incremental": true, 7 | "isolatedModules": true, 8 | "importsNotUsedAsValues": "error", 9 | "noEmit": true, 10 | "allowJs": true, 11 | "esModuleInterop": true, 12 | "jsx": "react-jsx", 13 | "moduleResolution": "node", 14 | "module": "es2020", 15 | "target": "es2020", 16 | "strict": true, 17 | "skipLibCheck": true, 18 | "resolveJsonModule": true, 19 | "noUncheckedIndexedAccess": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "typeRoots": ["./types", "./node_modules/@types"], 22 | "baseUrl": ".", 23 | "paths": { 24 | "~/*": ["./app/*"] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/utils/session.server.ts: -------------------------------------------------------------------------------- 1 | import type {Session} from '@remix-run/node' 2 | import {createCookieSessionStorage} from '@remix-run/node' 3 | import {getRequiredServerEnvVar} from './misc' 4 | 5 | const sessionExpirationTime = 1000 * 60 * 60 * 24 * 365 // 1 year 6 | 7 | export let sessionStorage = createCookieSessionStorage({ 8 | cookie: { 9 | name: '__bereghici.dev_session', 10 | secure: true, 11 | secrets: [getRequiredServerEnvVar('SESSION_SECRET')], 12 | sameSite: 'lax', 13 | path: '/', 14 | maxAge: sessionExpirationTime / 1000, 15 | httpOnly: true, 16 | }, 17 | }) 18 | 19 | export function getSession(request: Request): Promise { 20 | return sessionStorage.getSession(request.headers.get('Cookie')) 21 | } 22 | 23 | export let {commitSession, destroySession} = sessionStorage 24 | -------------------------------------------------------------------------------- /app/components/github-repo-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type {GitHubRepo} from '~/types' 3 | import Link from './link' 4 | import {Paragraph} from './typography' 5 | 6 | export default function GithubRepoCard({repo}: {repo: GitHubRepo}) { 7 | const {name, description, owner} = repo 8 | return ( 9 | 14 |
15 | {owner.login}/ 16 | {name} 17 |
18 | {description && {description}} 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/components/navbar/navlink.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {NavLink} from '@remix-run/react' 3 | import clsx from 'clsx' 4 | 5 | export default function NavigationLink({ 6 | children, 7 | ...rest 8 | }: Parameters['0']) { 9 | return ( 10 |
  • 11 | 14 | clsx( 15 | 'dark:hover:bg-gray-700 hidden p-1 hover:bg-gray-200 rounded-lg sm:px-3 sm:py-2 md:inline-block', 16 | { 17 | 'font-bold text-gray-800 dark:text-gray-100': isActive, 18 | 'font-normal text-gray-600 dark:text-gray-200': !isActive, 19 | }, 20 | ) 21 | } 22 | {...rest} 23 | > 24 | {children} 25 | 26 |
  • 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const fromRoot = d => path.join(__dirname, d) 4 | module.exports = { 5 | roots: [fromRoot('app'), fromRoot('content')], 6 | resetMocks: true, 7 | coveragePathIgnorePatterns: [], 8 | collectCoverageFrom: ['**/app/**/*.{js,ts,tsx}'], 9 | coverageThreshold: null, 10 | testEnvironment: 'jsdom', 11 | transform: { 12 | '^.+\\.tsx?$': 'esbuild-jest', 13 | '^.+\\.jsx?$': 'esbuild-jest', 14 | }, 15 | setupFilesAfterEnv: ['@testing-library/jest-dom'], 16 | moduleDirectories: ['node_modules', fromRoot('tests')], 17 | moduleFileExtensions: ['js', 'jsx', 'json', 'ts', 'tsx'], 18 | moduleNameMapper: { 19 | '~/(.*)': fromRoot('app/$1'), 20 | }, 21 | watchPlugins: [ 22 | 'jest-watch-typeahead/filename', 23 | 'jest-watch-typeahead/testname', 24 | ], 25 | } 26 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "../node_modules/@types/jest", 4 | "../node_modules/@testing-library/jest-dom" 5 | ], 6 | "include": [ 7 | "./index.ts", 8 | "e2e/**/*", 9 | "plugins/**/*", 10 | "support/**/*", 11 | "../node_modules/cypress", 12 | "../node_modules/@testing-library/cypress" 13 | ], 14 | "compilerOptions": { 15 | "baseUrl": ".", 16 | "noEmit": true, 17 | "types": ["node", "cypress", "@testing-library/cypress"], 18 | "esModuleInterop": true, 19 | "jsx": "react", 20 | "moduleResolution": "node", 21 | "target": "es2019", 22 | "strict": true, 23 | "skipLibCheck": true, 24 | "resolveJsonModule": true, 25 | "typeRoots": ["../types", "../node_modules/@types"], 26 | 27 | "paths": { 28 | "~/*": ["../app/*"] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/components/navbar/menu-item.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {NavLink} from '@remix-run/react' 3 | import clsx from 'clsx' 4 | 5 | export default function MenuItem({ 6 | children, 7 | ...rest 8 | }: Parameters['0']) { 9 | return ( 10 |
  • 11 | 14 | clsx('dark:hover:bg-gray-800 flex p-6 w-auto hover:bg-gray-200', { 15 | 'font-semibold text-gray-800 dark:text-gray-200': isActive, 16 | 'font-normal text-gray-600 dark:text-gray-400': !isActive, 17 | }) 18 | } 19 | {...rest} 20 | > 21 | {children} 22 | 23 |
  • 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /app/components/hero.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {H1, Paragraph} from '~/components/typography' 3 | 4 | export default function Hero() { 5 | return ( 6 |
    7 |
    8 |

    Alexandru Bereghici

    9 | 10 | Software engineer specializing in JavaScript ecosystem. 11 | 12 |
    13 |
    14 | Alexandru Bereghici 21 |
    22 |
    23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /app/components/navbar/navbar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import ResponsiveContainer from '~/components/responsive-container' 3 | import NavLink from './navlink' 4 | import ThemeToggle from './theme-toggle' 5 | import MobileMenu from './mobile-menu' 6 | import SkipNav from './skip-nav' 7 | 8 | import LINKS from './links' 9 | 10 | export default function NavBar() { 11 | return ( 12 | 16 | 17 |
    18 | 19 |
      20 | {LINKS.map(link => ( 21 | 22 | {link.name} 23 | 24 | ))} 25 |
    26 |
    27 | 28 |
    29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /app/utils/auth.server.ts: -------------------------------------------------------------------------------- 1 | import {Authenticator} from 'remix-auth' 2 | import {GitHubStrategy} from 'remix-auth-github' 3 | import {getRequiredServerEnvVar} from '~/utils/misc' 4 | import {sessionStorage} from '~/utils/session.server' 5 | import type {GithubUser} from '~/types' 6 | 7 | export let authenticator = new Authenticator(sessionStorage) 8 | 9 | let gitHubStrategy = new GitHubStrategy( 10 | { 11 | clientID: getRequiredServerEnvVar('GITHUB_CLIENT_ID'), 12 | clientSecret: getRequiredServerEnvVar('GITHUB_CLIENT_SECRET'), 13 | callbackURL: getRequiredServerEnvVar('GITHUB_CALLBACK_URL'), 14 | }, 15 | async ({profile}) => { 16 | return { 17 | id: profile.id, 18 | displayName: profile.displayName, 19 | photos: profile.photos, 20 | name: profile.name, 21 | admin: profile.id == getRequiredServerEnvVar('GITHUB_ADMIN_ID'), 22 | } 23 | }, 24 | ) 25 | 26 | authenticator.use(gitHubStrategy) 27 | -------------------------------------------------------------------------------- /content/pages/about/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: About me 3 | description: Information and bios about me 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 | Hey, my name is Alexandru Bereghici. I'm a software engineer based in Chisinau, 17 | Republic of Moldova 🇲🇩. I've been working on the web professionally since 2015 18 | and unprofessionally since 2010 when I created my first website. 19 | 20 | This site it's a place where I can explore new ideas, experiment with different 21 | technologies, and spread knowledge. 22 | 23 | 📭 Let's get in touch via 24 | [alexandru.brg@gmail.com ](mailto:alexandru.brg@gmail.com) 25 | 26 |
    27 | 28 | 29 | 📘 Curriculum Vitae 30 | 31 | 32 | 33 | 🧩 Uses 34 | 35 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Mocked: Unnecessary (any value can be used) 2 | # Technically we have a fallback in development so this doesn't even need to be set 3 | SESSION_SECRET=anything_works_here 4 | 5 | REFRESH_CACHE_SECRET=anything_works_here 6 | 7 | # If you're running the redis db from docker-compose then this is the URL you should use 8 | REDIS_URL="redis://:alex_rocks@localhost:6379" 9 | 10 | # If you're running the postgres db from docker-compose then this is the URL you should use 11 | DATABASE_URL="postgresql://alex:alex_rocks@localhost:5432/bereghici_dev_db?schema=public" 12 | 13 | SPOTIFY_CLIENT_ID="" 14 | SPOTIFY_CLIENT_SECRET="" 15 | SPOTIFY_REFRESH_TOKEN="" 16 | 17 | GA_TRACKING_ID="G-EXP64F8MN8" 18 | 19 | SENTRY_DSN="" 20 | 21 | GITHUB_TOKEN="" 22 | GITHUB_CLIENT_ID="github_client_id" 23 | GITHUB_CLIENT_SECRET="github_client_secret" 24 | GITHUB_CALLBACK_URL="github_callback_url" 25 | GITHUB_ADMIN_ID="github_admin_id" 26 | 27 | ENABLE_TEST_ROUTES=true -------------------------------------------------------------------------------- /cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | module.exports = ( 2 | on: Cypress.PluginEvents, 3 | config: Cypress.PluginConfigOptions, 4 | ) => { 5 | const isDev = config.watchForFileChanges 6 | const port = process.env.PORT ?? (isDev ? '3000' : '8811') 7 | const configOverrides: Partial = { 8 | baseUrl: `http://localhost:${port}`, 9 | viewportWidth: 1030, 10 | viewportHeight: 800, 11 | integrationFolder: 'cypress/e2e', 12 | video: !process.env.CI, 13 | screenshotOnRunFailure: !process.env.CI, 14 | } 15 | Object.assign(config, configOverrides) 16 | 17 | on('before:browser:launch', (browser, options) => { 18 | if (browser.name === 'chrome') { 19 | options.args.push('--no-sandbox', '--allow-file-access-from-files') 20 | } 21 | return options 22 | }) 23 | 24 | on('task', { 25 | log(message) { 26 | console.log(message) 27 | return null 28 | }, 29 | }) 30 | 31 | return config 32 | } 33 | -------------------------------------------------------------------------------- /cypress/e2e/comments.ts: -------------------------------------------------------------------------------- 1 | describe('comments', () => { 2 | before(() => { 3 | cy.authenticate({ 4 | redirectUrl: '/', 5 | }) 6 | }) 7 | it('should allow a user to post a comment', () => { 8 | cy.visit('/') 9 | 10 | cy.findByRole('navigation').within(() => { 11 | cy.findByRole('link', {name: /📰 blog/i}).click() 12 | }) 13 | 14 | cy.location('pathname', {timeout: 10000}).should('include', '/blog') 15 | 16 | cy.findByRole('heading', { 17 | name: /headings & accessibility/i, 18 | }) 19 | .should('be.visible') 20 | .click({force: true}) 21 | 22 | cy.findByTestId('comments-form').should('be.visible') 23 | 24 | const message = 'Cypress: Testing comments' 25 | 26 | cy.get('textarea[name="body"]').type(message) 27 | 28 | cy.contains('Submit').click() 29 | 30 | cy.contains(message).should('be.visible') 31 | 32 | cy.contains('Delete').first().click() 33 | 34 | cy.contains(message).should('not.exist') 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /prisma/migrations/20220111130314_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Post" ( 3 | "id" TEXT NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL, 6 | "slug" TEXT NOT NULL, 7 | "views" INTEGER NOT NULL DEFAULT 0, 8 | 9 | CONSTRAINT "Post_pkey" PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateTable 13 | CREATE TABLE "Comment" ( 14 | "id" TEXT NOT NULL, 15 | "postId" TEXT NOT NULL, 16 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 17 | "updatedAt" TIMESTAMP(3) NOT NULL, 18 | "authorName" TEXT NOT NULL, 19 | "authorAvatarUrl" TEXT, 20 | "body" TEXT NOT NULL, 21 | 22 | CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") 23 | ); 24 | 25 | -- CreateIndex 26 | CREATE UNIQUE INDEX "Post_slug_key" ON "Post"("slug"); 27 | 28 | -- AddForeignKey 29 | ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; 30 | -------------------------------------------------------------------------------- /app/components/link.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import clsx from 'clsx' 3 | import type {LinkProps} from '@remix-run/react' 4 | import {Link as RemixLink} from '@remix-run/react' 5 | 6 | type Props = LinkProps & { 7 | external?: boolean 8 | } 9 | 10 | export default function Link({ 11 | external, 12 | className, 13 | to, 14 | children, 15 | ...rest 16 | }: Props) { 17 | const classes = clsx( 18 | 'dark:hover:text-gray-500 dark:text-gray-400 hover:text-gray-500 text-gray-600 transition', 19 | className, 20 | ) 21 | 22 | const href = typeof to === 'string' ? to : to.pathname 23 | 24 | if (external) { 25 | return ( 26 | 33 | {children} 34 | 35 | ) 36 | } 37 | 38 | return ( 39 | 40 | {children} 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /app/utils/seo.ts: -------------------------------------------------------------------------------- 1 | import {getImageBuilder} from './images' 2 | 3 | export function getSocialMetas({ 4 | url, 5 | title = 'Helping people make the world a better place through quality software', 6 | description = 'Make the world better with software', 7 | image, 8 | keywords = '', 9 | }: { 10 | image: string 11 | url: string 12 | title?: string 13 | description?: string 14 | keywords?: string 15 | }) { 16 | const imageUrl = getImageBuilder(image)({ 17 | quality: 'auto', 18 | format: 'auto', 19 | }) 20 | 21 | return { 22 | title, 23 | description, 24 | keywords, 25 | image: imageUrl, 26 | 'og:url': url, 27 | 'og:title': title, 28 | 'og:description': description, 29 | 'og:image': imageUrl, 30 | 'twitter:card': imageUrl ? 'summary_large_image' : 'summary', 31 | 'twitter:creator': '@alexandrubrg', 32 | 'twitter:site': '@alexandrubrg', 33 | 'twitter:title': title, 34 | 'twitter:description': description, 35 | 'twitter:image': imageUrl, 36 | 'twitter:alt': title, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev/config').AppConfig} 3 | */ 4 | module.exports = { 5 | appDirectory: 'app', 6 | assetsBuildDirectory: 'public/build', 7 | publicPath: '/build/', 8 | serverBuildDirectory: 'build', 9 | devServerPort: 8002, 10 | ignoredRouteFiles: ['.*'], 11 | serverPlatform: 'node', 12 | routes(defineRoutes) { 13 | return defineRoutes(route => { 14 | if (process.env.ENABLE_TEST_ROUTES === 'true') { 15 | if (process.env.NODE_ENV === 'production' && process.env.FLY_APP_NAME) { 16 | console.warn( 17 | `🚨 🚨 🚨 🚨 ENABLE_TEST_ROUTES is true, NODE_ENV is "production" and FLY_APP_NAME is ${process.env.FLY_APP_NAME} so we're not going to enable test routes because this is probably a mistake. We do NOT want test routes enabled on Fly. 🚨 🚨 🚨 🚨 🚨`, 18 | ) 19 | return 20 | } 21 | route( 22 | '__tests/github/authenticate', 23 | '__test_routes__/github/authenticate.tsx', 24 | ) 25 | } 26 | }) 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind utilities; 3 | @tailwind components; 4 | 5 | @font-face { 6 | font-family: 'IBM Plex Sans'; 7 | font-style: normal; 8 | font-weight: 100 900; 9 | font-display: optional; 10 | src: url(/fonts/ibm-plex-sans-var.woff2) format('woff2'); 11 | } 12 | 13 | @font-face { 14 | font-family: 'IBM Plex Sans'; 15 | font-style: italic; 16 | font-weight: 100 900; 17 | font-display: optional; 18 | src: url(/fonts/ibm-plex-sans-var-italic.woff2) format('woff2'); 19 | } 20 | 21 | @layer utilities { 22 | .bg-primary { 23 | @apply bg-gray-100 dark:bg-gray-900; 24 | } 25 | 26 | .bg-secondary { 27 | @apply dark:bg-gray-800 bg-white; 28 | } 29 | 30 | .bg-inverse { 31 | @apply bg-black dark:bg-white; 32 | } 33 | 34 | .text-primary { 35 | @apply text-black dark:text-white; 36 | } 37 | 38 | .text-secondary { 39 | @apply dark:text-gray-200 text-gray-600; 40 | } 41 | 42 | .text-inverse { 43 | @apply dark:text-black text-white; 44 | } 45 | 46 | .text-danger { 47 | @apply text-red-500; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: '🌃 Nightly' 2 | 3 | on: 4 | schedule: 5 | # * is a special character in YAML so you have to quote this string 6 | - cron: '0 12 * * *' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | lockfile: 11 | if: github.repository_owner == 'abereghici' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Setup Node 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: 16 21 | 22 | - name: Clear lockfile 23 | run: rm -rf package-lock.json node_modules 24 | 25 | - name: Install dependencies 26 | run: npm i 27 | 28 | - name: Create Pull Request 29 | id: createpr 30 | uses: peter-evans/create-pull-request@v3 31 | with: 32 | token: ${{ secrets.GITHUB_TOKEN }} 33 | commit-message: '[CI] update lockfile' 34 | title: '[CI] update lockfile' 35 | body: > 36 | This PR is auto-generated by a nightly GitHub action. It should 37 | automatically be merged if tests pass. 38 | -------------------------------------------------------------------------------- /styles/prose.css: -------------------------------------------------------------------------------- 1 | .prose { 2 | max-width: 100%; 3 | } 4 | .prose .anchor { 5 | @apply absolute invisible; 6 | 7 | margin-left: -1em; 8 | padding-right: 0.5em; 9 | width: 80%; 10 | max-width: 700px; 11 | cursor: pointer; 12 | } 13 | 14 | .anchor:hover { 15 | @apply no-underline visible; 16 | } 17 | 18 | .prose a { 19 | @apply transition-all; 20 | word-break: break-word; 21 | } 22 | 23 | .prose .anchor:after { 24 | @apply text-gray-300 dark:text-gray-700; 25 | content: '#'; 26 | } 27 | 28 | .prose *:hover > .anchor { 29 | @apply no-underline visible; 30 | } 31 | 32 | .prose pre { 33 | @apply bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700; 34 | } 35 | 36 | .prose code { 37 | @apply px-1 py-0.5 dark:text-gray-200 text-gray-800 bg-gray-100 dark:bg-gray-900 border border-gray-100 dark:border-gray-800 rounded-lg; 38 | } 39 | 40 | .prose pre code { 41 | @apply dark:text-gray-200 text-gray-800 whitespace-pre-wrap; 42 | } 43 | 44 | .prose > :first-child { 45 | /* Override removing top margin, causing layout shift */ 46 | margin-top: 1.25em !important; 47 | margin-bottom: 1.25em !important; 48 | } 49 | -------------------------------------------------------------------------------- /app/__test_routes__/github/authenticate.tsx: -------------------------------------------------------------------------------- 1 | import type {LoaderFunction} from '@remix-run/node' 2 | import {redirect} from '@remix-run/node' 3 | import type {GithubUser} from '~/types' 4 | import {authenticator} from '~/utils/auth.server' 5 | import {commitSession, getSession} from '~/utils/session.server' 6 | 7 | export const loader: LoaderFunction = async ({request}) => { 8 | const url = new URL(request.url) 9 | const redirectUrl = url.searchParams.get('redirectUrl') ?? '/' 10 | 11 | const user: GithubUser = { 12 | id: '1', 13 | displayName: 'Test User', 14 | photos: [{value: 'https://avatars0.githubusercontent.com/u/1?v=4'}], 15 | name: { 16 | familyName: 'User', 17 | givenName: 'Test', 18 | middleName: '', 19 | }, 20 | admin: true, 21 | } 22 | 23 | let session = await getSession(request) 24 | session.set(authenticator.sessionKey, user) 25 | session.set(authenticator.sessionStrategyKey, 'github') 26 | session.set(authenticator.sessionErrorKey, null) 27 | 28 | let headers = new Headers({'Set-Cookie': await commitSession(session)}) 29 | 30 | return redirect(redirectUrl, {headers}) 31 | } 32 | 33 | export default () => null 34 | -------------------------------------------------------------------------------- /other/build-server.js: -------------------------------------------------------------------------------- 1 | const fsExtra = require('fs-extra') 2 | const path = require('path') 3 | const glob = require('glob') 4 | const pkg = require('../package.json') 5 | 6 | const here = (...s) => path.join(__dirname, ...s) 7 | 8 | const allFiles = glob.sync(here('../server/**/*.*'), { 9 | ignore: ['**/tsconfig.json', '**/eslint*', '**/__tests__/**'], 10 | }) 11 | 12 | const entries = [] 13 | for (const file of allFiles) { 14 | if (/\.(ts|js|tsx|jsx)$/.test(file)) { 15 | entries.push(file) 16 | } else { 17 | const dest = file.replace(here('../server'), here('../server-build')) 18 | fsExtra.ensureDir(path.parse(dest).dir) 19 | fsExtra.copySync(file, dest) 20 | console.log(`copied: ${file.replace(`${here('../server')}/`, '')}`) 21 | } 22 | } 23 | 24 | console.log() 25 | console.log('building...') 26 | 27 | require('esbuild') 28 | .build({ 29 | entryPoints: glob.sync(here('../server/**/*.+(ts|js|tsx|jsx)')), 30 | outdir: here('../server-build'), 31 | target: [`node${pkg.engines.node}`], 32 | platform: 'node', 33 | format: 'cjs', 34 | logLevel: 'info', 35 | }) 36 | .catch(error => { 37 | console.error(error) 38 | process.exit(1) 39 | }) 40 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for bereghici-dev on 2021-12-13T12:29:03+02:00 2 | 3 | app = "bereghici-dev" 4 | 5 | kill_signal = "SIGINT" 6 | kill_timeout = 5 7 | processes = [] 8 | 9 | [env] 10 | PORT = "8080" 11 | NODE_ENV = "production" 12 | FLY = "true" 13 | PRIMARY_REGION = "fra" 14 | 15 | [deploy] 16 | release_command = "npx prisma migrate deploy" 17 | 18 | 19 | [experimental] 20 | allowed_public_ports = [] 21 | auto_rollback = true 22 | 23 | [[services]] 24 | internal_port = 8080 25 | processes = ["app"] 26 | protocol = "tcp" 27 | script_checks = [] 28 | 29 | [services.concurrency] 30 | hard_limit = 200 31 | soft_limit = 150 32 | type = "requests" 33 | 34 | [[services.ports]] 35 | handlers = ["http"] 36 | port = 80 37 | 38 | [[services.ports]] 39 | handlers = ["tls", "http"] 40 | port = 443 41 | 42 | [[services.tcp_checks]] 43 | grace_period = "1s" 44 | interval = "15s" 45 | restart_limit = 0 46 | timeout = "2s" 47 | [[services.http_checks]] 48 | interval = 10000 49 | grace_period = "5s" 50 | method = "get" 51 | path = "/healthcheck" 52 | protocol = "http" 53 | timeout = 2000 54 | tls_skip_verify = false 55 | [services.http_checks.headers] 56 | -------------------------------------------------------------------------------- /other/is-deployable.js: -------------------------------------------------------------------------------- 1 | // try to keep this dep-free so we don't have to install deps 2 | const {getChangedFiles, fetchJson} = require('./get-changed-files') 3 | const [currentCommitSha] = process.argv.slice(2) 4 | 5 | async function go() { 6 | const buildInfo = await fetchJson('https://bereghici.dev/build/info.json') 7 | const compareCommitSha = buildInfo.commit.sha 8 | const changedFiles = await getChangedFiles(currentCommitSha, compareCommitSha) 9 | console.error('Determining whether the changed files are deployable', { 10 | currentCommitSha, 11 | compareCommitSha, 12 | changedFiles, 13 | }) 14 | // deploy if: 15 | // - there was an error getting the changed files (null) 16 | // - there are no changed files 17 | // - there are changed files, but at least one of them is non-content 18 | const isDeployable = 19 | changedFiles === null || 20 | changedFiles.length === 0 || 21 | changedFiles.some(({filename}) => !filename.startsWith('content')) 22 | 23 | console.error( 24 | isDeployable 25 | ? '🟢 There are deployable changes' 26 | : '🔴 No deployable changes', 27 | {isDeployable}, 28 | ) 29 | console.log(isDeployable) 30 | } 31 | 32 | go().catch(e => { 33 | console.error(e) 34 | console.log('true') 35 | }) 36 | -------------------------------------------------------------------------------- /app/utils/metrics.server.ts: -------------------------------------------------------------------------------- 1 | import {performance} from 'perf_hooks' 2 | 3 | type Timings = Record> 4 | 5 | async function time({ 6 | name, 7 | type, 8 | fn, 9 | timings, 10 | }: { 11 | name: string 12 | type: string 13 | fn: () => ReturnType | Promise 14 | timings?: Timings 15 | }): Promise { 16 | if (!timings) return fn() 17 | 18 | const start = performance.now() 19 | const result = await fn() 20 | type = type.replace(/ /g, '_') 21 | let timingType = timings[type] 22 | if (!timingType) { 23 | // eslint-disable-next-line no-multi-assign 24 | timingType = timings[type] = [] 25 | } 26 | 27 | timingType.push({name, type, time: performance.now() - start}) 28 | return result 29 | } 30 | 31 | function getServerTimeHeader(timings: Timings) { 32 | return Object.entries(timings) 33 | .map(([key, timingInfos]) => { 34 | const dur = timingInfos 35 | .reduce((acc, timingInfo) => acc + timingInfo.time, 0) 36 | .toFixed(1) 37 | const desc = timingInfos.map(t => t.name).join(' & ') 38 | return `${key};dur=${dur};desc="${desc}"` 39 | }) 40 | .join(',') 41 | } 42 | 43 | export {time, getServerTimeHeader} 44 | export type {Timings} 45 | -------------------------------------------------------------------------------- /app/components/blog-post-comment-authenticate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {Form, useTransition} from '@remix-run/react' 3 | import Button from './button' 4 | import {H5, Paragraph} from './typography' 5 | 6 | type Props = { 7 | error: string | null 8 | } 9 | 10 | export default function BlogPostCommentAuthenticate({error}: Props) { 11 | const authentication = useTransition() 12 | 13 | const submitting = authentication.state === 'submitting' 14 | 15 | return ( 16 |
    20 |
    You must be logged in to post a comment
    21 | 22 | Your information is only used to display your name and reply by email. 23 | 24 | 25 | 28 | 29 | {error ? ( 30 | 31 | {error} 32 | 33 | ) : null} 34 |
    35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /app/components/track-item.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import type {SpotifySong} from '~/types' 4 | import Link from './link' 5 | import {Paragraph} from './typography' 6 | 7 | export default function TrackItem({ 8 | item, 9 | index, 10 | }: { 11 | item: SpotifySong 12 | index: number 13 | }) { 14 | return ( 15 |
    19 | 20 | {index + 1} 21 | 22 |
    23 |
    24 | {item.title} 31 |
    32 |
    33 | 34 | {item.title} 35 | 36 | 37 | {item.artist} 38 | 39 |
    40 |
    41 |
    42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /other/generate-build-info.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | // this is installed by remix... 4 | // eslint-disable-next-line import/no-extraneous-dependencies 5 | const fetch = require('node-fetch') 6 | 7 | const commit = process.env.COMMIT_SHA 8 | 9 | async function getCommit() { 10 | if (!commit) return `No COMMIT_SHA environment variable set.` 11 | try { 12 | const res = await fetch( 13 | `https://api.github.com/repos/abereghici/remix-bereghici-dev/commits/${commit}`, 14 | ) 15 | const data = await res.json() 16 | return { 17 | isDeployCommit: commit === 'HEAD' ? 'Unknown' : true, 18 | sha: data.sha, 19 | author: data.commit.author.name, 20 | date: data.commit.author.date, 21 | message: data.commit.message, 22 | link: data.html_url, 23 | } 24 | } catch (error) { 25 | return `Unable to get git commit info: ${error.message}` 26 | } 27 | } 28 | 29 | async function go() { 30 | const buildInfo = { 31 | buildTime: Date.now(), 32 | commit: await getCommit(), 33 | } 34 | 35 | fs.writeFileSync( 36 | path.join(__dirname, '../public/build/info.json'), 37 | JSON.stringify(buildInfo, null, 2), 38 | ) 39 | console.log('build info generated', buildInfo) 40 | } 41 | go() 42 | 43 | /* 44 | eslint 45 | consistent-return: "off", 46 | */ 47 | -------------------------------------------------------------------------------- /app/components/blog-post-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import clsx from 'clsx' 3 | import {Link} from '@remix-run/react' 4 | import {Paragraph, Title} from './typography' 5 | import type {PostItem} from '~/types' 6 | 7 | export default function BlogPostCard({ 8 | gradient, 9 | post, 10 | }: { 11 | post: PostItem 12 | gradient: string 13 | }) { 14 | const {frontmatter, slug, views} = post 15 | const {title, description} = frontmatter 16 | return ( 17 | 22 |
    23 | 24 | {title} 25 | 26 | 27 | {description} 28 | 29 |
    30 | 31 | 👀 32 | 33 | 34 | {views ? views.toLocaleString() : '–'} 35 | 36 |
    37 |
    38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Commands to know: 5 | // `npx prisma generate` - update TypeScript definitions based on this schema 6 | // `npx prisma db push` - push the schema changes to the database 7 | // `npx prisma studio` - open the Studio, which allows you to edit the schema. 8 | // `npx prisma migrate reset` - reset the migrations to the last version. This will reset the DB and run the seed script 9 | // `npx prisma migrate dev --name ` - generate a migration file for any changes you make to the schema (this will be committed). 10 | 11 | generator client { 12 | provider = "prisma-client-js" 13 | } 14 | 15 | datasource db { 16 | provider = "postgresql" 17 | url = env("DATABASE_URL") 18 | } 19 | 20 | model Post { 21 | id String @id @default(uuid()) 22 | createdAt DateTime @default(now()) 23 | updatedAt DateTime @updatedAt 24 | slug String @unique 25 | views Int @default(0) 26 | comments Comment[] 27 | } 28 | 29 | model Comment { 30 | id String @id @default(uuid()) 31 | post Post @relation(fields: [postId], references: [id], onDelete: Cascade) 32 | postId String 33 | createdAt DateTime @default(now()) 34 | updatedAt DateTime @updatedAt 35 | authorName String 36 | authorAvatarUrl String? 37 | body String 38 | } 39 | -------------------------------------------------------------------------------- /other/refresh-on-content-changes.js: -------------------------------------------------------------------------------- 1 | // the `entry.server.tsx` file requires app/refresh.ignored.js 2 | // so if we change our content then update app/refresh.ignored.js we'll 3 | // get an auto-refresh even though content isn't directly required in our app. 4 | const fs = require('fs') 5 | const path = require('path') 6 | require('dotenv').config() 7 | // eslint-disable-next-line import/no-extraneous-dependencies 8 | require('@remix-run/node').installGlobals() 9 | // eslint-disable-next-line import/no-extraneous-dependencies 10 | const chokidar = require('chokidar') 11 | const {postRefreshCache} = require('./utils') 12 | 13 | const refreshPath = path.join(__dirname, '../app/refresh.ignored.js') 14 | 15 | chokidar 16 | .watch(path.join(__dirname, '../content')) 17 | .on('change', async updatedFile => { 18 | console.log('changed', updatedFile) 19 | await postRefreshCache({ 20 | http: require('http'), 21 | options: { 22 | hostname: 'localhost', 23 | port: 3000, 24 | }, 25 | postData: { 26 | contentPaths: [updatedFile.replace(`${process.cwd()}/content/`, '')], 27 | }, 28 | }).then( 29 | response => console.log(`Content change request finished.`, {response}), 30 | error => console.error(`Content change request errored`, {error}), 31 | ) 32 | // give the cache a second to update 33 | setTimeout(() => { 34 | fs.writeFileSync(refreshPath, `// ${Date.now()}: ${updatedFile}`) 35 | }, 250) 36 | }) 37 | -------------------------------------------------------------------------------- /app/other-routes.server.ts: -------------------------------------------------------------------------------- 1 | import {getSitemapXml} from '~/utils/sitemap.server' 2 | import {getRssFeedXml} from '~/utils/blog-rss-feed.server' 3 | import type {RemixServerProps} from '@remix-run/react' 4 | 5 | type Handler = ( 6 | request: Request, 7 | remixContext: RemixServerProps['context'], 8 | ) => Promise | null 9 | 10 | // Just made it this way to make it easier to check for handled routes in 11 | // our `routes/$slug.tsx` catch-all route. 12 | const pathedRoutes: Record = { 13 | '/blog/rss.xml': async request => { 14 | const rss = await getRssFeedXml(request) 15 | return new Response(rss, { 16 | headers: { 17 | 'Content-Type': 'application/xml', 18 | 'Content-Length': String(Buffer.byteLength(rss)), 19 | }, 20 | }) 21 | }, 22 | '/sitemap.xml': async (request, remixContext) => { 23 | const sitemap = await getSitemapXml(request, remixContext) 24 | return new Response(sitemap, { 25 | headers: { 26 | 'Content-Type': 'application/xml', 27 | 'Content-Length': String(Buffer.byteLength(sitemap)), 28 | }, 29 | }) 30 | }, 31 | } 32 | 33 | const routes: Array = [ 34 | ...Object.entries(pathedRoutes).map(([path, handler]) => { 35 | return (request: Request, remixContext: RemixServerProps['context']) => { 36 | if (new URL(request.url).pathname !== path) return null 37 | 38 | return handler(request, remixContext) 39 | } 40 | }), 41 | ] 42 | 43 | export {routes, pathedRoutes} 44 | -------------------------------------------------------------------------------- /content/blog/aws-ssm-node.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Retrieving AWS SSM Parameters with Node 3 | description: A small snippet for my future self. 4 | date: 2022-05-03 5 | categories: 6 | - node 7 | - aws 8 | - javascript 9 | - snippets 10 | meta: 11 | keywords: 12 | - aws 13 | - node 14 | - ssm 15 | - javascript 16 | - param store 17 | bannerCloudinaryId: bereghici-dev/blog/aws_logo_smile_1200x630_nnavfk 18 | --- 19 | 20 | 24 | 25 | SSM (Systems Manager) is a service provided by AWS that allows you to securely 26 | store and retrieve data for your application (amongst other things). This can be 27 | environment based connection urls, authentication credentials, or properties 28 | you’d like to change without needing a re-deploy of your application. 29 | 30 | In SSM you can store strings, list of strings and encrypted strings. Also, you 31 | can store as JSON and later serialize it to a javascript object. 32 | 33 | ```javascript 34 | import SSM from 'aws-sdk/clients/ssm' 35 | 36 | const ssm = new SSM() 37 | 38 | export async function loadParameter(parameterName: string) { 39 | try { 40 | const {Parameter} = await ssm 41 | .getParameter({ 42 | Name: `/your/namespace/${parameterName}`, 43 | WithDecryption: true, 44 | }) 45 | .promise() 46 | 47 | return Parameter?.Value ?? null 48 | } catch (e) { 49 | console.error(e) 50 | return null 51 | } 52 | } 53 | ``` 54 | -------------------------------------------------------------------------------- /other/refresh-changed-content.js: -------------------------------------------------------------------------------- 1 | // try to keep this dep-free so we don't have to install deps 2 | const {getChangedFiles, fetchJson} = require('./get-changed-files') 3 | const {postRefreshCache} = require('./utils') 4 | 5 | const [currentCommitSha] = process.argv.slice(2) 6 | 7 | async function go() { 8 | const shaInfo = await fetchJson( 9 | 'https://bereghici.dev/refresh-commit-sha.json', 10 | ) 11 | let compareSha = shaInfo?.sha 12 | if (!compareSha) { 13 | const buildInfo = await fetchJson('https://bereghici.dev/build/info.json') 14 | compareSha = buildInfo.commit.sha 15 | } 16 | if (typeof compareSha !== 'string') { 17 | console.log('🤷‍♂️ No sha to compare to. Unsure what to refresh.') 18 | return 19 | } 20 | 21 | const changedFiles = 22 | (await getChangedFiles(currentCommitSha, compareSha)) ?? [] 23 | const contentPaths = changedFiles 24 | .filter(f => f.filename.startsWith('content')) 25 | .map(f => f.filename.replace(/^content\//, '')) 26 | if (contentPaths.length) { 27 | console.log(`⚡️ Content changed. Requesting the cache be refreshed.`, { 28 | currentCommitSha, 29 | compareSha, 30 | contentPaths, 31 | }) 32 | const response = await postRefreshCache({ 33 | postData: { 34 | contentPaths, 35 | commitSha: currentCommitSha, 36 | }, 37 | }) 38 | console.log(`Content change request finished.`, {response}) 39 | } else { 40 | console.log('🆗 Not refreshing changed content because no content changed.') 41 | } 42 | } 43 | 44 | void go() 45 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # base node image 2 | FROM node:16-bullseye-slim as base 3 | 4 | # install open ssl for prisma 5 | RUN apt-get update && apt-get install -y openssl ca-certificates 6 | 7 | # install all node_modules, including dev 8 | FROM base as deps 9 | 10 | ENV CYPRESS_INSTALL_BINARY=0 11 | ENV HUSKY_SKIP_INSTALL=1 12 | 13 | RUN mkdir /app/ 14 | WORKDIR /app/ 15 | 16 | ADD package.json package-lock.json ./ 17 | RUN npm install --production=false 18 | 19 | # setup production node_modules 20 | FROM base as production-deps 21 | 22 | RUN mkdir /app/ 23 | WORKDIR /app/ 24 | 25 | COPY --from=deps /app/node_modules /app/node_modules 26 | ADD package.json package-lock.json /app/ 27 | RUN npm prune --production 28 | 29 | # build app 30 | FROM base as build 31 | 32 | ARG COMMIT_SHA 33 | ENV COMMIT_SHA=$COMMIT_SHA 34 | 35 | RUN mkdir /app/ 36 | WORKDIR /app/ 37 | 38 | COPY --from=deps /app/node_modules /app/node_modules 39 | 40 | # schema doesn't change much so these will stay cached 41 | ADD prisma . 42 | RUN npx prisma generate 43 | 44 | # app code changes all the time 45 | ADD . . 46 | RUN npm run build 47 | 48 | # build smaller image for running 49 | FROM base 50 | 51 | ENV NODE_ENV=production 52 | 53 | RUN mkdir /app/ 54 | WORKDIR /app/ 55 | 56 | COPY --from=production-deps /app/node_modules /app/node_modules 57 | COPY --from=build /app/node_modules/.prisma /app/node_modules/.prisma 58 | COPY --from=build /app/build /app/build 59 | COPY --from=build /app/public /app/public 60 | COPY --from=build /app/server-build /app/server-build 61 | ADD . . 62 | 63 | CMD ["npm", "run", "start"] -------------------------------------------------------------------------------- /app/utils/blog-rss-feed.server.ts: -------------------------------------------------------------------------------- 1 | import {getBlogMdxListItems} from '~/utils/mdx' 2 | import {getDomainUrl} from '~/utils/misc' 3 | 4 | async function getRssFeedXml(request: Request) { 5 | const posts = await getBlogMdxListItems({request}) 6 | 7 | const blogUrl = `${getDomainUrl(request)}/blog` 8 | 9 | return ` 10 | 11 | 12 | Alexandru Bereghici Blog 13 | ${blogUrl} 14 | Alexandru Bereghici Blog 15 | All rights reserved copyright Alexandru Bereghici 2022 16 | en-us 17 | 18 | 40 19 | ${posts 20 | .map(post => 21 | ` 22 | 23 | ${cdata(post.frontmatter.title)} 24 | ${cdata(post.frontmatter.description)} 25 | ${new Date( 26 | post.frontmatter.date, 27 | ).toUTCString()} 28 | ${blogUrl}/${post.slug} 29 | ${blogUrl}/${post.slug} 30 | 31 | `.trim(), 32 | ) 33 | .join('\n')} 34 | 35 | 36 | `.trim() 37 | } 38 | 39 | function cdata(s: string) { 40 | return `` 41 | } 42 | 43 | export {getRssFeedXml} 44 | -------------------------------------------------------------------------------- /other/pm2.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'Server', 5 | script: [ 6 | 'node', 7 | '--inspect', 8 | '--require ./node_modules/dotenv/config', 9 | '--require ./mocks', 10 | './index.js', 11 | ] 12 | .filter(Boolean) 13 | .join(' '), 14 | watch: ['./index.js', './server/**/*.ts', './.env'], 15 | env: { 16 | NODE_ENV: process.env.NODE_ENV ?? 'development', 17 | ENABLE_TEST_ROUTES: process.env.ENABLE_TEST_ROUTES ?? true, 18 | RUNNING_E2E: process.env.RUNNING_E2E, 19 | FORCE_COLOR: '1', 20 | }, 21 | }, 22 | { 23 | name: 'Remix', 24 | script: 'remix watch', 25 | ignore_watch: ['.'], 26 | env: { 27 | NODE_ENV: process.env.NODE_ENV ?? 'development', 28 | ENABLE_TEST_ROUTES: process.env.ENABLE_TEST_ROUTES ?? true, 29 | RUNNING_E2E: process.env.RUNNING_E2E, 30 | FORCE_COLOR: '1', 31 | }, 32 | }, 33 | { 34 | name: 'Content', 35 | script: 'node ./other/refresh-on-content-changes.js', 36 | ignore_watch: ['.'], 37 | }, 38 | { 39 | name: 'Postcss', 40 | script: 'postcss styles/**/*.css --base styles --dir app/styles', 41 | autorestart: false, 42 | watch: [ 43 | './tailwind.config.js', 44 | './app/**/*.ts', 45 | './app/**/*.tsx', 46 | './styles/**/*.css', 47 | ], 48 | env: { 49 | NODE_ENV: process.env.NODE_ENV ?? 'development', 50 | FORCE_COLOR: '1', 51 | }, 52 | }, 53 | ], 54 | } 55 | -------------------------------------------------------------------------------- /other/utils.js: -------------------------------------------------------------------------------- 1 | // try to keep this dep-free so we don't have to install deps 2 | function postRefreshCache({ 3 | http = require('https'), 4 | postData, 5 | options: {headers: headersOverrides, ...optionsOverrides} = {}, 6 | }) { 7 | return new Promise((resolve, reject) => { 8 | try { 9 | const postDataString = JSON.stringify(postData) 10 | const searchParams = new URLSearchParams() 11 | searchParams.set('_data', 'routes/action/refresh-cache') 12 | const options = { 13 | hostname: 'bereghici.dev', 14 | port: 443, 15 | path: `/action/refresh-cache?${searchParams.toString()}`, 16 | method: 'POST', 17 | headers: { 18 | auth: process.env.REFRESH_CACHE_SECRET, 19 | 'Content-Type': 'application/json', 20 | 'Content-Length': Buffer.byteLength(postDataString), 21 | ...headersOverrides, 22 | }, 23 | ...optionsOverrides, 24 | } 25 | 26 | const req = http 27 | .request(options, res => { 28 | let data = '' 29 | res.on('data', d => { 30 | data += d 31 | }) 32 | 33 | res.on('end', () => { 34 | try { 35 | resolve(JSON.parse(data)) 36 | } catch (error) { 37 | reject(data) 38 | } 39 | }) 40 | }) 41 | .on('error', reject) 42 | req.write(postDataString) 43 | req.end() 44 | } catch (error) { 45 | console.log('oh no', error) 46 | reject(error) 47 | } 48 | }) 49 | } 50 | 51 | module.exports = {postRefreshCache} 52 | -------------------------------------------------------------------------------- /app/components/navbar/mobile-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import clsx from 'clsx' 3 | import ResponsiveContainer from '~/components/responsive-container' 4 | 5 | import MenuItem from './menu-item' 6 | import MenuToggle from './menu-toggle' 7 | import LINKS from './links' 8 | 9 | export default function MobileMenu() { 10 | const [isMenuOpen, setIsMenuOpen] = React.useState(false) 11 | 12 | function toggleMenu() { 13 | setIsMenuOpen(prev => !prev) 14 | } 15 | 16 | function close() { 17 | setIsMenuOpen(false) 18 | } 19 | 20 | React.useEffect(() => { 21 | const handler = (e: MediaQueryListEvent) => { 22 | if (e.matches && isMenuOpen) { 23 | close() 24 | } 25 | } 26 | 27 | const mql: MediaQueryList = window.matchMedia('(min-width: 768px)') 28 | mql.addEventListener('change', handler) 29 | 30 | return function cleanup() { 31 | mql.removeEventListener('change', handler) 32 | } 33 | }, [isMenuOpen]) 34 | 35 | return ( 36 | <> 37 | 41 | {isMenuOpen && ( 42 | 48 | {LINKS.map(({to, name}) => ( 49 | 50 | {name} 51 | 52 | ))} 53 | 54 | )} 55 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /app/utils/images.tsx: -------------------------------------------------------------------------------- 1 | import type {TransformerOption} from '@cld-apis/types' 2 | import {setConfig, buildImageUrl} from 'cloudinary-build-url' 3 | 4 | setConfig({ 5 | cloudName: 'bereghici-dev', 6 | }) 7 | 8 | type ImageBuilder = { 9 | (transformations?: TransformerOption): string 10 | alt: string 11 | id: string 12 | } 13 | 14 | function getImageBuilder(id: string, alt: string = ''): ImageBuilder { 15 | function imageBuilder(transformations?: TransformerOption) { 16 | return buildImageUrl(id, {transformations}) 17 | } 18 | imageBuilder.alt = alt 19 | imageBuilder.id = id 20 | return imageBuilder 21 | } 22 | 23 | function getImgProps( 24 | imageBuilder: ImageBuilder, 25 | { 26 | widths, 27 | sizes, 28 | transformations, 29 | }: { 30 | widths: Array 31 | sizes: Array 32 | transformations?: TransformerOption 33 | }, 34 | ) { 35 | const averageSize = Math.ceil(widths.reduce((a, s) => a + s) / widths.length) 36 | 37 | return { 38 | alt: imageBuilder.alt, 39 | src: imageBuilder({ 40 | quality: 'auto', 41 | format: 'auto', 42 | ...transformations, 43 | resize: {width: averageSize, ...transformations?.resize}, 44 | }), 45 | srcSet: widths 46 | .map(width => 47 | [ 48 | imageBuilder({ 49 | quality: 'auto', 50 | format: 'auto', 51 | ...transformations, 52 | resize: {width, ...transformations?.resize}, 53 | }), 54 | `${width}w`, 55 | ].join(' '), 56 | ) 57 | .join(', '), 58 | sizes: sizes.join(', '), 59 | } 60 | } 61 | 62 | export {getImgProps, getImageBuilder} 63 | export type {ImageBuilder} 64 | -------------------------------------------------------------------------------- /app/utils/misc.tsx: -------------------------------------------------------------------------------- 1 | function getDomainUrl(request: Request) { 2 | const host = 3 | request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') 4 | if (!host) { 5 | throw new Error('Could not determine domain URL.') 6 | } 7 | const protocol = host.includes('localhost') ? 'http' : 'https' 8 | return `${protocol}://${host}` 9 | } 10 | 11 | function removeTrailingSlash(s: string) { 12 | return s.endsWith('/') ? s.slice(0, -1) : s 13 | } 14 | 15 | function getUrl(requestInfo?: {origin: string; path: string}) { 16 | return removeTrailingSlash( 17 | `${requestInfo?.origin ?? 'https://bereghici.dev'}${ 18 | requestInfo?.path ?? '' 19 | }`, 20 | ) 21 | } 22 | 23 | function getDisplayUrl(requestInfo?: {origin: string; path: string}) { 24 | return getUrl(requestInfo).replace(/^https?:\/\//, '') 25 | } 26 | 27 | function getRequiredEnvVarFromObj( 28 | obj: Record, 29 | key: string, 30 | devValue: string = `${key}-dev-value`, 31 | ) { 32 | let value = devValue 33 | const envVal = obj[key] 34 | if (envVal) { 35 | value = envVal 36 | } else if (obj.NODE_ENV === 'production') { 37 | throw new Error(`${key} is a required env variable`) 38 | } 39 | return value 40 | } 41 | 42 | function getRequiredServerEnvVar(key: string, devValue?: string) { 43 | return getRequiredEnvVarFromObj(process.env, key, devValue) 44 | } 45 | 46 | function typedBoolean( 47 | value: T, 48 | ): value is Exclude { 49 | return Boolean(value) 50 | } 51 | 52 | export { 53 | getDomainUrl, 54 | getUrl, 55 | getDisplayUrl, 56 | getRequiredServerEnvVar, 57 | typedBoolean, 58 | removeTrailingSlash, 59 | } 60 | -------------------------------------------------------------------------------- /styles/code-highlight.css: -------------------------------------------------------------------------------- 1 | .token.comment, 2 | .token.prolog, 3 | .token.doctype, 4 | .token.cdata { 5 | @apply dark:text-gray-300 text-gray-700; 6 | } 7 | 8 | .token.punctuation { 9 | @apply dark:text-gray-300 text-gray-700; 10 | } 11 | 12 | .token.property, 13 | .token.tag, 14 | .token.boolean, 15 | .token.number, 16 | .token.constant, 17 | .token.symbol, 18 | .token.deleted { 19 | @apply text-green-500; 20 | } 21 | 22 | .token.selector, 23 | .token.attr-name, 24 | .token.string, 25 | .token.char, 26 | .token.builtin, 27 | .token.inserted { 28 | @apply text-purple-500; 29 | } 30 | 31 | .token.operator, 32 | .token.entity, 33 | .token.url, 34 | .language-css .token.string, 35 | .style .token.string { 36 | @apply text-yellow-500; 37 | } 38 | 39 | .token.atrule, 40 | .token.attr-value, 41 | .token.keyword { 42 | @apply text-blue-500; 43 | } 44 | 45 | .token.function, 46 | .token.class-name { 47 | @apply text-pink-500; 48 | } 49 | 50 | .token.regex, 51 | .token.important, 52 | .token.variable { 53 | @apply text-yellow-500; 54 | } 55 | 56 | code[class*='language-'], 57 | pre[class*='language-'] { 58 | @apply dark:text-gray-50 text-gray-800; 59 | } 60 | 61 | pre::-webkit-scrollbar { 62 | display: none; 63 | } 64 | 65 | pre { 66 | -ms-overflow-style: none; /* IE and Edge */ 67 | scrollbar-width: none; /* Firefox */ 68 | } 69 | 70 | .rehype-code-title { 71 | @apply px-5 py-3 dark:text-gray-200 text-gray-800 font-mono text-sm font-bold bg-gray-200 dark:bg-gray-800 border border-b-0 border-gray-200 dark:border-gray-700 rounded-t-lg; 72 | } 73 | 74 | .rehype-code-title + pre { 75 | @apply mt-0 rounded-t-none; 76 | } 77 | 78 | .highlight-line { 79 | @apply block -mx-4 px-4 bg-gray-100 dark:bg-gray-800 border-l-4 border-blue-500; 80 | } 81 | -------------------------------------------------------------------------------- /app/routes/top-tracks.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type {LoaderFunction} from '@remix-run/node' 3 | import {json} from '@remix-run/node' 4 | import {useLoaderData} from '@remix-run/react' 5 | 6 | import type {Timings} from '~/utils/metrics.server' 7 | import {getServerTimeHeader} from '~/utils/metrics.server' 8 | import {ServerError} from '~/components/errors' 9 | import {H1, Paragraph} from '~/components/typography' 10 | import ResponsiveContainer from '~/components/responsive-container' 11 | import type {SpotifySong} from '~/types' 12 | import {getTopTracksCached} from '~/utils/spotify.server' 13 | import TrackItem from '~/components/track-item' 14 | 15 | type LoaderData = { 16 | tracks: SpotifySong[] 17 | } 18 | 19 | export const loader: LoaderFunction = async ({request}) => { 20 | const timings: Timings = {} 21 | 22 | const tracks = await getTopTracksCached({ 23 | request, 24 | timings, 25 | }) 26 | const data: LoaderData = {tracks} 27 | return json(data, { 28 | headers: { 29 | 'Server-Timing': getServerTimeHeader(timings), 30 | }, 31 | }) 32 | } 33 | 34 | export default function Index() { 35 | const {tracks} = useLoaderData() 36 | 37 | return ( 38 | 39 |

    Top Tracks

    40 | 41 | Here's my top tracks on Spotify updated daily 42 | 43 | {tracks.map((track, index) => ( 44 | 45 | ))} 46 |
    47 | ) 48 | } 49 | 50 | export function ErrorBoundary({error}: {error: Error}) { 51 | console.error(error) 52 | return 53 | } 54 | -------------------------------------------------------------------------------- /app/routes/about.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type {LoaderFunction} from '@remix-run/node' 3 | import {json} from '@remix-run/node' 4 | import {useLoaderData} from '@remix-run/react' 5 | import type {Timings} from '~/utils/metrics.server' 6 | import {getServerTimeHeader} from '~/utils/metrics.server' 7 | import ResponsiveContainer from '~/components/responsive-container' 8 | import {ServerError} from '~/components/errors' 9 | import {getMdxPage, mdxPageMeta, useMdxComponent} from '~/utils/mdx' 10 | import type {MdxPage} from '~/types' 11 | import {H1} from '~/components/typography' 12 | 13 | type LoaderData = { 14 | page: MdxPage 15 | } 16 | 17 | export const meta = mdxPageMeta 18 | 19 | export const loader: LoaderFunction = async ({request}) => { 20 | const timings: Timings = {} 21 | 22 | const page = await getMdxPage( 23 | { 24 | slug: 'about', 25 | contentDir: 'pages', 26 | }, 27 | {request, timings}, 28 | ) 29 | 30 | if (!page) { 31 | throw new Response('Not found', {status: 404}) 32 | } 33 | 34 | const data: LoaderData = {page} 35 | return json(data, { 36 | headers: { 37 | 'Server-Timing': getServerTimeHeader(timings), 38 | }, 39 | }) 40 | } 41 | 42 | export default function About() { 43 | const {page} = useLoaderData() 44 | const {title} = page.frontmatter 45 | const Component = useMdxComponent(page.code) 46 | 47 | return ( 48 | 49 |

    {title}

    50 |
    51 | 52 |
    53 |
    54 | ) 55 | } 56 | 57 | export function ErrorBoundary({error}: {error: Error}) { 58 | console.error(error) 59 | return 60 | } 61 | -------------------------------------------------------------------------------- /app/routes/cv.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type {LoaderFunction} from '@remix-run/node' 3 | import {json} from '@remix-run/node' 4 | import {useLoaderData} from '@remix-run/react' 5 | 6 | import type {Timings} from '~/utils/metrics.server' 7 | import {getServerTimeHeader} from '~/utils/metrics.server' 8 | import ResponsiveContainer from '~/components/responsive-container' 9 | import {ServerError} from '~/components/errors' 10 | import {getMdxPage, mdxPageMeta, useMdxComponent} from '~/utils/mdx' 11 | import type {MdxPage} from '~/types' 12 | import {H1} from '~/components/typography' 13 | 14 | type LoaderData = { 15 | page: MdxPage 16 | } 17 | 18 | export const meta = mdxPageMeta 19 | 20 | export const loader: LoaderFunction = async ({request}) => { 21 | const timings: Timings = {} 22 | 23 | const page = await getMdxPage( 24 | { 25 | slug: 'cv', 26 | contentDir: 'pages', 27 | }, 28 | {request, timings}, 29 | ) 30 | 31 | if (!page) { 32 | throw new Response('Not found', {status: 404}) 33 | } 34 | 35 | const data: LoaderData = {page} 36 | return json(data, { 37 | headers: { 38 | 'Server-Timing': getServerTimeHeader(timings), 39 | }, 40 | }) 41 | } 42 | 43 | export default function CV() { 44 | const {page} = useLoaderData() 45 | const {title} = page.frontmatter 46 | 47 | const Component = useMdxComponent(page.code) 48 | 49 | return ( 50 | 51 |

    {title}

    52 |
    53 | 54 |
    55 |
    56 | ) 57 | } 58 | 59 | export function ErrorBoundary({error}: {error: Error}) { 60 | console.error(error) 61 | return 62 | } 63 | -------------------------------------------------------------------------------- /app/routes/uses.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type {LoaderFunction} from '@remix-run/node' 3 | import {json} from '@remix-run/node' 4 | import {useLoaderData} from '@remix-run/react' 5 | import type {Timings} from '~/utils/metrics.server' 6 | import {getServerTimeHeader} from '~/utils/metrics.server' 7 | import ResponsiveContainer from '~/components/responsive-container' 8 | import {ServerError} from '~/components/errors' 9 | import {getMdxPage, mdxPageMeta, useMdxComponent} from '~/utils/mdx' 10 | import type {MdxPage} from '~/types' 11 | import {H1} from '~/components/typography' 12 | 13 | type LoaderData = { 14 | page: MdxPage 15 | } 16 | 17 | export const meta = mdxPageMeta 18 | 19 | export const loader: LoaderFunction = async ({request}) => { 20 | const timings: Timings = {} 21 | 22 | const page = await getMdxPage( 23 | { 24 | slug: 'uses', 25 | contentDir: 'pages', 26 | }, 27 | {request, timings}, 28 | ) 29 | 30 | if (!page) { 31 | throw new Response('Not found', {status: 404}) 32 | } 33 | 34 | const data: LoaderData = {page} 35 | return json(data, { 36 | headers: { 37 | 'Server-Timing': getServerTimeHeader(timings), 38 | }, 39 | }) 40 | } 41 | 42 | export default function Uses() { 43 | const {page} = useLoaderData() 44 | const {title} = page.frontmatter 45 | 46 | const Component = useMdxComponent(page.code) 47 | 48 | return ( 49 | 50 |

    {title}

    51 |
    52 | 53 |
    54 |
    55 | ) 56 | } 57 | 58 | export function ErrorBoundary({error}: {error: Error}) { 59 | console.error(error) 60 | return 61 | } 62 | -------------------------------------------------------------------------------- /app/utils/prisma.server.tsx: -------------------------------------------------------------------------------- 1 | import {PrismaClient} from '@prisma/client' 2 | import {getRequiredServerEnvVar} from './misc' 3 | 4 | declare global { 5 | // eslint-disable-next-line no-var,vars-on-top 6 | var prisma: ReturnType | undefined 7 | } 8 | 9 | /** 10 | * https://github.com/prisma/studio/issues/614 11 | * 12 | */ 13 | // @ts-expect-error ts(2339) 14 | // eslint-disable-next-line no-extend-native 15 | BigInt.prototype.toJSON = function toJSON() { 16 | return Number(this) 17 | } 18 | 19 | const DATABASE_URL = getRequiredServerEnvVar('DATABASE_URL') 20 | 21 | const logThreshold = 50 22 | 23 | function getClient(connectionUrl: URL): PrismaClient { 24 | console.log(`Setting up prisma client to ${connectionUrl.host}`) 25 | // NOTE: during development if you change anything in this function, remember 26 | // that this only runs once per server restart and won't automatically be 27 | // re-run per request like everything else is. 28 | const client = new PrismaClient({ 29 | log: [ 30 | {level: 'query', emit: 'event'}, 31 | {level: 'error', emit: 'stdout'}, 32 | {level: 'info', emit: 'stdout'}, 33 | {level: 'warn', emit: 'stdout'}, 34 | ], 35 | datasources: { 36 | db: { 37 | url: connectionUrl.toString(), 38 | }, 39 | }, 40 | }) 41 | client.$on('query', (e: {duration: number; query: unknown}) => { 42 | if (e.duration < logThreshold) return 43 | 44 | const dur = `${e.duration}ms` 45 | console.log(`prisma:query - ${dur} - ${e.query}`) 46 | }) 47 | // make the connection eagerly so the first request doesn't have to wait 48 | void client.$connect() 49 | return client 50 | } 51 | 52 | const prisma = 53 | global.prisma ?? (global.prisma = getClient(new URL(DATABASE_URL))) 54 | 55 | export {prisma} 56 | -------------------------------------------------------------------------------- /other/get-changed-files.js: -------------------------------------------------------------------------------- 1 | // try to keep this dep-free so we don't have to install deps 2 | const execSync = require('child_process').execSync 3 | const https = require('https') 4 | 5 | function fetchJson(url) { 6 | return new Promise((resolve, reject) => { 7 | https 8 | .get(url, res => { 9 | let data = '' 10 | res.on('data', d => { 11 | data += d 12 | }) 13 | 14 | res.on('end', () => { 15 | try { 16 | resolve(JSON.parse(data)) 17 | } catch (error) { 18 | reject(error) 19 | } 20 | }) 21 | }) 22 | .on('error', e => { 23 | reject(e) 24 | }) 25 | }) 26 | } 27 | 28 | const changeTypes = { 29 | M: 'modified', 30 | A: 'added', 31 | D: 'deleted', 32 | R: 'moved', 33 | } 34 | 35 | async function getChangedFiles(currentCommitSha, compareCommitSha) { 36 | try { 37 | const lineParser = /^(?\w).*?\s+(?.+$)/ 38 | const gitOutput = execSync( 39 | `git diff --name-status ${currentCommitSha} ${compareCommitSha}`, 40 | ).toString() 41 | const changedFiles = gitOutput 42 | .split('\n') 43 | .map(line => line.match(lineParser)?.groups) 44 | .filter(Boolean) 45 | const changes = [] 46 | for (const {change, filename} of changedFiles) { 47 | const changeType = changeTypes[change] 48 | if (changeType) { 49 | changes.push({changeType: changeTypes[change], filename}) 50 | } else { 51 | console.error(`Unknown change type: ${change} ${filename}`) 52 | } 53 | } 54 | return changes 55 | } catch (error) { 56 | console.error(`Something went wrong trying to get changed files.`, error) 57 | return null 58 | } 59 | } 60 | 61 | module.exports = {getChangedFiles, fetchJson} 62 | -------------------------------------------------------------------------------- /app/components/blog-post-comment-input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {useFetcher} from '@remix-run/react' 3 | import TextareaAutosize from 'react-textarea-autosize' 4 | import {H5, Paragraph} from './typography' 5 | import Button from './button' 6 | 7 | export default function BlogPostCommentInput() { 8 | const comments = useFetcher() 9 | const ref = React.useRef(null) 10 | 11 | const busy = comments.state === 'submitting' 12 | 13 | React.useEffect(() => { 14 | if (comments.type === 'done' && comments.data.success) { 15 | ref.current?.reset() 16 | } 17 | }, [comments]) 18 | 19 | return ( 20 |
    21 |
    Leave a comment
    22 | 28 | 29 | 37 | {comments.data?.error ? ( 38 | 39 | {comments.data.error} 40 | 41 | ) : null} 42 | 45 | 46 |
    47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /app/components/blog-post.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {Link} from '@remix-run/react' 3 | import {Title, Paragraph} from '~/components/typography' 4 | import Tag from '~/components/tag' 5 | import type {PostItem} from '~/types' 6 | import {getImageBuilder, getImgProps} from '~/utils/images' 7 | 8 | export default function BlogPost({post}: {post: PostItem}) { 9 | const {slug, views, frontmatter} = post 10 | const {title, description, categories, bannerCloudinaryId} = frontmatter 11 | 12 | return ( 13 | 14 |
    15 | {bannerCloudinaryId && ( 16 | 25 | )} 26 |
    27 |
    28 | {categories.map(category => ( 29 | 30 | ))} 31 |
    32 | 33 | {title} 34 | 35 | 36 | 41 | {description} 42 | 43 | 44 | 45 | 👀 {views ? views.toLocaleString() : '–'} 46 | 47 |
    48 |
    49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /app/utils/homepage.server.ts: -------------------------------------------------------------------------------- 1 | import type {CachifiedOptions} from './cache.server' 2 | import {cachified} from './cache.server' 3 | import {getRepositoriesContributedTo} from './github.server' 4 | import {redisCache} from './redis.server' 5 | 6 | const defaultMaxAge = 1000 * 60 * 60 * 24 * 1 // 1 day 7 | 8 | async function getFeaturedGithubContributions(options?: CachifiedOptions) { 9 | return cachified({ 10 | cache: redisCache, 11 | maxAge: defaultMaxAge, 12 | ...options, 13 | key: `featured-repositories-contributed-to`, 14 | checkValue: (value: unknown) => Array.isArray(value), 15 | getFreshValue: async () => { 16 | try { 17 | const featuredRepos = [ 18 | 'twilio-labs/paste', 19 | 'justinribeiro/lighthouse-action', 20 | 'remix-run/remix', 21 | 'csstree/csstree', 22 | 'date-fns/date-fns', 23 | ] 24 | const {contributedRepos} = await getRepositoriesContributedTo() 25 | 26 | return contributedRepos.filter(({name, owner}) => 27 | featuredRepos.includes(`${owner.login}/${name}`), 28 | ) 29 | } catch (e: unknown) { 30 | console.error(e) 31 | } 32 | 33 | return [] 34 | }, 35 | }) 36 | } 37 | 38 | async function getGithubContributions(options?: CachifiedOptions) { 39 | return cachified({ 40 | cache: redisCache, 41 | maxAge: defaultMaxAge, 42 | ...options, 43 | key: `repositories-contributed-to`, 44 | checkValue: (value: unknown) => Array.isArray(value), 45 | getFreshValue: async () => { 46 | try { 47 | const {contributedRepos} = await getRepositoriesContributedTo() 48 | 49 | return contributedRepos 50 | } catch (e: unknown) { 51 | console.error(e) 52 | } 53 | 54 | return [] 55 | }, 56 | }) 57 | } 58 | 59 | export {getFeaturedGithubContributions, getGithubContributions} 60 | -------------------------------------------------------------------------------- /app/components/blog-post-comment.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import format from 'date-fns/format' 3 | import type {GithubUser, Comment} from '~/types' 4 | import {useFetcher} from '@remix-run/react' 5 | import {Paragraph} from './typography' 6 | 7 | type Props = { 8 | comment: Comment 9 | user: GithubUser | null 10 | } 11 | 12 | export default function BlogPostComment({comment, user}: Props) { 13 | const commentFetcher = useFetcher() 14 | 15 | const busy = commentFetcher.state === 'submitting' 16 | 17 | const deleteComment = () => { 18 | commentFetcher.submit( 19 | { 20 | actionType: 'deleteComment', 21 | commentId: comment.id, 22 | }, 23 | {method: 'post'}, 24 | ) 25 | } 26 | 27 | return ( 28 |
    29 | {comment.body} 30 |
    31 | {comment.authorAvatarUrl && ( 32 | User avatar 39 | )} 40 | {comment.authorName} 41 | / 42 | 43 | {format(new Date(comment.updatedAt), "d MMM yyyy 'at' h:mm bb")} 44 | 45 | {user && user.admin && ( 46 | <> 47 | / 48 | 55 | 56 | )} 57 |
    58 |
    59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /content/pages/uses/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: What I use 3 | description: What I use 4 | meta: 5 | keywords: 6 | - bereghici 7 | - alexandru 8 | - uses 9 | - information 10 | - ama 11 | bannerCloudinaryId: bereghici-dev/blog/avatar_bwdhvv 12 | --- 13 | 14 | ## Tech Stack 15 | 16 | - [TypeScript](https://www.typescriptlang.org/) - my main programming language. 17 | - [React.js](https://reactjs.org) - UI creation. 18 | - [Tailwind](https://tailwindcss.com/) and [PostCSS](https://postcss.org/) - 19 | styling. 20 | - [Next.js](https://nextjs.org/) and [Remix](https://remix.run/) - frameworks. 21 | - [EsLint](https://eslint.org/) and [Prettier](https://prettier.io/) - code 22 | linting & formatting. 23 | - [ESBuild](https://esbuild.github.io/) - javascript bundler. 24 | - [Prisma](https://www.prisma.io/) - ORM for Node.js. 25 | - [Jest](https://jestjs.io/) - unit testing. 26 | - [React Testing Library](https://testing-library.com/react) - React testing. 27 | - [Cypress](https://www.cypress.io/) - integration and E2E testing. 28 | - [MSW](https://mswjs.io/) - API mocking. 29 | 30 | ## Editor 31 | 32 | [VSCode](https://code.visualstudio.com) it's my IDE/code editor. 33 | 34 | ## Services 35 | 36 | - [Google Analytics](https://analytics.google.com) - tracking. 37 | - [GitHub](https://github.com) this is where I host my code. I also run CI/CD 38 | pipelines with [GitHub Action](https://github.com/features/actions) . 39 | - [Fly.io](http://fly.io/) and [Netlify](https://www.netlify.com/) are my 40 | deployment platforms. 41 | - [Cloudinary](https://kcd.im/cloudinary) is my image hosting service. 42 | - [Sentry](https://sentry.io) is my error reporting service. 43 | 44 | ## Tools 45 | 46 | - [Chrome](https://www.google.com/chrome/) as my main browser. 47 | - [Hyper](https://hyper.is/) as my terminal, outside VSCode. 48 | - [GitHub](https://github.com) for my code repositories. 49 | - [GitHub Mobile](https://github.com/mobile) to check GitHub on my phone. 50 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import {renderToString} from 'react-dom/server' 2 | import type {RemixServerProps} from '@remix-run/react' 3 | import {RemixServer} from '@remix-run/react' 4 | import {routes as otherRoutes} from './other-routes.server' 5 | 6 | export default async function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | remixContext: RemixServerProps['context'], 11 | ) { 12 | for (const handler of otherRoutes) { 13 | // eslint-disable-next-line no-await-in-loop 14 | const otherRouteResponse = await handler(request, remixContext) 15 | if (otherRouteResponse) return otherRouteResponse 16 | } 17 | 18 | const markup = renderToString( 19 | , 20 | ) 21 | 22 | if (process.env.NODE_ENV !== 'production') { 23 | responseHeaders.set('Cache-Control', 'no-store') 24 | } 25 | 26 | const html = `${markup}` 27 | 28 | responseHeaders.set('Content-Type', 'text/html') 29 | responseHeaders.set('Content-Length', String(Buffer.byteLength(html))) 30 | responseHeaders.set('X-Frame-Options', 'deny') 31 | responseHeaders.set('X-Content-Type-Options', 'nosniff') 32 | responseHeaders.set('Referrer-Policy', 'no-referrer-when-downgrade') 33 | responseHeaders.set('Permissions-Policy', 'fullscreen=()') 34 | 35 | // https://securityheaders.com 36 | const ContentSecurityPolicy = ` 37 | default-src 'self'; 38 | worker-src 'self'; 39 | script-src 'self' 'unsafe-inline' 'unsafe-eval' *.googletagmanager.com; 40 | child-src 'self'; 41 | frame-src 'self' codesandbox.io; 42 | style-src 'self' 'unsafe-inline' ; 43 | img-src * blob: data:; 44 | media-src 'none'; 45 | connect-src *; 46 | font-src 'self'; 47 | ` 48 | responseHeaders.set( 49 | 'Content-Security-Policy', 50 | ContentSecurityPolicy.replace(/\n/g, ''), 51 | ) 52 | 53 | return new Response(html, { 54 | status: responseStatusCode, 55 | headers: responseHeaders, 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /app/routes/blog/index.tsx: -------------------------------------------------------------------------------- 1 | import type {LoaderFunction} from '@remix-run/node' 2 | import {json} from '@remix-run/node' 3 | import {useLoaderData} from '@remix-run/react' 4 | import {motion} from 'framer-motion' 5 | import {getAllPosts, getAllPostViewsCount} from '~/utils/blog.server' 6 | import {ServerError} from '~/components/errors' 7 | import {H1, Paragraph} from '~/components/typography' 8 | import ResponsiveContainer from '~/components/responsive-container' 9 | import BlogPost from '~/components/blog-post' 10 | import type {PostItem} from '~/types' 11 | import type {Timings} from '~/utils/metrics.server' 12 | import {getServerTimeHeader} from '~/utils/metrics.server' 13 | 14 | type LoaderData = { 15 | posts: PostItem[] 16 | totalViewsCount: number 17 | } 18 | 19 | export const loader: LoaderFunction = async ({request}) => { 20 | const timings: Timings = {} 21 | 22 | const posts = await getAllPosts({request, timings}) 23 | const totalViewsCount = await getAllPostViewsCount() 24 | 25 | const data: LoaderData = {posts, totalViewsCount} 26 | return json(data, { 27 | headers: { 28 | 'Server-Timing': getServerTimeHeader(timings), 29 | }, 30 | }) 31 | } 32 | 33 | export default function Index() { 34 | const {posts, totalViewsCount} = useLoaderData() as unknown as LoaderData 35 | 36 | return ( 37 | 38 |

    All posts

    39 | 40 | Total views: {totalViewsCount} 41 | 42 |
      43 | {posts.map(post => ( 44 | 50 | 51 | 52 | ))} 53 |
    54 |
    55 | ) 56 | } 57 | 58 | export function ErrorBoundary({error}: {error: Error}) { 59 | console.error(error) 60 | return 61 | } 62 | -------------------------------------------------------------------------------- /app/components/navbar/menu-toggle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import clsx from 'clsx' 3 | 4 | export default function MenuToggle({ 5 | state, 6 | onToggle, 7 | }: { 8 | state: 'opened' | 'closed' 9 | onToggle: () => void 10 | }) { 11 | return ( 12 | 21 | ) 22 | } 23 | 24 | function MenuIcon({ 25 | hidden, 26 | ...props 27 | }: JSX.IntrinsicElements['svg'] & {hidden: boolean}) { 28 | return ( 29 | 39 | 46 | 53 | 54 | ) 55 | } 56 | 57 | function CloseIcon({ 58 | hidden, 59 | ...props 60 | }: JSX.IntrinsicElements['svg'] & {hidden: boolean}) { 61 | return ( 62 | 77 | 78 | 79 | 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /app/routes/github-activity.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type {LoaderFunction} from '@remix-run/node' 3 | import {json} from '@remix-run/node' 4 | import {useLoaderData} from '@remix-run/react' 5 | import {getGithubContributions} from '~/utils/homepage.server' 6 | import type {Timings} from '~/utils/metrics.server' 7 | import {getServerTimeHeader} from '~/utils/metrics.server' 8 | import {ServerError} from '~/components/errors' 9 | import {H1} from '~/components/typography' 10 | import ResponsiveContainer from '~/components/responsive-container' 11 | import GithubRepoCard from '~/components/github-repo-card' 12 | import Link from '~/components/link' 13 | import type {GitHubRepo} from '~/types' 14 | 15 | type LoaderData = { 16 | repos: GitHubRepo[] 17 | } 18 | 19 | export const loader: LoaderFunction = async ({request}) => { 20 | const timings: Timings = {} 21 | 22 | const repos = await getGithubContributions({ 23 | request, 24 | timings, 25 | }) 26 | const data: LoaderData = {repos} 27 | return json(data, { 28 | headers: { 29 | 'Server-Timing': getServerTimeHeader(timings), 30 | }, 31 | }) 32 | } 33 | 34 | export default function Index() { 35 | const {repos} = useLoaderData() 36 | 37 | return ( 38 | 39 |

    GitHub Contributions

    40 | 41 |
      42 | {repos.map((repo: GitHubRepo) => ( 43 | 44 | ))} 45 |
    46 | 47 | 52 | View on GitHub 53 | 54 | 👉 55 | 56 | 57 |
    58 | ) 59 | } 60 | 61 | export function ErrorBoundary({error}: {error: Error}) { 62 | console.error(error) 63 | return 64 | } 65 | -------------------------------------------------------------------------------- /app/components/use-on-read.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export default function useOnRead({ 4 | parentElRef, 5 | time, 6 | onRead, 7 | }: { 8 | parentElRef: React.RefObject 9 | time: number | undefined 10 | onRead: () => void 11 | }) { 12 | React.useEffect(() => { 13 | const parentEl = parentElRef.current 14 | if (!parentEl || !time) return 15 | 16 | const visibilityEl = document.createElement('div') 17 | 18 | let scrolledTheMain = false 19 | const observer = new IntersectionObserver(entries => { 20 | const isVisible = entries.some(entry => { 21 | return entry.target === visibilityEl && entry.isIntersecting 22 | }) 23 | if (isVisible) { 24 | scrolledTheMain = true 25 | maybeMarkAsRead() 26 | observer.disconnect() 27 | visibilityEl.remove() 28 | } 29 | }) 30 | 31 | let startTime = new Date().getTime() 32 | let timeoutTime = time * 0.3 33 | let timerId: ReturnType 34 | let timerFinished = false 35 | function startTimer() { 36 | timerId = setTimeout(() => { 37 | timerFinished = true 38 | document.removeEventListener('visibilitychange', handleVisibilityChange) 39 | maybeMarkAsRead() 40 | }, timeoutTime) 41 | } 42 | 43 | function handleVisibilityChange() { 44 | if (document.hidden) { 45 | clearTimeout(timerId) 46 | const timeElapsedSoFar = new Date().getTime() - startTime 47 | timeoutTime = timeoutTime - timeElapsedSoFar 48 | } else { 49 | startTime = new Date().getTime() 50 | startTimer() 51 | } 52 | } 53 | 54 | function maybeMarkAsRead() { 55 | if (timerFinished && scrolledTheMain) { 56 | cleanup() 57 | onRead() 58 | } 59 | } 60 | 61 | // dirty-up 62 | parentEl.append(visibilityEl) 63 | observer.observe(visibilityEl) 64 | startTimer() 65 | document.addEventListener('visibilitychange', handleVisibilityChange) 66 | 67 | function cleanup() { 68 | document.removeEventListener('visibilitychange', handleVisibilityChange) 69 | clearTimeout(timerId) 70 | observer.disconnect() 71 | visibilityEl.remove() 72 | } 73 | return cleanup 74 | }, [time, onRead, parentElRef]) 75 | } 76 | -------------------------------------------------------------------------------- /app/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import ResponsiveContainer from './responsive-container' 3 | import Link from './link' 4 | import NowPlaying from './now-playing' 5 | import type {SpotifySong} from '~/types' 6 | 7 | export default function Footer({ 8 | nowPlayingSong, 9 | }: { 10 | nowPlayingSong: SpotifySong | null 11 | }) { 12 | return ( 13 | 14 |
    15 | 16 | 17 | 18 |
    19 |
    20 |
    21 | 22 | Home 23 | 24 |
    25 |
    26 | 27 | Blog 28 | 29 |
    30 |
    31 | 32 | About 33 | 34 |
    35 |
    36 |
    37 |
    38 | 39 | Github 40 | 41 |
    42 |
    43 | 44 | Twitter 45 | 46 |
    47 |
    48 | 49 | Linkedin 50 | 51 |
    52 |
    53 |
    54 |
    55 | 56 | Email 57 | 58 |
    59 |
    60 | 61 | RSS 62 | 63 |
    64 |
    65 |
    66 |
    67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import type {LoaderFunction} from '@remix-run/node' 2 | import {json} from '@remix-run/node' 3 | import {useLoaderData} from '@remix-run/react' 4 | import {motion} from 'framer-motion' 5 | import {getAllPosts} from '~/utils/blog.server' 6 | 7 | import type {Timings} from '~/utils/metrics.server' 8 | import {getServerTimeHeader} from '~/utils/metrics.server' 9 | import ResponsiveContainer from '~/components/responsive-container' 10 | import Hero from '~/components/hero' 11 | import BlogPostCard from '~/components/blog-post-card' 12 | import {H2} from '~/components/typography' 13 | import Link from '~/components/link' 14 | import {ServerError} from '~/components/errors' 15 | import type {PostItem} from '~/types' 16 | 17 | type LoaderData = { 18 | posts: PostItem[] 19 | } 20 | 21 | export const loader: LoaderFunction = async ({request}) => { 22 | const timings: Timings = {} 23 | 24 | const posts = await getAllPosts({ 25 | limit: 3, 26 | request, 27 | timings, 28 | }) 29 | 30 | const data: LoaderData = {posts} 31 | return json(data, { 32 | headers: { 33 | 'Server-Timing': getServerTimeHeader(timings), 34 | }, 35 | }) 36 | } 37 | 38 | const gradients = [ 39 | 'from-[#D8B4FE] to-[#818CF8]', 40 | 'from-[#6EE7B7] via-[#3B82F6] to-[#9333EA]', 41 | 'from-[#FDE68A] via-[#FCA5A5] to-[#FECACA]', 42 | ] 43 | 44 | export default function IndexRoute() { 45 | const {posts} = useLoaderData() as unknown as LoaderData 46 | 47 | return ( 48 | 49 | 50 |

    Latest Posts

    51 |
    52 | {posts.map((post, index) => ( 53 | 59 | 60 | 61 | ))} 62 |
    63 | 67 | Read all posts 68 | 69 | 👉 70 | 71 | 72 |
    73 | ) 74 | } 75 | 76 | export function ErrorBoundary({error}: {error: Error}) { 77 | console.error(error) 78 | return 79 | } 80 | -------------------------------------------------------------------------------- /app/components/typography.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import clsx from 'clsx' 3 | 4 | type Variant = 'primary' | 'secondary' | 'inverse' | 'danger' 5 | 6 | type TitleProps = { 7 | variant?: Variant 8 | as?: React.ElementType | string 9 | className?: string 10 | id?: string 11 | } & ( 12 | | {children: React.ReactNode} 13 | | { 14 | dangerouslySetInnerHTML: { 15 | __html: string 16 | } 17 | } 18 | ) 19 | 20 | const fontSize = { 21 | h1: 'text-4xl font-bold md:text-5xl', 22 | h2: 'text-3xl font-bold md:text-4xl', 23 | h3: 'text-2xl font-medium md:text-3xl', 24 | h4: 'text-xl font-medium md:text-2xl', 25 | h5: 'text-lg font-medium md:text-xl', 26 | h6: 'text-lg font-medium', 27 | } 28 | 29 | const titleColors = { 30 | primary: 'text-primary', 31 | secondary: 'text-secondary', 32 | danger: 'text-danger', 33 | inverse: 'text-inverse', 34 | } 35 | 36 | function Title({ 37 | variant = 'primary', 38 | size, 39 | as, 40 | className, 41 | ...rest 42 | }: TitleProps & {size: keyof typeof fontSize}) { 43 | const Tag = as ?? size 44 | return ( 45 | 49 | ) 50 | } 51 | 52 | function H1(props: TitleProps) { 53 | return 54 | } 55 | 56 | function H2(props: TitleProps) { 57 | return <Title {...props} size="h2" /> 58 | } 59 | 60 | function H3(props: TitleProps) { 61 | return <Title {...props} size="h3" /> 62 | } 63 | 64 | function H4(props: TitleProps) { 65 | return <Title {...props} size="h4" /> 66 | } 67 | 68 | function H5(props: TitleProps) { 69 | return <Title {...props} size="h5" /> 70 | } 71 | 72 | function H6(props: TitleProps) { 73 | return <Title {...props} size="h6" /> 74 | } 75 | 76 | type ParagraphProps = { 77 | className?: string 78 | variant?: Variant 79 | size?: 'small' | 'medium' | 'large' 80 | as?: React.ElementType 81 | } & ({children: React.ReactNode} | {dangerouslySetInnerHTML: {__html: string}}) 82 | 83 | function Paragraph({ 84 | className, 85 | as = 'p', 86 | variant = 'primary', 87 | size = 'medium', 88 | ...rest 89 | }: ParagraphProps) { 90 | return React.createElement(as, { 91 | className: clsx( 92 | 'max-w-full', 93 | titleColors[variant], 94 | { 95 | 'text-sm': size === 'small', 96 | 'text-md': size === 'medium', 97 | 'text-lg': size === 'large', 98 | }, 99 | className, 100 | ), 101 | ...rest, 102 | }) 103 | } 104 | 105 | export {H1, H2, H3, H4, H5, H6, Paragraph, Title} 106 | -------------------------------------------------------------------------------- /app/components/now-playing.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {useMatches} from '@remix-run/react' 3 | import type {SpotifySong} from '~/types' 4 | import Link from './link' 5 | 6 | export default function NowPlaying({song}: {song: SpotifySong | null}) { 7 | const matches = useMatches() 8 | 9 | const isTopTracksRoute = matches.find( 10 | match => match.pathname === '/top-tracks', 11 | ) 12 | 13 | return ( 14 | <div className="mb-8"> 15 | <div className="flex flex-row-reverse items-center mb-3 w-full space-x-0 sm:flex-row sm:space-x-2"> 16 | <svg className="mt-[-2px] ml-auto w-5 h-5" viewBox="0 0 168 168"> 17 | <path 18 | fill="#1ED760" 19 | d="M83.996.277C37.747.277.253 37.77.253 84.019c0 46.251 37.494 83.741 83.743 83.741 46.254 0 83.744-37.49 83.744-83.741 0-46.246-37.49-83.738-83.745-83.738l.001-.004zm38.404 120.78a5.217 5.217 0 01-7.18 1.73c-19.662-12.01-44.414-14.73-73.564-8.07a5.222 5.222 0 01-6.249-3.93 5.213 5.213 0 013.926-6.25c31.9-7.291 59.263-4.15 81.337 9.34 2.46 1.51 3.24 4.72 1.73 7.18zm10.25-22.805c-1.89 3.075-5.91 4.045-8.98 2.155-22.51-13.839-56.823-17.846-83.448-9.764-3.453 1.043-7.1-.903-8.148-4.35a6.538 6.538 0 014.354-8.143c30.413-9.228 68.222-4.758 94.072 11.127 3.07 1.89 4.04 5.91 2.15 8.976v-.001zm.88-23.744c-26.99-16.031-71.52-17.505-97.289-9.684-4.138 1.255-8.514-1.081-9.768-5.219a7.835 7.835 0 015.221-9.771c29.581-8.98 78.756-7.245 109.83 11.202a7.823 7.823 0 012.74 10.733c-2.2 3.722-7.02 4.949-10.73 2.739z" 20 | /> 21 | </svg> 22 | <div className="inline-flex flex-col w-full max-w-full truncate sm:flex-row"> 23 | {song?.songUrl ? ( 24 | <a 25 | className="capsize max-w-max dark:text-gray-200 text-gray-800 font-medium truncate hover:underline" 26 | href={song.songUrl} 27 | target="_blank" 28 | rel="noopener noreferrer" 29 | > 30 | {song.title} 31 | </a> 32 | ) : ( 33 | <p className="capsize dark:text-gray-200 text-gray-800 font-medium"> 34 | Not Playing 35 | </p> 36 | )} 37 | <span className="capsize hidden mx-2 dark:text-gray-300 text-gray-500 sm:block"> 38 | {' – '} 39 | </span> 40 | <p className="capsize max-w-max dark:text-gray-300 text-gray-500 truncate"> 41 | {song?.artist ?? 'Spotify'} 42 | </p> 43 | </div> 44 | </div> 45 | {isTopTracksRoute ? null : ( 46 | <Link to="/top-tracks" className="hover:underline"> 47 | 🎵 My top tracks on Spotify updated daily. 👉 48 | </Link> 49 | )} 50 | </div> 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /app/components/navbar/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {useTheme, Theme} from 'remix-themes' 3 | import {motion} from 'framer-motion' 4 | 5 | export const useLoaded = () => { 6 | const [loaded, setLoaded] = React.useState(false) 7 | React.useEffect(() => setLoaded(true), []) 8 | return loaded 9 | } 10 | 11 | export default function ThemeToggle() { 12 | const [theme, setTheme] = useTheme() 13 | 14 | const loaded = useLoaded() 15 | 16 | return ( 17 | <motion.button 18 | aria-label="Toggle Theme" 19 | type="button" 20 | className="flex items-center justify-center w-9 h-9 bg-gray-200 dark:bg-gray-600 rounded-lg hover:ring-2 ring-gray-300" 21 | onClick={() => setTheme(theme === Theme.DARK ? Theme.LIGHT : Theme.DARK)} 22 | > 23 | {theme === Theme.DARK && loaded && <SunIcon />} 24 | {theme === Theme.LIGHT && loaded && <MoonIcon />} 25 | </motion.button> 26 | ) 27 | } 28 | 29 | const transition = { 30 | type: 'spring', 31 | stiffness: 200, 32 | damping: 10, 33 | } 34 | 35 | const whileTap = { 36 | scale: 0.95, 37 | rotate: 15, 38 | } 39 | 40 | function MoonIcon() { 41 | const variants = { 42 | initial: {scale: 0.6, rotate: 90}, 43 | animate: {scale: 1, rotate: 0, transition}, 44 | whileTap, 45 | } 46 | 47 | return ( 48 | <svg 49 | xmlns="http://www.w3.org/2000/svg" 50 | viewBox="0 0 24 24" 51 | fill="none" 52 | stroke="currentColor" 53 | className="w-5 h-5 dark:text-gray-200 text-gray-800" 54 | > 55 | <motion.path 56 | initial="initial" 57 | animate="animate" 58 | whileTap="whileTap" 59 | variants={variants} 60 | strokeLinecap="round" 61 | strokeLinejoin="round" 62 | strokeWidth={2} 63 | d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" 64 | /> 65 | </svg> 66 | ) 67 | } 68 | 69 | function SunIcon() { 70 | const variants = { 71 | initial: {rotate: 45}, 72 | animate: {rotate: 0, transition}, 73 | } 74 | 75 | return ( 76 | <svg 77 | xmlns="http://www.w3.org/2000/svg" 78 | viewBox="0 0 24 24" 79 | fill="none" 80 | stroke="currentColor" 81 | className=" w-5 h-5 dark:text-gray-200 text-gray-800" 82 | > 83 | <motion.path 84 | initial="initial" 85 | animate="animate" 86 | variants={variants} 87 | strokeLinecap="round" 88 | strokeLinejoin="round" 89 | strokeWidth={2} 90 | d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" 91 | /> 92 | ) 93 | </svg> 94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /app/utils/redis.server.ts: -------------------------------------------------------------------------------- 1 | import redis from 'redis' 2 | import {getRequiredServerEnvVar} from './misc' 3 | 4 | declare global { 5 | // This prevents us from making multiple connections to the db when the 6 | // require cache is cleared. 7 | // eslint-disable-next-line 8 | var primaryClient: redis.RedisClient | undefined 9 | } 10 | 11 | const REDIS_URL = getRequiredServerEnvVar('REDIS_URL') 12 | const primary = new URL(REDIS_URL) 13 | const isLocalHost = primary.hostname === 'localhost' 14 | const isInternal = primary.hostname.includes('.internal') 15 | 16 | const PRIMARY_REGION = getRequiredServerEnvVar('PRIMARY_REGION') 17 | 18 | let primaryClient: redis.RedisClient | null = null 19 | 20 | if (!isLocalHost) { 21 | primary.host = `${PRIMARY_REGION}.${primary.host}` 22 | } 23 | 24 | primaryClient = createClient({ 25 | url: primary.toString(), 26 | family: isInternal ? 'IPv6' : 'IPv4', 27 | }) 28 | 29 | function createClient(options: redis.ClientOpts): redis.RedisClient { 30 | const name = 'primaryClient' 31 | let client = global[name] 32 | if (!client) { 33 | const url = new URL(options.url ?? 'http://no-redis-url.example.com?weird') 34 | // eslint-disable-next-line no-multi-assign 35 | client = global[name] = redis.createClient(options) 36 | 37 | client.on('error', (error: string) => { 38 | console.error(`REDIS ${name} (${url.host}) ERROR:`, error) 39 | }) 40 | } 41 | return client 42 | } 43 | 44 | // NOTE: Caching should never crash the app, so instead of rejecting all these 45 | // promises, we'll just resolve things with null and log the error. 46 | 47 | function get<Value = unknown>(key: string): Promise<Value | null> { 48 | return new Promise(resolve => { 49 | primaryClient?.get(key, (err: Error | null, result: string | null) => { 50 | if (err) { 51 | console.error(`REDIS ERROR with .get:`, err) 52 | } 53 | resolve(result ? (JSON.parse(result) as Value) : null) 54 | }) 55 | }) 56 | } 57 | 58 | function set<Value>(key: string, value: Value): Promise<'OK'> { 59 | return new Promise(resolve => { 60 | primaryClient?.set( 61 | key, 62 | JSON.stringify(value), 63 | (err: Error | null, reply: 'OK') => { 64 | if (err) console.error(`REDIS ERROR with .set:`, err) 65 | resolve(reply) 66 | }, 67 | ) 68 | }) 69 | } 70 | 71 | function del(key: string): Promise<string> { 72 | return new Promise(resolve => { 73 | primaryClient?.del(key, (err: Error | null, result: number | null) => { 74 | if (err) { 75 | console.error('Primary delete error', err) 76 | resolve('error') 77 | } else { 78 | resolve(`${key} deleted: ${result}`) 79 | } 80 | }) 81 | }) 82 | } 83 | 84 | const redisCache = {get, set, del, name: 'redis'} 85 | export {get, set, del, redisCache} 86 | -------------------------------------------------------------------------------- /app/components/errors.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | import errorStack from 'error-stack-parser' 4 | import ResponsiveContainer from './responsive-container' 5 | import {H1, H2, H5, H6, Paragraph} from './typography' 6 | 7 | function RedBox({error}: {error: Error}) { 8 | const [isVisible, setIsVisible] = React.useState(true) 9 | const frames = errorStack.parse(error) 10 | 11 | return ( 12 | <div 13 | className={clsx( 14 | 'fixed z-10 inset-0 flex items-center justify-center transition', 15 | { 16 | 'opacity-0 pointer-events-none': !isVisible, 17 | }, 18 | )} 19 | > 20 | <button 21 | className="absolute inset-0 block w-full h-full bg-black opacity-75" 22 | onClick={() => setIsVisible(false)} 23 | /> 24 | <div className="border-lg text-primary relative mx-5vw my-16 p-12 max-h-75vh bg-red-500 rounded-lg overflow-y-auto"> 25 | <H2>{error.message}</H2> 26 | <div> 27 | {frames.map(frame => ( 28 | <div 29 | key={[frame.fileName, frame.lineNumber, frame.columnNumber].join( 30 | '-', 31 | )} 32 | className="pt-4" 33 | > 34 | <H6 as="div" className="pt-2"> 35 | {frame.functionName} 36 | </H6> 37 | <div className="font-mono opacity-75"> 38 | {frame.fileName}:{frame.lineNumber}:{frame.columnNumber} 39 | </div> 40 | </div> 41 | ))} 42 | </div> 43 | </div> 44 | </div> 45 | ) 46 | } 47 | 48 | function ErrorPage({ 49 | error, 50 | errorCode, 51 | message, 52 | }: { 53 | error?: Error 54 | errorCode: string 55 | message: string 56 | }) { 57 | return ( 58 | <ResponsiveContainer className="mb-20 mt-20 text-center"> 59 | <H1 className="mb-4">{errorCode}</H1> 60 | <H5 className="text-secondary">{message}</H5> 61 | {['404', '500'].includes(errorCode) ? ( 62 | <Paragraph className="mb-8 mt-8 text-9xl"> 63 | {errorCode === '500' && <>💣</>} 64 | {errorCode === '404' && <>🤭</>} 65 | </Paragraph> 66 | ) : null} 67 | 68 | {error && 69 | (typeof window === 'undefined' 70 | ? process.env.NODE_ENV === 'development' 71 | : window.ENV.NODE_ENV === 'development') ? ( 72 | <RedBox error={error} /> 73 | ) : null} 74 | </ResponsiveContainer> 75 | ) 76 | } 77 | 78 | function ServerError({error}: {error?: Error}) { 79 | return ( 80 | <ErrorPage 81 | error={error} 82 | errorCode="500" 83 | message="Oh no, something went wrong." 84 | /> 85 | ) 86 | } 87 | 88 | function FourOhFour({error}: {error?: Error}) { 89 | return ( 90 | <ErrorPage 91 | error={error} 92 | errorCode="404" 93 | message=" Oh no, we can't find the page you're looking for." 94 | /> 95 | ) 96 | } 97 | 98 | export {ErrorPage, ServerError, FourOhFour} 99 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="@remix-run/dev" /> 2 | /// <reference types="@remix-run/node/globals" /> 3 | 4 | import type {ActionFunction, LoaderFunction} from '@remix-run/node' 5 | import type calculateReadingTime from 'reading-time' 6 | import type {GitHubProfile} from 'remix-auth-github' 7 | import type {Post as PrismaPost, Comment} from '@prisma/client' 8 | 9 | export type SpotifySong = { 10 | album: string 11 | albumImageUrl: string 12 | artist: string 13 | isPlaying?: boolean 14 | songUrl: string 15 | title: string 16 | } 17 | 18 | type GitHubFile = {path: string; content: string} 19 | 20 | type GitHubRepo = { 21 | id: string 22 | name: string 23 | url: string 24 | description: string 25 | owner: { 26 | login: string 27 | } 28 | } 29 | 30 | type GithubUser = Pick< 31 | GitHubProfile, 32 | 'id' | 'displayName' | 'photos' | 'name' 33 | > & { 34 | admin: boolean 35 | } 36 | 37 | type MdxPage = { 38 | code: string 39 | slug: string 40 | readTime?: ReturnType<typeof calculateReadingTime> 41 | editLink: string 42 | frontmatter: { 43 | title: string 44 | description: string 45 | date: string | Date 46 | draft?: boolean 47 | categories: Array<string> 48 | bannerCloudinaryId?: string 49 | meta: { 50 | keywords: Array<string> 51 | [key: string]: unknown 52 | } 53 | } 54 | } 55 | 56 | interface Post extends MdxPage, PrismaPost { 57 | comments?: Array<Comment> 58 | } 59 | interface PostItem extends MdxListItem, PrismaPost {} 60 | 61 | type MdxListItem = Omit<MdxPage, 'code'> 62 | 63 | type AppLoader< 64 | Params extends Record<string, unknown> = Record<string, unknown>, 65 | > = ( 66 | args: Omit<Parameters<LoaderFunction>['0'], 'params'> & {params: Params}, 67 | ) => ReturnType<LoaderFunction> 68 | 69 | type AppAction< 70 | Params extends Record<string, unknown> = Record<string, unknown>, 71 | > = ( 72 | args: Omit<Parameters<ActionFunction>['0'], 'params'> & {params: Params}, 73 | ) => ReturnType<ActionFunction> 74 | 75 | type AppSitemapEntry = { 76 | route: string 77 | lastmod?: string 78 | changefreq?: 79 | | 'always' 80 | | 'hourly' 81 | | 'daily' 82 | | 'weekly' 83 | | 'monthly' 84 | | 'yearly' 85 | | 'never' 86 | priority?: 0.0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1.0 87 | } 88 | 89 | type AppHandle = { 90 | /** this just allows us to identify routes more directly rather than relying on pathnames */ 91 | id?: string 92 | getSitemapEntries?: ( 93 | request: Request, 94 | ) => 95 | | Promise<Array<AppSitemapEntry | null> | null> 96 | | Array<AppSitemapEntry | null> 97 | | null 98 | scroll?: false 99 | } 100 | 101 | export { 102 | GitHubFile, 103 | GitHubRepo, 104 | GithubUser, 105 | AppLoader, 106 | AppAction, 107 | MdxPage, 108 | MdxListItem, 109 | Post, 110 | Comment, 111 | PostItem, 112 | AppSitemapEntry, 113 | AppHandle, 114 | } 115 | -------------------------------------------------------------------------------- /app/routes/action/refresh-cache.tsx: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import * as React from 'react' 3 | import {json, redirect} from '@remix-run/node' 4 | import type {ActionFunction} from '@remix-run/node' 5 | import {getRequiredServerEnvVar} from '~/utils/misc' 6 | import {redisCache} from '~/utils/redis.server' 7 | import {getMdxDirList, getMdxPage, getBlogMdxListItems} from '~/utils/mdx' 8 | import type {AppHandle} from '~/types' 9 | 10 | type Body = 11 | | {keys: Array<string>; commitSha?: string} 12 | | {contentPaths: Array<string>; commitSha?: string} 13 | 14 | export const commitShaKey = 'meta:last-refresh-commit-sha' 15 | 16 | export const handle: AppHandle = { 17 | getSitemapEntries: () => null, 18 | } 19 | 20 | export const action: ActionFunction = async ({request}) => { 21 | // Everything in this function is fire and forget, so we don't need to await 22 | // anything. 23 | 24 | if ( 25 | request.headers.get('auth') !== 26 | getRequiredServerEnvVar('REFRESH_CACHE_SECRET') 27 | ) { 28 | return redirect('https://www.youtube.com/watch?v=dQw4w9WgXcQ') 29 | } 30 | 31 | const body = (await request.json()) as Body 32 | 33 | function setShaInRedis() { 34 | if (body.commitSha) { 35 | void redisCache.set( 36 | commitShaKey, 37 | JSON.stringify({sha: body.commitSha, date: new Date()}), 38 | ) 39 | } 40 | } 41 | 42 | if ('keys' in body && Array.isArray(body.keys)) { 43 | for (const key of body.keys) { 44 | void redisCache.del(key) 45 | } 46 | 47 | setShaInRedis() 48 | 49 | return json({ 50 | message: 'Deleting redis cache keys', 51 | keys: body.keys, 52 | commitSha: body.commitSha, 53 | }) 54 | } 55 | if ('contentPaths' in body && Array.isArray(body.contentPaths)) { 56 | const refreshingContentPaths = [] 57 | for (const contentPath of body.contentPaths) { 58 | if (typeof contentPath !== 'string') continue 59 | 60 | if (contentPath.startsWith('blog') || contentPath.startsWith('pages')) { 61 | const [contentDir, dirOrFilename] = contentPath.split('/') 62 | if (!contentDir || !dirOrFilename) continue 63 | const slug = path.parse(dirOrFilename).name 64 | 65 | refreshingContentPaths.push(contentPath) 66 | void getMdxPage({contentDir, slug}, {forceFresh: true}) 67 | } 68 | } 69 | 70 | // if any blog contentPaths were changed then let's update the dir list 71 | // so it will appear on the blog page. 72 | if (refreshingContentPaths.some(p => p.startsWith('blog'))) { 73 | void getBlogMdxListItems({ 74 | request, 75 | forceFresh: 'blog:dir-list,blog:mdx-list-items', 76 | }) 77 | } 78 | if (refreshingContentPaths.some(p => p.startsWith('pages'))) { 79 | void getMdxDirList('pages', {forceFresh: true}) 80 | } 81 | 82 | setShaInRedis() 83 | 84 | return json({ 85 | message: 'Refreshing cache for content paths', 86 | contentPaths: refreshingContentPaths, 87 | commitSha: body.commitSha, 88 | }) 89 | } 90 | return json({message: 'no action taken'}, {status: 400}) 91 | } 92 | 93 | export const loader = () => redirect('/', {status: 404}) 94 | 95 | export default function MarkRead() { 96 | return <div>Oops... You should not see this.</div> 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 | <url> 15 | <loc>${domainUrl}${route}</loc> 16 | ${lastmod ? `<lastmod>${lastmod}</lastmod>` : ''} 17 | ${changefreq ? `<changefreq>${changefreq}</changefreq>` : ''} 18 | ${priority ? `<priority>${priority}</priority>` : ''} 19 | </url> 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<AppSitemapEntry> = [] 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 | <?xml version="1.0" encoding="UTF-8"?> 92 | <urlset 93 | xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" 94 | xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 95 | xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd" 96 | > 97 | ${sitemapEntries.map(entry => getEntry(entry)).join('')} 98 | </urlset> 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 | <Image 20 | cloudinaryId="bereghici-dev/blog/VjP0l-APtpo_hegnhl" 21 | imgProps={{alt: 'A bug'}} 22 | /> 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<FrontmatterType extends Record<string, unknown>>( 8 | slug: string, 9 | githubFiles: Array<GitHubFile>, 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<GitHubFile> = 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<ItemType extends Record<string, unknown>>( 83 | array: Array<ItemType>, 84 | {keyName, valueName}: {keyName: keyof ItemType; valueName: keyof ItemType}, 85 | ) { 86 | const obj: Record<string, ItemType[keyof ItemType]> = {} 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<string, unknown>, 111 | >(...args: Parameters<typeof compileMdx>) { 112 | const queue = await getQueue() 113 | const result = await queue.add(() => compileMdx<FrontmatterType>(...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 | <Image 28 | cloudinaryId="bereghici-dev/blog/cheat-sheets-for-developers_azxvcj" 29 | imgProps={{alt: 'Cheat Sheets for Developers'}} 30 | /> 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<string>) { 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<Array<PostItem>> { 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<Post | null> { 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<string>) => 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 | <Image 20 | cloudinaryId="bereghici-dev/blog/heading_accessibility_v31ihj" 21 | imgProps={{alt: 'Headings & Accessibility'}} 22 | /> 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 `<h4>` without a `<h3>`. 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 `<h1>` 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 | <LevelContext.Provider value={level + 1}>{children}</LevelContext.Provider> 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 `<h2>` element 66 | but styled as a `<h4>`, 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 <Element className={classes}>{children}</Element>; 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 | <iframe 109 | src="https://codesandbox.io/embed/headings-accessibility-cf90z?autoresize=1&fontsize=12&hidenavigation=1&theme=dark&view=editor" 110 | style={{ 111 | width: '100%', 112 | height: '500px', 113 | border: 0, 114 | borderRadius: '4px', 115 | overflow: 'hidden', 116 | }} 117 | title="headings-accessibility" 118 | allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking" 119 | sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts" 120 | ></iframe> 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<HTMLElement> 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<SpeechSynthesisUtterance>(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 | <div className="flex items-center"> 67 | <motion.button 68 | className="flex items-center justify-center w-7 h-7 bg-gray-200 dark:bg-gray-600 rounded-lg hover:ring-2 ring-gray-300" 69 | onClick={onClick} 70 | > 71 | {playing ? <PauseIcon /> : <PlayIcon />} 72 | </motion.button> 73 | <p> 74 | <Paragraph as="div" size="small" variant="secondary" className="ml-2"> 75 | Would you prefer to have this post read to you? 76 | </Paragraph> 77 | <Paragraph 78 | as="div" 79 | size="small" 80 | variant="secondary" 81 | className="ml-2 text-xs" 82 | > 83 | SpeechSynthesis is still experimental. This could be buggy 84 | </Paragraph> 85 | </p> 86 | </div> 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 | <svg 104 | viewBox="0 0 330 330" 105 | stroke="currentColor" 106 | width={15} 107 | className="dark:fill-white fill-black" 108 | > 109 | <motion.path 110 | initial="initial" 111 | animate="animate" 112 | whileTap="whileTap" 113 | variants={variants} 114 | strokeLinecap="round" 115 | strokeLinejoin="round" 116 | strokeWidth={2} 117 | className="gray-200 dark:gray-600" 118 | d="M37.728,328.12c2.266,1.256,4.77,1.88,7.272,1.88c2.763,0,5.522-0.763,7.95-2.28l240-149.999 119 | c4.386-2.741,7.05-7.548,7.05-12.72c0-5.172-2.664-9.979-7.05-12.72L52.95,2.28c-4.625-2.891-10.453-3.043-15.222-0.4 120 | C32.959,4.524,30,9.547,30,15v300C30,320.453,32.959,325.476,37.728,328.12z" 121 | /> 122 | </svg> 123 | ) 124 | } 125 | 126 | function PauseIcon() { 127 | return ( 128 | <svg 129 | viewBox="0 0 512 512" 130 | width={15} 131 | className="dark:fill-white fill-black" 132 | > 133 | <motion.path 134 | initial="initial" 135 | animate="animate" 136 | whileTap="whileTap" 137 | variants={variants} 138 | strokeLinecap="round" 139 | strokeLinejoin="round" 140 | strokeWidth={2} 141 | d="M120.16 45A20.162 20.162 0 0 0 100 65.16v381.68A20.162 20.162 0 0 0 120.16 467h65.68A20.162 20.162 0 0 0 206 446.84V65.16A20.162 20.162 0 0 0 185.84 45h-65.68zm206 0A20.162 20.162 0 0 0 306 65.16v381.68A20.162 20.162 0 0 0 326.16 467h65.68A20.162 20.162 0 0 0 412 446.84V65.16A20.162 20.162 0 0 0 391.84 45h-65.68z" 142 | /> 143 | </svg> 144 | ) 145 | } 146 | 147 | export default function SpeechPostWrapper(props: Props) { 148 | const speechSupported = 149 | typeof window !== 'undefined' && 'speechSynthesis' in window 150 | return ( 151 | <ClientOnly fallback={null}> 152 | {() => (speechSupported ? <SpeechPost {...props} /> : null)} 153 | </ClientOnly> 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 | <Image 23 | cloudinaryId="bereghici-dev/blog/using_notion_as_developer_mauyax" 24 | imgProps={{alt: 'An organized desktop with a mackbook'}} 25 | /> 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 | <Image 39 | cloudinaryId="bereghici-dev/blog/main_notion_page_chipkg" 40 | imgProps={{alt: 'Main notion page'}} 41 | /> 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 | <Image 47 | cloudinaryId="bereghici-dev/blog/frontend_notion_page_kmd17o" 48 | imgProps={{alt: 'Frontend notion page'}} 49 | /> 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 | <Image 61 | cloudinaryId="bereghici-dev/blog/accessibility_bookmarks_sxjrmo" 62 | imgProps={{alt: 'Bookmarks notion page'}} 63 | /> 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 | <Image 74 | cloudinaryId="bereghici-dev/blog/task_list_notion_page_vknph3" 75 | imgProps={{alt: 'A Notion task list'}} 76 | /> 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 | <Image 86 | cloudinaryId="bereghici-dev/blog/reading_list_notion_page_p2s7hx" 87 | imgProps={{alt: 'A Notion reading list'}} 88 | /> 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 | <Image 99 | cloudinaryId="bereghici-dev/blog/saved_twitter_notion_page_enaszt" 100 | imgProps={{alt: 'A Notion page for saved tweets'}} 101 | /> 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 | <Image 117 | cloudinaryId="bereghici-dev/blog/rss_source_notion_page_wr5nmz" 118 | imgProps={{alt: 'A Notion page for managing RSS sources'}} 119 | /> 120 | 121 | This is the RSS feed: 122 | 123 | <Image 124 | cloudinaryId="bereghici-dev/blog/rss_feed_notion_page_zgt3vc" 125 | imgProps={{alt: 'A RSS Feed in notion page '}} 126 | /> 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 | <Image 20 | cloudinaryId="bereghici-dev/blog/build-a-scalable-front-end-with-rush-monorepo-and-react-part5_wgbjfi" 21 | imgProps={{alt: 'Rushjs logo'}} 22 | /> 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<typeof getEnv> 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<LoaderData>() 123 | const [theme] = useTheme() 124 | 125 | return ( 126 | <html lang="en" className={theme ?? ''}> 127 | <head> 128 | <meta charSet="utf-8" /> 129 | <Meta /> 130 | <PreventFlashOnWrongTheme 131 | ssrTheme={Boolean(data.requestInfo.session.theme)} 132 | /> 133 | <Links /> 134 | </head> 135 | <body className="bg-primary"> 136 | <header> 137 | <Navbar /> 138 | </header> 139 | <main id="main"> 140 | <Outlet /> 141 | </main> 142 | <Footer nowPlayingSong={data.nowPlayingSong} /> 143 | <script 144 | dangerouslySetInnerHTML={{ 145 | __html: `window.ENV = ${JSON.stringify(data.ENV)};`, 146 | }} 147 | /> 148 | <script 149 | defer 150 | src={`https://www.googletagmanager.com/gtag/js?id=${data.ENV.GA_TRACKING_ID}`} 151 | /> 152 | <script 153 | defer 154 | dangerouslySetInnerHTML={{ 155 | __html: `window.dataLayer = window.dataLayer || []; 156 | function gtag() { 157 | dataLayer.push(arguments); 158 | } 159 | gtag('js', new Date()); 160 | gtag('config', '${data.ENV.GA_TRACKING_ID}', { 161 | page_path: window.location.pathname, 162 | }); 163 | `, 164 | }} 165 | /> 166 | <ScrollRestoration /> 167 | <Scripts /> 168 | {process.env.NODE_ENV === 'development' ? <LiveReload /> : null} 169 | </body> 170 | </html> 171 | ) 172 | } 173 | 174 | export default function AppWithProviders() { 175 | const data = useLoaderData<LoaderData>() 176 | 177 | return ( 178 | <ThemeProvider 179 | specifiedTheme={data.requestInfo.session.theme} 180 | themeAction="/action/set-theme" 181 | > 182 | <App /> 183 | </ThemeProvider> 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 | <html lang="en"> 194 | <head> 195 | <title>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 | --------------------------------------------------------------------------------