Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render

This commit is contained in:
2026-06-04 07:52:57 +02:00
parent 0b33a1b074
commit 15870ddb1f
191 changed files with 15453 additions and 1786 deletions

View File

@@ -199,6 +199,30 @@ function contentViewEventType(contentType) {
return 'academy_content_view'
}
function analyticsMetadata(analytics, extra = {}) {
const metadata = analytics?.metadata && typeof analytics.metadata === 'object' ? analytics.metadata : {}
return {
page_name: analytics?.pageName,
...metadata,
...extra,
}
}
function analyticsTrackingKey(analytics) {
if (analytics?.trackingKey) {
return String(analytics.trackingKey)
}
const metadata = analytics?.metadata && typeof analytics.metadata === 'object' ? analytics.metadata : {}
const pairs = Object.entries(metadata)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}:${String(value)}`)
return pairs.join('|')
}
export function trackUpgradeClick(analytics, metadata = {}) {
if (!analytics?.eventUrl) {
return
@@ -217,20 +241,17 @@ export function useAcademyPageAnalytics(analytics) {
return undefined
}
const baseKey = `${analytics.pageName || window.location.pathname}:${analytics.contentType || 'page'}:${analytics.contentId || 'none'}`
const trackingKey = analyticsTrackingKey(analytics)
const baseKey = `${analytics.pageName || window.location.pathname}:${analytics.contentType || 'page'}:${analytics.contentId || 'none'}:${trackingKey || 'default'}`
void trackAcademyEvent('academy_page_view', analytics.contentType || null, analytics.contentId || null, {
page_name: analytics.pageName,
}, {
void trackAcademyEvent('academy_page_view', analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics), {
url: analytics.eventUrl,
pageName: analytics.pageName,
onceKey: `${baseKey}:page-view`,
})
if (analytics.contentType || analytics.contentId) {
void trackAcademyEvent(contentViewEventType(analytics.contentType), analytics.contentType || null, analytics.contentId || null, {
page_name: analytics.pageName,
}, {
void trackAcademyEvent(contentViewEventType(analytics.contentType), analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics), {
url: analytics.eventUrl,
pageName: analytics.pageName,
onceKey: `${baseKey}:content-view`,
@@ -238,9 +259,7 @@ export function useAcademyPageAnalytics(analytics) {
}
if (analytics.isPremium && analytics.isLocked) {
void trackAcademyEvent('academy_premium_preview_view', analytics.contentType || null, analytics.contentId || null, {
page_name: analytics.pageName,
}, {
void trackAcademyEvent('academy_premium_preview_view', analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics), {
url: analytics.eventUrl,
pageName: analytics.pageName,
onceKey: `${baseKey}:premium-preview`,
@@ -248,10 +267,9 @@ export function useAcademyPageAnalytics(analytics) {
}
const engagedTimer = window.setTimeout(() => {
void trackAcademyEvent('academy_engaged_view', analytics.contentType || null, analytics.contentId || null, {
page_name: analytics.pageName,
void trackAcademyEvent('academy_engaged_view', analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics, {
engaged_seconds: 15,
}, {
}), {
url: analytics.eventUrl,
pageName: analytics.pageName,
onceKey: `${baseKey}:engaged`,
@@ -275,10 +293,9 @@ export function useAcademyPageAnalytics(analytics) {
}
sentMilestones.add(milestone.threshold)
void trackAcademyEvent(milestone.eventType, analytics.contentType || null, analytics.contentId || null, {
page_name: analytics.pageName,
void trackAcademyEvent(milestone.eventType, analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics, {
scroll_percent: milestone.threshold,
}, {
}), {
url: analytics.eventUrl,
pageName: analytics.pageName,
onceKey: `${baseKey}:scroll-${milestone.threshold}`,
@@ -292,5 +309,5 @@ export function useAcademyPageAnalytics(analytics) {
window.clearTimeout(engagedTimer)
window.removeEventListener('scroll', onScroll)
}
}, [analytics?.contentId, analytics?.contentType, analytics?.enabled, analytics?.eventUrl, analytics?.isLocked, analytics?.isPremium, analytics?.pageName])
}, [analytics?.contentId, analytics?.contentType, analytics?.enabled, analytics?.eventUrl, analytics?.isLocked, analytics?.isPremium, analytics?.pageName, analytics?.trackingKey, JSON.stringify(analytics?.metadata || {})])
}

View File

@@ -1,11 +1,18 @@
import React from 'react'
import { cleanup, render, waitFor } from '@testing-library/react'
function prepareEnvironment() {
document.head.innerHTML = '<meta name="csrf-token" content="csrf-token" />'
vi.spyOn(Storage.prototype, 'getItem').mockReturnValue('visitor-123')
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
const storage = new Map([['academy.analytics.visitor-id', 'visitor-123']])
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => storage.get(String(key)) ?? null)
vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key, value) => {
storage.set(String(key), String(value))
})
globalThis.fetch = vi.fn(() => Promise.resolve({ ok: true, headers: { get: () => 'application/json' }, json: () => Promise.resolve({ ok: true }) }))
}
function cleanupEnvironment() {
cleanup()
vi.restoreAllMocks()
document.head.innerHTML = ''
}
@@ -66,5 +73,73 @@ test('academy search click attribution falls back to keepalive fetch when sendBe
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
expect(globalThis.fetch.mock.calls[0][1].keepalive).toBe(true)
cleanupEnvironment()
})
test('academy page analytics includes custom metadata and varies page-view once keys by tracking context', async () => {
prepareEnvironment()
const { useAcademyPageAnalytics } = await import('./academyAnalytics.js')
Object.defineProperty(navigator, 'sendBeacon', {
configurable: true,
value: undefined,
})
function TestPage({ analytics }) {
useAcademyPageAnalytics(analytics)
return React.createElement('div', null, 'Academy page')
}
const { rerender } = render(
React.createElement(TestPage, {
analytics: {
enabled: true,
eventUrl: '/academy/analytics/events',
pageName: 'academy_prompts_popular',
contentType: 'academy_prompt_popular',
contentId: null,
trackingKey: 'period:30d',
metadata: { period: '30d', period_days: 30 },
},
}),
)
await waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledTimes(2)
})
const firstPageView = globalThis.fetch.mock.calls
.map((call) => JSON.parse(call[1].body))
.find((payload) => payload.event_type === 'academy_page_view')
expect(firstPageView.metadata.period).toBe('30d')
expect(firstPageView.metadata.period_days).toBe(30)
rerender(
React.createElement(TestPage, {
analytics: {
enabled: true,
eventUrl: '/academy/analytics/events',
pageName: 'academy_prompts_popular',
contentType: 'academy_prompt_popular',
contentId: null,
trackingKey: 'period:7d',
metadata: { period: '7d', period_days: 7 },
},
}),
)
await waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledTimes(4)
})
const pageViews = globalThis.fetch.mock.calls
.map((call) => JSON.parse(call[1].body))
.filter((payload) => payload.event_type === 'academy_page_view')
expect(pageViews).toHaveLength(2)
expect(pageViews[1].metadata.period).toBe('7d')
cleanupEnvironment()
})

View File

@@ -23,6 +23,17 @@ function normalizeType(value, fallback = 'error') {
return fallback
}
function firstValidationError(errors) {
if (!errors || typeof errors !== 'object') return ''
for (const value of Object.values(errors)) {
if (Array.isArray(value) && value[0]) return String(value[0]).trim()
if (typeof value === 'string' && value.trim()) return value.trim()
}
return ''
}
export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
const status = Number(error?.response?.status || 0)
const payload = error?.response?.data || {}
@@ -30,6 +41,7 @@ export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
const mapped = REASON_MAP[reason]
const errorCode = String(error?.code || '').toUpperCase()
const rawMessage = typeof error?.message === 'string' ? error.message.trim() : ''
const validationMessage = firstValidationError(payload?.errors)
const timedOut = errorCode === 'ECONNABORTED' || /timeout/i.test(rawMessage)
const requestTooLarge = status === 413
@@ -41,6 +53,7 @@ export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
(requestTooLarge ? 'Server rejected this upload chunk as too large. Retrying with smaller chunks may help, or increase Nginx/PHP upload limits.' : '') ||
(timedOut ? 'Upload request timed out before the server responded. Check Nginx/PHP-FPM body handling and try again.' : '') ||
mapped?.message ||
validationMessage ||
(typeof payload?.message === 'string' && payload.message.trim()) ||
rawMessage ||
fallback