Compare commits

..
6 Commits
51 changed files with 5145 additions and 4120 deletions
+7 -8
View File
@@ -18,25 +18,24 @@ jobs:
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
STORYBOOK_DISABLE_TELEMETRY: "1" STORYBOOK_DISABLE_TELEMETRY: "1"
steps: steps:
- uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6.0.2 - uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" # v7.0.1
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: "pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061" # v4.2.0 - uses: "pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86" # v6.0.10
- uses: "pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061" # v4.2.0
- name: "Setup Node.js" - name: "Setup Node.js"
uses: "actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238" # v6.2.0 uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020" # v7.0.0
with: with:
node-version: "24.13.1" node-version: "26.7.0"
cache: "pnpm" cache: "pnpm"
- run: "node --version"
- name: "Install dependencies" - name: "Install dependencies"
run: "pnpm install --frozen-lockfile" run: "pnpm clean-install"
- name: "Run Chromatic" - name: "Run Chromatic"
uses: "chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0" # latest uses: "chromaui/action@534eebfc19023579541d106f7b61d5ad70ed65c7" # latest
with: with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
workingDir: "apps/storybook" workingDir: "apps/storybook"
+8 -6
View File
@@ -18,23 +18,25 @@ jobs:
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
STORYBOOK_DISABLE_TELEMETRY: "1" STORYBOOK_DISABLE_TELEMETRY: "1"
steps: steps:
- uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6.0.2 - uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" # v7.0.1
with:
fetch-depth: 0
- uses: "pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061" # v4.2.0 - uses: "pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86" # v6.0.10
- name: "Setup Node.js" - name: "Setup Node.js"
uses: "actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238" # v6.2.0 uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020" # v7.0.0
with: with:
node-version: "24.13.1" node-version: "26.7.0"
cache: "pnpm" cache: "pnpm"
- run: "node --version"
- name: "Install dependencies" - name: "Install dependencies"
run: "pnpm install --frozen-lockfile" run: "pnpm clean-install"
- name: "Install Playwright" - name: "Install Playwright"
run: "pnpm exec playwright install --with-deps" run: "pnpm exec playwright install --with-deps"
- run: "node --run lint:editorconfig"
- run: "node --run lint:markdown" - run: "node --run lint:markdown"
- run: "node --run lint:turbo" - run: "node --run lint:turbo"
# - run: "node --run lint:typescript" # already covered by oxlint # - run: "node --run lint:typescript" # already covered by oxlint
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "./node_modules/oxfmt/configuration_schema.json", "$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": ["pnpm-lock.yaml"], "ignorePatterns": ["pnpm-lock.yaml"],
"semi": false, "semi": false,
"experimentalTailwindcss": { "sortTailwindcss": {
"stylesheet": "./configs/config-tailwind/styles.css", "stylesheet": "./configs/config-tailwind/styles.css",
"functions": ["classNames"] "functions": ["classNames"]
} }
+283 -2
View File
@@ -1,8 +1,177 @@
{ {
"$schema": "./node_modules/oxlint/configuration_schema.json", "$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["node_modules/eslint-config-conventions/.oxlintrc.json"], "options": {
"typeAware": true,
"typeCheck": true,
"denyWarnings": true,
"reportUnusedDisableDirectives": "error"
},
"env": {
"builtin": true,
"browser": true,
"node": true,
"shared-node-browser": true
},
"plugins": ["eslint", "typescript", "unicorn", "react", "oxc", "import", "promise"],
"categories": {
"correctness": "error",
"suspicious": "error",
"pedantic": "error",
"perf": "error"
},
"rules": { "rules": {
"import-x/extensions": [ "no-await-in-loop": "off",
"no-loop-func": "off",
"no-negated-condition": "off",
"no-inline-comments": "off",
"max-lines": "off",
"max-depth": "off",
"max-classes-per-file": "off",
"max-lines-per-function": "off",
"require-await": "off",
"no-lonely-if": "off",
"array-callback-return": "off",
"no-shadow": "off",
"no-throw-literal": "off",
"consistent-return": "off",
"getter-return": "error",
"no-undef": "error",
"no-unreachable": "error",
"use-isnan": [
"error",
{
"enforceForSwitchCase": true,
"enforceForIndexOf": true
}
],
"valid-typeof": [
"error",
{
"requireStringLiterals": true
}
],
"default-param-last": "error",
"default-case-last": "error",
"eqeqeq": [
"error",
"always",
{
"null": "ignore"
}
],
"grouped-accessor-pairs": "error",
"new-cap": [
"error",
{
"newIsCap": true,
"capIsNew": false,
"properties": true
}
],
"no-implicit-coercion": "error",
"no-extra-boolean-cast": [
"error",
{
"enforceForInnerExpressions": true
}
],
"no-empty": [
"error",
{
"allowEmptyCatch": true
}
],
"no-multi-str": "error",
"no-new-func": "error",
"no-proto": "error",
"no-regex-spaces": "error",
"no-useless-computed-key": "error",
"no-else-return": [
"error",
{
"allowElseIf": false
}
],
"no-var": "error",
"no-void": [
"error",
{
"allowAsStatement": true
}
],
"prefer-const": [
"error",
{
"destructuring": "all"
}
],
"prefer-object-has-own": "error",
"yoda": "error",
"curly": "error",
"func-style": "error",
"arrow-body-style": ["error", "always"],
"object-shorthand": ["error", "properties"],
"promise/param-names": "error",
"promise/no-nesting": "error",
"unicorn/catch-error-name": "error",
"unicorn/consistent-date-clone": "error",
"unicorn/error-message": "error",
"unicorn/no-array-for-each": "error",
"unicorn/no-array-method-this-argument": "error",
"unicorn/no-document-cookie": "error",
"unicorn/no-zero-fractions": "error",
"unicorn/number-literal-case": "error",
"unicorn/prefer-node-protocol": "error",
"unicorn/throw-new-error": "error",
"unicorn/require-array-join-separator": "error",
"unicorn/prefer-number-properties": "error",
"unicorn/prefer-modern-math-apis": "error",
"unicorn/prefer-structured-clone": "error",
"unicorn/consistent-existence-index-check": "error",
"unicorn/no-array-reverse": "off",
"unicorn/no-array-sort": "off",
"unicorn/no-lonely-if": "off",
"unicorn/prefer-string-replace-all": "off",
"unicorn/prefer-at": "off",
"unicorn/prefer-query-selector": "off",
"unicorn/prefer-top-level-await": "off",
"unicorn/prefer-string-slice": "off",
"unicorn/no-immediate-mutation": "off",
"unicorn/no-useless-undefined": "off",
"unicorn/no-object-as-default-parameter": "off",
"react/unsupported-syntax": "error",
"react/no-deriving-state-in-effects": "error",
"react/rule-suppression": "error",
"react/syntax": "error",
"react/todo": "error",
"react/invariant": "error",
"react/capitalized-calls": "error",
"react/exhaustive-effect-dependencies": "error",
"react/memo-dependencies": "error",
"react/no-array-index-key": "off",
"react/no-unknown-property": "off",
"react/react-in-jsx-scope": "off",
"react/iframe-missing-sandbox": "off",
"react/self-closing-comp": "error",
"react/jsx-boolean-value": "error",
"react/jsx-no-target-blank": "off",
"react/jsx-no-useless-fragment": "off",
"react/no-unstable-nested-components": "off",
"import/no-webpack-loader-syntax": "error",
"import/export": "error",
"import/no-duplicates": "error",
"import/no-named-default": "error",
"import/no-anonymous-default-export": "error",
"import/consistent-type-specifier-style": "error",
"import/no-unassigned-import": "off",
"import/no-named-as-default-member": "off",
"import/max-dependencies": "off",
"import/extensions": [
"error", "error",
"ignorePackages", "ignorePackages",
{ {
@@ -11,6 +180,118 @@
"js": "never", "js": "never",
"jsx": "never" "jsx": "never"
} }
],
"typescript/ban-types": "off",
"typescript/no-unnecessary-type-arguments": "off",
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-unsafe-member-access": "off",
"typescript/no-confusing-void-expression": "off",
"typescript/no-unsafe-assignment": "off",
"typescript/no-misused-promises": "off",
"typescript/return-await": ["error", "always"],
"typescript/require-await": "off",
"typescript/switch-exhaustiveness-check": "off",
"typescript/ban-ts-comment": "off",
"typescript/prefer-readonly-parameter-types": "off",
"typescript/strict-void-return": "off",
"no-unused-vars": [
"error",
{
"args": "all",
"argsIgnorePattern": "^_",
"caughtErrors": "all",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"ignoreRestSiblings": true
}
],
"no-use-before-define": [
"error",
{
"functions": false,
"classes": false,
"enums": false,
"variables": false,
"typedefs": false
}
],
"no-redeclare": [
"error",
{
"builtinGlobals": false
}
],
"typescript/only-throw-error": "off",
"no-unused-expressions": [
"error",
{
"allowShortCircuit": true,
"allowTernary": true,
"allowTaggedTemplates": true
}
],
"typescript/adjacent-overload-signatures": "error",
"typescript/consistent-type-definitions": "error",
"typescript/consistent-type-imports": "error",
"typescript/explicit-member-accessibility": "error",
"typescript/explicit-function-return-type": [
"error",
{
"allowExpressions": true,
"allowHigherOrderFunctions": true,
"allowTypedFunctionExpressions": true,
"allowDirectConstAssertionInArrowFunctions": true
}
],
"typescript/no-extraneous-class": [
"error",
{
"allowWithDecorator": true
}
],
"typescript/no-floating-promises": [
"error",
{
"allowForKnownSafeCalls": [
{
"from": "package",
"name": ["test", "it", "suite", "describe"],
"package": "node:test"
}
]
}
],
"typescript/no-non-null-assertion": "error",
"typescript/no-this-alias": "error",
"typescript/no-require-imports": "error",
"typescript/prefer-function-type": "error",
"typescript/prefer-find": "error",
"typescript/prefer-nullish-coalescing": "off",
"typescript/prefer-readonly": "error",
"typescript/prefer-reduce-type-parameter": "error",
"typescript/prefer-return-this-type": "error",
"typescript/promise-function-async": "error",
"typescript/require-array-sort-compare": "error",
"typescript/restrict-plus-operands": [
"error",
{
"skipCompoundAssignments": true
}
],
"typescript/restrict-template-expressions": "error",
"typescript/strict-boolean-expressions": [
"error",
{
"allowString": false,
"allowNumber": false,
"allowNullableObject": false,
"allowNullableBoolean": false,
"allowNullableString": false,
"allowNullableNumber": false,
"allowAny": false
}
] ]
} }
} }
+3 -4
View File
@@ -27,8 +27,8 @@ The commit message guidelines adheres to [Conventional Commits](https://www.conv
### Prerequisites ### Prerequisites
- [Node.js](https://nodejs.org/) >= v24.0.0 [(`nvm install 24`)](https://nvm.sh) - [Node.js](https://nodejs.org/) >= v26.1.0 [(`nvm install 26`)](https://nvm.sh)
- [pnpm](https://pnpm.io/) [(`npm install --global corepack@0.34.6 && corepack enable`)](https://github.com/nodejs/corepack) - [pnpm](https://pnpm.io/) [(`npm install --global corepack@0.35.0 && corepack enable`)](https://github.com/nodejs/corepack)
- [Docker](https://www.docker.com/) - [Docker](https://www.docker.com/)
### Installation ### Installation
@@ -45,7 +45,7 @@ cp .env.example .env
cp apps/website/.env.example apps/website/.env cp apps/website/.env.example apps/website/.env
# Install dependencies # Install dependencies
pnpm install --frozen-lockfile pnpm clean-install
# Install Playwright browser binaries and their dependencies (tests) # Install Playwright browser binaries and their dependencies (tests)
pnpm exec playwright install --with-deps pnpm exec playwright install --with-deps
@@ -58,7 +58,6 @@ pnpm exec playwright install --with-deps
node --run dev node --run dev
# Lint # Lint
node --run lint:editorconfig
node --run lint:markdown node --run lint:markdown
node --run lint:turbo node --run lint:turbo
# node --run lint:typescript # already covered by oxlint # node --run lint:typescript # already covered by oxlint
+1
View File
@@ -0,0 +1 @@
declare module "*.css"
+2 -2
View File
@@ -37,8 +37,8 @@ const preview: Preview = {
controls: { controls: {
disableSaveFromUI: true, disableSaveFromUI: true,
matchers: { matchers: {
color: /(background|color)$/i, color: /(background|color)$/iu,
date: /date$/i, date: /date$/iu,
}, },
}, },
}, },
+1 -1
View File
@@ -17,7 +17,7 @@ const args = util.parseArgs({
const host = args.values.host const host = args.values.host
const basePath = args.values.path const basePath = args.values.path
const port = Number.parseInt(args.values.port, 10) const port = Math.trunc(Number(args.values.port))
if (Number.isNaN(port)) { if (Number.isNaN(port)) {
console.error("Error: Invalid port number.") console.error("Error: Invalid port number.")
process.exit(1) process.exit(1)
+5 -5
View File
@@ -1,7 +1,7 @@
FROM node:24.13.1-slim@sha256:a81a03dd965b4052269a57fac857004022b522a4bf06e7a739e25e18bce45af2 AS node-pnpm FROM docker.io/node:26.7.0-slim@sha256:4ebb5ace66f15a24c14c492e01a8beeed4fddf970a856109f5126e703e5fe503 AS node-pnpm
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME/bin:$PATH"
RUN npm install --global corepack@0.34.6 && corepack enable RUN npm install --global corepack@0.35.0 && corepack enable
ENV TURBO_TELEMETRY_DISABLED=1 ENV TURBO_TELEMETRY_DISABLED=1
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV DO_NOT_TRACK=1 ENV DO_NOT_TRACK=1
@@ -9,7 +9,7 @@ WORKDIR /usr/src/app
FROM node-pnpm AS builder FROM node-pnpm AS builder
COPY ./ ./ COPY ./ ./
RUN pnpm install --global turbo@2.8.9 RUN pnpm install --global turbo@2.10.11
RUN turbo prune @repo/website --docker RUN turbo prune @repo/website --docker
FROM node-pnpm AS installer FROM node-pnpm AS installer
@@ -18,7 +18,7 @@ ENV IS_STANDALONE=true
COPY .gitignore .gitignore COPY .gitignore .gitignore
COPY --from=builder /usr/src/app/out/json/ ./ COPY --from=builder /usr/src/app/out/json/ ./
COPY --from=builder /usr/src/app/out/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=builder /usr/src/app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm clean-install
COPY --from=builder /usr/src/app/out/full/ ./ COPY --from=builder /usr/src/app/out/full/ ./
COPY turbo.json turbo.json COPY turbo.json turbo.json
@@ -3,13 +3,10 @@ import { notFound } from "next/navigation"
import { getBlogPostBySlug, getBlogPosts } from "@repo/blog" import { getBlogPostBySlug, getBlogPosts } from "@repo/blog"
import { BlogPostUI } from "@repo/blog/BlogPostUI" import { BlogPostUI } from "@repo/blog/BlogPostUI"
import type { Locale } from "@repo/utils/constants"
import { setRequestLocale } from "next-intl/server"
interface BlogPostPageProps { interface BlogPostPageProps {
params: Promise<{ params: Promise<{
slug: string slug: string
locale: Locale
}> }>
} }
@@ -47,9 +44,7 @@ export const generateStaticParams = async (): Promise<Array<{ slug: string }>> =
const BlogPostPage: React.FC<BlogPostPageProps> = async (props) => { const BlogPostPage: React.FC<BlogPostPageProps> = async (props) => {
const { params } = props const { params } = props
const { locale, slug } = await params const { slug } = await params
// Enable static rendering
setRequestLocale(locale)
const blogPost = await getBlogPostBySlug(slug) const blogPost = await getBlogPostBySlug(slug)
if (blogPost == null) { if (blogPost == null) {
+1 -11
View File
@@ -1,11 +1,9 @@
import { getBlogPosts } from "@repo/blog" import { getBlogPosts } from "@repo/blog"
import { BlogPosts } from "@repo/blog/BlogPosts" import { BlogPosts } from "@repo/blog/BlogPosts"
import type { LocaleProps } from "@repo/i18n/routing"
import { MainLayout } from "@repo/ui/Layout/MainLayout" import { MainLayout } from "@repo/ui/Layout/MainLayout"
import { Section, SectionDescription, SectionTitle } from "@repo/ui/Layout/Section" import { Section, SectionDescription, SectionTitle } from "@repo/ui/Layout/Section"
import { LOCALE_DEFAULT } from "@repo/utils/constants" import { LOCALE_DEFAULT } from "@repo/utils/constants"
import type { Metadata } from "next" import type { Metadata } from "next"
import { setRequestLocale } from "next-intl/server"
const title = "Blog | Théo LUDWIG" const title = "Blog | Théo LUDWIG"
const description = "The latest news about my journey of learning computer science." const description = "The latest news about my journey of learning computer science."
@@ -26,15 +24,7 @@ export const generateMetadata = async (): Promise<Metadata> => {
} }
} }
interface BlogPageProps extends LocaleProps {} const BlogPage: React.FC = async () => {
const BlogPage: React.FC<BlogPageProps> = async (props) => {
const { params } = props
const { locale } = await params
// Enable static rendering
setRequestLocale(locale)
const posts = await getBlogPosts() const posts = await getBlogPosts()
return ( return (
+3 -13
View File
@@ -1,23 +1,13 @@
import "@repo/config-tailwind/styles.css" import "@repo/config-tailwind/styles.css"
import type { Locale } from "@repo/utils/constants"
import { Footer } from "@repo/ui/Layout/Footer" import { Footer } from "@repo/ui/Layout/Footer"
import { Header } from "@repo/ui/Layout/Header" import { Header } from "@repo/ui/Layout/Header"
import { ThemeProvider } from "@repo/ui/Layout/Header/SwitchTheme" import { ThemeProvider } from "@repo/ui/Layout/Header/SwitchTheme"
import { VERSION } from "@repo/utils/constants" import { VERSION } from "@repo/utils/constants"
import { setRequestLocale } from "next-intl/server"
interface MainLayoutProps extends React.PropsWithChildren { interface MainLayoutProps extends React.PropsWithChildren {}
params: Promise<{
locale: string
}>
}
const MainLayout: React.FC<MainLayoutProps> = async (props) => { const MainLayout: React.FC<MainLayoutProps> = (props) => {
const { children, params } = props const { children } = props
const { locale } = await params
// Enable static rendering
setRequestLocale(locale as Locale)
return ( return (
<ThemeProvider> <ThemeProvider>
+1 -11
View File
@@ -1,4 +1,3 @@
import type { LocaleProps } from "@repo/i18n/routing"
import { About } from "@repo/ui/Home/About" import { About } from "@repo/ui/Home/About"
import { Interests } from "@repo/ui/Home/Interests" import { Interests } from "@repo/ui/Home/Interests"
import { OpenSource } from "@repo/ui/Home/OpenSource" import { OpenSource } from "@repo/ui/Home/OpenSource"
@@ -6,17 +5,8 @@ import { Portfolio } from "@repo/ui/Home/Portfolio"
import { Skills } from "@repo/ui/Home/Skills" import { Skills } from "@repo/ui/Home/Skills"
import { MainLayout } from "@repo/ui/Layout/MainLayout" import { MainLayout } from "@repo/ui/Layout/MainLayout"
import { RevealFade } from "@repo/ui/Layout/Section" import { RevealFade } from "@repo/ui/Layout/Section"
import { setRequestLocale } from "next-intl/server"
interface HomePageProps extends LocaleProps {}
const HomePage: React.FC<HomePageProps> = async (props) => {
const { params } = props
const { locale } = await params
// Enable static rendering
setRequestLocale(locale)
const HomePage: React.FC = () => {
return ( return (
<MainLayout> <MainLayout>
<About /> <About />
@@ -1,20 +1,10 @@
import "@repo/config-tailwind/styles.css" import "@repo/config-tailwind/styles.css"
import type { Locale } from "@repo/utils/constants"
import { ThemeProvider } from "@repo/ui/Layout/Header/SwitchTheme" import { ThemeProvider } from "@repo/ui/Layout/Header/SwitchTheme"
import { setRequestLocale } from "next-intl/server"
interface CurriculumVitaeLayoutProps extends React.PropsWithChildren { interface CurriculumVitaeLayoutProps extends React.PropsWithChildren {}
params: Promise<{
locale: string
}>
}
const CurriculumVitaeLayout: React.FC<CurriculumVitaeLayoutProps> = async (props) => { const CurriculumVitaeLayout: React.FC<CurriculumVitaeLayoutProps> = (props) => {
const { children, params } = props const { children } = props
const { locale } = await params
// Enable static rendering
setRequestLocale(locale as Locale)
return <ThemeProvider forcedTheme="light">{children}</ThemeProvider> return <ThemeProvider forcedTheme="light">{children}</ThemeProvider>
} }
@@ -1,16 +1,6 @@
import type { LocaleProps } from "@repo/i18n/routing"
import { CurriculumVitae } from "@repo/ui/CurriculumVitae" import { CurriculumVitae } from "@repo/ui/CurriculumVitae"
import { setRequestLocale } from "next-intl/server"
interface CurriculumVitaeProps extends LocaleProps {}
const CurriculumVitaePage: React.FC<CurriculumVitaeProps> = async (props) => {
const { params } = props
const { locale } = await params
// Enable static rendering
setRequestLocale(locale)
const CurriculumVitaePage: React.FC = () => {
return <CurriculumVitae /> return <CurriculumVitae />
} }
+6 -20
View File
@@ -3,7 +3,7 @@ import type { Locale } from "@repo/utils/constants"
import { LOCALES } from "@repo/utils/constants" import { LOCALES } from "@repo/utils/constants"
import type { Metadata, Viewport } from "next" import type { Metadata, Viewport } from "next"
import { NextIntlClientProvider } from "next-intl" import { NextIntlClientProvider } from "next-intl"
import { getMessages, getTranslations, setRequestLocale } from "next-intl/server" import { getLocale, getMessages, getTranslations } from "next-intl/server"
import Script from "next/script" import Script from "next/script"
const DOMAIN = "theoludwig.fr" const DOMAIN = "theoludwig.fr"
@@ -12,15 +12,8 @@ export const viewport: Viewport = {
themeColor: "#00aeff", themeColor: "#00aeff",
} }
export const generateMetadata = async ({ export const generateMetadata = async (): Promise<Metadata> => {
params, const t = await getTranslations()
}: {
params: Promise<{
locale: string
}>
}): Promise<Metadata> => {
const { locale } = await params
const t = await getTranslations({ locale: locale as Locale })
const title = t("meta.title") const title = t("meta.title")
const description = `${title} - ${t("meta.description")}` const description = `${title} - ${t("meta.description")}`
const image = "/images/logo.webp" const image = "/images/logo.webp"
@@ -63,19 +56,12 @@ export const generateStaticParams = (): Array<{ locale: Locale }> => {
}) })
} }
interface LocaleLayoutProps extends React.PropsWithChildren { interface LocaleLayoutProps extends React.PropsWithChildren {}
params: Promise<{
locale: string
}>
}
const LocaleLayout: React.FC<LocaleLayoutProps> = async (props) => { const LocaleLayout: React.FC<LocaleLayoutProps> = async (props) => {
const { children, params } = props const { children } = props
const { locale } = await params
// Enable static rendering
setRequestLocale(locale as Locale)
const locale = await getLocale()
const messages = await getMessages() const messages = await getMessages()
return ( return (
-7
View File
@@ -1,7 +0,0 @@
interface RootLayoutProps extends React.PropsWithChildren {}
const RootLayout = ({ children }: RootLayoutProps): React.ReactNode => {
return children
}
export default RootLayout
-20
View File
@@ -1,20 +0,0 @@
"use client"
import Error from "next/error"
/**
* Render the default Next.js 404 page when a route
* is requested that doesn't match the middleware and
* therefore doesn't have a locale associated with it.
*/
const NotFound: React.FC = () => {
return (
<html lang="en">
<body>
<Error statusCode={404} />
</body>
</html>
)
}
export default NotFound
+5
View File
@@ -13,3 +13,8 @@ services:
environment: environment:
PORT: ${WEBSITE_PORT-3000} PORT: ${WEBSITE_PORT-3000}
env_file: "./apps/website/.env" env_file: "./apps/website/.env"
logging:
driver: "json-file"
options:
max-size: "10M"
max-file: "3"
+2 -2
View File
@@ -1,5 +1,5 @@
import type { ClassValue } from "clsx" import type { ClassValue } from "@repo/utils/clsx"
import { clsx } from "clsx" import { clsx } from "@repo/utils/clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
export const classNames = (...inputs: ClassValue[]): string => { export const classNames = (...inputs: ClassValue[]): string => {
+1 -1
View File
@@ -17,7 +17,7 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/montserrat": "catalog:", "@fontsource/montserrat": "catalog:",
"clsx": "catalog:", "@repo/utils": "workspace:*",
"tailwind-merge": "catalog:" "tailwind-merge": "catalog:"
}, },
"devDependencies": { "devDependencies": {
+7 -7
View File
@@ -51,15 +51,15 @@
@apply tracking-wider italic; @apply tracking-wider italic;
} }
blockquote { blockquote {
@apply border-gray-lighter border-l-4 pl-3 italic; @apply border-l-4 border-gray-lighter pl-3 italic;
} }
kbd { kbd {
@apply bg-gray-lighter rounded-md px-2 dark:text-black; @apply rounded-md bg-gray-lighter px-2 dark:text-black;
} }
mark { mark {
@apply bg-yellow rounded-md px-2; @apply rounded-md bg-yellow px-2;
} }
ol { ol {
@@ -81,7 +81,7 @@
} }
body { body {
@apply bg-background dark:bg-background-dark font-sans text-black dark:text-white; @apply bg-background font-sans text-black dark:bg-background-dark dark:text-white;
} }
@keyframes ripple { @keyframes ripple {
@@ -101,7 +101,7 @@ body {
} }
.prose { .prose {
@apply dark:text-gray-lighter !max-w-5xl scroll-smooth text-black; @apply !max-w-5xl scroll-smooth text-black dark:text-gray-lighter;
} }
.prose p { .prose p {
@@ -122,11 +122,11 @@ body {
} }
.prose a { .prose a {
@apply text-primary dark:text-primary-dark !font-semibold; @apply !font-semibold text-primary dark:text-primary-dark;
} }
.prose strong { .prose strong {
@apply dark:text-gray-lighter text-black; @apply text-black dark:text-gray-lighter;
} }
.prose h2, .prose h2,
+3 -6
View File
@@ -6,19 +6,16 @@
"scripts": { "scripts": {
"dev": "turbo run dev --parallel", "dev": "turbo run dev --parallel",
"start": "turbo run start --parallel", "start": "turbo run start --parallel",
"lint:editorconfig": "editorconfig-checker",
"lint:markdown": "markdownlint-cli2", "lint:markdown": "markdownlint-cli2",
"lint:turbo": "turbo boundaries", "lint:turbo": "turbo boundaries",
"lint:typescript": "turbo run lint:typescript", "lint:typescript": "turbo run lint:typescript",
"lint:oxlint": "turbo run typegen && oxlint . --type-aware --type-check --deny-warnings --report-unused-disable-directives", "lint:oxlint": "turbo run typegen && oxlint .",
"lint:oxfmt": "oxfmt . --check", "lint:oxfmt": "oxfmt . --check",
"oxfmt": "oxfmt .", "oxfmt": "oxfmt .",
"test": "turbo run test", "test": "turbo run test",
"build": "turbo run build" "build": "turbo run build"
}, },
"devDependencies": { "devDependencies": {
"editorconfig-checker": "catalog:",
"eslint-config-conventions": "catalog:",
"markdownlint": "catalog:", "markdownlint": "catalog:",
"markdownlint-cli2": "catalog:", "markdownlint-cli2": "catalog:",
"markdownlint-rule-relative-links": "catalog:", "markdownlint-rule-relative-links": "catalog:",
@@ -30,7 +27,7 @@
"typescript": "catalog:" "typescript": "catalog:"
}, },
"engines": { "engines": {
"node": ">=24.0.0" "node": ">=26.1.0"
}, },
"packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc" "packageManager": "pnpm@11.23.0+sha512.f00082e5b283a199b74e079da28d155c008fe232f44c8a06ea7ddfa014ecf719fc362f790ecc67b28106ddac2afb24c49a9a079159da560b2ce7d1e98efd11af"
} }
+2 -2
View File
@@ -9,7 +9,7 @@ import rehypeSlug from "rehype-slug"
import remarkGfm from "remark-gfm" import remarkGfm from "remark-gfm"
import remarkMath from "remark-math" import remarkMath from "remark-math"
import { Link } from "@repo/i18n/routing" import { Link } from "@repo/i18n/navigation"
import "katex/dist/katex.min.css" import "katex/dist/katex.min.css"
import { BlogPostComments } from "./BlogPostComments.tsx" import { BlogPostComments } from "./BlogPostComments.tsx"
@@ -84,7 +84,7 @@ export const BlogPostContent: React.FC<BlogPostContentProps> = async (props) =>
}, },
img: (properties) => { img: (properties) => {
const { src = "", alt = "Blog Image" } = properties const { src = "", alt = "Blog Image" } = properties
const source = src.replace("../../../apps/website/public/", "/") const source = (src as string).replace("../../../apps/website/public/", "/")
return ( return (
<span className="flex flex-col items-center justify-center"> <span className="flex flex-col items-center justify-center">
<Image src={source} alt={alt} width={1000} height={1000} className="size-auto" /> <Image src={source} alt={alt} width={1000} height={1000} className="size-auto" />
+1 -1
View File
@@ -1,4 +1,4 @@
import { Link } from "@repo/i18n/routing" import { Link } from "@repo/i18n/navigation"
import { Typography } from "@repo/ui/Design/Typography" import { Typography } from "@repo/ui/Design/Typography"
import { Section, SectionContent } from "@repo/ui/Layout/Section" import { Section, SectionContent } from "@repo/ui/Layout/Section"
import { getISODate } from "@repo/utils/dates" import { getISODate } from "@repo/utils/dates"
+1
View File
@@ -0,0 +1 @@
declare module "*.css"
+1
View File
@@ -6,6 +6,7 @@
"exports": { "exports": {
"./translations/*.json": "./src/translations/*.json", "./translations/*.json": "./src/translations/*.json",
"./messages.d.ts": "./src/messages.d.ts", "./messages.d.ts": "./src/messages.d.ts",
"./navigation": "./src/navigation.ts",
"./request": "./src/request.ts", "./request": "./src/request.ts",
"./routing": "./src/routing.ts" "./routing": "./src/routing.ts"
}, },
+6
View File
@@ -0,0 +1,6 @@
import { createNavigation } from "next-intl/navigation"
import { routing } from "./routing.ts"
export const { Link, redirect, usePathname, useRouter, getPathname, permanentRedirect } =
createNavigation(routing)
+4 -4
View File
@@ -1,18 +1,18 @@
import type { AbstractIntlMessages } from "next-intl"
import { hasLocale } from "next-intl" import { hasLocale } from "next-intl"
import { getRequestConfig } from "next-intl/server" import { getRequestConfig } from "next-intl/server"
import * as rootParams from "next/root-params"
import { routing } from "./routing.ts" import { routing } from "./routing.ts"
import { LOCALE_DEFAULT } from "@repo/utils/constants" import { LOCALE_DEFAULT } from "@repo/utils/constants"
import { deepMerge } from "@repo/utils/objects" import { deepMerge } from "@repo/utils/objects"
export default getRequestConfig(async ({ requestLocale }) => { export default getRequestConfig(async ({ locale: localeOverride }) => {
const requested = await requestLocale const requested = localeOverride ?? (await rootParams.locale())
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
const userMessages = (await import(`./translations/${locale}.json`)).default const userMessages = (await import(`./translations/${locale}.json`)).default
const defaultMessages = (await import(`./translations/${LOCALE_DEFAULT}.json`)).default const defaultMessages = (await import(`./translations/${LOCALE_DEFAULT}.json`)).default
const messages = deepMerge<AbstractIntlMessages>(defaultMessages, userMessages) const messages = deepMerge(defaultMessages, userMessages)
return { return {
locale, locale,
+1
View File
@@ -0,0 +1 @@
export function locale(): Promise<string | undefined>
-12
View File
@@ -1,8 +1,5 @@
import { createNavigation } from "next-intl/navigation"
import { LOCALES, LOCALE_DEFAULT, LOCALE_PREFIX } from "@repo/utils/constants" import { LOCALES, LOCALE_DEFAULT, LOCALE_PREFIX } from "@repo/utils/constants"
import { defineRouting } from "next-intl/routing" import { defineRouting } from "next-intl/routing"
import type { Locale } from "@repo/utils/constants"
// Countries: https://github.com/umpirsky/country-list/blob/master/data/en/country.json // Countries: https://github.com/umpirsky/country-list/blob/master/data/en/country.json
// Country flag picture: https://purecatamphetamine.github.io/country-flag-icons/3x2/US.svg // Country flag picture: https://purecatamphetamine.github.io/country-flag-icons/3x2/US.svg
@@ -11,17 +8,8 @@ import type { Locale } from "@repo/utils/constants"
// Locale code is a combination of ISO 639-1 language code and ISO 3166-1 country code. // Locale code is a combination of ISO 639-1 language code and ISO 3166-1 country code.
// For example, `fr-FR` is a locale code for French language in France. // For example, `fr-FR` is a locale code for French language in France.
export interface LocaleProps {
params: Promise<{
locale: Locale
}>
}
export const routing = defineRouting({ export const routing = defineRouting({
locales: LOCALES, locales: LOCALES,
defaultLocale: LOCALE_DEFAULT, defaultLocale: LOCALE_DEFAULT,
localePrefix: LOCALE_PREFIX, localePrefix: LOCALE_PREFIX,
}) })
export const { Link, redirect, usePathname, useRouter, getPathname, permanentRedirect } =
createNavigation(routing)
+7 -2
View File
@@ -90,8 +90,8 @@
}, },
"interests": { "interests": {
"title": "Interests & hobbies", "title": "Interests & hobbies",
"fusey": "<link>Fusey (fusey.gg)</link>: website I'm developing for the game ARK that tracks the number of players connected to the servers in real time and has <strong>over ~5,000 visitors each month, ~100,000 members on Discord, and ~120,000 followers on X/Twitter</strong>.", "fusey": "<link>Fusey (fusey.gg)</link>: company I co-founded with two partners. <strong>Find creators. Fill your community.</strong> A platform to find and book creators to promote a gaming community.",
"open-source": "<strong>Open-Source Contributor</strong>: publishing and <strong>contributing to npm packages</strong> and <link-github>GitHub</link-github> repositories, notably with <link-markdownlint>markdownlint-rule-relative-links</link-markdownlint> (around 20,000 downloads per week), contributions to <link-leon>Leon</link-leon>, an open-source personal assistant, among other projects." "open-source": "<strong>Open-Source Contributor</strong>: publishing and <strong>contributing to npm packages</strong> and <link-github>GitHub</link-github> repositories, notably with <link-markdownlint>markdownlint-rule-relative-links</link-markdownlint> (around 20,000 downloads per week) among other projects."
}, },
"work": { "work": {
"ircad": { "ircad": {
@@ -107,6 +107,11 @@
"feature-architecture": "IRCAD Core project architecture in <strong>TypeScript Monorepo</strong> with Turborepo, <strong>automatic deployment (CI/CD)</strong> and <strong>self-hosted internally with Docker Compose</strong>." "feature-architecture": "IRCAD Core project architecture in <strong>TypeScript Monorepo</strong> with Turborepo, <strong>automatic deployment (CI/CD)</strong> and <strong>self-hosted internally with Docker Compose</strong>."
} }
}, },
"ircad-taiwan": {
"duration": "2 months",
"position": "Full Stack Web Developer Intern",
"summary": "Development of a participants management application and their hotel reservations."
},
"numerize": { "numerize": {
"duration": "3 months", "duration": "3 months",
"position": "Full Stack Web Developer Intern", "position": "Full Stack Web Developer Intern",
+7 -2
View File
@@ -90,8 +90,8 @@
}, },
"interests": { "interests": {
"title": "Intérêts & loisirs", "title": "Intérêts & loisirs",
"fusey": "<link>Fusey (fusey.gg)</link> : site web que je développe pour le jeu ARK qui permet de suivre en temps réel le nombre de joueurs connectés sur les serveurs et a plus de <strong>~5 000 visiteurs chaque mois, ~100 000 membres sur Discord et ~120 000 followers sur X/Twitter</strong>.", "fusey": "<link>Fusey (fusey.gg)</link> : entreprise que j'ai cofondée avec deux associés. <strong>Trouvez des cateurs. Remplissez votre communauté.</strong> Une plateforme pour trouver et réserver des créateurs de contenu afin de promouvoir une communauté gaming.",
"open-source": "<strong>Contributeur de l'Open-Source</strong> : publications et <strong>contributions sur npm</strong> et <link-github>GitHub</link-github> avec notamment <link-markdownlint>markdownlint-rule-relative-links</link-markdownlint> avec ~20 000 téléchargements par semaine, contributions à <link-leon>Leon</link-leon>, un assistant personnel open source, parmis d'autres projets." "open-source": "<strong>Contributeur de l'Open-Source</strong> : publications et <strong>contributions sur npm</strong> et <link-github>GitHub</link-github> avec notamment <link-markdownlint>markdownlint-rule-relative-links</link-markdownlint> avec ~20 000 téléchargements par semaine parmis d'autres projets."
}, },
"work": { "work": {
"ircad": { "ircad": {
@@ -107,6 +107,11 @@
"feature-architecture": "Architecture du projet IRCAD Core en <strong>Monorepo TypeScript</strong> avec Turborepo, <strong>déploiement automatique (CI/CD)</strong> et <strong>hébergé en interne avec Docker Compose</strong>." "feature-architecture": "Architecture du projet IRCAD Core en <strong>Monorepo TypeScript</strong> avec Turborepo, <strong>déploiement automatique (CI/CD)</strong> et <strong>hébergé en interne avec Docker Compose</strong>."
} }
}, },
"ircad-taiwan": {
"duration": "2 mois",
"position": "Stagiaire Développeur Web Full Stack",
"summary": "Développement d'une application de gestion des participants et de leurs réservations d'hôtel."
},
"numerize": { "numerize": {
"duration": "3 mois", "duration": "3 mois",
"position": "Stagiaire Développeur Web Full Stack", "position": "Stagiaire Développeur Web Full Stack",
+4 -1
View File
@@ -2,6 +2,9 @@
"extends": "@repo/config-typescript/tsconfig.json", "extends": "@repo/config-typescript/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"], "lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["@total-typescript/ts-reset"] "types": ["@total-typescript/ts-reset"],
"paths": {
"next/root-params": ["./src/root-params.d.ts"]
}
} }
} }
@@ -19,13 +19,6 @@ export const CurriculumVitaeInterests: React.FC<CurriculumVitaeInterestsProps> =
</a> </a>
) )
}, },
"link-leon": (children) => {
return (
<a href="https://github.com/leon-ai/leon" target="_blank" className="font-semibold">
{children}
</a>
)
},
"link-markdownlint": (children) => { "link-markdownlint": (children) => {
return ( return (
<a <a
@@ -1,4 +1,4 @@
import { Link } from "@repo/i18n/routing" import { Link } from "@repo/i18n/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import Image from "next/image" import Image from "next/image"
import { BirthDate } from "../Home/About/AboutList/BirthDate.tsx" import { BirthDate } from "../Home/About/AboutList/BirthDate.tsx"
@@ -41,6 +41,16 @@ export const CurriculumVitaeWork: React.FC<CurriculumVitaeWorkProps> = () => {
}), }),
], ],
}, },
{
summary: t("curriculum-vitae.work.ircad-taiwan.summary"),
website: "https://ircadtaiwan.com.tw",
name: "IRCAD Taiwan",
location: "Taiwan",
position: t("curriculum-vitae.work.ircad-taiwan.position"),
dates: "20/07/2026 - 21/09/2026",
duration: t("curriculum-vitae.work.ircad-taiwan.duration"),
tasks: [],
},
{ {
summary: t("curriculum-vitae.work.numerize.summary"), summary: t("curriculum-vitae.work.numerize.summary"),
website: "https://numerize.com/", website: "https://numerize.com/",
@@ -98,12 +108,12 @@ export const CurriculumVitaeWork: React.FC<CurriculumVitaeWorkProps> = () => {
<></> <></>
)} )}
{workExperience.summary != null ? ( {workExperience.summary == null ? (
<></>
) : (
<div className="mt-2"> <div className="mt-2">
<p>{workExperience.summary}</p> <p>{workExperience.summary}</p>
</div> </div>
) : (
<></>
)} )}
</li> </li>
) )
@@ -2,7 +2,6 @@ import type { Meta, StoryObj } from "@storybook/nextjs"
import { expect, fn, userEvent, within } from "storybook/test" import { expect, fn, userEvent, within } from "storybook/test"
import { FaCheck } from "react-icons/fa" import { FaCheck } from "react-icons/fa"
import type { ButtonLinkProps } from "./Button.tsx"
import { Button } from "./Button.tsx" import { Button } from "./Button.tsx"
const meta = { const meta = {
@@ -133,10 +132,10 @@ export const LinkWithIcons: Story = {
render: (args) => { render: (args) => {
return ( return (
<ButtonContainer> <ButtonContainer>
<Button leftIcon={<FaCheck size={18} />} {...(args as ButtonLinkProps)}> <Button leftIcon={<FaCheck size={18} />} {...args}>
Link Left Icon Link Left Icon
</Button> </Button>
<Button rightIcon={<FaCheck size={18} />} {...(args as ButtonLinkProps)}> <Button rightIcon={<FaCheck size={18} />} {...args}>
Link Right Icon Link Right Icon
</Button> </Button>
</ButtonContainer> </ButtonContainer>
+4 -4
View File
@@ -1,5 +1,5 @@
import { classNames } from "@repo/config-tailwind/classNames" import { classNames } from "@repo/config-tailwind/classNames"
import { Link as NextLink } from "@repo/i18n/routing" import { Link as NextLink } from "@repo/i18n/navigation"
import { Spinner } from "../Spinner/Spinner.tsx" import { Spinner } from "../Spinner/Spinner.tsx"
import { Ripple } from "./Ripple.tsx" import { Ripple } from "./Ripple.tsx"
@@ -59,9 +59,9 @@ export const Button: React.FC<ButtonProps> = (props) => {
return ( return (
<NextLink className={classNames(buttonVariants({ variant, size }), className)} {...rest}> <NextLink className={classNames(buttonVariants({ variant, size }), className)} {...rest}>
{leftIcon != null ? <span className="mr-2">{leftIcon}</span> : null} {leftIcon == null ? null : <span className="mr-2">{leftIcon}</span>}
<span>{children}</span> <span>{children}</span>
{rightIcon != null ? <span className="ml-2">{rightIcon}</span> : null} {rightIcon == null ? null : <span className="ml-2">{rightIcon}</span>}
<Ripple color={rippleColor} /> <Ripple color={rippleColor} />
</NextLink> </NextLink>
@@ -94,7 +94,7 @@ export const Button: React.FC<ButtonProps> = (props) => {
disabled={isDisabled} disabled={isDisabled}
{...rest} {...rest}
> >
{leftIconElement != null ? <span className="mr-2">{leftIconElement}</span> : null} {leftIconElement == null ? null : <span className="mr-2">{leftIconElement}</span>}
<span>{children}</span> <span>{children}</span>
{rightIcon != null && !isLoading ? <span className="ml-2">{rightIcon}</span> : null} {rightIcon != null && !isLoading ? <span className="ml-2">{rightIcon}</span> : null}
+1 -1
View File
@@ -1,5 +1,5 @@
import { classNames } from "@repo/config-tailwind/classNames" import { classNames } from "@repo/config-tailwind/classNames"
import { Link as NextLink } from "@repo/i18n/routing" import { Link as NextLink } from "@repo/i18n/navigation"
import { FiExternalLink } from "react-icons/fi" import { FiExternalLink } from "react-icons/fi"
export interface LinkProps extends React.ComponentProps<typeof NextLink> { export interface LinkProps extends React.ComponentProps<typeof NextLink> {
@@ -20,6 +20,7 @@ const typographyVariants = (options?: { variant?: TypographyVariant }): string =
export type TypographyProps<Component extends React.ElementType = "p"> = { export type TypographyProps<Component extends React.ElementType = "p"> = {
as?: Component as?: Component
} & React.ComponentPropsWithoutRef<Component> & { } & React.ComponentPropsWithoutRef<Component> & {
className?: string
variant?: TypographyVariant variant?: TypographyVariant
} }
@@ -1,6 +1,6 @@
"use client" "use client"
import { useRouter } from "@repo/i18n/routing" import { useRouter } from "@repo/i18n/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { useEffect, useTransition } from "react" import { useEffect, useTransition } from "react"
import { Button } from "../../Design/Button/Button.tsx" import { Button } from "../../Design/Button/Button.tsx"
@@ -11,12 +11,12 @@ export const AboutItem: React.FC<AboutItemProps> = (props) => {
<li className="flex items-center justify-between sm:justify-start"> <li className="flex items-center justify-between sm:justify-start">
<strong className="w-24 text-sm text-black lg:w-32 dark:text-white">{label}</strong> <strong className="w-24 text-sm text-black lg:w-32 dark:text-white">{label}</strong>
<span className="block text-sm font-normal text-black dark:text-gray-lighter"> <span className="block text-sm font-normal text-black dark:text-gray-lighter">
{link != null ? ( {link == null ? (
value
) : (
<a className="hover:underline" href={link}> <a className="hover:underline" href={link}>
{value} {value}
</a> </a>
) : (
value
)} )}
</span> </span>
</li> </li>
@@ -1,7 +1,7 @@
"use client" "use client"
import { classNames } from "@repo/config-tailwind/classNames" import { classNames } from "@repo/config-tailwind/classNames"
import { usePathname, useRouter } from "@repo/i18n/routing" import { usePathname, useRouter } from "@repo/i18n/navigation"
import { LOCALES } from "@repo/utils/constants" import { LOCALES } from "@repo/utils/constants"
import { useLocale } from "next-intl" import { useLocale } from "next-intl"
import { useEffect, useRef } from "react" import { useEffect, useRef } from "react"
+12 -6
View File
@@ -4,7 +4,7 @@ import { classNames } from "@repo/config-tailwind/classNames"
import type { Theme } from "@repo/utils/constants" import type { Theme } from "@repo/utils/constants"
import { THEME_DEFAULT } from "@repo/utils/constants" import { THEME_DEFAULT } from "@repo/utils/constants"
import { ThemeProvider as NextThemeProvider, useTheme as useNextTheme } from "next-themes" import { ThemeProvider as NextThemeProvider, useTheme as useNextTheme } from "next-themes"
import { useEffect, useState } from "react" import { useSyncExternalStore } from "react"
export interface ThemeProviderProps extends React.PropsWithChildren { export interface ThemeProviderProps extends React.PropsWithChildren {
forcedTheme?: Theme forcedTheme?: Theme
@@ -30,13 +30,19 @@ export interface UseThemeOutput {
toggleTheme: () => void toggleTheme: () => void
} }
const subscribeToNothing = (): (() => void) => {
return () => {}
}
const getClientSnapshot = (): boolean => {
return true
}
const getServerSnapshot = (): boolean => {
return false
}
export const useTheme = (): UseThemeOutput => { export const useTheme = (): UseThemeOutput => {
const { setTheme, theme: themeData } = useNextTheme() const { setTheme, theme: themeData } = useNextTheme()
const [isMounted, setIsMounted] = useState(false) const isMounted = useSyncExternalStore(subscribeToNothing, getClientSnapshot, getServerSnapshot)
useEffect(() => {
setIsMounted(true)
}, [])
const theme = isMounted ? (themeData as Theme) : THEME_DEFAULT const theme = isMounted ? (themeData as Theme) : THEME_DEFAULT
+1
View File
@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"exports": { "exports": {
"./constants": "./src/constants.ts", "./constants": "./src/constants.ts",
"./clsx": "./src/clsx.ts",
"./dates": "./src/dates.ts", "./dates": "./src/dates.ts",
"./objects": "./src/objects.ts", "./objects": "./src/objects.ts",
"./strings": "./src/strings.ts", "./strings": "./src/strings.ts",
+25
View File
@@ -0,0 +1,25 @@
type ClassDictionary = Record<string, unknown>
export type ClassValue = ClassDictionary | string | null | boolean | undefined
/**
* Utility for constructing className strings conditionally.
* @see https://github.com/lukeed/clsx
*/
export const clsx = (inputs: ClassValue[]): string => {
let result = ""
for (const input of inputs) {
if (typeof input === "string") {
if (input.length > 0) {
result = result.length > 0 ? result + " " + input : input
}
} else if (typeof input === "object" && input !== null) {
for (const key of Object.keys(input)) {
if (input[key] != null && input[key] !== false && input[key] !== 0 && input[key] !== "") {
result = result.length > 0 ? result + " " + key : key
}
}
}
}
return result
}
+166
View File
@@ -0,0 +1,166 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import type { ClassValue } from "../clsx.ts"
import { clsx } from "../clsx.ts"
describe("clsx", () => {
describe("strings", () => {
it("should return empty string for empty input", () => {
// Arrange - Given
const input: ClassValue[] = [""]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "")
})
it("should return the string as-is", () => {
// Arrange - Given
const input: ClassValue[] = ["foo"]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "foo")
})
it("should join multiple strings with space", () => {
// Arrange - Given
const input: ClassValue[] = ["foo", "bar"]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "foo bar")
})
it("should skip falsy values between strings", () => {
// Arrange - Given
const input: ClassValue[] = ["foo", false, "bar", null, "baz"]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "foo bar baz")
})
})
describe("objects", () => {
it("should return empty string for empty object", () => {
// Arrange - Given
const input: ClassValue[] = [{}]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "")
})
it("should include keys with truthy values", () => {
// Arrange - Given
const input: ClassValue[] = [{ foo: true, bar: false }]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "foo")
})
it("should exclude keys with falsy values (0, null, false)", () => {
// Arrange - Given
const input: ClassValue[] = [{ foo: 1, bar: 0, baz: 1 }]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "foo baz")
})
it("should join keys from multiple objects", () => {
// Arrange - Given
const input: ClassValue[] = [{ foo: 1 }, { bar: 2 }]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "foo bar")
})
it("should handle null between objects", () => {
// Arrange - Given
const input: ClassValue[] = [{ foo: 1 }, null, { baz: 1, bat: 0 }]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "foo baz")
})
})
describe("mixed", () => {
it("should handle strings and objects together", () => {
// Arrange - Given
const input: ClassValue[] = ["hello", { world: 1, push: true }]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "hello world push")
})
it("should ignore non-string/object values", () => {
// Arrange - Given
const input: ClassValue[] = [undefined, "hello", null, true]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "hello")
})
it("should return empty string with no arguments", () => {
// Arrange - Given
const input: ClassValue[] = []
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "")
})
it("should not append empty string with separator after a non-empty token", () => {
// Arrange - Given
const input: ClassValue[] = ["foo", ""]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "foo")
})
it("should exclude object keys whose value is null, undefined, or empty string", () => {
// Arrange - Given
const input: ClassValue[] = [{ a: null, b: undefined, c: "", d: "ok" }]
// Act - When
const output = clsx(input)
// Assert - Then
assert.strictEqual(output, "d")
})
})
})
+1 -1
View File
@@ -10,7 +10,7 @@ export const getPathnameWithoutLocale = (input: string): string => {
const locale = LOCALES.find((locale) => { const locale = LOCALES.find((locale) => {
return input.startsWith(`/${locale}`) return input.startsWith(`/${locale}`)
}) })
const pathname = locale != null ? input.slice(locale.length + 1) : input const pathname = locale == null ? input : input.slice(locale.length + 1)
if (pathname.length <= 0) { if (pathname.length <= 0) {
return `/${pathname}` return `/${pathname}`
} }
+4493 -3865
View File
File diff suppressed because it is too large Load Diff
+41 -43
View File
@@ -3,39 +3,46 @@ packages:
- "configs/*" - "configs/*"
- "packages/*" - "packages/*"
allowBuilds:
"@swc/core": true
"@parcel/watcher": true
"sharp": true
"unrs-resolver": true
"core-js-pure": true
"esbuild": true
minimumReleaseAge: 1440
minimumReleaseAgeStrict: true
minimumReleaseAgeIgnoreMissingTime: false
catalogMode: "strict" catalogMode: "strict"
catalog: catalog:
# Turborepo
"turbo": "2.8.9"
# TypeScript/Linting # TypeScript/Linting
"typescript": "5.9.3" "turbo": "2.10.11"
"@types/node": "25.2.3" "typescript": "7.0.2"
"@types/node": "26.2.0"
"@total-typescript/ts-reset": "0.6.1" "@total-typescript/ts-reset": "0.6.1"
"oxlint": "1.47.0" "oxlint": "1.79.0"
"oxlint-tsgolint": "0.12.2" "oxlint-tsgolint": "7.0.2001"
"oxfmt": "0.32.0" "oxfmt": "0.64.0"
"eslint-config-conventions": "21.2.0"
"editorconfig-checker": "6.1.1"
# Utils # Utils
"mime": "4.1.0" "mime": "4.1.0"
# React.js/Next.js # React.js/Next.js
"next": "16.1.6" "next": "16.3.2"
"next-intl": "4.8.3" "next-intl": "4.13.7"
"next-themes": "0.4.6" "next-themes": "0.4.6"
"react": "19.2.4" "react": "19.2.8"
"react-dom": "19.2.4" "react-dom": "19.2.8"
"@types/react": "19.2.14" "@types/react": "19.2.18"
"@types/react-dom": "19.2.3" "@types/react-dom": "19.2.5"
"react-icons": "5.5.0" "react-icons": "5.7.0"
# Blog # Blog
"@giscus/react": "3.1.0" "@giscus/react": "3.1.0"
"gray-matter": "4.0.3" "gray-matter": "4.0.3"
"katex": "0.16.28" "katex": "0.18.4"
"next-mdx-remote": "5.0.0" "next-mdx-remote": "5.0.0"
"@mdx-js/mdx": "3.1.1" "@mdx-js/mdx": "3.1.1"
"rehype-katex": "7.0.1" "rehype-katex": "7.0.1"
@@ -47,38 +54,29 @@ catalog:
"@shikijs/rehype": "1.24.0" "@shikijs/rehype": "1.24.0"
# Markdown Lint # Markdown Lint
"markdownlint-cli2": "0.21.0" "markdownlint-cli2": "0.23.2"
"markdownlint": "0.40.0" "markdownlint": "0.41.1"
"markdownlint-rule-relative-links": "5.0.1" "markdownlint-rule-relative-links": "5.1.2"
# Storybook # Storybook
"storybook": &storybook "10.2.8" "storybook": &storybook "10.5.10"
"@storybook/addon-docs": *storybook "@storybook/addon-docs": *storybook
"@storybook/addon-a11y": *storybook "@storybook/addon-a11y": *storybook
"@storybook/nextjs": *storybook "@storybook/nextjs": *storybook
"@storybook/addon-themes": *storybook "@storybook/addon-themes": *storybook
"@storybook/test-runner": "0.24.2" "@storybook/test-runner": "0.24.4"
"@chromatic-com/storybook": "5.0.1" "@chromatic-com/storybook": "5.3.0"
"chromatic": "15.1.0" "chromatic": "18.5.0"
# Testing # Testing
"playwright": &playwright "1.58.2" "playwright": &playwright "1.62.1"
"@playwright/test": *playwright "@playwright/test": *playwright
"start-server-and-test": "2.1.3" "start-server-and-test": "3.0.12"
# CSS # CSS
"postcss": "8.5.6" "postcss": "8.5.26"
"@tailwindcss/postcss": "4.1.18" "tailwindcss": &tailwindcss "4.3.3"
"@tailwindcss/typography": "0.5.19" "@tailwindcss/postcss": *tailwindcss
"tailwindcss": "4.1.18" "@tailwindcss/typography": "0.5.20"
"tailwind-merge": "3.4.1" "tailwind-merge": "3.6.0"
"clsx": "2.1.1" "@fontsource/montserrat": "5.3.0"
"@fontsource/montserrat": "5.2.8"
allowBuilds:
"@swc/core": true
"@parcel/watcher": true
"sharp": true
"unrs-resolver": true
"core-js-pure": true
"esbuild": true