Skip to content

fix: usage with CSP and nonce #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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=<whatever>`.

### 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
<script lang="ts">
import { themeConfig } from '$lib/theme-config';
import { ThemeProvider } from '@sejohnson/svelte-themes';
let { children } = $props();
</script>

<ThemeProvider {...themeConfig} disableScriptInjection>
{@render children?.()}
</ThemeProvider>
```

#### app.html
In your `app.html`, add a placeholder for the script tag. Do this just above `%sveltekit.head%`.
```html
<script nonce="%sveltekit.nonce%">
//svelte-themes.script//
</script>
```

#### 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
Expand Down
5 changes: 5 additions & 0 deletions examples/csp/README.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions examples/csp/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
35 changes: 35 additions & 0 deletions examples/csp/src/app.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
13 changes: 13 additions & 0 deletions examples/csp/src/app.d.ts
Original file line number Diff line number Diff line change
@@ -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 {};
15 changes: 15 additions & 0 deletions examples/csp/src/app.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en" style="background-color: var(--primary)">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script nonce="%sveltekit.nonce%">
//svelte-themes.script//
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
11 changes: 11 additions & 0 deletions examples/csp/src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions examples/csp/src/lib/theme-config.ts
Original file line number Diff line number Diff line change
@@ -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']
};
38 changes: 38 additions & 0 deletions examples/csp/src/lib/theme-toggles.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script lang="ts">
import { getTheme, HydrationWatcher } from '@sejohnson/svelte-themes';
const theme = getTheme();

// The active theme is not available on the server.
// If you have styling that is conditionally applied using JavaScript based on the active-theme,
// you have to await the mounted state before rendering the active theme.
// (If possible, it is recommended to use CSS variables for styling that is conditionally applied based on the active theme.)
// If you're using Tailwind, that just means setting `attribute="class"` in the ThemeProvider and using `dark:` modifiers.
const watcher = new HydrationWatcher();

const themeMapping: Record<string, string> = {
light: 'Default',
'dark-classic': 'Dark',
tangerine: 'Tangerine',
'dark-tangerine': 'Tangerine (dark)',
mint: 'Mint',
'dark-mint': 'Mint (dark)'
};
</script>

<div>
<div class="mt-16 grid grid-cols-3 grid-rows-2 grid-flow-col gap-4">
{#each Object.entries(themeMapping) as [key, value] (key)}
<button
class="px-4 py-2 font-semibold rounded-md transition-colors duration-200 bg-[var(--primary)] text-[var(--primary-foreground)] {watcher.hydrated &&
theme.selectedTheme == key
? 'border border-[var(--primary-foreground)]'
: ''}"
onclick={() => {
theme.selectedTheme = key;
}}
>
{value}
</button>
{/each}
</div>
</div>
13 changes: 13 additions & 0 deletions examples/csp/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
import { themeConfig } from '$lib/theme-config';
import '../app.css';
import { ThemeProvider } from '@sejohnson/svelte-themes';

let { children } = $props();
</script>

<div class="min-h-dvh">
<ThemeProvider {...themeConfig} disableScriptInjection>
{@render children?.()}
</ThemeProvider>
</div>
13 changes: 13 additions & 0 deletions examples/csp/src/routes/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
import ThemeToggles from '$lib/theme-toggles.svelte';
</script>

<div class="w-full container p-4 mx-auto">
<div class="py-20 flex flex-col items-center justify-center text-[var(--primary-foreground)]">
<h1 class="text-5xl text-center font-bold">
Svelte Themes +{' '}
<span class="text-primary-foreground bg-primary py-2 px-4 rounded">Multi</span> Themes
</h1>
<ThemeToggles />
</div>
</div>
Binary file added examples/csp/static/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 32 additions & 0 deletions examples/csp/svelte.config.js
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 19 additions & 0 deletions examples/csp/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions examples/csp/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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()]
});
6 changes: 1 addition & 5 deletions svelte-themes/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -78,7 +74,7 @@ export type Config = Readonly<{
}>;

export type ResolvedConfig = Readonly<
RequiredExcept<Config, 'domValues' | 'nonce' | 'scriptProps' | 'forcedTheme'>
RequiredExcept<Config, 'domValues' | 'scriptProps' | 'forcedTheme'>
>;

type RequiredExcept<T, K extends keyof T> = Required<Omit<T, K>> & Pick<T, K>;
28 changes: 17 additions & 11 deletions svelte-themes/src/lib/dom.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'];

Expand Down Expand Up @@ -68,3 +70,7 @@ export function script({
}
}
}

export function scriptAsString(props: ScriptConfig) {
return `(${script.toString()})(${JSON.stringify(props)})`;
}
Loading