Skip to content

Commit 6d3f9ec

Browse files
authored
Merge pull request #1412 from prezly/feature/custom-font-support
Feature/custom font support
2 parents ce477c2 + 5f73bef commit 6d3f9ec

File tree

21 files changed

+508
-118
lines changed

21 files changed

+508
-118
lines changed

app/[localeCode]/layout.tsx

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
BroadcastNotificationsProvider,
1616
BroadcastPageTypesProvider,
1717
BroadcastPreviewProvider,
18+
PreviewSettingsProvider,
1819
BroadcastStoryProvider,
1920
BroadcastTranslationsProvider,
2021
} from '@/modules/Broadcast';
@@ -81,45 +82,47 @@ export default async function MainLayout(props: Props) {
8182
const newsroom = await app().newsroom();
8283

8384
return (
84-
<html lang={isoCode} dir={direction}>
85-
<head>
86-
<meta name="og:locale" content={isoCode} />
87-
<Preconnect />
88-
<Branding />
89-
</head>
90-
<body>
91-
<AppContext localeCode={localeCode}>
92-
{isTrackingEnabled && (
93-
<Analytics
94-
meta={{
95-
newsroom: newsroom.uuid,
96-
tracking_policy: newsroom.tracking_policy,
97-
}}
98-
trackingPolicy={newsroom.tracking_policy}
99-
plausible={{
100-
isEnabled: newsroom.is_plausible_enabled,
101-
siteId: newsroom.plausible_site_id,
102-
}}
103-
segment={{ writeKey: newsroom.segment_analytics_id }}
104-
google={{ analyticsId: newsroom.google_analytics_id }}
105-
/>
106-
)}
107-
<Notifications localeCode={localeCode} />
108-
<PreviewBar newsroom={newsroom} />
109-
<div className={styles.layout}>
110-
<Header localeCode={localeCode} />
111-
<main className={styles.content}>{children}</main>
112-
<SubscribeForm />
113-
<Boilerplate localeCode={localeCode} />
114-
<Footer localeCode={localeCode} />
115-
</div>
116-
<ScrollToTopButton />
117-
<CookieConsent localeCode={localeCode} />
118-
<PreviewPageMask />
119-
<WindowScrollListener />
120-
</AppContext>
121-
</body>
122-
</html>
85+
<PreviewSettingsProvider>
86+
<html lang={isoCode} dir={direction}>
87+
<head>
88+
<meta name="og:locale" content={isoCode} />
89+
<Preconnect />
90+
<Branding />
91+
</head>
92+
<body>
93+
<AppContext localeCode={localeCode}>
94+
{isTrackingEnabled && (
95+
<Analytics
96+
meta={{
97+
newsroom: newsroom.uuid,
98+
tracking_policy: newsroom.tracking_policy,
99+
}}
100+
trackingPolicy={newsroom.tracking_policy}
101+
plausible={{
102+
isEnabled: newsroom.is_plausible_enabled,
103+
siteId: newsroom.plausible_site_id,
104+
}}
105+
segment={{ writeKey: newsroom.segment_analytics_id }}
106+
google={{ analyticsId: newsroom.google_analytics_id }}
107+
/>
108+
)}
109+
<Notifications localeCode={localeCode} />
110+
<PreviewBar newsroom={newsroom} />
111+
<div className={styles.layout}>
112+
<Header localeCode={localeCode} />
113+
<main className={styles.content}>{children}</main>
114+
<SubscribeForm />
115+
<Boilerplate localeCode={localeCode} />
116+
<Footer localeCode={localeCode} />
117+
</div>
118+
<ScrollToTopButton />
119+
<CookieConsent localeCode={localeCode} />
120+
<PreviewPageMask />
121+
<WindowScrollListener />
122+
</AppContext>
123+
</body>
124+
</html>
125+
</PreviewSettingsProvider>
123126
);
124127
}
125128

src/components/WindowScrollListener/WindowScrollListener.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ type ScrollToMessage = {
1212
value: number;
1313
};
1414

15+
type ScrollToSelectorMessage = {
16+
type: 'scrollToSelector';
17+
selector: string;
18+
};
19+
1520
export function WindowScrollListener() {
1621
useEffect(() => {
1722
function onScroll() {
@@ -30,10 +35,15 @@ export function WindowScrollListener() {
3035
}, []);
3136

3237
useEffect(() => {
33-
function onMessage(event: MessageEvent<ScrollToMessage>) {
38+
function onMessage(event: MessageEvent<ScrollToMessage | ScrollToSelectorMessage>) {
3439
if (event.data.type === 'scrollTo') {
3540
window.scrollTo({ top: event.data.value });
3641
}
42+
if (event.data.type === 'scrollToSelector') {
43+
document
44+
.querySelector(event.data.selector)
45+
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
46+
}
3747
}
3848

3949
window.addEventListener('message', onMessage);

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { useDevice } from './useDevice';
22
export { useMaskParam } from './useMaskParam';
3+
export { usePreviewSettings } from './usePreviewSettings';

src/hooks/usePreviewSettings.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use client';
2+
3+
import { usePreviewSettingsContext } from '@/modules/Broadcast';
4+
5+
const isPreviewMode = process.env.PREZLY_MODE === 'preview';
6+
7+
/**
8+
* Returns the latest preview settings received via postMessage from the parent frame,
9+
* or decoded from the URL hash (standalone preview links).
10+
* Settings are persisted to sessionStorage so they survive client-side navigation.
11+
* Returns `null` until settings are available, allowing callers to distinguish
12+
* "no settings yet" (fall back to props) from "settings received" (use as authoritative source).
13+
*/
14+
export function usePreviewSettings(): Record<string, string> | null {
15+
const { settings } = usePreviewSettingsContext();
16+
return isPreviewMode ? settings : null;
17+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use client';
2+
3+
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
4+
5+
import { decodePreviewHash } from '@/utils';
6+
7+
interface PreviewSettingsContextValue {
8+
settings: Record<string, string> | null;
9+
}
10+
11+
const context = createContext<PreviewSettingsContextValue>({ settings: null });
12+
13+
export function PreviewSettingsProvider({ children }: { children: ReactNode }) {
14+
const [settings, setSettings] = useState<Record<string, string> | null>(null);
15+
16+
// Seed from URL hash or sessionStorage
17+
useEffect(() => {
18+
const hashSettings = decodePreviewHash(window.location.hash);
19+
if (hashSettings) {
20+
setSettings(hashSettings);
21+
// sessionStorage is scoped to the browser tab and clears automatically when the tab closes.
22+
try {
23+
sessionStorage.setItem('previewSettings', JSON.stringify(hashSettings));
24+
} catch {}
25+
} else {
26+
try {
27+
const stored = sessionStorage.getItem('previewSettings');
28+
if (stored) setSettings(JSON.parse(stored));
29+
} catch {}
30+
}
31+
}, []);
32+
33+
// Single postMessage listener for all preview settings consumers
34+
useEffect(() => {
35+
function handleMessage(event: MessageEvent) {
36+
if (event.data?.type === 'settingsUpdate') {
37+
setSettings(event.data.settings);
38+
try {
39+
sessionStorage.setItem('previewSettings', JSON.stringify(event.data.settings));
40+
} catch {}
41+
}
42+
}
43+
window.addEventListener('message', handleMessage);
44+
return () => window.removeEventListener('message', handleMessage);
45+
}, []);
46+
47+
return <context.Provider value={{ settings }}>{children}</context.Provider>;
48+
}
49+
50+
export function usePreviewSettingsContext() {
51+
return useContext(context);
52+
}

src/modules/Broadcast/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export * from './BroadcastGallery';
22
export * from './BroadcastNotifications';
33
export * from './BroadcastPageType';
44
export * from './BroadcastPreview';
5+
export * from './PreviewSettings';
56
export * from './BroadcastStory';
67
export * from './BroadcastTranslations';

src/modules/Footer/ui/Footer.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useSearchParams } from 'next/navigation';
44
import type { ReactNode } from 'react';
55

66
import { MadeWithPrezly } from '@/components/MadeWithPrezly';
7+
import { usePreviewSettings } from '@/hooks';
78
import { parseBoolean } from '@/utils';
89

910
import styles from './Footer.module.scss';
@@ -15,10 +16,12 @@ interface Props {
1516

1617
export function Footer({ children, ...props }: Props) {
1718
const searchParams = useSearchParams();
18-
const isPreviewMode = process.env.PREZLY_MODE === 'preview';
19+
const previewSettings = usePreviewSettings();
1920

2021
let { isWhiteLabeled } = props;
21-
if (isPreviewMode && searchParams.has('is_white_labeled')) {
22+
if (previewSettings) {
23+
isWhiteLabeled = parseBoolean(previewSettings.is_white_labeled ?? String(isWhiteLabeled));
24+
} else if (process.env.PREZLY_MODE === 'preview' && searchParams.has('is_white_labeled')) {
2225
isWhiteLabeled = parseBoolean(searchParams.get('is_white_labeled'));
2326
}
2427

src/modules/Head/components/BrandingSettings.tsx

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import {
22
DEFAULT_THEME_SETTINGS,
3+
Font,
34
getGoogleFontName,
45
getRelatedFont,
6+
normalizeCustomFontDefinition,
7+
type CustomFont,
8+
type CustomFontDefinition,
9+
type FontVariant,
510
type ThemeSettings,
611
} from '@/theme-settings';
712
import { withoutUndefined } from '@/utils';
@@ -13,14 +18,98 @@ interface Props {
1318
settings: Partial<ThemeSettings>;
1419
}
1520

16-
export function BrandingSettings({ settings }: Props) {
17-
const compiledSettings: ThemeSettings = {
18-
...DEFAULT_THEME_SETTINGS,
19-
...withoutUndefined(settings),
20-
};
21+
function getGoogleFontsUrl(families: string[]): string {
22+
return `https://fonts.googleapis.com/css2?display=swap&${families
23+
.map((family) => `family=${family}`)
24+
.join('&')}`;
25+
}
26+
27+
function getFontFaceStyle(definition: CustomFontDefinition): string {
28+
return `@font-face { font-family: '${definition.family}'; src: url('${definition.url}'); font-display: swap; }`;
29+
}
2130

22-
const primaryGoogleFontName = getGoogleFontName(compiledSettings.font).replace(' ', '+');
23-
const relatedFont = getRelatedFont(compiledSettings.font);
31+
function getFontFaceStyleFromVariant(family: string, variant: FontVariant): string {
32+
return `@font-face { font-family: '${family}'; src: url('${variant.src}'); font-weight: ${variant.weight}; font-style: ${variant.style}; font-display: swap; }`;
33+
}
34+
35+
function collectCustomFontSlot(
36+
slot: CustomFontDefinition,
37+
weights: string,
38+
googleFamilies: string[],
39+
cssLinkUrls: string[],
40+
fontFaceStyles: string[],
41+
) {
42+
const normalized = normalizeCustomFontDefinition(slot);
43+
44+
switch (normalized.source) {
45+
case 'google':
46+
googleFamilies.push(`${normalized.family.replace(/ /g, '+')}:wght@${weights}`);
47+
break;
48+
case 'typekit':
49+
if (normalized.url) {
50+
cssLinkUrls.push(normalized.url);
51+
}
52+
break;
53+
case 'upload':
54+
if (normalized.variants?.length) {
55+
for (const v of normalized.variants) {
56+
fontFaceStyles.push(getFontFaceStyleFromVariant(normalized.family, v));
57+
}
58+
} else if (normalized.url) {
59+
fontFaceStyles.push(getFontFaceStyle(normalized));
60+
}
61+
break;
62+
case 'link':
63+
if (normalized.variants?.length) {
64+
for (const v of normalized.variants) {
65+
fontFaceStyles.push(getFontFaceStyleFromVariant(normalized.family, v));
66+
}
67+
} else if (normalized.url) {
68+
fontFaceStyles.push(getFontFaceStyle(normalized));
69+
}
70+
break;
71+
}
72+
}
73+
74+
function renderCustomFontElements(customFont: CustomFont) {
75+
const googleFamilies: string[] = [];
76+
const cssLinkUrls: string[] = [];
77+
const fontFaceStyles: string[] = [];
78+
79+
collectCustomFontSlot(
80+
customFont.heading,
81+
'400;600;700',
82+
googleFamilies,
83+
cssLinkUrls,
84+
fontFaceStyles,
85+
);
86+
collectCustomFontSlot(
87+
customFont.paragraph,
88+
'400;500;600;700;900',
89+
googleFamilies,
90+
cssLinkUrls,
91+
fontFaceStyles,
92+
);
93+
94+
return (
95+
<>
96+
{googleFamilies.length > 0 && (
97+
<link href={getGoogleFontsUrl(googleFamilies)} rel="stylesheet" />
98+
)}
99+
{[...new Set(cssLinkUrls)].map((url) => (
100+
<link key={url} href={url} rel="stylesheet" />
101+
))}
102+
{fontFaceStyles.length > 0 && (
103+
// biome-ignore lint/security/noDangerouslySetInnerHtml: Font-face CSS from trusted backend data
104+
<style dangerouslySetInnerHTML={{ __html: fontFaceStyles.join('\n') }} />
105+
)}
106+
</>
107+
);
108+
}
109+
110+
function renderPresetFontElements(font: Font) {
111+
const primaryGoogleFontName = getGoogleFontName(font).replace(' ', '+');
112+
const relatedFont = getRelatedFont(font);
24113

25114
let families = [];
26115
if (relatedFont) {
@@ -34,14 +123,21 @@ export function BrandingSettings({ settings }: Props) {
34123
families = [`${primaryGoogleFontName}:wght@400;500;600;700;900`];
35124
}
36125

126+
return <link href={getGoogleFontsUrl(families)} rel="stylesheet" />;
127+
}
128+
129+
export function BrandingSettings({ settings }: Props) {
130+
const compiledSettings: ThemeSettings = {
131+
...DEFAULT_THEME_SETTINGS,
132+
...withoutUndefined(settings),
133+
};
134+
135+
const { font, custom_font } = compiledSettings;
136+
const isCustomFont = font === Font.CUSTOM && custom_font !== null;
137+
37138
return (
38139
<>
39-
<link
40-
href={`https://fonts.googleapis.com/css2?display=swap&${families
41-
.map((family) => `family=${family}`)
42-
.join('&')}`}
43-
rel="stylesheet"
44-
/>
140+
{isCustomFont ? renderCustomFontElements(custom_font) : renderPresetFontElements(font)}
45141

46142
<InjectCssVariables variables={getCssVariables(compiledSettings)} />
47143
</>

0 commit comments

Comments
 (0)