diff --git a/README.md b/README.md
index 90d23f2b7..7f19657c5 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,8 @@ A few projects that use `@codegouvfr/react-dsfr`.
- https://code.gouv.fr/sill
- https://immersion-facile.beta.gouv.fr/
+- https://egapro.travail.gouv.fr/
+- https://maisondelautisme.gouv.fr/
- https://refugies.info/fr
- https://www.mediateur-public.fr/
- https://signal.conso.gouv.fr/
diff --git a/package.json b/package.json
index 7a30dd92a..031dc9f2a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@codegouvfr/react-dsfr",
- "version": "0.76.4",
+ "version": "0.77.0-rc.1",
"description": "French State Design System React integration library",
"repository": {
"type": "git",
@@ -97,7 +97,7 @@
"husky": "^4.3.8",
"lint-staged": "^11.0.0",
"memoizee": "^0.4.15",
- "next": "13.4.4",
+ "next": "13.5.1",
"oppa": "^0.4.0",
"parse-numeric-range": "^1.3.0",
"powerhooks": "^0.22.0",
@@ -107,7 +107,7 @@
"remixicon": "^3.2.0",
"storybook-dark-mode": "^1.1.2",
"ts-node": "^10.9.1",
- "tss-react": "^4.9.0",
+ "tss-react": "^4.9.1",
"type-route": "^1.0.1",
"typescript": "^4.9.1",
"vitest": "^0.24.3"
diff --git a/src/next-appdir/DsfrHead.tsx b/src/next-appdir/DsfrHead.tsx
index 293663d1d..bed5e6467 100644
--- a/src/next-appdir/DsfrHead.tsx
+++ b/src/next-appdir/DsfrHead.tsx
@@ -8,10 +8,13 @@ import { getScriptToRunAsap } from "../useIsDark/scriptToRunAsap";
import { fontUrlByFileBasename } from "./zz_internal/fontUrlByFileBasename";
import { getDefaultColorSchemeServerSide } from "./zz_internal/defaultColorScheme";
import { setLink, type RegisteredLinkProps } from "../link";
+import { assert } from "tsafe/assert";
//NOTE: As of now there is no way to enforce ordering in Next Appdir
//See: https://github.com/vercel/next.js/issues/16630
// @import url(...) doesn't work. Using Sass and @use is our last resort.
import "../assets/dsfr_plus_icons.scss";
+// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in doc
+import { type startReactDsfr } from "./zz_internal/start";
export type DsfrHeadProps = {
/** If not provided no fonts are preloaded.
@@ -20,12 +23,32 @@ export type DsfrHeadProps = {
preloadFonts?: (keyof typeof fontUrlByFileBasename)[];
/** Default: */
Link?: (props: RegisteredLinkProps & { children: ReactNode }) => ReturnType;
+ /**
+ * When set, the value will be used as the nonce attribute of subsequent script tags.
+ *
+ * Don't forget to add `doCheckNonce: true` in {@link startReactDsfr} options.
+ *
+ * @see https://developer.mozilla.org/fr/docs/Web/HTML/Global_attributes/nonce
+ */
+ nonce?: string;
+ /**
+ * Enable Trusted Types with a custom policy name.
+ *
+ * Don't forget to add `trustedTypesPolicyName` in {@link startReactDsfr} options.
+ *
+ * @see https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types
+ * @see {@link DEFAULT_TRUSTED_TYPES_POLICY_NAME}
+ * @default "react-dsfr"
+ */
+ trustedTypesPolicyName?: string;
};
const isProduction = process.env.NODE_ENV !== "development";
export function DsfrHead(props: DsfrHeadProps) {
- const { preloadFonts = [], Link } = props;
+ const { preloadFonts = [], Link, nonce, trustedTypesPolicyName = "react-dsfr" } = props;
+
+ assert(nonce !== "", "nonce cannot be an empty string");
const defaultColorScheme = getDefaultColorSchemeServerSide();
@@ -53,9 +76,25 @@ export function DsfrHead(props: DsfrHeadProps) {
- {isProduction && (
+
+ {nonce !== undefined && (
)}
>
diff --git a/src/next-appdir/zz_internal/start.ts b/src/next-appdir/zz_internal/start.ts
index aa2f19c1a..dce440288 100644
--- a/src/next-appdir/zz_internal/start.ts
+++ b/src/next-appdir/zz_internal/start.ts
@@ -4,6 +4,8 @@ import type { RegisteredLinkProps } from "../../link";
import { setLink } from "../../link";
import { type DefaultColorScheme, setDefaultColorSchemeClientSide } from "./defaultColorScheme";
import { isBrowser } from "../../tools/isBrowser";
+// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in doc
+import { type DsfrHead } from "../DsfrHead";
let isAfterFirstEffect = false;
const actions: (() => void)[] = [];
@@ -14,8 +16,41 @@ export function startReactDsfr(params: {
verbose?: boolean;
/** Default: */
Link?: (props: RegisteredLinkProps & { children: ReactNode }) => ReturnType;
+ /**
+ * When true, the nonce of the script tag will be checked, fetched from {@link DsfrHead} component and injected in react-dsfr scripts.
+ *
+ * @see https://developer.mozilla.org/fr/docs/Web/HTML/Global_attributes/nonce
+ * @default false
+ */
+ doCheckNonce?: boolean;
+ /**
+ * Enable Trusted Types with a custom policy name.
+ *
+ * Don't forget to also add the policy name in {@link DsfrHead} component.
+ *
+ * `` and `-asap` should be set in your Content-Security-Policy header.
+ *
+ * For example:
+ * ```txt
+ * With a policy name of "react-dsfr":
+ * Content-Security-Policy:
+ * require-trusted-types-for 'script';
+ * trusted-types react-dsfr react-dsfr-asap nextjs nextjs#bundler;
+ * ```
+ *
+ * @see https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types
+ * @see {@link DEFAULT_TRUSTED_TYPES_POLICY_NAME}
+ * @default "react-dsfr"
+ */
+ trustedTypesPolicyName?: string;
}) {
- const { defaultColorScheme, verbose = false, Link } = params;
+ const {
+ defaultColorScheme,
+ verbose = false,
+ Link,
+ doCheckNonce = false,
+ trustedTypesPolicyName = "react-dsfr"
+ } = params;
setDefaultColorSchemeClientSide({ defaultColorScheme });
@@ -27,6 +62,8 @@ export function startReactDsfr(params: {
start({
defaultColorScheme,
verbose,
+ doCheckNonce,
+ trustedTypesPolicyName,
"nextParams": {
"doPersistDarkModePreferenceWithCookie": false,
"registerEffectAction": action => {
diff --git a/src/next-pagesdir.tsx b/src/next-pagesdir.tsx
index 210f6fe63..4290e7d0c 100644
--- a/src/next-pagesdir.tsx
+++ b/src/next-pagesdir.tsx
@@ -41,6 +41,23 @@ export type CreateNextDsfrIntegrationApiParams = {
doPersistDarkModePreferenceWithCookie?: boolean;
/** Default: ()=> "fr" */
useLang?: () => string;
+ /**
+ * Enable Trusted Types with a custom policy name.
+ *
+ * `` and `-asap` should be set in your Content-Security-Policy header.
+ *
+ * For example:
+ * ```txt
+ * With a policy name of "react-dsfr":
+ * Content-Security-Policy:
+ * require-trusted-types-for 'script';
+ * trusted-types react-dsfr react-dsfr-asap nextjs nextjs#bundler;
+ * ```
+ *
+ * @see https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types
+ * @default "react-dsfr"
+ */
+ trustedTypesPolicyName?: string;
};
function readIsDarkInCookie(cookie: string) {
@@ -88,7 +105,8 @@ export function createNextDsfrIntegrationApi(
Link,
preloadFonts = [],
doPersistDarkModePreferenceWithCookie = false,
- useLang
+ useLang,
+ trustedTypesPolicyName = "react-dsfr"
} = params;
let isAfterFirstEffect = false;
@@ -106,6 +124,8 @@ export function createNextDsfrIntegrationApi(
start({
defaultColorScheme,
verbose,
+ "doCheckNonce": false,
+ trustedTypesPolicyName,
"nextParams": {
doPersistDarkModePreferenceWithCookie,
"registerEffectAction": action => {
@@ -177,10 +197,14 @@ export function createNextDsfrIntegrationApi(
/>
>
)}
- {isProduction && (
+ {isProduction && !isBrowser && (
)}
diff --git a/src/spa.ts b/src/spa.ts
index 2b25d6f33..629f9edc9 100644
--- a/src/spa.ts
+++ b/src/spa.ts
@@ -4,6 +4,7 @@ import type { RegisterLink, RegisteredLinkProps } from "./link";
import { setLink } from "./link";
import { setUseLang } from "./i18n";
import type { ColorScheme } from "./useIsDark";
+import { assert } from "tsafe/assert";
export type { RegisterLink, RegisteredLinkProps };
@@ -15,8 +16,39 @@ export function startReactDsfr(params: {
Link?: (props: RegisteredLinkProps & { children: ReactNode }) => ReturnType;
/** Default: ()=> "fr" */
useLang?: () => string;
+ /**
+ * When set, the value will be used as the nonce attribute of subsequent script tags.
+ *
+ * @see https://developer.mozilla.org/fr/docs/Web/HTML/Global_attributes/nonce
+ */
+ nonce?: string;
+ /**
+ * Enable Trusted Types with a custom policy name.
+ *
+ * `` and `-asap` should be set in your Content-Security-Policy header.
+ *
+ * For example:
+ * ```txt
+ * With a policy name of "react-dsfr":
+ * Content-Security-Policy:
+ * require-trusted-types-for 'script';
+ * trusted-types react-dsfr react-dsfr-asap nextjs nextjs#bundler;
+ * ```
+ *
+ * @see https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types
+ * @see {@link DEFAULT_TRUSTED_TYPES_POLICY_NAME}
+ * @default "react-dsfr"
+ */
+ trustedTypesPolicyName?: string;
}) {
- const { defaultColorScheme, verbose = false, Link, useLang } = params;
+ const {
+ defaultColorScheme,
+ verbose = false,
+ Link,
+ useLang,
+ nonce,
+ trustedTypesPolicyName = "react-dsfr"
+ } = params;
if (Link !== undefined) {
setLink({ Link });
@@ -26,9 +58,18 @@ export function startReactDsfr(params: {
setUseLang({ useLang });
}
+ assert(nonce !== "", "nonce cannot be an empty string");
+
+ const doCheckNonce = nonce !== undefined;
+ if (doCheckNonce) {
+ window.ssrNonce = nonce;
+ }
+
start({
defaultColorScheme,
verbose,
+ doCheckNonce,
+ trustedTypesPolicyName,
"nextParams": undefined
});
}
diff --git a/src/start.ts b/src/start.ts
index 5ad7c5c7e..2da4325bc 100644
--- a/src/start.ts
+++ b/src/start.ts
@@ -13,12 +13,15 @@ type Params = {
registerEffectAction: (effect: () => void) => void;
}
| undefined;
+ doCheckNonce: boolean;
+ trustedTypesPolicyName: string;
};
let isStarted = false;
export async function start(params: Params) {
- const { defaultColorScheme, verbose, nextParams } = params;
+ const { defaultColorScheme, verbose, nextParams, doCheckNonce, trustedTypesPolicyName } =
+ params;
assert(isBrowser);
@@ -35,7 +38,9 @@ export async function start(params: Params) {
"colorSchemeExplicitlyProvidedAsParameter": defaultColorScheme,
"doPersistDarkModePreferenceWithCookie":
nextParams === undefined ? false : nextParams.doPersistDarkModePreferenceWithCookie,
- registerEffectAction
+ registerEffectAction,
+ doCheckNonce,
+ trustedTypesPolicyName
});
// @ts-expect-error
diff --git a/src/useIsDark/client.ts b/src/useIsDark/client.ts
index 69a74c196..2228e3ef8 100644
--- a/src/useIsDark/client.ts
+++ b/src/useIsDark/client.ts
@@ -98,11 +98,15 @@ export function startClientSideIsDarkLogic(params: {
registerEffectAction: (action: () => void) => void;
doPersistDarkModePreferenceWithCookie: boolean;
colorSchemeExplicitlyProvidedAsParameter: ColorScheme | "system";
+ doCheckNonce: boolean;
+ trustedTypesPolicyName: string;
}) {
const {
doPersistDarkModePreferenceWithCookie,
registerEffectAction,
- colorSchemeExplicitlyProvidedAsParameter
+ colorSchemeExplicitlyProvidedAsParameter,
+ doCheckNonce = false,
+ trustedTypesPolicyName
} = params;
const { clientSideIsDark, ssrWasPerformedWithIsDark: ssrWasPerformedWithIsDark_ } = ((): {
@@ -115,8 +119,7 @@ export function startClientSideIsDarkLogic(params: {
return {
"clientSideIsDark": isDarkFromHtmlAttribute,
"ssrWasPerformedWithIsDark":
- ((window as any).ssrWasPerformedWithIsDark as boolean | undefined) ??
- isDarkFromHtmlAttribute
+ window.ssrWasPerformedWithIsDark ?? isDarkFromHtmlAttribute
};
}
@@ -174,6 +177,14 @@ export function startClientSideIsDarkLogic(params: {
ssrWasPerformedWithIsDark = ssrWasPerformedWithIsDark_;
+ const trustedTypes = (window as any).trustedTypes;
+ const sanitizer =
+ typeof trustedTypes !== "undefined"
+ ? trustedTypes.createPolicy(trustedTypesPolicyName, { createHTML: (s: string) => s })
+ : {
+ createHTML: (s: string) => s
+ };
+
$clientSideIsDark.current = clientSideIsDark;
[data_fr_scheme, data_fr_theme].forEach(attr =>
@@ -222,13 +233,23 @@ export function startClientSideIsDarkLogic(params: {
{
const setRootColorScheme = (isDark: boolean) => {
+ const nonce = window.ssrNonce;
+ if (doCheckNonce && !nonce) {
+ return;
+ }
document.getElementById(rootColorSchemeStyleTagId)?.remove();
const element = document.createElement("style");
element.id = rootColorSchemeStyleTagId;
- element.innerHTML = `:root { color-scheme: ${isDark ? "dark" : "light"}; }`;
+ if (nonce) {
+ element.setAttribute("nonce", nonce);
+ }
+
+ element.innerHTML = sanitizer.createHTML(
+ `:root { color-scheme: ${isDark ? "dark" : "light"}; }`
+ );
document.head.appendChild(element);
};
diff --git a/src/useIsDark/scriptToRunAsap.ts b/src/useIsDark/scriptToRunAsap.ts
index eb795d5af..37f69603b 100644
--- a/src/useIsDark/scriptToRunAsap.ts
+++ b/src/useIsDark/scriptToRunAsap.ts
@@ -2,10 +2,31 @@ import type { ColorScheme } from "./client";
import { data_fr_scheme, data_fr_theme, rootColorSchemeStyleTagId } from "./constants";
import { fr } from "../fr";
-export const getScriptToRunAsap = (defaultColorScheme: ColorScheme | "system") => `
+type GetScriptToRunAsap = (props: {
+ defaultColorScheme: ColorScheme | "system";
+ nonce: string | undefined;
+ trustedTypesPolicyName: string;
+}) => string;
+
+declare global {
+ interface Window {
+ ssrWasPerformedWithIsDark?: boolean;
+ ssrNonce?: string;
+ }
+}
+
+// TODO enhance to use DOMPurify with trustedTypes
+export const getScriptToRunAsap: GetScriptToRunAsap = ({
+ defaultColorScheme,
+ nonce = "",
+ trustedTypesPolicyName
+}) => `
{
window.ssrWasPerformedWithIsDark = "${defaultColorScheme}" === "dark";
+ const sanitizer = typeof trustedTypes !== "undefined" ? trustedTypes.createPolicy("${trustedTypesPolicyName}-asap", { createHTML: s => s }) : {
+ createHTML: s => s,
+ };
const isDark = (() => {
@@ -70,9 +91,13 @@ export const getScriptToRunAsap = (defaultColorScheme: ColorScheme | "system") =
element = document.createElement("style");
+ if ("${nonce}" !== "") {
+ element.setAttribute("nonce", "${nonce}");
+ }
+
element.id = "${rootColorSchemeStyleTagId}";
- element.innerHTML = \`:root { color-scheme: \${isDark ? "dark" : "light"}; }\`;
+ element.innerHTML = sanitizer.createHTML(\`:root { color-scheme: \${isDark ? "dark" : "light"}; }\`);
document.head.appendChild(element);
diff --git a/test/integration/cra/package.json b/test/integration/cra/package.json
index 0eab2530b..d2880a8ce 100644
--- a/test/integration/cra/package.json
+++ b/test/integration/cra/package.json
@@ -8,7 +8,6 @@
"react-scripts": "5.0.1",
"type-route": "^0.7.2",
"@mui/material": "^5.13.3",
- "tss-react": "^4.8.6",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.10.16",
@@ -50,4 +49,4 @@
"last 1 safari version"
]
}
-}
+}
\ No newline at end of file
diff --git a/test/integration/cra/public/index.html b/test/integration/cra/public/index.html
index 3e9c6d027..d68fe7b7a 100644
--- a/test/integration/cra/public/index.html
+++ b/test/integration/cra/public/index.html
@@ -5,6 +5,8 @@
+
diff --git a/test/integration/cra/yarn.lock b/test/integration/cra/yarn.lock
index 1dac73480..6ef6a2e1d 100644
--- a/test/integration/cra/yarn.lock
+++ b/test/integration/cra/yarn.lock
@@ -1103,7 +1103,7 @@
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@codegouvfr/react-dsfr@file:../../../dist":
- version "0.58.9"
+ version "0.75.8"
dependencies:
tsafe "^1.6.3"
@@ -1268,7 +1268,7 @@
source-map "^0.5.7"
stylis "4.2.0"
-"@emotion/cache@*", "@emotion/cache@^11.11.0":
+"@emotion/cache@^11.11.0":
version "11.11.0"
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff"
integrity sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==
@@ -1310,7 +1310,7 @@
"@emotion/weak-memoize" "^0.3.1"
hoist-non-react-statics "^3.3.1"
-"@emotion/serialize@*", "@emotion/serialize@^1.1.2":
+"@emotion/serialize@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.1.2.tgz#017a6e4c9b8a803bd576ff3d52a0ea6fa5a62b51"
integrity sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==
@@ -1348,7 +1348,7 @@
resolved "https://registry.yarnpkg.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz#08de79f54eb3406f9daaf77c76e35313da963963"
integrity sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==
-"@emotion/utils@*", "@emotion/utils@^1.2.1":
+"@emotion/utils@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.1.tgz#bbab58465738d31ae4cb3dbb6fc00a5991f755e4"
integrity sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==
@@ -8540,15 +8540,6 @@ tslib@^2.0.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e"
integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==
-tss-react@^4.8.6:
- version "4.8.6"
- resolved "https://registry.yarnpkg.com/tss-react/-/tss-react-4.8.6.tgz#c1d9ede44474e3119bb21ef6ef7ae4057cbee751"
- integrity sha512-+ucvy+SLFUUxd3zA3QS9Q7bo5FerR8VIUOHieyvYYMoBqtpVinnOA0aTOSXcSdl4lqjFc/9gNA5x0B5iIWk7hA==
- dependencies:
- "@emotion/cache" "*"
- "@emotion/serialize" "*"
- "@emotion/utils" "*"
-
tsutils@^3.21.0:
version "3.21.0"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
diff --git a/test/integration/next-appdir/app/StartDsfr.tsx b/test/integration/next-appdir/app/StartDsfr.tsx
index e33383f67..f1bf45b96 100644
--- a/test/integration/next-appdir/app/StartDsfr.tsx
+++ b/test/integration/next-appdir/app/StartDsfr.tsx
@@ -13,7 +13,8 @@ declare module "@codegouvfr/react-dsfr/next-appdir" {
startReactDsfr({
defaultColorScheme,
- Link
+ Link,
+ "doCheckNonce": true
});
export function StartDsfr(){
diff --git a/test/integration/next-appdir/app/layout.tsx b/test/integration/next-appdir/app/layout.tsx
index d81a4a5f2..4e3318458 100644
--- a/test/integration/next-appdir/app/layout.tsx
+++ b/test/integration/next-appdir/app/layout.tsx
@@ -14,9 +14,18 @@ import Link from "next/link";
import { ConsentBannerAndConsentManagement, FooterConsentManagementItem, FooterPersonalDataPolicyItem } from "./consentManagement";
import { ClientFooterItem } from "../ui/ClientFooterItem";
import { ClientHeaderQuickAccessItem } from "../ui/ClientHeaderQuickAccessItem";
+import { headers } from "next/headers";
+import { getScriptNonceFromHeader } from "next/dist/server/app-render/get-script-nonce-from-header"; // or use your own implementation
+import style from "./main.module.css";
+import { cx } from '@codegouvfr/react-dsfr/tools/cx';
export default function RootLayout({ children }: { children: JSX.Element; }) {
+ const csp = headers().get("Content-Security-Policy");
+ let nonce: string | undefined;
+ if (csp) {
+ nonce = getScriptNonceFromHeader(csp);
+ }
//NOTE: If we had i18n setup we would get lang from the props.
//See https://github.com/vercel/next.js/blob/canary/examples/app-dir-i18n-routing/app/%5Blang%5D/layout.tsx
@@ -41,18 +50,13 @@ export default function RootLayout({ children }: { children: JSX.Element; }) {
//"Spectral-Regular",
//"Spectral-ExtraBold"
]}
+ nonce={nonce}
/>
-
+
-
+
INTITULE
OFFICIEL>}
@@ -74,14 +78,7 @@ export default function RootLayout({ children }: { children: JSX.Element; }) {
]}
navigation={}
/>
-
+
{children}