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