diff --git a/.eslintrc.js b/.eslintrc.js
index 7ab79eed..4b5e94d2 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -214,6 +214,12 @@ module.exports = {
// @see {@link https://robinpokorny.medium.com/index-as-a-key-is-an-anti-pattern-e0349aece318}
'react/no-array-index-key': 'warn',
+ // Allow `tw` properties when using Tailwind with `@vercel/og`.
+ // @see {@link https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unknown-property.md}
+ // @see {@link https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation}
+ // @see {@link http://bit.ly/3Kfwovz}
+ 'react/no-unknown-property': ['error', { ignore: ['tw'] }],
+
// Configure `jsx-a11y` to recognize RMWC input components as controls.
// {@link https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/label-has-associated-control.md#case-my-label-and-input-components-are-custom-components}
'jsx-a11y/label-has-associated-control': [
diff --git a/app/root.tsx b/app/root.tsx
index 42131e8a..246c9b1c 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -48,8 +48,6 @@ export const handle: Handle = {
breadcrumb: () => nicholas.engineering,
}
-export const config = { runtime: 'edge' }
-
function Header() {
const matches = useMatches()
const user = useOptionalUser()
diff --git a/app/routes/og.tsx b/app/routes/og.tsx
new file mode 100644
index 00000000..5edc0a39
--- /dev/null
+++ b/app/routes/og.tsx
@@ -0,0 +1,37 @@
+import { ImageResponse } from '@vercel/og'
+
+export const config = { runtime: 'edge' }
+
+export function loader() {
+ return new ImageResponse(
+ (
+
+

+
nicholas.engineering
+
+ ),
+ {
+ width: 800,
+ height: 400,
+ },
+ )
+}
diff --git a/app/routes/shows.$showId.og.tsx b/app/routes/shows.$showId.og.tsx
new file mode 100644
index 00000000..f2bb213d
--- /dev/null
+++ b/app/routes/shows.$showId.og.tsx
@@ -0,0 +1,45 @@
+import { ImageResponse } from '@vercel/og'
+import { type LoaderArgs } from '@vercel/remix'
+
+import { prisma } from 'db.server'
+import { log } from 'log.server'
+
+export const config = { runtime: 'edge' }
+
+export async function loader({ params }: LoaderArgs) {
+ log.debug('getting show...')
+ const showId = Number(params.showId)
+ if (Number.isNaN(showId)) throw new Response(null, { status: 404 })
+ const show = await prisma.show.findUnique({
+ where: { id: showId },
+ include: {
+ looks: { include: { image: true }, orderBy: { number: 'asc' }, take: 3 },
+ },
+ })
+ log.debug('got show %o', show)
+ if (show == null) throw new Response(null, { status: 404 })
+ return new ImageResponse(
+ (
+
+
+
+ {show.looks.map((look) => (
+
+

+
+ ))}
+
+
+ ),
+ {
+ width: 800,
+ height: 400,
+ },
+ )
+}
diff --git a/package.json b/package.json
index b035c011..ad0d8aa9 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,10 @@
"build:css": "pnpm generate:css --minify",
"build:remix": "remix build",
"build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --bundle",
- "vercel-build": "replace 'prisma/client' 'prisma/client/edge' app -r && pnpm build",
+ "vercel-build": "pnpm replace && pnpm build",
+ "replace": "run-p 'replace:*'",
+ "replace:prisma": "replace 'prisma/client' 'prisma/client/edge' app -r",
+ "replace:og": "replace '@m5r/og' '@vercel/og' app -r",
"dev": "run-p 'dev:*' | pino-pretty",
"dev:prisma": "prisma generate --watch",
"dev:build": "cross-env NODE_ENV=development pnpm build:server --watch",
@@ -60,6 +63,7 @@
"@remix-run/express": "^1.19.1",
"@remix-run/react": "^1.19.1",
"@vercel/analytics": "^0.1.11",
+ "@vercel/og": "^0.5.9",
"@vercel/remix": "^1.19.1",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c688c30c..550c7383 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -49,6 +49,9 @@ dependencies:
'@vercel/analytics':
specifier: ^0.1.11
version: 0.1.11(react@18.2.0)
+ '@vercel/og':
+ specifier: ^0.5.9
+ version: 0.5.9
'@vercel/remix':
specifier: ^1.19.1
version: 1.19.1(react-dom@18.2.0)(react@18.2.0)
@@ -4153,6 +4156,11 @@ packages:
dependencies:
web-streams-polyfill: 3.2.1
+ /@resvg/resvg-wasm@2.4.1:
+ resolution: {integrity: sha512-yi6R0HyHtsoWTRA06Col4WoDs7SvlXU3DLMNP2bdAgs7HK18dTEVl1weXgxRzi8gwLteGUbIg29zulxIB3GSdg==}
+ engines: {node: '>= 10'}
+ dev: false
+
/@rollup/pluginutils@4.2.1:
resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
engines: {node: '>= 8.0.0'}
@@ -4296,6 +4304,15 @@ packages:
- supports-color
dev: true
+ /@shuding/opentype.js@1.4.0-beta.0:
+ resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
+ engines: {node: '>= 8.0.0'}
+ hasBin: true
+ dependencies:
+ fflate: 0.7.4
+ string.prototype.codepointat: 0.2.1
+ dev: false
+
/@sideway/address@4.1.4:
resolution: {integrity: sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==}
dependencies:
@@ -5031,6 +5048,15 @@ packages:
react: 18.2.0
dev: false
+ /@vercel/og@0.5.9:
+ resolution: {integrity: sha512-CtjaV/BVHtNCjRtxGqn8Q6AKFLqcG34Byxr91+mY+4eqyp/09LVe9jEeY9WXjbaKvu8syWPMteTpY+YQUQYzSg==}
+ engines: {node: '>=16'}
+ dependencies:
+ '@resvg/resvg-wasm': 2.4.1
+ satori: 0.10.1
+ yoga-wasm-web: 0.3.3
+ dev: false
+
/@vercel/remix@1.19.1(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-lIiLbXq5y8g2vQmDkxBsYEWvr38LCqU45ThZ+v1as8P6U6PEBtVFtdWq9YRsG1IaOP15sgJyXglB43gpIJol4Q==}
engines: {node: '>=14'}
@@ -5557,6 +5583,11 @@ packages:
/balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ /base64-js@0.0.8:
+ resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
+ engines: {node: '>= 0.4'}
+ dev: false
+
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -5823,6 +5854,10 @@ packages:
engines: {node: '>=6'}
dev: true
+ /camelize@1.0.1:
+ resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
+ dev: false
+
/caniuse-lite@1.0.30001473:
resolution: {integrity: sha512-ewDad7+D2vlyy+E4UJuVfiBsU69IL+8oVmTuZnH5Q6CIUbxNfI50uVpRHbUPDD6SUaN2o0Lh4DhTrvLG/Tn1yg==}
dev: true
@@ -6383,6 +6418,19 @@ packages:
type-fest: 1.4.0
dev: true
+ /css-background-parser@0.1.0:
+ resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==}
+ dev: false
+
+ /css-box-shadow@1.0.0-3:
+ resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==}
+ dev: false
+
+ /css-color-keywords@1.0.0:
+ resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
+ engines: {node: '>=4'}
+ dev: false
+
/css-select@5.1.0:
resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==}
dependencies:
@@ -6393,6 +6441,14 @@ packages:
nth-check: 2.1.1
dev: true
+ /css-to-react-native@3.2.0:
+ resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
+ dependencies:
+ camelize: 1.0.1
+ css-color-keywords: 1.0.0
+ postcss-value-parser: 4.2.0
+ dev: false
+
/css-what@5.1.0:
resolution: {integrity: sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==}
engines: {node: '>= 6'}
@@ -6845,6 +6901,10 @@ packages:
resolution: {integrity: sha512-MGO1k0w1RgrfdbLVwmXcDhHHuxCn2qRgR7dYsJvWFKDttvYPx6FNzCGG0c/fBBvzK2LDh3UV7Tt9awnHnvAAUQ==}
dev: true
+ /emoji-regex@10.2.1:
+ resolution: {integrity: sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==}
+ dev: false
+
/emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
dev: true
@@ -7933,6 +7993,10 @@ packages:
web-streams-polyfill: 3.2.1
dev: true
+ /fflate@0.7.4:
+ resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==}
+ dev: false
+
/figures@2.0.0:
resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==}
engines: {node: '>=4'}
@@ -8637,6 +8701,11 @@ packages:
readable-stream: 3.6.2
dev: false
+ /hex-rgb@4.3.0:
+ resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==}
+ engines: {node: '>=6'}
+ dev: false
+
/hook-std@3.0.0:
resolution: {integrity: sha512-jHRQzjSDzMtFy34AGj1DN+vq54WVuhSvKgrHf0OMiFQTwDD4L/qqofVEWjLOBMTn5+lCD3fPg32W9yOfnEJTTw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -9616,6 +9685,13 @@ packages:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
+ /linebreak@1.1.0:
+ resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
+ dependencies:
+ base64-js: 0.0.8
+ unicode-trie: 2.0.0
+ dev: false
+
/lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -11245,7 +11321,6 @@ packages:
/pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
- dev: true
/parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
@@ -11254,6 +11329,13 @@ packages:
callsites: 3.1.0
dev: true
+ /parse-css-color@0.2.1:
+ resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
+ dependencies:
+ color-name: 1.1.4
+ hex-rgb: 4.3.0
+ dev: false
+
/parse-entities@4.0.1:
resolution: {integrity: sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==}
dependencies:
@@ -12775,6 +12857,22 @@ packages:
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ /satori@0.10.1:
+ resolution: {integrity: sha512-F4bTCkDp931tLb7+UCNPBuSQwXhikrUkI4fBQo6fA8lF0Evqqgg3nDyUpRktQpR5Ry1DIiIVqLyEwkAms87ykg==}
+ engines: {node: '>=16'}
+ dependencies:
+ '@shuding/opentype.js': 1.4.0-beta.0
+ css-background-parser: 0.1.0
+ css-box-shadow: 1.0.0-3
+ css-to-react-native: 3.2.0
+ emoji-regex: 10.2.1
+ escape-html: 1.0.3
+ linebreak: 1.1.0
+ parse-css-color: 0.2.1
+ postcss-value-parser: 4.2.0
+ yoga-wasm-web: 0.3.3
+ dev: false
+
/scheduler@0.23.0:
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
dependencies:
@@ -13320,6 +13418,10 @@ packages:
strip-ansi: 7.1.0
dev: true
+ /string.prototype.codepointat@0.2.1:
+ resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==}
+ dev: false
+
/string.prototype.matchall@4.0.8:
resolution: {integrity: sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==}
dependencies:
@@ -13687,6 +13789,10 @@ packages:
globrex: 0.1.2
dev: true
+ /tiny-inflate@1.0.3:
+ resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
+ dev: false
+
/tiny-invariant@1.3.1:
resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==}
dev: false
@@ -14008,6 +14114,13 @@ packages:
engines: {node: '>=4'}
dev: true
+ /unicode-trie@2.0.0:
+ resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
+ dependencies:
+ pako: 0.2.9
+ tiny-inflate: 1.0.3
+ dev: false
+
/unified@10.1.2:
resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==}
dependencies:
@@ -14772,6 +14885,10 @@ packages:
engines: {node: '>=12.20'}
dev: true
+ /yoga-wasm-web@0.3.3:
+ resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==}
+ dev: false
+
/zod@3.21.4:
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
diff --git a/remix.config.js b/remix.config.js
index 56547c6b..91e9490f 100644
--- a/remix.config.js
+++ b/remix.config.js
@@ -14,10 +14,4 @@ module.exports = {
ignoredRouteFiles: ['**/.*', '**/*.css', '**/*.test.{js,jsx,ts,tsx}'],
serverModuleFormat: 'cjs',
serverDependenciesToBundle: ['nanoid/non-secure'],
- images: {
- sizes: [200, 300, 400, 500, 600, 700, 800, 900, 1000],
- domains: ['aritzia.scene7.com'],
- minimumCacheTTL: 60,
- formats: ['image/webp', 'image/avif'],
- },
}