diff --git a/README.md b/README.md index 2b1efc6..23ac03e 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,6 @@ All your theme configuration is passed to ThemeProvider. - accepts `class` and `data-*` (meaning any data attribute, `data-mode`, `data-color`, etc.) ([example](#class-instead-of-data-attribute)). - `value`: Optional mapping of theme name to attribute value. - value is an `object` where key is the theme name (eg. `'dark'` or `'light'`) and value is the attribute value (eg. `'my-dark-theme'`) ([example](#differing-dom-attribute-and-theme-name)). -- `nonce`: Optional nonce passed to the injected `script` tag, used to allow-list the next-themes script in your CSP. - `scriptProps`: Optional props to pass to the injected `script` tag ([example](#using-with-cloudflare-rocket-loader)). > [!NOTE] @@ -352,6 +351,72 @@ Done! `system`, `dark`, and `light` will all work as expected. If you need to support custom color schemes, you can define your own `@custom-variant` rules to match `data-theme=`. +### CSP + +If you are using a Content Security Policy (CSP) without allowing `unsafe-inline` scripts, some extra setup is needed. Because the ThemeProvider component doesn’t have access to the nonce generated by SvelteKit, it cannot inject the required script automatically. However, SvelteKit does augment scripts in `app.html` with a nonce [automatically](https://svelte.dev/docs/kit/configuration#csp). We can use this to our advantage by manually injecting the script tag in `app.html` and replacing it with the actual script in `hooks.server.ts`. + +See the [csp example](./examples/csp/) for a SvelteKit app with a CSP. + +Follow these steps to set it up: + +#### Create a config +Create a config with your desired options, e.g. `lib/theme-config.ts`. You must define all options, even if you are using the default values, e.g.: + +```ts +import type { ResolvedConfig } from '@sejohnson/svelte-themes'; +export const themeConfig: ResolvedConfig = { + attribute: 'data-theme', + storageKey: 'theme', + enableColorScheme: true, + disableTransitionOnChange: false, + defaultTheme: 'light', + enableSystem: true, + themes: ['light', 'dark', 'system'], +}; +``` + +#### ThemeProvider +Pass the config to the `ThemeProvider` in your `+layout.svelte`. Also set `disableScriptInjection` to `true` so it doesn't inject the script tag. + +```svelte + + + + {@render children?.()} + +``` + +#### app.html +In your `app.html`, add a placeholder for the script tag. Do this just above `%sveltekit.head%`. +```html + +``` + +#### hook.server.ts +In `src/hooks.server.ts`, add the following code to replace the placeholder with the actual script: + +```ts +import type { Handle } from '@sveltejs/kit'; +import { scriptAsString } from '@sejohnson/svelte-themes'; +import { themeConfig } from '$lib/theme-config'; + +export const handle = (async ({ event, resolve }) => { + return resolve(event, { + transformPageChunk: ({ html }) => { + return html.replace('//svelte-themes.script//', scriptAsString(themeConfig)); + } + }); +}) satisfies Handle; +``` + +Voila! You now have a working CSP setup with svelte-themes. + ## Discussion ### The Flash diff --git a/examples/csp/README.md b/examples/csp/README.md new file mode 100644 index 0000000..0b4c2f1 --- /dev/null +++ b/examples/csp/README.md @@ -0,0 +1,5 @@ +# CSP example + +> An example on how to use `svelte-themes` with CSP enabled in SvelteKit. + +Based on the multi-theme example. diff --git a/examples/csp/package.json b/examples/csp/package.json new file mode 100644 index 0000000..966707b --- /dev/null +++ b/examples/csp/package.json @@ -0,0 +1,26 @@ +{ + "name": "examples-csp", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sejohnson/svelte-themes": "workspace:*", + "@sveltejs/adapter-vercel": "^5.5.2", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^6.0.0" + } +} diff --git a/examples/csp/src/app.css b/examples/csp/src/app.css new file mode 100644 index 0000000..876eddd --- /dev/null +++ b/examples/csp/src/app.css @@ -0,0 +1,35 @@ +@import 'tailwindcss'; + +@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); + +@layer base { + :root { + --primary: #1e293b; + --primary-foreground: #f8fafc; + } + + html[data-theme='dark-classic'] { + --primary: #cbd5e1; + --primary-foreground: #0f172a; + } + + html[data-theme='tangerine'] { + --primary: #fcd34d; + --primary-foreground: #0f172a; + } + + html[data-theme='dark-tangerine'] { + --primary: #d97706; + --primary-foreground: #0f172a; + } + + html[data-theme='mint'] { + --primary: #6ee7b7; + --primary-foreground: #0f172a; + } + + html[data-theme='dark-mint'] { + --primary: #047857; + --primary-foreground: #f8fafc; + } +} diff --git a/examples/csp/src/app.d.ts b/examples/csp/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/examples/csp/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/examples/csp/src/app.html b/examples/csp/src/app.html new file mode 100644 index 0000000..1457685 --- /dev/null +++ b/examples/csp/src/app.html @@ -0,0 +1,15 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/examples/csp/src/hooks.server.ts b/examples/csp/src/hooks.server.ts new file mode 100644 index 0000000..a509d0c --- /dev/null +++ b/examples/csp/src/hooks.server.ts @@ -0,0 +1,11 @@ +import type { Handle } from '@sveltejs/kit'; +import { scriptAsString } from '@sejohnson/svelte-themes'; +import { themeConfig } from '$lib/theme-config'; + +export const handle = (async ({ event, resolve }) => { + return resolve(event, { + transformPageChunk: ({ html }) => { + return html.replace('//svelte-themes.script//', scriptAsString(themeConfig)); + } + }); +}) satisfies Handle; diff --git a/examples/csp/src/lib/theme-config.ts b/examples/csp/src/lib/theme-config.ts new file mode 100644 index 0000000..bda9cae --- /dev/null +++ b/examples/csp/src/lib/theme-config.ts @@ -0,0 +1,11 @@ +import type { ResolvedConfig } from '@sejohnson/svelte-themes'; + +export const themeConfig: ResolvedConfig = { + attribute: 'data-theme', + storageKey: 'theme', + enableColorScheme: true, + disableTransitionOnChange: false, + defaultTheme: 'light', + enableSystem: false, + themes: ['light', 'dark-classic', 'tangerine', 'dark-tangerine', 'mint', 'dark-mint'] +}; diff --git a/examples/csp/src/lib/theme-toggles.svelte b/examples/csp/src/lib/theme-toggles.svelte new file mode 100644 index 0000000..07006d2 --- /dev/null +++ b/examples/csp/src/lib/theme-toggles.svelte @@ -0,0 +1,38 @@ + + +
+
+ {#each Object.entries(themeMapping) as [key, value] (key)} + + {/each} +
+
diff --git a/examples/csp/src/routes/+layout.svelte b/examples/csp/src/routes/+layout.svelte new file mode 100644 index 0000000..c22b8a4 --- /dev/null +++ b/examples/csp/src/routes/+layout.svelte @@ -0,0 +1,13 @@ + + +
+ + {@render children?.()} + +
diff --git a/examples/csp/src/routes/+page.svelte b/examples/csp/src/routes/+page.svelte new file mode 100644 index 0000000..15488f2 --- /dev/null +++ b/examples/csp/src/routes/+page.svelte @@ -0,0 +1,13 @@ + + +
+
+

+ Svelte Themes +{' '} + Multi Themes +

+ +
+
diff --git a/examples/csp/static/favicon.png b/examples/csp/static/favicon.png new file mode 100644 index 0000000..825b9e6 Binary files /dev/null and b/examples/csp/static/favicon.png differ diff --git a/examples/csp/svelte.config.js b/examples/csp/svelte.config.js new file mode 100644 index 0000000..f60886a --- /dev/null +++ b/examples/csp/svelte.config.js @@ -0,0 +1,32 @@ +import adapter from '@sveltejs/adapter-vercel'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + csp: { + directives: { + 'default-src': ['self'], + 'script-src': ['self'], + 'style-src': ['self'], + 'img-src': ['self'], + 'connect-src': ['self'], + 'font-src': ['self'], + 'media-src': ['self'], + 'form-action': ['self'], + 'base-uri': ['self'], + 'manifest-src': ['self'], + 'worker-src': ['self'], + 'frame-src': ['self'], + 'object-src': ['self'] + } + } + } +}; + +export default config; diff --git a/examples/csp/tsconfig.json b/examples/csp/tsconfig.json new file mode 100644 index 0000000..0b2d886 --- /dev/null +++ b/examples/csp/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/examples/csp/vite.config.ts b/examples/csp/vite.config.ts new file mode 100644 index 0000000..a69fa55 --- /dev/null +++ b/examples/csp/vite.config.ts @@ -0,0 +1,7 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit(), tailwindcss()] +}); diff --git a/svelte-themes/src/lib/config.ts b/svelte-themes/src/lib/config.ts index 1b45b4d..b7ad7e6 100644 --- a/svelte-themes/src/lib/config.ts +++ b/svelte-themes/src/lib/config.ts @@ -63,10 +63,6 @@ export type Config = Readonly<{ * ``` */ domValues?: ValueObject | undefined; - /** - * Nonce string to pass to the inline script and style elements for CSP headers. - */ - nonce?: string; /** * Props to pass the inline script */ @@ -78,7 +74,7 @@ export type Config = Readonly<{ }>; export type ResolvedConfig = Readonly< - RequiredExcept + RequiredExcept >; type RequiredExcept = Required> & Pick; diff --git a/svelte-themes/src/lib/dom.ts b/svelte-themes/src/lib/dom.ts index 04a672b..4f7c2d3 100644 --- a/svelte-themes/src/lib/dom.ts +++ b/svelte-themes/src/lib/dom.ts @@ -1,15 +1,6 @@ import type { ResolvedConfig } from './config.js'; -export function script({ - attribute, - storageKey, - defaultTheme, - themes, - domValues, - enableSystem, - enableColorScheme, - forcedTheme -}: Pick< +export type ScriptConfig = Pick< ResolvedConfig, | 'attribute' | 'storageKey' @@ -19,7 +10,18 @@ export function script({ | 'enableSystem' | 'enableColorScheme' | 'forcedTheme' ->) { +>; + +function script({ + attribute, + storageKey, + defaultTheme, + themes, + domValues, + enableSystem, + enableColorScheme, + forcedTheme +}: ScriptConfig) { const el = document.documentElement; const systemThemes = ['light', 'dark']; @@ -68,3 +70,7 @@ export function script({ } } } + +export function scriptAsString(props: ScriptConfig) { + return `(${script.toString()})(${JSON.stringify(props)})`; +} diff --git a/svelte-themes/src/lib/head-script.svelte b/svelte-themes/src/lib/head-script.svelte index 44d9fbf..8b32a9f 100644 --- a/svelte-themes/src/lib/head-script.svelte +++ b/svelte-themes/src/lib/head-script.svelte @@ -1,7 +1,6 @@ diff --git a/svelte-themes/src/lib/index.ts b/svelte-themes/src/lib/index.ts index bb792b2..cf7f879 100644 --- a/svelte-themes/src/lib/index.ts +++ b/svelte-themes/src/lib/index.ts @@ -1,4 +1,5 @@ export { getTheme, hasTheme } from './context.js'; export { default as ThemeProvider } from './theme-provider.svelte'; -export type { Config } from './config.js'; +export type { Config, ResolvedConfig } from './config.js'; export { HydrationWatcher } from './utils.svelte.js'; +export { scriptAsString } from './dom.js'; diff --git a/svelte-themes/src/lib/theme-provider.svelte b/svelte-themes/src/lib/theme-provider.svelte index d675fbb..89a4832 100644 --- a/svelte-themes/src/lib/theme-provider.svelte +++ b/svelte-themes/src/lib/theme-provider.svelte @@ -17,10 +17,18 @@ attribute = 'data-theme', children, domValues, - nonce, forcedTheme, - scriptProps - }: Config & { children?: Snippet } = $props(); + scriptProps, + disableScriptInjection = false + }: Config & { + children?: Snippet; + /** + * Disable automatic injection of the script. + * See the CSP section in the README for details. + * @default false + */ + disableScriptInjection?: boolean; + } = $props(); if (!hasTheme()) { const theme = new Theme({ @@ -48,9 +56,6 @@ get domValues() { return domValues; }, - get nonce() { - return nonce; - }, get forcedTheme() { return forcedTheme; } @@ -84,7 +89,7 @@ }); -{#if !watcher.hydrated} +{#if !watcher.hydrated && !disableScriptInjection} diff --git a/svelte-themes/src/lib/theme.svelte.ts b/svelte-themes/src/lib/theme.svelte.ts index d8811ef..d4de7eb 100644 --- a/svelte-themes/src/lib/theme.svelte.ts +++ b/svelte-themes/src/lib/theme.svelte.ts @@ -144,9 +144,6 @@ export class Theme { #disableAnimation() { const css = document.createElement('style'); - if (this.#config.nonce) { - css.setAttribute('nonce', this.#config.nonce); - } css.appendChild( document.createTextNode( `*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}`