From 008a5e49757eb139bd69ec655e9b6717bbbbe935 Mon Sep 17 00:00:00 2001 From: BatLeDev Date: Thu, 11 Jun 2026 10:26:45 +0200 Subject: [PATCH] feat: site resource preloads via theme schema and SPA injection - add a generic `preloadLinks` list ({ href, as?, type?, crossorigin? }) to the theme schema and the self-hosted Nunito default to defaultTheme. - serve-spa exposes the preload `` tags (built from the sites _hashes payload) as a `{PRELOAD_LINKS}` template variable; services opt in by adding the placeholder to their HTML before the theme stylesheet. --- packages/common-types/theme/index.ts | 1 + packages/common-types/theme/schema.ts | 25 +++++++++++++++++++++++++ packages/express/serve-spa.ts | 27 ++++++++++++++++++++++----- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/common-types/theme/index.ts b/packages/common-types/theme/index.ts index b64abce..58e08b9 100644 --- a/packages/common-types/theme/index.ts +++ b/packages/common-types/theme/index.ts @@ -11,6 +11,7 @@ export const defaultTheme = { bodyFontFamilyCss: "@font-face{font-family:Nunito;font-style:italic;font-weight:200 1000;font-display:swap;src:url({SITE_PATH}/simple-directory/fonts/XRXX3I6Li01BKofIMNaORs71cA-Bm_i0Dk1.woff2) format('woff2');unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Nunito;font-style:italic;font-weight:200 1000;font-display:swap;src:url({SITE_PATH}/simple-directory/fonts/XRXX3I6Li01BKofIMNaHRs71cA-Cznx39fA.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Nunito;font-style:italic;font-weight:200 1000;font-display:swap;src:url({SITE_PATH}/simple-directory/fonts/XRXX3I6Li01BKofIMNaMRs71cA-CuWrHpFO.woff2) format('woff2');unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Nunito;font-style:italic;font-weight:200 1000;font-display:swap;src:url({SITE_PATH}/simple-directory/fonts/XRXX3I6Li01BKofIMNaNRs71cA-D1eeM49Z.woff2) format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Nunito;font-style:italic;font-weight:200 1000;font-display:swap;src:url({SITE_PATH}/simple-directory/fonts/XRXX3I6Li01BKofIMNaDRs4-BbMn9XSX.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Nunito;font-style:normal;font-weight:200 1000;font-display:swap;src:url({SITE_PATH}/simple-directory/fonts/XRXV3I6Li01BKofIOOaBXso-BWI5zH9R.woff2) format('woff2');unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Nunito;font-style:normal;font-weight:200 1000;font-display:swap;src:url({SITE_PATH}/simple-directory/fonts/XRXV3I6Li01BKofIMeaBXso-C3IBG1kp.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Nunito;font-style:normal;font-weight:200 1000;font-display:swap;src:url({SITE_PATH}/simple-directory/fonts/XRXV3I6Li01BKofIOuaBXso-B55YuedR.woff2) format('woff2');unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Nunito;font-style:normal;font-weight:200 1000;font-display:swap;src:url({SITE_PATH}/simple-directory/fonts/XRXV3I6Li01BKofIO-aBXso-DcJfvmGA.woff2) format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Nunito;font-style:normal;font-weight:200 1000;font-display:swap;src:url({SITE_PATH}/simple-directory/fonts/XRXV3I6Li01BKofINeaB-BaTF6Vo7.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}", headingFontFamily: undefined, headingFontFamilyCss: undefined, + preloadLinks: [{ href: '/simple-directory/fonts/XRXV3I6Li01BKofINeaB-BaTF6Vo7.woff2', as: 'font', type: 'font/woff2', crossorigin: true }], colors: { // standard vuetify colors, see https://vuetifyjs.com/en/styles/colors/#material-colors background: '#FAFAFA', // grey-lighten-5 diff --git a/packages/common-types/theme/schema.ts b/packages/common-types/theme/schema.ts index fea8472..6bc3bbc 100644 --- a/packages/common-types/theme/schema.ts +++ b/packages/common-types/theme/schema.ts @@ -241,6 +241,31 @@ export default { }, type: 'string' }, + preloadLinks: { + title: 'Liens de préchargement', + 'x-i18n-title': { + fr: 'Liens de préchargement', + en: 'Preload links', + es: 'Enlaces de precarga', + it: 'Collegamenti di precaricamento', + pt: 'Links de pré-carregamento', + de: 'Vorlade-Links' + }, + description: 'Liste des ressources (polices, etc.) à précharger via des balises . Généralement renseignée automatiquement.', + type: 'array', + items: { + type: 'object', + additionalProperties: true, + required: ['href'], + properties: { + href: { type: 'string', title: 'URL' }, + as: { type: 'string', title: 'Type de ressource (attribut as)' }, + type: { type: 'string', title: 'Type MIME' }, + crossorigin: { type: 'boolean', title: 'Requête CORS (crossorigin)' } + } + }, + layout: 'none' + }, assistedMode: { type: 'boolean', title: 'Mode de gestion des couleurs simplifié', diff --git a/packages/express/serve-spa.ts b/packages/express/serve-spa.ts index 2793a2b..9ef4ef6 100644 --- a/packages/express/serve-spa.ts +++ b/packages/express/serve-spa.ts @@ -8,6 +8,7 @@ import { static as expressStatic, type Request } from 'express' import { reqSitePath, reqSiteUrl, reqOrigin } from '@data-fair/lib-express' import serialize from 'serialize-javascript' import axios from '@data-fair/lib-node/axios.js' +import type { Theme } from '@data-fair/lib-common-types/theme/index.js' type CSPDirectives = Record type CSPHeader = string | CSPDirectives | boolean @@ -49,6 +50,23 @@ export function getCSPHeader (cspHeader: CSPHeader, nonce?: boolean) { else if (typeof cspHeader === 'object') return getCSPHeaderFromDirectives(cspHeader) } +// the _hashes payload returns the theme's preload links verbatim +type PreloadLink = NonNullable[number] + +// escape values interpolated into HTML attributes — the href comes from +// site-configured theme data, so it must not be able to break out of the attribute +const escapeHtmlAttr = (value: string): string => + value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') + +function buildPreloadLinks (links?: PreloadLink[]): string { + return (links ?? []).map(l => { + const as = l.as ? ` as="${escapeHtmlAttr(l.as)}"` : '' + const type = l.type ? ` type="${escapeHtmlAttr(l.type)}"` : '' + const crossorigin = l.crossorigin ? ' crossorigin' : '' + return `` + }).join('') +} + type ServeSpaOptions = { ignoreSitePath?: boolean, privateDirectoryUrl?: string, @@ -78,8 +96,7 @@ async function createHtmlMiddleware (directory: string, baseParams: Record, ts: number }> = {} const minuteMS = 1000 * 60 @@ -167,13 +184,13 @@ const getSiteHashes = async (privateDirectoryUrl: string, siteUrl: string) => { const now = new Date().getTime() if (!cache[siteUrl] || cache[siteUrl].ts < (now - minuteMS)) { const url = new URL(siteUrl) - const hashes = axios.get<{ publicInfo: string, themeCss: string }>(privateDirectoryUrl + '/simple-directory/api/sites/_hashes', { + const hashes = axios.get<{ publicInfo: string, themeCss: string, preloadLinks?: PreloadLink[] }>(privateDirectoryUrl + '/simple-directory/api/sites/_hashes', { headers: { 'x-forwarded-proto': url.protocol.slice(0, -1), 'x-forwarded-host': url.hostname, 'x-forwarded-port': url.port } - }).then(r => ({ THEME_CSS_HASH: r.data.themeCss + '/', PUBLIC_SITE_INFO_HASH: r.data.publicInfo + '/' })) + }).then(r => ({ THEME_CSS_HASH: r.data.themeCss + '/', PUBLIC_SITE_INFO_HASH: r.data.publicInfo + '/', PRELOAD_LINKS: buildPreloadLinks(r.data.preloadLinks) })) cache[siteUrl] = { ts: now, hashes } } return cache[siteUrl].hashes