Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions .lycheeignore
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions pwa/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
**/*.md
#Do not ignore speakers and conferences
!data/**/*.md
data/docs
data/docs/**
**/._*
**/.dockerignore
**/.DS_Store
Expand All @@ -20,3 +22,9 @@ node_modules/
.editorconfig
.env.*.local
.env.local

out/
core/
core.temp/
docs.temp/
.next/
1 change: 1 addition & 0 deletions pwa/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ; \
Expand Down
122 changes: 64 additions & 58 deletions pwa/api/con/conferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
);
108 changes: 59 additions & 49 deletions pwa/api/con/speakers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
6 changes: 1 addition & 5 deletions pwa/app/(common)/components/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,7 @@ export default function Admin() {
</ListPoint>
<ListPoint direction="left">
<strong>Hydra</strong> and{" "}
<Link
href="/docs/admin/openapi/"
prefetch={false}
className="link"
>
<Link href="/docs/admin/" prefetch={false} className="link">
<strong>OpenAPI</strong>
</Link>{" "}
compatible
Expand Down
6 changes: 1 addition & 5 deletions pwa/app/(common)/components/Features.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,7 @@ export default function Features() {
</Link>
</FeatureItem>
<FeatureItem Icon={icons.CaddyIcon} index={5}>
<Link
className="link"
href="/docs/distribution/caddy/#configuring-the-caddy-web-server"
prefetch={false}
>
<Link className="link" href="/docs/symfony/caddy/" prefetch={false}>
Caddy server
</Link>{" "}
integration HTTPS & HTTP/3
Expand Down
7 changes: 5 additions & 2 deletions pwa/utils/getPlaceholder.tsx
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
31 changes: 31 additions & 0 deletions pwa/utils/memoizeAsync.ts
Original file line number Diff line number Diff line change
@@ -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<Args extends any[], R>(
fn: (...args: Args) => Promise<R>,
keyFn: (...args: Args) => string = (...args) => JSON.stringify(args)
): (...args: Args) => Promise<R> {
const store = new Map<string, Promise<R>>();

return (...args: Args): Promise<R> => {
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;
};
}
Loading