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.
Quick Start
Section titled “Quick Start”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 neededconst 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.
How It Works
Section titled “How It Works”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.
Adopted Stylesheets
Section titled “Adopted Stylesheets”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.
Token-Based Dynamic Styles
Section titled “Token-Based Dynamic Styles”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:
- The declaration set is serialized and hashed into a style token (e.g.,
s0,s1,s2). - A CSS rule is inserted into the adopted stylesheet:
[data-notectl-style-token="s0"] { color: red; font-size: 18px; }
- 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.
Token Deduplication
Section titled “Token Deduplication”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.
Configuration
Section titled “Configuration”const editor = await createEditor({ styleNonce: undefined, // only needed for fallback <style> elements});styleNonce
Section titled “styleNonce”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__,});CSP Policy Examples
Section titled “CSP Policy Examples”Modern Browsers (Adopted Stylesheets)
Section titled “Modern Browsers (Adopted Stylesheets)”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.
Full Compatibility (With Nonce Fallback)
Section titled “Full Compatibility (With Nonce Fallback)”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>',});When Is a Nonce Needed?
Section titled “When Is a Nonce Needed?”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.
| Feature | Nonce Needed? | Notes |
|---|---|---|
| Adopted Stylesheets (primary path) | No | CSP-exempt by spec |
Fallback <style> element | Yes | When adoptedStyleSheets unavailable |
Token data- attributes | No | Attributes, not styles |
Browser Support for Adopted Stylesheets
Section titled “Browser Support for Adopted Stylesheets”| Browser | adoptedStyleSheets Support |
|---|---|
| Chrome / Edge | 73+ |
| Firefox | 101+ |
| Safari | 16.4+ |
All evergreen browsers support adopted stylesheets. A nonce fallback is only necessary if you target older environments.
Server-Side Nonce Integration
Section titled “Server-Side Nonce Integration”Generate a unique nonce per response and pass it to the client:
// Express.js middlewareimport 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__,});HTML Export and Serialization
Section titled “HTML Export and Serialization”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 theirstyleattributes 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.
Rendering on a CSP-Strict Page
Section titled “Rendering on a CSP-Strict Page”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 upremoveAdoptedStyles(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 upremoveContentStyles('notectl-content-styles');Option C: External stylesheet — serve the CSS from your backend:
// Store CSS alongside HTMLawait saveToBackend({ html, css });
// On the rendering page:// <link rel="stylesheet" href="/api/content/123/styles.css">// <div>{html}</div>Round-Trip: Re-Importing Class-Based HTML
Section titled “Round-Trip: Re-Importing Class-Based HTML”The styleMap returned by getContentHTML({ cssMode: 'classes' }) enables re-importing class-based HTML back into the editor with all styles preserved:
// Exportconst { 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 preservationconst 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.
Which Serialization Mode Should I Use?
Section titled “Which Serialization Mode Should I Use?”| Scenario | Mode | Notes |
|---|---|---|
| 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/paste | Automatic | Always uses inline styles for cross-app compat |
| Re-importing into the editor | 'classes' + styleMap | Full round-trip preservation |
| Storing editor state (lossless) | getJSON() / setJSON() | Best for editor-to-editor workflows |
CSS Delivery API Reference
Section titled “CSS Delivery API Reference”import { adoptContentStyles, removeAdoptedStyles, injectContentStyles, removeContentStyles,} from '@notectl/core';adoptContentStyles(css, options?)
Section titled “adoptContentStyles(css, options?)”Injects CSS via adoptedStyleSheets — no DOM element, no nonce needed.
| Option | Type | Default | Description |
|---|---|---|---|
target | Document | ShadowRoot | globalThis.document | Where to adopt the stylesheet |
replace | boolean | false | When true, removes previously adopted notectl sheets first |
Returns the CSSStyleSheet for cleanup via removeAdoptedStyles.
removeAdoptedStyles(sheet, target?)
Section titled “removeAdoptedStyles(sheet, target?)”Removes a specific adopted stylesheet. Pass the sheet returned by adoptContentStyles.
injectContentStyles(css, options?)
Section titled “injectContentStyles(css, options?)”Creates or updates a <style> element with the given CSS. Use when adoptedStyleSheets is unavailable (SSR, older browsers).
| Option | Type | Default | Description |
|---|---|---|---|
nonce | string | — | CSP nonce attribute for the <style> element |
id | string | — | Element ID. If an element with this ID exists, its content is replaced |
container | HTMLElement | document.head | Where to append the <style> element |
document | Document | globalThis.document | Target document (useful for iframes) |
Returns the HTMLStyleElement for manual cleanup.
removeContentStyles(id, document?)
Section titled “removeContentStyles(id, document?)”Removes a <style> element by ID.
Troubleshooting
Section titled “Troubleshooting””Refused to apply inline style”
Section titled “”Refused to apply inline style””Refused to apply inline style because it violates the followingContent 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
NodeViewimplementations that bypass the token-based style system
”Refused to apply a stylesheet”
Section titled “”Refused to apply a stylesheet””Refused to apply a stylesheet because its hash, nonce, or'unsafe-inline' does not appear in the style-src directiveThis means a <style> element was inserted without a matching nonce. This happens when:
- The browser does not support
adoptedStyleSheets(rare in modern browsers) - No
styleNoncewas 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:
- Check your production CSP headers with browser DevTools (Network tab → response headers)
- If using a nonce, confirm it matches between the HTTP header and
styleNoncevalue