diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 42a5088c..6bc1a885 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -177,6 +177,8 @@ jobs: needs: deploy runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v3 - name: Define release name run: | set -o pipefail diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 00000000..1d91f163 --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,8 @@ +# Customer/reference websites that reject automated requests with HTTP 403 +# (bot protection / WAF). The links are valid for real users; lychee just +# can't reach them. Patterns are regexes matched against the full URL. +^https?://(www\.)?decathlon\.fr +^https?://(www\.)?vestiairecollective\.com +^https?://(www\.)?cartier\.com +^https?://(www\.)?volvocars\.com +^https?://gitlab\.com/mobicoop diff --git a/pwa/.dockerignore b/pwa/.dockerignore index dde1fbd1..7212af18 100644 --- a/pwa/.dockerignore +++ b/pwa/.dockerignore @@ -2,6 +2,8 @@ **/*.md #Do not ignore speakers and conferences !data/**/*.md +data/docs +data/docs/** **/._* **/.dockerignore **/.DS_Store @@ -20,3 +22,9 @@ node_modules/ .editorconfig .env.*.local .env.local + +out/ +core/ +core.temp/ +docs.temp/ +.next/ diff --git a/pwa/Dockerfile b/pwa/Dockerfile index 92912783..9bf344f5 100644 --- a/pwa/Dockerfile +++ b/pwa/Dockerfile @@ -86,6 +86,7 @@ RUN --mount=type=secret,id=GITHUB_KEY \ # ADD https://soyuka.me/contributors.json ./data/contributors.json RUN --mount=type=secret,id=GITHUB_KEY \ + --mount=type=cache,target=/srv/app/.next/cache \ export GITHUB_KEY=$(cat /run/secrets/GITHUB_KEY) ; \ if [ -z "$GITHUB_KEY" ]; then \ echo "Please set the GITHUB_KEY secret" && exit 1 ; \ diff --git a/pwa/api/con/conferences.ts b/pwa/api/con/conferences.ts index 7de960d5..3d5107d6 100644 --- a/pwa/api/con/conferences.ts +++ b/pwa/api/con/conferences.ts @@ -8,24 +8,23 @@ import { getSpeakerById } from "./speakers"; import { Conference, Day, Speaker, Track } from "types/con"; import { Locale, i18n } from "i18n/i18n-config"; import MarkdownIt from "markdown-it"; +import { memoizeAsync } from "utils/memoizeAsync"; -export const getAllConferences = async ( - edition: string, - withSpeakers: boolean, - locale: Locale -) => { - const slugs = ( - await readdir(path.join(process.cwd(), `data/con/${edition}/conferences`)) - ) - .filter((el) => path.extname(el) === ".md") - .map((slug) => slug.replace(/\.md$/, "")); - - return Promise.all( - slugs.map((slug) => - getConferenceData(edition, slug, false, withSpeakers, locale) +export const getAllConferences = memoizeAsync( + async (edition: string, withSpeakers: boolean, locale: Locale) => { + const slugs = ( + await readdir(path.join(process.cwd(), `data/con/${edition}/conferences`)) ) - ); -}; + .filter((el) => path.extname(el) === ".md") + .map((slug) => slug.replace(/\.md$/, "")); + + return Promise.all( + slugs.map((slug) => + getConferenceData(edition, slug, false, withSpeakers, locale) + ) + ); + } +); export const getConferencesBySpeaker = async ( edition: string, @@ -48,51 +47,58 @@ export const getAllConferenceSlugs = async (edition = "2022") => { .map((slug: string) => slug.replace(/\.md$/, "")); }; -export const getConferenceData = async ( - edition: string, - slug: string, - withDescription = true, - withSpeakers = false, - locale: Locale = i18n.defaultLocale -) => { - const fileContents = await readFile( - path.join(process.cwd(), `data/con/${edition}/conferences/${slug}.md`), - "utf8" - ); +export const getConferenceData = memoizeAsync( + async ( + edition: string, + slug: string, + withDescription = true, + withSpeakers = false, + locale: Locale = i18n.defaultLocale + ) => { + const fileContents = await readFile( + path.join(process.cwd(), `data/con/${edition}/conferences/${slug}.md`), + "utf8" + ); - const days = (await import(`data/con/${edition}/days`)).default; - const tracks = (await import(`data/con/${edition}/tracks`)).default; - // Use gray-matter to parse the post metadata section - const matterResult = matter(fileContents); + const days = (await import(`data/con/${edition}/days`)).default; + const tracks = (await import(`data/con/${edition}/tracks`)).default; + // Use gray-matter to parse the post metadata section + const matterResult = matter(fileContents); - const md = new MarkdownIt({ - html: true, - linkify: true, - typographer: true, - }); + const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: true, + }); - const contentHtml = withDescription ? md.render(matterResult.content) : ""; + const contentHtml = withDescription ? md.render(matterResult.content) : ""; - const speakers = matterResult.data.speakers - .split(" ") - .map((slug: string) => slug.substring(1)); + const speakers = matterResult.data.speakers + .split(" ") + .map((slug: string) => slug.substring(1)); - const fullSpeakers = await Promise.all( - speakers.map((id: string) => getSpeakerById(edition, id, locale)) - ); + const fullSpeakers = await Promise.all( + speakers.map((id: string) => getSpeakerById(edition, id, locale)) + ); - // Combine the data with the id and contentHtml - return { - slug, - edition, - description: contentHtml, - url: edition === '2026' ? `/con/${edition}/conferences/#${slug}` : `/con/${edition}/conferences/${slug}`, - ...matterResult.data, - title: unbreakable(extractTitleFromMarkdown(matterResult.content) || ""), - speakers: withSpeakers ? fullSpeakers : speakers, - track: tracks.find((track: Track) => track.id === matterResult.data.track), - day: - days.length > 1 && - days.find((day: Day) => day.date === matterResult.data.date), - } as unknown as Conference; -}; + // Combine the data with the id and contentHtml + return { + slug, + edition, + description: contentHtml, + url: + edition === "2026" + ? `/con/${edition}/conferences/#${slug}` + : `/con/${edition}/conferences/${slug}`, + ...matterResult.data, + title: unbreakable(extractTitleFromMarkdown(matterResult.content) || ""), + speakers: withSpeakers ? fullSpeakers : speakers, + track: tracks.find( + (track: Track) => track.id === matterResult.data.track + ), + day: + days.length > 1 && + days.find((day: Day) => day.date === matterResult.data.date), + } as unknown as Conference; + } +); diff --git a/pwa/api/con/speakers.ts b/pwa/api/con/speakers.ts index 8a9c629c..ecb7fa48 100644 --- a/pwa/api/con/speakers.ts +++ b/pwa/api/con/speakers.ts @@ -3,28 +3,33 @@ import path from "node:path"; import matter from "gray-matter"; import { Locale } from "i18n/i18n-config"; import { getPlaceholder } from "utils/getPlaceholder"; +import { memoizeAsync } from "utils/memoizeAsync"; import { Speaker } from "types/con"; import MarkdownIt from "markdown-it"; -export const getAllSpeakers = async (edition: string, locale: Locale) => { - try { - const slugs = ( - await readdir( - path.resolve(process.cwd(), `data/con/${edition}/speakers/${locale}`) +export const getAllSpeakers = memoizeAsync( + async (edition: string, locale: Locale) => { + try { + const slugs = ( + await readdir( + path.resolve(process.cwd(), `data/con/${edition}/speakers/${locale}`) + ) ) - ) - .filter((el) => path.extname(el) === ".md") - .map((slug: string) => slug.replace(/\.md$/, "")); + .filter((el) => path.extname(el) === ".md") + .map((slug: string) => slug.replace(/\.md$/, "")); - return Promise.all( - slugs.map((slug: string) => getSpeakerData(edition, slug, locale, false)) - ); - } catch (e) { - console.error(e); - return []; + return Promise.all( + slugs.map((slug: string) => + getSpeakerData(edition, slug, locale, false) + ) + ); + } catch (e) { + console.error(e); + return [] as Speaker[]; + } } -}; +); export const getAllSpeakerSlugs = async (edition: string, locale: string) => { try { @@ -42,45 +47,50 @@ export const getAllSpeakerSlugs = async (edition: string, locale: string) => { } }; -export const getSpeakerData = async ( - edition: string, - slug: string, - locale: string, - withDescription = true -) => { - const fileContents = await readFile( - `data/con/${edition}/speakers/${locale}/${slug}.md`, - "utf8" - ); - // Use gray-matter to parse the post metadata section - const matterResult = matter(fileContents); +export const getSpeakerData = memoizeAsync( + async ( + edition: string, + slug: string, + locale: string, + withDescription = true + ) => { + const fileContents = await readFile( + `data/con/${edition}/speakers/${locale}/${slug}.md`, + "utf8" + ); + // Use gray-matter to parse the post metadata section + const matterResult = matter(fileContents); - const { id } = matterResult.data; + const { id } = matterResult.data; - const md = new MarkdownIt({ - html: true, - linkify: true, - typographer: true, - }); + const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: true, + }); - const contentHtml = withDescription ? md.render(matterResult.content) : ""; + const contentHtml = withDescription ? md.render(matterResult.content) : ""; - const placeholder = await getPlaceholder( - path.join(process.cwd(), `/public/images/con/${edition}/speakers/${id}.png`) - ); + const placeholder = await getPlaceholder( + path.join( + process.cwd(), + `/public/images/con/${edition}/speakers/${id}.png` + ) + ); - // Combine the data with the id and contentHtml - return { - slug, - edition, - contentHtml, - image: `/images/con/${edition}/speakers/${id}.png`, - placeholder, - url: `/${locale}/con/${edition}/speakers/${slug}`, - ...matterResult.data, - number: matterResult.data.number || 0, - } as Speaker; -}; + // Combine the data with the id and contentHtml + return { + slug, + edition, + contentHtml, + image: `/images/con/${edition}/speakers/${id}.png`, + placeholder, + url: `/${locale}/con/${edition}/speakers/${slug}`, + ...matterResult.data, + number: matterResult.data.number || 0, + } as Speaker; + } +); export const getSpeakerById = async ( edition: string, diff --git a/pwa/app/(common)/components/Admin.tsx b/pwa/app/(common)/components/Admin.tsx index 31fc6560..6ebae258 100644 --- a/pwa/app/(common)/components/Admin.tsx +++ b/pwa/app/(common)/components/Admin.tsx @@ -21,11 +21,7 @@ export default function Admin() { Hydra and{" "} - + OpenAPI {" "} compatible diff --git a/pwa/app/(common)/components/Features.tsx b/pwa/app/(common)/components/Features.tsx index aa8f260c..0b75128a 100644 --- a/pwa/app/(common)/components/Features.tsx +++ b/pwa/app/(common)/components/Features.tsx @@ -53,11 +53,7 @@ export default function Features() { - + Caddy server {" "} integration HTTPS & HTTP/3 diff --git a/pwa/utils/getPlaceholder.tsx b/pwa/utils/getPlaceholder.tsx index d275b255..29592de2 100644 --- a/pwa/utils/getPlaceholder.tsx +++ b/pwa/utils/getPlaceholder.tsx @@ -1,7 +1,10 @@ import sharp from "sharp"; -import { cache } from "react"; +import { memoizeAsync } from "utils/memoizeAsync"; -export const getPlaceholder = cache(async (imagePath: string) => { +// Memoized at the process level (not React's per-render cache): during the +// build the same speaker images are requested across many pages, and Sharp +// (native image processing) is expensive. This ensures one resize per image. +export const getPlaceholder = memoizeAsync(async (imagePath: string) => { try { // Redimensionner l'image à une taille très petite const resizedImageBuffer = await sharp(imagePath) diff --git a/pwa/utils/memoizeAsync.ts b/pwa/utils/memoizeAsync.ts new file mode 100644 index 00000000..71e0c598 --- /dev/null +++ b/pwa/utils/memoizeAsync.ts @@ -0,0 +1,31 @@ +/** + * Process-level memoization for async functions. + * + * Unlike React's `cache()` (which only dedupes within a single render/request), + * this keeps results for the lifetime of the Node process. During `next build` + * the same data files would otherwise be read, parsed and image-processed + * hundreds of times — once per generated page. Static generation runs across a + * handful of worker processes, so each worker memoizes the pages it renders. + * + * The returned promise is cached (so concurrent callers share one run), and a + * rejection is evicted so failures are not memoized. + */ +export function memoizeAsync( + fn: (...args: Args) => Promise, + keyFn: (...args: Args) => string = (...args) => JSON.stringify(args) +): (...args: Args) => Promise { + const store = new Map>(); + + return (...args: Args): Promise => { + const key = keyFn(...args); + const existing = store.get(key); + if (existing) return existing; + + const promise = fn(...args).catch((error) => { + store.delete(key); + throw error; + }); + store.set(key, promise); + return promise; + }; +}