Skip to content

Commit 7c6fab8

Browse files
authored
feat: optimize build (#679) (#680)
* feat: optimize build
1 parent 7fd73a0 commit 7c6fab8

10 files changed

Lines changed: 180 additions & 119 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ jobs:
177177
needs: deploy
178178
runs-on: ubuntu-latest
179179
steps:
180+
- name: Checkout
181+
uses: actions/checkout@v3
180182
- name: Define release name
181183
run: |
182184
set -o pipefail

.lycheeignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Customer/reference websites that reject automated requests with HTTP 403
2+
# (bot protection / WAF). The links are valid for real users; lychee just
3+
# can't reach them. Patterns are regexes matched against the full URL.
4+
^https?://(www\.)?decathlon\.fr
5+
^https?://(www\.)?vestiairecollective\.com
6+
^https?://(www\.)?cartier\.com
7+
^https?://(www\.)?volvocars\.com
8+
^https?://gitlab\.com/mobicoop

pwa/.dockerignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
**/*.md
33
#Do not ignore speakers and conferences
44
!data/**/*.md
5+
data/docs
6+
data/docs/**
57
**/._*
68
**/.dockerignore
79
**/.DS_Store
@@ -20,3 +22,9 @@ node_modules/
2022
.editorconfig
2123
.env.*.local
2224
.env.local
25+
26+
out/
27+
core/
28+
core.temp/
29+
docs.temp/
30+
.next/

pwa/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ RUN --mount=type=secret,id=GITHUB_KEY \
8686
# ADD https://soyuka.me/contributors.json ./data/contributors.json
8787

8888
RUN --mount=type=secret,id=GITHUB_KEY \
89+
--mount=type=cache,target=/srv/app/.next/cache \
8990
export GITHUB_KEY=$(cat /run/secrets/GITHUB_KEY) ; \
9091
if [ -z "$GITHUB_KEY" ]; then \
9192
echo "Please set the GITHUB_KEY secret" && exit 1 ; \

pwa/api/con/conferences.ts

Lines changed: 64 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,23 @@ import { getSpeakerById } from "./speakers";
88
import { Conference, Day, Speaker, Track } from "types/con";
99
import { Locale, i18n } from "i18n/i18n-config";
1010
import MarkdownIt from "markdown-it";
11+
import { memoizeAsync } from "utils/memoizeAsync";
1112

12-
export const getAllConferences = async (
13-
edition: string,
14-
withSpeakers: boolean,
15-
locale: Locale
16-
) => {
17-
const slugs = (
18-
await readdir(path.join(process.cwd(), `data/con/${edition}/conferences`))
19-
)
20-
.filter((el) => path.extname(el) === ".md")
21-
.map((slug) => slug.replace(/\.md$/, ""));
22-
23-
return Promise.all(
24-
slugs.map((slug) =>
25-
getConferenceData(edition, slug, false, withSpeakers, locale)
13+
export const getAllConferences = memoizeAsync(
14+
async (edition: string, withSpeakers: boolean, locale: Locale) => {
15+
const slugs = (
16+
await readdir(path.join(process.cwd(), `data/con/${edition}/conferences`))
2617
)
27-
);
28-
};
18+
.filter((el) => path.extname(el) === ".md")
19+
.map((slug) => slug.replace(/\.md$/, ""));
20+
21+
return Promise.all(
22+
slugs.map((slug) =>
23+
getConferenceData(edition, slug, false, withSpeakers, locale)
24+
)
25+
);
26+
}
27+
);
2928

3029
export const getConferencesBySpeaker = async (
3130
edition: string,
@@ -48,51 +47,58 @@ export const getAllConferenceSlugs = async (edition = "2022") => {
4847
.map((slug: string) => slug.replace(/\.md$/, ""));
4948
};
5049

51-
export const getConferenceData = async (
52-
edition: string,
53-
slug: string,
54-
withDescription = true,
55-
withSpeakers = false,
56-
locale: Locale = i18n.defaultLocale
57-
) => {
58-
const fileContents = await readFile(
59-
path.join(process.cwd(), `data/con/${edition}/conferences/${slug}.md`),
60-
"utf8"
61-
);
50+
export const getConferenceData = memoizeAsync(
51+
async (
52+
edition: string,
53+
slug: string,
54+
withDescription = true,
55+
withSpeakers = false,
56+
locale: Locale = i18n.defaultLocale
57+
) => {
58+
const fileContents = await readFile(
59+
path.join(process.cwd(), `data/con/${edition}/conferences/${slug}.md`),
60+
"utf8"
61+
);
6262

63-
const days = (await import(`data/con/${edition}/days`)).default;
64-
const tracks = (await import(`data/con/${edition}/tracks`)).default;
65-
// Use gray-matter to parse the post metadata section
66-
const matterResult = matter(fileContents);
63+
const days = (await import(`data/con/${edition}/days`)).default;
64+
const tracks = (await import(`data/con/${edition}/tracks`)).default;
65+
// Use gray-matter to parse the post metadata section
66+
const matterResult = matter(fileContents);
6767

68-
const md = new MarkdownIt({
69-
html: true,
70-
linkify: true,
71-
typographer: true,
72-
});
68+
const md = new MarkdownIt({
69+
html: true,
70+
linkify: true,
71+
typographer: true,
72+
});
7373

74-
const contentHtml = withDescription ? md.render(matterResult.content) : "";
74+
const contentHtml = withDescription ? md.render(matterResult.content) : "";
7575

76-
const speakers = matterResult.data.speakers
77-
.split(" ")
78-
.map((slug: string) => slug.substring(1));
76+
const speakers = matterResult.data.speakers
77+
.split(" ")
78+
.map((slug: string) => slug.substring(1));
7979

80-
const fullSpeakers = await Promise.all(
81-
speakers.map((id: string) => getSpeakerById(edition, id, locale))
82-
);
80+
const fullSpeakers = await Promise.all(
81+
speakers.map((id: string) => getSpeakerById(edition, id, locale))
82+
);
8383

84-
// Combine the data with the id and contentHtml
85-
return {
86-
slug,
87-
edition,
88-
description: contentHtml,
89-
url: edition === '2026' ? `/con/${edition}/conferences/#${slug}` : `/con/${edition}/conferences/${slug}`,
90-
...matterResult.data,
91-
title: unbreakable(extractTitleFromMarkdown(matterResult.content) || ""),
92-
speakers: withSpeakers ? fullSpeakers : speakers,
93-
track: tracks.find((track: Track) => track.id === matterResult.data.track),
94-
day:
95-
days.length > 1 &&
96-
days.find((day: Day) => day.date === matterResult.data.date),
97-
} as unknown as Conference;
98-
};
84+
// Combine the data with the id and contentHtml
85+
return {
86+
slug,
87+
edition,
88+
description: contentHtml,
89+
url:
90+
edition === "2026"
91+
? `/con/${edition}/conferences/#${slug}`
92+
: `/con/${edition}/conferences/${slug}`,
93+
...matterResult.data,
94+
title: unbreakable(extractTitleFromMarkdown(matterResult.content) || ""),
95+
speakers: withSpeakers ? fullSpeakers : speakers,
96+
track: tracks.find(
97+
(track: Track) => track.id === matterResult.data.track
98+
),
99+
day:
100+
days.length > 1 &&
101+
days.find((day: Day) => day.date === matterResult.data.date),
102+
} as unknown as Conference;
103+
}
104+
);

pwa/api/con/speakers.ts

Lines changed: 59 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,33 @@ import path from "node:path";
33
import matter from "gray-matter";
44
import { Locale } from "i18n/i18n-config";
55
import { getPlaceholder } from "utils/getPlaceholder";
6+
import { memoizeAsync } from "utils/memoizeAsync";
67

78
import { Speaker } from "types/con";
89
import MarkdownIt from "markdown-it";
910

10-
export const getAllSpeakers = async (edition: string, locale: Locale) => {
11-
try {
12-
const slugs = (
13-
await readdir(
14-
path.resolve(process.cwd(), `data/con/${edition}/speakers/${locale}`)
11+
export const getAllSpeakers = memoizeAsync(
12+
async (edition: string, locale: Locale) => {
13+
try {
14+
const slugs = (
15+
await readdir(
16+
path.resolve(process.cwd(), `data/con/${edition}/speakers/${locale}`)
17+
)
1518
)
16-
)
17-
.filter((el) => path.extname(el) === ".md")
18-
.map((slug: string) => slug.replace(/\.md$/, ""));
19+
.filter((el) => path.extname(el) === ".md")
20+
.map((slug: string) => slug.replace(/\.md$/, ""));
1921

20-
return Promise.all(
21-
slugs.map((slug: string) => getSpeakerData(edition, slug, locale, false))
22-
);
23-
} catch (e) {
24-
console.error(e);
25-
return [];
22+
return Promise.all(
23+
slugs.map((slug: string) =>
24+
getSpeakerData(edition, slug, locale, false)
25+
)
26+
);
27+
} catch (e) {
28+
console.error(e);
29+
return [] as Speaker[];
30+
}
2631
}
27-
};
32+
);
2833

2934
export const getAllSpeakerSlugs = async (edition: string, locale: string) => {
3035
try {
@@ -42,45 +47,50 @@ export const getAllSpeakerSlugs = async (edition: string, locale: string) => {
4247
}
4348
};
4449

45-
export const getSpeakerData = async (
46-
edition: string,
47-
slug: string,
48-
locale: string,
49-
withDescription = true
50-
) => {
51-
const fileContents = await readFile(
52-
`data/con/${edition}/speakers/${locale}/${slug}.md`,
53-
"utf8"
54-
);
55-
// Use gray-matter to parse the post metadata section
56-
const matterResult = matter(fileContents);
50+
export const getSpeakerData = memoizeAsync(
51+
async (
52+
edition: string,
53+
slug: string,
54+
locale: string,
55+
withDescription = true
56+
) => {
57+
const fileContents = await readFile(
58+
`data/con/${edition}/speakers/${locale}/${slug}.md`,
59+
"utf8"
60+
);
61+
// Use gray-matter to parse the post metadata section
62+
const matterResult = matter(fileContents);
5763

58-
const { id } = matterResult.data;
64+
const { id } = matterResult.data;
5965

60-
const md = new MarkdownIt({
61-
html: true,
62-
linkify: true,
63-
typographer: true,
64-
});
66+
const md = new MarkdownIt({
67+
html: true,
68+
linkify: true,
69+
typographer: true,
70+
});
6571

66-
const contentHtml = withDescription ? md.render(matterResult.content) : "";
72+
const contentHtml = withDescription ? md.render(matterResult.content) : "";
6773

68-
const placeholder = await getPlaceholder(
69-
path.join(process.cwd(), `/public/images/con/${edition}/speakers/${id}.png`)
70-
);
74+
const placeholder = await getPlaceholder(
75+
path.join(
76+
process.cwd(),
77+
`/public/images/con/${edition}/speakers/${id}.png`
78+
)
79+
);
7180

72-
// Combine the data with the id and contentHtml
73-
return {
74-
slug,
75-
edition,
76-
contentHtml,
77-
image: `/images/con/${edition}/speakers/${id}.png`,
78-
placeholder,
79-
url: `/${locale}/con/${edition}/speakers/${slug}`,
80-
...matterResult.data,
81-
number: matterResult.data.number || 0,
82-
} as Speaker;
83-
};
81+
// Combine the data with the id and contentHtml
82+
return {
83+
slug,
84+
edition,
85+
contentHtml,
86+
image: `/images/con/${edition}/speakers/${id}.png`,
87+
placeholder,
88+
url: `/${locale}/con/${edition}/speakers/${slug}`,
89+
...matterResult.data,
90+
number: matterResult.data.number || 0,
91+
} as Speaker;
92+
}
93+
);
8494

8595
export const getSpeakerById = async (
8696
edition: string,

pwa/app/(common)/components/Admin.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,7 @@ export default function Admin() {
2121
</ListPoint>
2222
<ListPoint direction="left">
2323
<strong>Hydra</strong> and{" "}
24-
<Link
25-
href="/docs/admin/openapi/"
26-
prefetch={false}
27-
className="link"
28-
>
24+
<Link href="/docs/admin/" prefetch={false} className="link">
2925
<strong>OpenAPI</strong>
3026
</Link>{" "}
3127
compatible

pwa/app/(common)/components/Features.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,7 @@ export default function Features() {
5353
</Link>
5454
</FeatureItem>
5555
<FeatureItem Icon={icons.CaddyIcon} index={5}>
56-
<Link
57-
className="link"
58-
href="/docs/distribution/caddy/#configuring-the-caddy-web-server"
59-
prefetch={false}
60-
>
56+
<Link className="link" href="/docs/symfony/caddy/" prefetch={false}>
6157
Caddy server
6258
</Link>{" "}
6359
integration HTTPS & HTTP/3

pwa/utils/getPlaceholder.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import sharp from "sharp";
2-
import { cache } from "react";
2+
import { memoizeAsync } from "utils/memoizeAsync";
33

4-
export const getPlaceholder = cache(async (imagePath: string) => {
4+
// Memoized at the process level (not React's per-render cache): during the
5+
// build the same speaker images are requested across many pages, and Sharp
6+
// (native image processing) is expensive. This ensures one resize per image.
7+
export const getPlaceholder = memoizeAsync(async (imagePath: string) => {
58
try {
69
// Redimensionner l'image à une taille très petite
710
const resizedImageBuffer = await sharp(imagePath)

pwa/utils/memoizeAsync.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Process-level memoization for async functions.
3+
*
4+
* Unlike React's `cache()` (which only dedupes within a single render/request),
5+
* this keeps results for the lifetime of the Node process. During `next build`
6+
* the same data files would otherwise be read, parsed and image-processed
7+
* hundreds of times — once per generated page. Static generation runs across a
8+
* handful of worker processes, so each worker memoizes the pages it renders.
9+
*
10+
* The returned promise is cached (so concurrent callers share one run), and a
11+
* rejection is evicted so failures are not memoized.
12+
*/
13+
export function memoizeAsync<Args extends any[], R>(
14+
fn: (...args: Args) => Promise<R>,
15+
keyFn: (...args: Args) => string = (...args) => JSON.stringify(args)
16+
): (...args: Args) => Promise<R> {
17+
const store = new Map<string, Promise<R>>();
18+
19+
return (...args: Args): Promise<R> => {
20+
const key = keyFn(...args);
21+
const existing = store.get(key);
22+
if (existing) return existing;
23+
24+
const promise = fn(...args).catch((error) => {
25+
store.delete(key);
26+
throw error;
27+
});
28+
store.set(key, promise);
29+
return promise;
30+
};
31+
}

0 commit comments

Comments
 (0)