Skip to content

Content Security Policy

notectl is designed for strict Content Security Policy environments. It works without requiring 'unsafe-inline' for styles — out of the box, with zero configuration needed for modern browsers.

If your CSP already allows scripts from 'self', notectl works out of the box:

import { createEditor } from '@notectl/core';
// CSP-compliant out of the box — no extra config needed
const editor = await createEditor({
placeholder: 'Start typing...',
});
document.getElementById('editor')!.appendChild(editor);

No nonce, no 'unsafe-inline', no special headers. The editor renders, styles dynamically, and never writes a single inline style attribute.

Rich text editors traditionally rely on inline style attributes for dynamic formatting (font size, text color, background color). This conflicts with style-src-attr: 'none' CSP policies. notectl solves this with a token-based stylesheet system.

notectl creates a CSSStyleSheet at initialization and attaches it to the Shadow DOM via adoptedStyleSheets:

new CSSStyleSheet() → shadow.adoptedStyleSheets.push(sheet)

Because adopted stylesheets are injected programmatically — not parsed from inline markup — they are exempt from CSP style-src restrictions. The browser treats them the same as stylesheets loaded from an allowed origin.

When the editor needs to apply a dynamic style (e.g., color: red; font-size: 18px), it does not set el.style.color = 'red'. Instead:

  1. The declaration set is serialized and hashed into a style token (e.g., s0, s1, s2).
  2. A CSS rule is inserted into the adopted stylesheet:
    [data-notectl-style-token="s0"] { color: red; font-size: 18px; }
  3. The element receives a data-notectl-style-token="s0" attribute instead of an inline style.

This means every dynamically-styled element uses a data attribute — never an inline style.

Identical style combinations share a single token and CSS rule. If 50 spans have the same color: red; font-size: 18px, they all reference s0 and there is one CSS rule. Tokens are reference-counted and garbage-collected when no elements use them.

const editor = await createEditor({
styleNonce: undefined, // only needed for fallback <style> elements
});

An optional nonce string for the fallback <style> element. Only relevant when the browser does not support adoptedStyleSheets and notectl falls back to a <style> element.

const editor = await createEditor({
styleNonce: window.__CSP_NONCE__,
});

For environments that support adoptedStyleSheets (all modern browsers — see compatibility table below), no style nonce is required:

Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
style-src-attr 'none';

This is the strictest possible style policy. notectl works because adopted stylesheets bypass CSP entirely.

If you need to support older browsers that lack adoptedStyleSheets, add a nonce for the fallback <style> element:

Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'nonce-<server-generated>';
style-src-attr 'none';

Pass the nonce to the editor:

await createEditor({
styleNonce: '<server-generated>',
});

A nonce is only needed when the browser does not support adoptedStyleSheets and notectl falls back to inserting a <style> element into the Shadow DOM.

FeatureNonce Needed?Notes
Adopted Stylesheets (primary path)NoCSP-exempt by spec
Fallback <style> elementYesWhen adoptedStyleSheets unavailable
Token data- attributesNoAttributes, not styles
BrowseradoptedStyleSheets Support
Chrome / Edge73+
Firefox101+
Safari16.4+

All evergreen browsers support adopted stylesheets. A nonce fallback is only necessary if you target older environments.

Generate a unique nonce per response and pass it to the client:

// Express.js middleware
import crypto from 'node:crypto';
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader(
'Content-Security-Policy',
`default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}';`
);
next();
});

Inject the nonce into the page and pass it to the editor:

<script nonce="<%= nonce %>">
window.__CSP_NONCE__ = '<%= nonce %>';
</script>
await createEditor({
styleNonce: window.__CSP_NONCE__,
});

By default, getContentHTML() produces static HTML with inline style attributes for dynamic marks:

<!-- Default output (cssMode: 'inline') -->
<p>
Normal text and
<span style="color: red; font-size: 18px">styled text</span>.
</p>
  • Semantic marks (bold, italic, underline, strikethrough) always use semantic HTML: <strong>, <em>, <u>, <s>.
  • Dynamic marks (color, font-size, background-color, font-family) use inline styles by default.

If you render exported HTML on a page with style-src-attr: 'none', the inline styles will be silently blocked by the browser.

Class-Based HTML Export (Zero Inline Styles)

Section titled “Class-Based HTML Export (Zero Inline Styles)”

To produce fully CSP-compliant exported HTML, use cssMode: 'classes':

const { html, css, styleMap } = editor.getContentHTML({ cssMode: 'classes' });

This produces HTML with zero style attributes. All styling is expressed via CSS class names:

<!-- html -->
<p class="notectl-align-center">
Normal text and
<span class="notectl-s-a3f2k9">styled text</span>.
</p>
/* css */
.notectl-s-a3f2k9 { color: red; font-size: 18px; }
.notectl-align-center { text-align: center; }

Key properties:

  • Zero inline styles — guaranteed. Even third-party plugins that forget to use ctx.styleAttr() have their style attributes stripped by DOMPurify as a defense-in-depth measure.
  • Deterministic class names — the same CSS declarations always produce the same class name (content-hashed), regardless of encounter order. Different documents with the same styling produce identical class names.
  • Minimal CSS — only rules actually used in the document are emitted. Identical style combinations are deduplicated into a single class.
  • Tables, code blocks, images — all plugin-generated styles (table borders, code block backgrounds, image alignment) are converted to classes.

Option A: adoptContentStyles (recommended) — uses adoptedStyleSheets, no DOM element, no nonce required:

import { adoptContentStyles, removeAdoptedStyles } from '@notectl/core';
const { html, css } = editor.getContentHTML({ cssMode: 'classes' });
const sheet = adoptContentStyles(css);
contentContainer.innerHTML = html;
// Later: clean up
removeAdoptedStyles(sheet);

This also works with Shadow DOM — pass { target: shadowRoot }.

Option B: injectContentStyles — creates a <style> element (needed for older browsers or SSR):

import { injectContentStyles, removeContentStyles } from '@notectl/core';
const { html, css } = editor.getContentHTML({ cssMode: 'classes' });
const style = injectContentStyles(css, {
nonce: window.__CSP_NONCE__,
id: 'notectl-content-styles',
});
contentContainer.innerHTML = html;
// Later: clean up
removeContentStyles('notectl-content-styles');

Option C: External stylesheet — serve the CSS from your backend:

// Store CSS alongside HTML
await saveToBackend({ html, css });
// On the rendering page:
// <link rel="stylesheet" href="/api/content/123/styles.css">
// <div>{html}</div>

The styleMap returned by getContentHTML({ cssMode: 'classes' }) enables re-importing class-based HTML back into the editor with all styles preserved:

// Export
const { html, css, styleMap } = editor.getContentHTML({ cssMode: 'classes' });
// Store html, css, and styleMap (serialize the Map as needed)
await saveToBackend({ html, css, styleMap: Object.fromEntries(styleMap) });
// Later: re-import with full style preservation
const savedStyleMap = new Map(Object.entries(saved.styleMap));
editor.setContentHTML(saved.html, { styleMap: savedStyleMap });

Without the styleMap, class-based HTML can still be imported — but class-only styles (colors, fonts, etc.) will be lost since the class names have no meaning without their CSS declarations. The styleMap “rehydrates” classes back to inline styles before parsing.

ScenarioModeNotes
Storing HTML for email clients'inline' (default)Self-contained, widest compatibility
Rendering on a CSP-strict page'classes'Zero inline styles, use injectContentStyles
Clipboard copy/pasteAutomaticAlways uses inline styles for cross-app compat
Re-importing into the editor'classes' + styleMapFull round-trip preservation
Storing editor state (lossless)getJSON() / setJSON()Best for editor-to-editor workflows
import {
adoptContentStyles, removeAdoptedStyles,
injectContentStyles, removeContentStyles,
} from '@notectl/core';

Injects CSS via adoptedStyleSheets — no DOM element, no nonce needed.

OptionTypeDefaultDescription
targetDocument | ShadowRootglobalThis.documentWhere to adopt the stylesheet
replacebooleanfalseWhen true, removes previously adopted notectl sheets first

Returns the CSSStyleSheet for cleanup via removeAdoptedStyles.

Removes a specific adopted stylesheet. Pass the sheet returned by adoptContentStyles.

Creates or updates a <style> element with the given CSS. Use when adoptedStyleSheets is unavailable (SSR, older browsers).

OptionTypeDefaultDescription
noncestringCSP nonce attribute for the <style> element
idstringElement ID. If an element with this ID exists, its content is replaced
containerHTMLElementdocument.headWhere to append the <style> element
documentDocumentglobalThis.documentTarget document (useful for iframes)

Returns the HTMLStyleElement for manual cleanup.

Removes a <style> element by ID.

Refused to apply inline style because it violates the following
Content Security Policy directive: "style-src-attr 'none'"

This means something is writing an inline style attribute. notectl does not do this. Check for:

  • Third-party plugins that write inline styles directly via el.style.x = ...
  • Custom NodeView implementations that bypass the token-based style system
Refused to apply a stylesheet because its hash, nonce, or
'unsafe-inline' does not appear in the style-src directive

This means a <style> element was inserted without a matching nonce. This happens when:

  1. The browser does not support adoptedStyleSheets (rare in modern browsers)
  2. No styleNonce was provided to the editor

Fix: pass styleNonce during initialization.

Styles Apply in Development but Not Production

Section titled “Styles Apply in Development but Not Production”

Development servers often have relaxed CSP or no CSP at all. If styles disappear in production:

  1. Check your production CSP headers with browser DevTools (Network tab → response headers)
  2. If using a nonce, confirm it matches between the HTTP header and styleNonce value