Shared RichTextEditor gains @tiptap/extension-text-align for heading and paragraph nodes, serializing alignment as inline text-align on the block node so it round-trips through save/reload. Fixed once at the shared component so it also flows into the upcoming rich_text and two_column page blocks. Server sanitize allowlist now permits `style` on p/h1-h6, constrained by allowedStyles to text-align (left/right/center/justify) only; all other CSS properties and values are stripped. Step 1 of the CMS Page Builder spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
77 lines
2.8 KiB
JavaScript
77 lines
2.8 KiB
JavaScript
const sanitizeHtml = require('sanitize-html')
|
|
|
|
// Allowlist for wiki/post body HTML. Anything not listed is stripped. This runs
|
|
// on every save so the stored value is already safe; the client re-sanitizes on
|
|
// render as defense in depth. Tuned for rich-text content from the admin editor.
|
|
const OPTIONS = {
|
|
allowedTags: [
|
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
'p', 'br', 'hr', 'blockquote', 'pre', 'code',
|
|
'ul', 'ol', 'li',
|
|
'strong', 'b', 'em', 'i', 'u', 's', 'sup', 'sub', 'mark', 'span',
|
|
'a', 'img', 'figure', 'figcaption',
|
|
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
],
|
|
allowedAttributes: {
|
|
a: ['href', 'name', 'target', 'rel', 'title'],
|
|
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3)
|
|
th: ['colspan', 'rowspan'],
|
|
td: ['colspan', 'rowspan'],
|
|
// Block alignment from the rich-text editor. `style` is only honored for the
|
|
// properties/values whitelisted in allowedStyles below — everything else in
|
|
// the style attribute is stripped.
|
|
p: ['style'],
|
|
h1: ['style'], h2: ['style'], h3: ['style'],
|
|
h4: ['style'], h5: ['style'], h6: ['style'],
|
|
},
|
|
// Restrict inline styles to text-align (left/right/center/justify) only. Any
|
|
// other CSS property, or an unlisted value, is discarded.
|
|
allowedStyles: {
|
|
'*': {
|
|
'text-align': [/^(left|right|center|justify)$/],
|
|
},
|
|
},
|
|
// http/https for links and images, mailto for links, plus relative URLs so
|
|
// uploaded images (/uploads/...) and internal links (/wiki/...) pass through.
|
|
allowedSchemes: ['http', 'https', 'mailto'],
|
|
allowedSchemesByTag: { img: ['http', 'https'] },
|
|
allowProtocolRelative: false,
|
|
// Force safe rel on links that open a new tab; drop empty/odd attributes.
|
|
transformTags: {
|
|
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true),
|
|
},
|
|
disallowedTagsMode: 'discard',
|
|
}
|
|
|
|
/**
|
|
* Sanitize a block of body HTML against the allowlist above.
|
|
* Null/empty input is returned unchanged.
|
|
* @param {string|null|undefined} html
|
|
* @returns {string|null|undefined}
|
|
*/
|
|
function cleanBody(html) {
|
|
if (html == null || html === '') return html
|
|
return sanitizeHtml(String(html), OPTIONS)
|
|
}
|
|
|
|
/**
|
|
* Derive a plain-text excerpt from body HTML. Strips tags, collapses
|
|
* whitespace, and truncates to `max` chars (with an ellipsis). Used as the
|
|
* excerpt fallback when an author leaves the excerpt field blank.
|
|
* @param {string|null|undefined} html
|
|
* @param {number} [max=280]
|
|
* @returns {string|null}
|
|
*/
|
|
function deriveExcerpt(html, max = 280) {
|
|
if (html == null) return null
|
|
const text = String(html)
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
if (!text) return null
|
|
return text.length > max ? `${text.slice(0, max - 3)}...` : text
|
|
}
|
|
|
|
module.exports = { cleanBody, deriveExcerpt, OPTIONS }
|