├── .eslintrc.js ├── .github ├── FUNDING.yml └── dependabot.yml ├── .gitignore ├── .nvmrc ├── README.md ├── api └── index.js ├── app ├── components │ └── spinner.tsx ├── entry.client.tsx ├── entry.server.tsx ├── root.tsx ├── routes │ ├── articles.tsx │ ├── bookmarks.tsx │ ├── index.tsx │ ├── rss.ts │ └── scripts │ │ └── analytics.ts ├── services │ ├── airtable.server.ts │ ├── cache-control.ts │ ├── cn.server.ts │ └── session.server.ts ├── styles │ ├── global.css │ └── highlight.css ├── utils.ts └── views │ └── article.tsx ├── config ├── cypress.json ├── jest.config.ts ├── jest │ ├── babel.config.js │ └── setup.js ├── pm2.config.js ├── postcss.config.js ├── remix.env.d.ts └── tailwind.js ├── content ├── articles │ ├── 7-principles-of-modern-web-applications.mdx │ ├── an-accessible-approach-to-frontend-testing.mdx │ ├── aplicaciones-real-time-alta-escala.mdx │ ├── aplicaciones-web-con-zero-server.mdx │ ├── automatizacion-shell-scripts.mdx │ ├── bye-platzi-hi-zeit.mdx │ ├── career-titles-levels.mdx │ ├── cargando-react-cdn.mdx │ ├── carrusel-elementos-dinamicos-react.mdx │ ├── ciclo-de-vida-de-un-componente-de-reactjs.mdx │ ├── combinando-react-y-redux.mdx │ ├── como-mantenerse-actualizado-con-el-ecosistema-de-javascript.mdx │ ├── componentes-con-react.mdx │ ├── componentes-react-hijos-personalizables.mdx │ ├── componentes-react-personalizables-props.mdx │ ├── composicion-componentes-react.mdx │ ├── contentz-es-omakase.mdx │ ├── contentz-netlify.mdx │ ├── crear-modulo-npm.mdx │ ├── cristalab │ │ ├── automatizando-tareas-frontend-gulp.mdx │ │ ├── como-usar-la-etiqueta-template-en-html5.mdx │ │ ├── el-modulo-flexbox-de-css3.mdx │ │ ├── javascript-orientado-a-objetos.mdx │ │ └── uso-de-modulos-en-javascript-con-ecmascript-6.mdx │ ├── definiendo-conceptos-closures-y-scope.mdx │ ├── documentacion.mdx │ ├── documentation.mdx │ ├── feature-flags-react.mdx │ ├── generador-sitios-estaticos-propio.mdx │ ├── generadores-asincronos-js.mdx │ ├── github-actions-npm-publish.mdx │ ├── hola-mundo-react.mdx │ ├── how-to-keep-updated-with-the-javascript-ecosystem.mdx │ ├── implementando-un-servidor-de-graphql.mdx │ ├── introduccion-a-graphql.mdx │ ├── introduccion-a-mdx.mdx │ ├── introduccion-a-redux.mdx │ ├── introducing-contentz.mdx │ ├── js-basics │ │ ├── array-prototype-filter.mdx │ │ ├── array-prototype-foreach.mdx │ │ ├── array-prototype-map.mdx │ │ └── array-prototype-push.mdx │ ├── libs │ │ └── react-use-permissions.mdx │ ├── markdown-react.mdx │ ├── medium │ │ ├── buenas-practicas-del-desarrollo-frontend.mdx │ │ ├── compilando-frontend-webpack.mdx │ │ ├── escribiendo-css-de-la-forma-correcta.mdx │ │ ├── i18n-react-formatjs.mdx │ │ ├── introduccion-a-ecmascript-2016-7.mdx │ │ ├── mi-experiencia-como-estudiante-de-los-cursos-de-platzi.mdx │ │ ├── que-es-scrum.mdx │ │ ├── renderizando-react-js-en-el-server-con-express-js-y-react-engine.mdx │ │ ├── usando-ecmascript-6-2015-con-babel.mdx │ │ ├── usando-ecmascript-6-en-tus-tareas-de-gulp.mdx │ │ └── ventajas-y-desventajas-de-los-pre-procesadores-de-css.mdx │ ├── mezclando-flujos-sincronos-y-asincronos.mdx │ ├── next-file-structure.mdx │ ├── next-swr-prefetch.mdx │ ├── next-tailwind.mdx │ ├── next │ │ └── web-vitals.mdx │ ├── platzi │ │ ├── ecmascript-nueva-sintaxis.mdx │ │ └── react-v014.mdx │ ├── presentando-contentz.mdx │ ├── que-son-y-como-funcionan-las-promesas-en-javascript.mdx │ ├── react-conditions-lists.mdx │ ├── react-prop-especial-children.mdx │ ├── react-props-valores-predefinidos.mdx │ ├── react-state-effects.mdx │ ├── react-v-16-6.mdx │ ├── react-working-with-forms.mdx │ ├── react │ │ ├── cargando-imagenes-con-suspense.mdx │ │ ├── estructura-archivos.mdx │ │ ├── file-structure.mdx │ │ └── suspense-image-loading.mdx │ ├── redirects-in-next-the-good-way.mdx │ ├── render-as-you-fetch.mdx │ ├── scalable-real-time-applications.mdx │ ├── sobre-el-ecosistema-y-la-fatiga-de-javascript.mdx │ ├── swr │ │ ├── geolocation.mdx │ │ ├── intro.mdx │ │ ├── mutate-immer.mdx │ │ ├── pagination.mdx │ │ ├── storage-sync.mdx │ │ ├── suspense.mdx │ │ ├── sync-session.mdx │ │ └── use-mutation.mdx │ ├── tailwind │ │ └── purge-unused-css.mdx │ ├── testing-in-next-dynamic-imports.mdx │ ├── tipos-datos-react.mdx │ ├── type-states-client-side-app.mdx │ ├── use-scoped-registry.mdx │ └── vercel │ │ ├── configure-gsuite.mdx │ │ └── setup-redirect.mdx ├── links.yml ├── pages │ ├── codeable │ │ └── resources.mdx │ ├── contact.mdx │ ├── services.mdx │ ├── tailwind.mdx │ └── uses.mdx └── slides │ ├── codeable │ └── html-css-basics.mdx │ ├── fault-tolerant-react-apps.mdx │ ├── frontend-testing.mdx │ ├── next-js.mdx │ ├── react-hooks.mdx │ └── software-engineers-career-path.mdx ├── data └── resume.json ├── legacy ├── layouts │ ├── ama.tsx │ ├── article-list.tsx │ ├── article.tsx │ ├── bookmarks.tsx │ ├── channel.tsx │ ├── course-react-query.tsx │ ├── course-swr.tsx │ ├── feed.tsx │ ├── home.tsx │ └── reader.tsx ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── ama.tsx │ ├── api │ │ ├── ama.ts │ │ ├── feedback.ts │ │ ├── search.ts │ │ ├── subscribe.ts │ │ └── webhooks │ │ │ └── cn.ts │ ├── articles.tsx │ ├── articles │ │ └── [...path].tsx │ ├── bookmarks.tsx │ ├── channel.tsx │ ├── courses │ │ ├── react-query.tsx │ │ └── swr.tsx │ ├── index.tsx │ └── reader │ │ ├── [feed].tsx │ │ └── index.tsx └── styles.css ├── package-lock.json ├── package.json ├── public ├── avatar.png ├── manifest.json ├── robots.txt └── static │ ├── articles │ ├── gh-actions-new.png │ ├── gh-secrets.png │ ├── mdx.png │ ├── netlify-add.png │ ├── npm-tokens-new.png │ ├── npm-tokens-result.png │ ├── npm-tokens.png │ └── swr │ │ └── intro.png │ ├── avatar.jpg │ ├── avatar │ ├── amazing.png │ ├── anger.png │ ├── angry.png │ ├── approves.png │ ├── congrats.png │ ├── disappointed.png │ ├── disapproves.png │ ├── face-palm.png │ ├── funny.png │ ├── happy.png │ ├── insult.png │ ├── laughing.png │ ├── luck.png │ ├── mind-blown.png │ ├── nope.png │ ├── open-mouth.png │ ├── pray.png │ ├── raised-hand.png │ ├── rolling-eyes.png │ ├── sad.png │ ├── scream.png │ ├── shrug.png │ ├── silence.png │ ├── sleeping.png │ ├── sweat-smile.png │ ├── thinking.png │ ├── victory.png │ ├── wink.png │ └── working.png │ ├── books │ ├── react-redux-kindle.png │ └── react-redux.png │ ├── favicon@128.png │ ├── favicon@192.png │ ├── favicon@400.png │ ├── favicon@48.png │ ├── favicon@96.png │ └── slides │ ├── nextjs.png │ └── t-shaped.jpeg ├── remix.config.js ├── scripts ├── change-visibility.js ├── migrate.js └── upload-links.js ├── test ├── cache-control.test.ts └── utils.test.ts ├── tsconfig.json └── vercel.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-module */ 2 | module.exports = { 3 | root: true, 4 | parser: "@typescript-eslint/parser", 5 | plugins: [ 6 | "@typescript-eslint", 7 | "unicorn", 8 | "jest", 9 | "import", 10 | "react", 11 | "prettier", 12 | "react-hooks", 13 | "jsx-a11y", 14 | "promise", 15 | "cypress", 16 | "testing-library", 17 | "jest-dom", 18 | ], 19 | extends: [ 20 | "plugin:unicorn/recommended", 21 | "plugin:react-hooks/recommended", 22 | "plugin:react/recommended", 23 | "plugin:@typescript-eslint/recommended", 24 | "plugin:prettier/recommended", 25 | "plugin:jest/recommended", 26 | "plugin:import/errors", 27 | "plugin:import/warnings", 28 | "plugin:import/typescript", 29 | "plugin:jsx-a11y/recommended", 30 | "plugin:promise/recommended", 31 | "plugin:cypress/recommended", 32 | "plugin:testing-library/react", 33 | "plugin:jest-dom/recommended", 34 | ], 35 | env: { 36 | "cypress/globals": true, 37 | }, 38 | settings: { 39 | react: { 40 | version: "detect", 41 | }, 42 | "import/resolver": { 43 | typescript: {}, 44 | }, 45 | }, 46 | rules: { 47 | "react/button-has-type": "error", 48 | "react/function-component-definition": [ 49 | "error", 50 | { 51 | namedComponents: "function-declaration", 52 | unnamedComponents: "arrow-function", 53 | }, 54 | ], 55 | "prefer-const": "off", 56 | "@typescript-eslint/explicit-module-boundary-types": "off", 57 | "react/react-in-jsx-scope": "off", 58 | "react/jsx-uses-react": "off", 59 | "no-unused-vars": "off", 60 | "no-var": "off", 61 | "unicorn/no-null": "off", 62 | "unicorn/prefer-node-protocol": "off", 63 | "unicorn/filename-case": "off", 64 | "unicorn/prevent-abbreviations": [ 65 | "error", 66 | { 67 | allowList: { Props: true, props: true, env: true }, 68 | }, 69 | ], 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | patreon: sergiodxa 4 | github: sergiodxa 5 | custom: https://paypal.me/sergiodxa 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | assignees: 10 | - sergiodxa 11 | ignore: 12 | - dependency-name: husky 13 | versions: 14 | - ">= 4.a, < 5" 15 | - dependency-name: "@types/node" 16 | versions: 17 | - 14.14.39 18 | - 14.14.41 19 | - 15.0.0 20 | - dependency-name: postcss 21 | versions: 22 | - 8.2.11 23 | - 8.2.12 24 | - dependency-name: "@types/airtable" 25 | versions: 26 | - 0.10.0 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /api/build 5 | /public/build 6 | /logs 7 | 8 | *.log 9 | 10 | .vercel 11 | .DS_Store 12 | .env 13 | app/styles/tailwind.css 14 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Personal Site 2 | 3 | This is the code behind sergiodxa.com 4 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | const { createRequestHandler } = require("@remix-run/vercel"); 2 | 3 | module.exports = createRequestHandler({ 4 | build: require("./build"), 5 | }); 6 | -------------------------------------------------------------------------------- /app/components/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export function Spinner(props: SVGProps) { 4 | return ( 5 | 6 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "remix"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable testing-library/render-result-naming-convention */ 2 | import "dotenv/config"; 3 | import etag from "etag"; 4 | import { renderToString } from "react-dom/server"; 5 | import type { EntryContext, HandleDataRequestFunction } from "remix"; 6 | import { RemixServer } from "remix"; 7 | import { notModified } from "remix-utils"; 8 | 9 | export default function handleRequest( 10 | request: Request, 11 | status: number, 12 | headers: Headers, 13 | remixContext: EntryContext 14 | ) { 15 | let markup = renderToString( 16 | 17 | ); 18 | 19 | headers.set("Content-Type", "text/html"); 20 | headers.set("ETag", etag(markup)); 21 | 22 | if (request.headers.get("If-None-Match") === headers.get("ETag")) { 23 | return notModified({ headers }); 24 | } 25 | 26 | return new Response("" + markup, { status, headers }); 27 | } 28 | 29 | export let handleDataRequest: HandleDataRequestFunction = async ( 30 | response: Response, 31 | { request } 32 | ) => { 33 | let body = await response.text(); 34 | 35 | if (request.method.toLowerCase() === "get") { 36 | response.headers.set("etag", etag(body)); 37 | if (request.headers.get("If-None-Match") === response.headers.get("ETag")) { 38 | return notModified({ headers: response.headers }); 39 | } 40 | } 41 | 42 | return response; 43 | }; 44 | -------------------------------------------------------------------------------- /app/routes/articles.tsx: -------------------------------------------------------------------------------- 1 | import type { Note } from "collected-notes"; 2 | import { 3 | Form, 4 | HeadersFunction, 5 | json, 6 | Link, 7 | LoaderFunction, 8 | MetaFunction, 9 | useLoaderData, 10 | useTransition, 11 | } from "remix"; 12 | import { CacheControl } from "~/services/cache-control"; 13 | import { cn, sitePath } from "~/services/cn.server"; 14 | 15 | type LoaderData = { 16 | term: string; 17 | page: number; 18 | notes: Pick[]; 19 | }; 20 | 21 | export let headers: HeadersFunction = () => { 22 | return { "Cache-Control": new CacheControl("swr").toString() }; 23 | }; 24 | 25 | export let meta: MetaFunction = () => { 26 | return { title: "Articles of Sergio Xalambrí" }; 27 | }; 28 | 29 | function getNotes(page = 1, term?: string) { 30 | if (term) return cn.search(sitePath, term, page, "public_site"); 31 | return cn.latestNotes(sitePath, page, "public_site"); 32 | } 33 | 34 | export let loader: LoaderFunction = async ({ request }) => { 35 | let url = new URL(request.url); 36 | let term = url.searchParams.get("q") ?? ""; 37 | let page = Number(url.searchParams.get("page") ?? 1); 38 | let notes = await getNotes(page, term); 39 | return json( 40 | { 41 | term, 42 | page, 43 | notes: notes.map((note) => ({ 44 | title: note.title, 45 | path: note.path, 46 | id: note.id, 47 | })), 48 | }, 49 | { headers: { "Cache-Control": new CacheControl("swr").toString() } } 50 | ); 51 | }; 52 | 53 | export default function Screen() { 54 | let { notes, page, term } = useLoaderData(); 55 | let { submission } = useTransition(); 56 | 57 | let count = notes.length; 58 | 59 | if (count === 0) { 60 | return ( 61 |
62 |

404 Not Found

63 |

64 | The requested URL /articles?page={page} was not found on this server. 65 |

66 |
67 | ); 68 | } 69 | 70 | return ( 71 |
72 |
73 |

Articles

74 | {term ? ( 75 |

76 | Showing {count} articles for the query{" "} 77 | {term}. 78 |

79 | ) : ( 80 |

These are my articles.

81 | )} 82 |
83 | 84 |
85 |
86 | 89 |
90 | 98 | 104 |
105 |
106 | 107 |
    108 | {notes.map((note) => ( 109 |
  • 110 | 111 | {note.title} 112 | 113 |
  • 114 | ))} 115 |
116 |
117 | 118 |
119 | {page > 1 && ( 120 | 121 | Previous page 122 | 123 | )} 124 | {count === 40 && ( 125 | 126 | Next page 127 | 128 | )} 129 |
130 |
131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /app/routes/bookmarks.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | HeadersFunction, 3 | LoaderFunction, 4 | MetaFunction, 5 | useLoaderData, 6 | } from "remix"; 7 | import { json } from "remix-utils"; 8 | import { Bookmark, getBookmarks } from "~/services/airtable.server"; 9 | import { CacheControl } from "~/services/cache-control"; 10 | 11 | type LoaderData = { 12 | bookmarks: Bookmark[]; 13 | }; 14 | 15 | export let headers: HeadersFunction = () => { 16 | return { "Cache-Control": new CacheControl("swr").toString() }; 17 | }; 18 | 19 | export let meta: MetaFunction = () => { 20 | return { title: "Bookmarks of Sergio Xalambrí" }; 21 | }; 22 | 23 | export let loader: LoaderFunction = async () => { 24 | let bookmarks = await getBookmarks(); 25 | return json({ bookmarks }); 26 | }; 27 | 28 | export default function Screen() { 29 | let { bookmarks } = useLoaderData(); 30 | 31 | return ( 32 |
33 |
34 |

Bookmarks

35 |
36 | 37 |
38 | 47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import type { Note } from "collected-notes"; 2 | import { 3 | HeadersFunction, 4 | Link, 5 | LoaderFunction, 6 | MetaFunction, 7 | useLoaderData, 8 | } from "remix"; 9 | import { json } from "remix-utils"; 10 | import { Bookmark, getBookmarks } from "~/services/airtable.server"; 11 | import { CacheControl } from "~/services/cache-control"; 12 | import { cn, sitePath } from "~/services/cn.server"; 13 | 14 | type LoaderData = { 15 | notes: Pick[]; 16 | bookmarks: Bookmark[]; 17 | }; 18 | 19 | export let headers: HeadersFunction = () => { 20 | return { "Cache-Control": new CacheControl("swr").toString() }; 21 | }; 22 | 23 | export let meta: MetaFunction = () => { 24 | return {}; 25 | }; 26 | 27 | export let loader: LoaderFunction = async () => { 28 | let [notes, bookmarks] = await Promise.all([ 29 | cn.latestNotes(sitePath, 1, "public_site"), 30 | getBookmarks(10), 31 | ]); 32 | return json({ 33 | notes: notes 34 | .slice(0, 10) 35 | .map((note) => ({ title: note.title, path: note.path, id: note.id })), 36 | bookmarks, 37 | }); 38 | }; 39 | 40 | export default function Screen() { 41 | let { notes, bookmarks } = useLoaderData(); 42 | 43 | return ( 44 |
45 |
46 |
47 |

Latest articles

48 |

49 | These are my latests articles. 50 |

51 |
52 | 53 |
54 |
    55 | {notes.map((note) => ( 56 |
  • 57 | {note.title} 58 |
  • 59 | ))} 60 |
61 |
62 | 63 |
64 | Want to see them all?{" "} 65 | 66 | Check full article list 67 | 68 |
69 |
70 | 71 |
72 |
73 |

Recent bookmarks

74 |

75 | The latests links I have bookmarked. 76 |

77 |
78 | 79 |
80 | 89 |
90 |
91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /app/routes/rss.ts: -------------------------------------------------------------------------------- 1 | import { LoaderFunction } from "remix"; 2 | 3 | export let loader: LoaderFunction = () => { 4 | return fetch("https://collectednotes.com/sergiodxa/feed/public_site.rss"); 5 | }; 6 | -------------------------------------------------------------------------------- /app/routes/scripts/analytics.ts: -------------------------------------------------------------------------------- 1 | import { LoaderFunction } from "@remix-run/server-runtime"; 2 | 3 | export let loader: LoaderFunction = async () => { 4 | let script = 5 | "window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', 'UA-48432002-3');"; 6 | return new Response(script, { 7 | headers: { 8 | "Content-Type": "text/javascript", 9 | }, 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /app/services/airtable.server.ts: -------------------------------------------------------------------------------- 1 | import Airtable, { FieldSet } from "airtable"; 2 | 3 | if (!process.env.AIRTABLE_API_KEY) { 4 | throw new Error("Missing AIRTABLE_API_KEY env variable"); 5 | } 6 | 7 | if (!process.env.AIRTABLE_BASE) { 8 | throw new Error("Missing AIRTABLE_BASE env variable"); 9 | } 10 | 11 | export type Bookmark = FieldSet & { 12 | title: string; 13 | url: string; 14 | }; 15 | 16 | export async function getBookmarks(limit = 100): Promise { 17 | const base = new Airtable({ 18 | apiKey: process.env.AIRTABLE_API_KEY as string, 19 | }).base(process.env.AIRTABLE_BASE as string); 20 | 21 | const table = base("links"); 22 | 23 | const records = await table 24 | .select({ 25 | maxRecords: limit, 26 | sort: [{ field: "created_at", direction: "desc" }], 27 | }) 28 | .firstPage(); 29 | 30 | return records.map((record) => ({ 31 | title: record.fields.title, 32 | url: record.fields.url, 33 | })); 34 | } 35 | -------------------------------------------------------------------------------- /app/services/cache-control.ts: -------------------------------------------------------------------------------- 1 | type CacheStrategy = "prevent" | "swr" | "forever" | "require revalidation"; 2 | 3 | type Cacheability = "public" | "private" | "no-cache" | "no-store"; 4 | 5 | let SECOND_PER_YEAR = 3.154e7; 6 | 7 | export class CacheControl { 8 | public cacheability: Cacheability = "public"; 9 | 10 | public maxAge?: number; 11 | public sMaxAge?: number; 12 | public maxStale?: number; 13 | public minFresh?: number; 14 | public staleWhileRevalidate?: number; 15 | public staleIfError?: number; 16 | public mustRevalidate?: boolean; 17 | public proxyRevalidate?: boolean; 18 | public immutable?: boolean; 19 | public noTransform?: boolean; 20 | public onlyIfCached?: boolean; 21 | 22 | constructor(readonly strategy?: CacheStrategy) { 23 | if (strategy === "prevent") { 24 | this.cacheability = "no-cache"; 25 | this.maxAge = 0; 26 | } 27 | if (strategy === "swr") { 28 | this.sMaxAge = 1; 29 | this.staleWhileRevalidate = SECOND_PER_YEAR; 30 | } 31 | if (strategy === "forever") { 32 | this.cacheability = "public"; 33 | this.maxAge = SECOND_PER_YEAR; 34 | this.immutable = true; 35 | } 36 | if (strategy === "require revalidation") { 37 | this.maxAge = 0; 38 | this.mustRevalidate = true; 39 | } 40 | } 41 | 42 | toString() { 43 | let result = []; 44 | 45 | if (this.cacheability) result.push(this.cacheability); 46 | 47 | if (this.maxAge !== undefined) result.push(`max-age=${this.maxAge}`); 48 | if (this.sMaxAge !== undefined) result.push(`s-maxage=${this.sMaxAge}`); 49 | if (this.maxStale !== undefined) result.push(`max-stale=${this.maxStale}`); 50 | if (this.minFresh !== undefined) result.push(`min-fresh=${this.minFresh}`); 51 | if (this.staleWhileRevalidate !== undefined) { 52 | result.push(`stale-while-revalidate=${this.staleWhileRevalidate}`); 53 | } 54 | if (this.staleIfError !== undefined) { 55 | result.push(`stale-if-error=${this.staleIfError}`); 56 | } 57 | 58 | if (this.mustRevalidate) result.push("must-revalidate"); 59 | if (this.proxyRevalidate) result.push("proxy-revalidate"); 60 | if (this.immutable) result.push("immutable"); 61 | if (this.noTransform) result.push("no-transform"); 62 | if (this.onlyIfCached) result.push("only-if-cached"); 63 | 64 | return result.join(", "); 65 | } 66 | 67 | toJSON() { 68 | return this.toString(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/services/cn.server.ts: -------------------------------------------------------------------------------- 1 | import { collectedNotes } from "collected-notes"; 2 | 3 | if (!process.env.CN_EMAIL) throw new Error("Missing CN_EMAIL env variable"); 4 | 5 | if (!process.env.CN_TOKEN) throw new Error("Missing CN_TOKEN env variable"); 6 | 7 | if (!process.env.CN_SITE_PATH) { 8 | throw new Error("Missing CN_SITE_PATH env variable"); 9 | } 10 | 11 | export let cn = collectedNotes(process.env.CN_EMAIL, process.env.CN_TOKEN); 12 | 13 | export let sitePath = process.env.CN_SITE_PATH; 14 | -------------------------------------------------------------------------------- /app/services/session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from "remix"; 2 | import { env } from "~/utils"; 3 | 4 | export let sessionStorage = createCookieSessionStorage({ 5 | cookie: { 6 | name: "__web_session", 7 | sameSite: "lax", 8 | path: "/", 9 | httpOnly: true, 10 | secrets: ["s3cr3t"], 11 | secure: env("production"), 12 | domain: env("production") ? "sergiodxa.com" : undefined, 13 | }, 14 | }); 15 | 16 | export let { getSession, commitSession, destroySession } = sessionStorage; 17 | -------------------------------------------------------------------------------- /app/styles/global.css: -------------------------------------------------------------------------------- 1 | :focus:not(:focus-visible) { 2 | outline: none; 3 | } 4 | 5 | body { 6 | background-color: #c0c0c0; 7 | color: black; 8 | } 9 | 10 | a { 11 | color: blue; 12 | text-decoration: underline; 13 | } 14 | 15 | a:visited { 16 | color: purple; 17 | } 18 | 19 | a:active { 20 | color: red; 21 | } 22 | 23 | .quote::before, 24 | .quote::after { 25 | content: '"'; 26 | } 27 | -------------------------------------------------------------------------------- /app/styles/highlight.css: -------------------------------------------------------------------------------- 1 | .highlight { 2 | font-weight: 400; 3 | max-width: 100%; 4 | overflow: hidden; 5 | padding: 0; 6 | } 7 | 8 | .highlight pre { 9 | overflow: auto; 10 | max-width: 100%; 11 | white-space: pre-wrap; 12 | background-color: #272822; 13 | border-radius: 4px; 14 | padding: 0.5em; 15 | } 16 | 17 | .highlight table td { 18 | padding: 5px; 19 | } 20 | 21 | .highlight table td { 22 | padding: 5px; 23 | } 24 | 25 | .highlight table pre { 26 | margin: 0; 27 | } 28 | 29 | .highlight, 30 | .highlight .w { 31 | color: #f8f8f2; 32 | } 33 | 34 | .highlight .err { 35 | color: #272822; 36 | background-color: #f92672; 37 | } 38 | 39 | .highlight .c, 40 | .highlight .cd, 41 | .highlight .cm, 42 | .highlight .c1, 43 | .highlight .cs { 44 | color: #75715e; 45 | } 46 | 47 | .highlight .cp { 48 | color: #f4bf75; 49 | } 50 | 51 | .highlight .nt { 52 | color: #f4bf75; 53 | } 54 | 55 | .highlight .o, 56 | .highlight .ow { 57 | color: #f8f8f2; 58 | } 59 | 60 | .highlight .p, 61 | .highlight .pi { 62 | color: #f8f8f2; 63 | } 64 | 65 | .highlight .gi { 66 | color: #a6e22e; 67 | } 68 | 69 | .highlight .gd { 70 | color: #f92672; 71 | } 72 | 73 | .highlight .gh { 74 | color: #66d9ef; 75 | background-color: #272822; 76 | font-weight: bold; 77 | } 78 | 79 | .highlight .k, 80 | .highlight .kn, 81 | .highlight .kp, 82 | .highlight .kr, 83 | .highlight .kv { 84 | color: #ae81ff; 85 | } 86 | 87 | .highlight .kc { 88 | color: #fd971f; 89 | } 90 | 91 | .highlight .kt { 92 | color: #fd971f; 93 | } 94 | 95 | .highlight .kd { 96 | color: #fd971f; 97 | } 98 | 99 | .highlight .s, 100 | .highlight .sb, 101 | .highlight .sc, 102 | .highlight .sd, 103 | .highlight .s2, 104 | .highlight .sh, 105 | .highlight .sx, 106 | .highlight .s1 { 107 | color: #a6e22e; 108 | } 109 | 110 | .highlight .sr { 111 | color: #a1efe4; 112 | } 113 | 114 | .highlight .si { 115 | color: #cc6633; 116 | } 117 | 118 | .highlight .se { 119 | color: #cc6633; 120 | } 121 | 122 | .highlight .nn { 123 | color: #f4bf75; 124 | } 125 | 126 | .highlight .nc { 127 | color: #f4bf75; 128 | } 129 | 130 | .highlight .no { 131 | color: #f4bf75; 132 | } 133 | 134 | .highlight .na { 135 | color: #66d9ef; 136 | } 137 | 138 | .highlight .m, 139 | .highlight .mf, 140 | .highlight .mh, 141 | .highlight .mi, 142 | .highlight .il, 143 | .highlight .mo, 144 | .highlight .mb, 145 | .highlight .mx { 146 | color: #a6e22e; 147 | } 148 | 149 | .highlight .ss { 150 | color: #a6e22e; 151 | } 152 | -------------------------------------------------------------------------------- /app/views/article.tsx: -------------------------------------------------------------------------------- 1 | import type { HTML } from "collected-notes"; 2 | import { 3 | HeadersFunction, 4 | json, 5 | LinksFunction, 6 | LoaderFunction, 7 | MetaFunction, 8 | useLoaderData, 9 | } from "remix"; 10 | import { CacheControl } from "~/services/cache-control"; 11 | import { cn, sitePath } from "~/services/cn.server"; 12 | import highlightStyles from "~/styles/highlight.css"; 13 | 14 | type LoaderData = { 15 | body: HTML; 16 | title: string; 17 | }; 18 | 19 | export let headers: HeadersFunction = () => { 20 | return { "Cache-Control": new CacheControl("swr").toString() }; 21 | }; 22 | 23 | export let meta: MetaFunction = ({ data }) => { 24 | let { title } = data as LoaderData; 25 | return { 26 | title: `${title} - Sergio Xalambrí`, 27 | }; 28 | }; 29 | export let links: LinksFunction = () => { 30 | return [{ rel: "stylesheet", href: highlightStyles }]; 31 | }; 32 | 33 | export let loader: LoaderFunction = async ({ request }) => { 34 | let { pathname } = new URL(request.url); 35 | let slug = pathname.slice( 36 | pathname.indexOf("/articles/") + "/articles/".length 37 | ); 38 | let { body, note } = await cn.body(sitePath, slug); 39 | 40 | return json( 41 | { body, title: note.title }, 42 | { headers: { "Cache-Control": new CacheControl("swr").toString() } } 43 | ); 44 | }; 45 | 46 | export default function Index() { 47 | let { body } = useLoaderData(); 48 | 49 | return ( 50 |
51 |
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /config/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000" 3 | } 4 | -------------------------------------------------------------------------------- /config/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types"; 2 | // eslint-disable-next-line unicorn/prefer-node-protocol 3 | // eslint-disable-next-line unicorn/import-style 4 | import * as path from "path"; 5 | 6 | const config: Config.InitialOptions = { 7 | verbose: true, 8 | rootDir: path.resolve("."), 9 | collectCoverageFrom: ["app/**/*.ts", "app/**/*.tsx"], 10 | setupFilesAfterEnv: ["/config/jest/setup.js"], 11 | testMatch: ["/test/**/*.test.tsx", "/test/**/*.test.ts"], 12 | transform: { 13 | "\\.[jt]sx?$": [ 14 | "babel-jest", 15 | { configFile: "./config/jest/babel.config.js" }, 16 | ], 17 | }, 18 | }; 19 | 20 | export default config; 21 | -------------------------------------------------------------------------------- /config/jest/babel.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This Babel configuration is not being used by Remix to compiler our app. 3 | * The reason to configure Babel in our project is because Jest needs this 4 | * file to support JSX and TypeScript. This is also the reason why the 5 | * preset-env targets is only the current version of Node.js 6 | */ 7 | /* eslint-disable unicorn/prefer-module */ 8 | module.exports = { 9 | presets: [ 10 | [ 11 | "@babel/preset-env", 12 | { 13 | targets: { node: "current" }, 14 | }, 15 | ], 16 | [ 17 | "@babel/preset-react", 18 | { 19 | runtime: "automatic", 20 | }, 21 | ], 22 | "@babel/preset-typescript", 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /config/jest/setup.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import "jest-fetch-mock/setupJest"; 3 | -------------------------------------------------------------------------------- /config/pm2.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-module */ 2 | module.exports = { 3 | apps: [ 4 | { 5 | name: "PostCSS", 6 | script: "yarn dev:css", 7 | ignore_watch: ["."], 8 | env: { 9 | NODE_ENV: "development", 10 | }, 11 | }, 12 | { 13 | name: "Remix", 14 | script: "yarn dev:app", 15 | ignore_watch: ["."], 16 | env: { 17 | NODE_ENV: "development", 18 | }, 19 | }, 20 | { 21 | name: "Vercel", 22 | script: "yarn start", 23 | ignore_watch: ["."], 24 | env: { 25 | NODE_ENV: "development", 26 | }, 27 | }, 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /config/postcss.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-module */ 2 | module.exports = { 3 | plugins: { 4 | tailwindcss: { config: "./config/tailwind.config.js" }, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /config/remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /config/tailwind.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-module */ 2 | const colors = require("tailwindcss/colors"); 3 | 4 | module.exports = { 5 | mode: "jit", 6 | 7 | content: ["./app/**/*.{ts,tsx}"], 8 | 9 | darkMode: "media", 10 | 11 | theme: { 12 | extend: { 13 | colors: { 14 | gray: colors.warmGray, 15 | }, 16 | 17 | maxWidth: { 18 | prose: "65ch", 19 | }, 20 | 21 | inset: { 22 | full: "100%", 23 | }, 24 | 25 | scale: { 26 | 500: "5", 27 | }, 28 | 29 | fontSize: { 30 | "7xl": "5rem", 31 | "8xl": "6rem", 32 | }, 33 | 34 | // typography(theme) { 35 | // return { 36 | // dark: { 37 | // css: { 38 | // color: theme("colors.gray.300"), 39 | // '[class~="lead"]': { 40 | // color: theme("colors.gray.400"), 41 | // }, 42 | // a: { 43 | // color: theme("colors.gray.100"), 44 | // }, 45 | // strong: { 46 | // color: theme("colors.gray.100"), 47 | // }, 48 | // "ul > li::before": { 49 | // backgroundColor: theme("colors.gray.700"), 50 | // }, 51 | // hr: { 52 | // borderColor: theme("colors.gray.800"), 53 | // }, 54 | // blockquote: { 55 | // color: theme("colors.gray.100"), 56 | // borderLeftColor: theme("colors.gray.800"), 57 | // }, 58 | // h1: { 59 | // color: theme("colors.gray.100"), 60 | // }, 61 | // h2: { 62 | // color: theme("colors.gray.100"), 63 | // }, 64 | // h3: { 65 | // color: theme("colors.gray.100"), 66 | // }, 67 | // h4: { 68 | // color: theme("colors.gray.100"), 69 | // }, 70 | // code: { 71 | // color: theme("colors.gray.100"), 72 | // }, 73 | // "a code": { 74 | // color: theme("colors.gray.100"), 75 | // }, 76 | // pre: { 77 | // color: theme("colors.gray.200"), 78 | // backgroundColor: theme("colors.gray.800"), 79 | // }, 80 | // thead: { 81 | // color: theme("colors.gray.100"), 82 | // borderBottomColor: theme("colors.gray.700"), 83 | // }, 84 | // "tbody tr": { 85 | // borderBottomColor: theme("colors.gray.800"), 86 | // }, 87 | // "h1 a": { 88 | // fontWeight: theme("fontWeight.bold"), 89 | // }, 90 | // "h2 a": { 91 | // fontWeight: theme("fontWeight.bold"), 92 | // }, 93 | // "h3 a": { 94 | // fontWeight: theme("fontWeight.bold"), 95 | // }, 96 | // "h4 a": { 97 | // fontWeight: theme("fontWeight.bold"), 98 | // }, 99 | // "h5 a": { 100 | // fontWeight: theme("fontWeight.bold"), 101 | // }, 102 | // "h6 a": { 103 | // fontWeight: theme("fontWeight.bold"), 104 | // }, 105 | // }, 106 | // }, 107 | // }; 108 | // }, 109 | }, 110 | }, 111 | 112 | variants: { 113 | extend: { 114 | padding: ["first", "last"], 115 | typography: ["dark"], 116 | }, 117 | }, 118 | 119 | plugins: [ 120 | require("@tailwindcss/typography"), 121 | require("@tailwindcss/forms"), 122 | require("@tailwindcss/line-clamp"), 123 | ], 124 | }; 125 | -------------------------------------------------------------------------------- /content/articles/bye-platzi-hi-zeit.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Bye Platzi, hi ▲ZEIT 3 | date: 2017-07-07T04:50:53.214Z 4 | description: 5 | Today I leave my job as Frontend Developer at Platzi to start working as 6 | Support Engineer at ▲ZEIT. 7 | published: true 8 | lang: en 9 | canonical_url: https://medium.com/@sergiodxa/2d1604a0b7b7 10 | tags: Platzi, ZEIT, Now, Career change 11 | --- 12 | 13 | Today I leave my job as Frontend Developer at [Platzi](https://platzi.com/) to 14 | start working as Support Engineer at [**▲ZEIT**](https://zeit.co/). 15 | 16 | ## Platzi 17 | 18 | I worked at Platzi for almost 2 years, it was a really cool job, I knew some 19 | amazing people who worked with me or were teachers in our courses. Some of them 20 | I now consider them as good friends. 21 | 22 | I also learned a lot about Frontend, Backend, video streaming, DevOps, etc. I 23 | worked in many awesome projects for the platform like our live system, the 24 | discussion system, the React-Django server render implementation, our video 25 | player, etc. 26 | 27 | I had the opportunity to open source some JS libraries, one of the most 28 | important is the Platzi’s markdown text editor 29 | [Pulse Editor](https://github.com/PlatziDev/pulse-editor) and a desktop 30 | application, made with Electron.js, [Pulse](https://github.com/PlatziDev/pulse). 31 | 32 | ## ▲ZEIT 33 | 34 | After knowing **▲ZEIT** and using their products and services I’m in love with 35 | that company and it’s mission. 36 | 37 | > Make Cloud Computing as Easy and Accessible as Mobile computing. 38 | 39 | Deploy an app to production it’s the last most important step in any software 40 | development, you can code the best app but it means nothing if it’s not online 41 | and available for your users. 42 | 43 | The **▲ZEIT** mission it’s to make easy that step that is sometimes too 44 | complicated, specially when working with a microservices architecture. 45 | 46 | ## My new job 47 | 48 | I’m going to be a Support Engineer, what does that means? My job is help you use 49 | **▲ZEIT** products and services without problem and guide you in the process if 50 | necessary. 51 | 52 | It’s a really big career shift, I’m going to keep developing but in a different 53 | way. I need to develop to understand our products and services. 54 | 55 | I need to know how to use [Next.js](https://github.com/zeit/next.js) in many 56 | ways and integrate it with other tools. I **must** know how use 57 | [Now](https://zeit.co/now) to deploy your applications and setup a deployment 58 | pipeline using it for any kind of application. 59 | 60 | That means I will learn a lot, I will teach a lot of people and I will develop 61 | many applications to test different possible use cases of our products and 62 | services. 63 | 64 | I also need to document a lot, since my primary job is teach how to use Now I 65 | must document it completely, and create a really good documentation so anyone 66 | can easily learn how to use it. 67 | 68 | ## Why English? 69 | 70 | Until now, I had the rule of only writing in Spanish, that was because I wanted 71 | to give more knowledge to the Spanish development community. But starting today 72 | I need to teach Now to any people in the world. 73 | 74 | That’s way I decided to start writing in English today. I’m going to keep 75 | learning a lot and writing as far as I can but I’ll do it exclusively in 76 | English. 77 | -------------------------------------------------------------------------------- /content/articles/cargando-react-cdn.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Cargando React desde un CDN 3 | date: 2019-02-26T15:57:55.585Z 4 | description: 5 | En este tutorial vamos a ver como crear un Hola Mundo en React sin usar JSX. 6 | lang: es 7 | published: true 8 | tags: Spanish, Frontend, React, JavaScript 9 | next: 10 | title: Hola Mundo con React 11 | path: /articles/hola-mundo-react 12 | description: Aprende a usar React para crear tu primer Hola, Mundo! 13 | --- 14 | 15 | 18 | 19 | Lo primero que necesitamos hacer para usar React es cargar el código de la 20 | librería. Hay muchas formas, ya sea desde un administrador de paquetes como npm 21 | o 22 | [yarn](https://sdx.im/link/https://platzi.com/blog/manejo-de-dependencias-javascript-con-yarn), 23 | la forma más fácil y la recomendada para empezar a aprender React es cargarlo 24 | como a cualquier archivo JS, con una etiqueta ` 36 | 37 | 38 | ``` 39 | 40 | Esas dos etiquetas `