diff --git a/site/netlify.toml b/site/netlify.toml index 71eee1eb2..83e9417fe 100644 --- a/site/netlify.toml +++ b/site/netlify.toml @@ -148,8 +148,8 @@ Content-Security-Policy = "default-src 'self' mon-entreprise.fr; style-src 'self status = 200 [[redirects]] - from = ":SITE_FR/simulateurs/dirigeant-sasu" - to = "/prerender/mon-entreprise/simulateurs/dirigeant-sasu/index.html" + from = ":SITE_FR/simulateurs/sasu" + to = "/prerender/mon-entreprise/simulateurs/sasu/index.html" status = 200 [[redirects]] diff --git a/site/package.json b/site/package.json index c3a5a3f2c..fc25fafb3 100644 --- a/site/package.json +++ b/site/package.json @@ -24,12 +24,12 @@ "start": "vite dev", "build": "NODE_OPTIONS='--max-old-space-size=6144'; yarn build:sitemap && vite build && yarn build:iframe-script", "build:ssr": "NODE_OPTIONS='--max-old-space-size=4096'; vite build --ssr ./source/entry-server.tsx --outDir ./dist/server --emptyOutDir && echo '{\"module\": \"commonjs\"}' > dist/package.json", - "build:prerender": "node prerender.cjs", + "build:prerender": "ts-node-esm prerender.ts", "build:iframe-script": "NODE_OPTIONS='--max-old-space-size=4096'; vite build --config vite-iframe-script.config.ts", "build:preview": "VITE_FR_BASE_URL=http://localhost:8888; VITE_EN_BASE_URL=http://localhost:8889; yarn build && yarn build:ssr && yarn build:prerender", "build:sitemap": "ts-node-esm scripts/build-sitemap.ts", - "preview:mon-entreprise": "sed 's|:SITE_FR||g' netlify.toml | sed 's|:API_URL|http://localhost:3004|g' > dist/netlify.toml && cd dist && npx netlify-cli dev -d ./ -p 8888", - "preview:infrance": "sed 's|:SITE_EN||g' | sed 's|:API_URL|http://localhost:3004|g' netlify.toml > dist/netlify.toml && cd dist && npx netlify-cli dev -d ./ -p 8889", + "preview:mon-entreprise": "sed 's|:SITE_EN|_|g' netlify.toml | sed 's|:SITE_FR||g' | sed 's|:API_URL|http://localhost:3004|g' > dist/netlify.toml && cd dist && npx netlify-cli dev -d ./ -p 8888", + "preview:infrance": " sed 's|:SITE_EN||g' netlify.toml | sed 's|:SITE_FR|_|g' | sed 's|:API_URL|http://localhost:3004|g' > dist/netlify.toml && cd dist && npx netlify-cli dev -d ./ -p 8889", "typecheck:watch": "tsc --skipLibCheck --noEmit --watch", "test": "vitest", "test:dev-e2e:mon-entreprise": "cypress open --e2e", diff --git a/site/prerender-worker.js b/site/prerender-worker.js new file mode 100644 index 000000000..26310945e --- /dev/null +++ b/site/prerender-worker.js @@ -0,0 +1,38 @@ +import { render } from './dist/server/entry-server.js' + +import { promises as fs, readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'url' + +const dirname = path.dirname(fileURLToPath(import.meta.url)) + +const cache = {} + +const htmlBodyStart = '' +const htmlBodyEnd = '' +const headTagsStart = '' +const headTagsEnd = '' + +const regexHTML = new RegExp(htmlBodyStart + '[\\s\\S]+' + htmlBodyEnd, 'm') +const regexHelmet = new RegExp(headTagsStart + '[\\s\\S]+' + headTagsEnd, 'm') + +export default async ({ site, url, lang }) => { + // TODO: Add CI test to enforce meta tags on SSR pages + const { html, styleTags, helmet } = render(url, lang) + + const template = + cache[site] ?? + readFileSync(path.join(dirname, `./dist/${site}.html`), 'utf-8') + + cache[site] = template + + const page = template + .replace(regexHTML, html) + .replace('', styleTags) + .replace(regexHelmet, helmet.title.toString() + helmet.meta.toString()) + + const dir = path.join(dirname, 'dist/prerender', site, url) + + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, 'index.html'), page) +} diff --git a/site/prerender.cjs b/site/prerender.cjs deleted file mode 100644 index 9a752e41f..000000000 --- a/site/prerender.cjs +++ /dev/null @@ -1,69 +0,0 @@ -/* eslint-env node */ -// TODO: Move to ESModule. But it was easier to make this script work with -// CommonJS when I wrote it. -// cf. https://github.com/vitejs/vite/blob/133fcea5223263b0ae08ac9a0422b55183ebd266/packages/vite/src/node/build.ts#L495 -// cf. https://github.com/vitejs/vite/pull/2157 -// cf. https://github.com/vitejs/vite/pull/6812 - -// TODO: We could use something like https://github.com/Aslemammad/tinypool to -// prerender all pages in parallel (used by vitest). Or move to SSR with a -// lambda and immutable caching. - -const { readFileSync, promises: fs } = require('fs') -const path = require('path') -const { render } = require('./dist/server/entry-server.js') - -const pagesToPrerender = { - 'mon-entreprise': [ - '/', - '/créer', - '/gérer', - '/simulateurs', - '/simulateurs/salaire-brut-net', - '/simulateurs/chômage-partiel', - '/simulateurs/auto-entrepreneur', - '/simulateurs/indépendant', - '/simulateurs/dirigeant-sasu', - '/simulateurs/artiste-auteur', - '/iframes/simulateur-embauche', - '/iframes/pamc', - ], - infrance: ['/', '/calculators/salary', '/iframes/simulateur-embauche'], -} - -const templates = Object.fromEntries( - Object.keys(pagesToPrerender).map((siteName) => [ - siteName, - readFileSync(path.join(__dirname, `./dist/${siteName}.html`), 'utf-8'), - ]) -) - -;(async function () { - await Promise.all( - Object.entries(pagesToPrerender).flatMap(([site, urls]) => - urls.map((url) => prerenderUrl(url, site)) - ) - ) -})() - -const htmlBodyStart = '' -const htmlBodyEnd = '' -const headTagsStart = '' -const headTagsEnd = '' - -async function prerenderUrl(url, site) { - const lang = site === 'mon-entreprise' ? 'fr' : 'en' - // TODO: Add CI test to enforce meta tags on SSR pages - const { html, styleTags, helmet } = await render(url, lang) - const page = templates[site] - .replace(new RegExp(htmlBodyStart + '[\\s\\S]+' + htmlBodyEnd, 'm'), html) - .replace('', styleTags) - .replace( - new RegExp(headTagsStart + '[\\s\\S]+' + headTagsEnd, 'm'), - helmet.title.toString() + helmet.meta.toString() - ) - - const dir = path.join(__dirname, 'dist/prerender', site, url) - await fs.mkdir(dir, { recursive: true }) - await fs.writeFile(path.join(dir, 'index.html'), page) -} diff --git a/site/prerender.ts b/site/prerender.ts new file mode 100644 index 000000000..9731f1fc0 --- /dev/null +++ b/site/prerender.ts @@ -0,0 +1,53 @@ +import { writeFileSync } from 'node:fs' +import Tinypool from 'tinypool' +import { constructLocalizedSitePath } from './source/sitePaths.js' + +const filename = new URL('./prerender-worker.js', import.meta.url).href +const pool = new Tinypool({ filename }) + +const sitePathFr = constructLocalizedSitePath('fr') +const sitePathEn = constructLocalizedSitePath('en') + +export const pagesToPrerender: { + 'mon-entreprise': string[] + infrance: string[] +} = { + 'mon-entreprise': [ + sitePathFr.index, + sitePathFr.créer.index, + sitePathFr.gérer.index, + sitePathFr.simulateurs.index, + sitePathFr.simulateurs.salarié, + sitePathFr.simulateurs['chômage-partiel'], + sitePathFr.simulateurs['auto-entrepreneur'], + sitePathFr.simulateurs.indépendant, + sitePathFr.simulateurs.sasu, + sitePathFr.simulateurs['artiste-auteur'], + '/iframes/simulateur-embauche', + '/iframes/pamc', + ], + infrance: [ + sitePathEn.index, + sitePathEn.simulateurs.salarié, + '/iframes/simulateur-embauche', + ], +} + +if (process.env.GENERATE_PRERENDER_PATHS_JSON) { + // This json file is used in e2e cypress test + writeFileSync( + 'cypress/prerender-paths.json', + JSON.stringify(pagesToPrerender) + ) + console.log('cypress/prerender-paths.json was generated!') + + process.exit() +} + +await Promise.all( + Object.entries(pagesToPrerender).flatMap(([site, urls]) => + urls.map((url) => + pool.run({ site, url, lang: site === 'mon-entreprise' ? 'fr' : 'en' }) + ) + ) +) diff --git a/site/scripts/preCommit/index.ts b/site/scripts/preCommit/index.ts index d0574abdd..be2dfd042 100644 --- a/site/scripts/preCommit/index.ts +++ b/site/scripts/preCommit/index.ts @@ -13,6 +13,10 @@ const results = await execOnFileChange({ ], run: 'yarn build:yaml-to-dts', }, + { + paths: ['./prerender.ts'], + run: 'GENERATE_PRERENDER_PATHS_JSON=true yarn build:prerender', + }, ], }) diff --git a/site/source/entry-server.tsx b/site/source/entry-server.tsx index 7a53e1178..343fba9be 100644 --- a/site/source/entry-server.tsx +++ b/site/source/entry-server.tsx @@ -16,7 +16,7 @@ export function render(url: string, lang: 'fr' | 'en') { console.error(err) ) - const html = ReactDOMServer.renderToString( + const element = ( @@ -28,6 +28,11 @@ export function render(url: string, lang: 'fr' | 'en') { ) + // first render + ReactDOMServer.renderToString(element) + // second render with the configured engine + const html = ReactDOMServer.renderToString(element) + const styleTags = sheet.getStyleTags() return { html, styleTags, helmet: helmetContext.helmet } diff --git a/site/source/sitePaths.ts b/site/source/sitePaths.ts index d5f59349d..c364c3244 100644 --- a/site/source/sitePaths.ts +++ b/site/source/sitePaths.ts @@ -200,38 +200,47 @@ const checkedSitePathsFr: RequiredPath & typeof rawSitePathsFr = rawSitePathsFr // If there is a type error here, check rawSitePathsEn object matches the metadata-src.ts pathId const checkedSitePathsEn: RequiredPath & typeof rawSitePathsEn = rawSitePathsEn -type SitePathObject = { - [K in keyof T]: T[K] extends string ? string : SitePathObject -} & { - index: string +type SitePathsFr = typeof checkedSitePathsFr +type SitePathsEn = typeof checkedSitePathsEn + +type SitePath = { [key: string]: string | SitePath } & { index: string } + +type SitePathBuilt = { + [K in keyof T]: T[K] extends string + ? K extends 'index' + ? `${Root}${T[K]}` + : T extends { index: string } + ? `${Root}${T['index']}${T[K]}` + : `${Root}${T[K]}` + : SitePathBuilt< + T[K] extends SitePath ? T[K] : never, + T extends { index: string } ? `${Root}${T['index']}` : `${Root}` + > } -function constructSitePaths( - root: string, - { index, ...sitePaths }: SitePathObject -): SitePathObject { - const entries = Object.entries(sitePaths) as [ - string, - string | SitePathObject - ][] +function constructSitePaths( + obj: SitePath, + root = '' +): SitePathBuilt { + const { index } = obj + const entries = Object.entries(obj) - return { - index: root + index, - ...Object.fromEntries( - entries.map(([k, value]) => [ - k, - typeof value === 'string' - ? root + index + value - : constructSitePaths(root + index, value), - ]) - ), - } as SitePathObject + return Object.fromEntries( + entries.map(([k, value]) => [ + k, + typeof value === 'string' + ? root + (k === 'index' ? value : index + value) + : constructSitePaths(value, root + index), + ]) + ) as SitePathBuilt } -export const constructLocalizedSitePath = (language: 'en' | 'fr') => { - const sitePaths = language === 'fr' ? checkedSitePathsFr : checkedSitePathsEn - - return constructSitePaths('', sitePaths) +export const constructLocalizedSitePath = ( + language: T +) => { + return constructSitePaths( + language === 'fr' ? checkedSitePathsFr : checkedSitePathsEn + ) } export type SitePathsType = ReturnType diff --git a/site/tsconfig.json b/site/tsconfig.json index 3c215f2e9..baa0540cc 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -27,6 +27,7 @@ "scripts", "test/**/*.ts", "vite.config.ts", - "vite-iframe-script.config.ts" + "vite-iframe-script.config.ts", + "prerender.ts" ] }