0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-08 02:52:39 -05:00

Custom fonts (#21337)

- Adding custom fonts for themes behind a feature flag
- Introduces new `@tryghost/custom-fonts` module to manage custom fonts
- UI updates for Branding and Theme settings

---------

Co-authored-by: Fabien O'Carroll <fabien@allou.is>
Co-authored-by: Sodbileg Gansukh <sodbileg.gansukh@gmail.com>
Co-authored-by: Peter Zimon <peter.zimon@gmail.com>
Co-authored-by: Sanne de Vries <sannedv@protonmail.com>
Co-authored-by: Daniël van der Winden <danielvanderwinden@ghost.org>
This commit is contained in:
Aileen Booker 2024-10-24 07:43:08 -04:00 committed by GitHub
parent 96239d31a6
commit c1ce322e86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2184 additions and 481 deletions

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="Browser-Page-Layout--Streamline-Ultimate" height="24" width="24"><desc>Browser Page Layout Streamline Icon: https://streamlinehq.com</desc><defs></defs><title>browser-page-layout</title><path d="M3 2.25h18s1.5 0 1.5 1.5v16.5s0 1.5 -1.5 1.5H3s-1.5 0 -1.5 -1.5V3.75s0 -1.5 1.5 -1.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="m1.5 6.75 21 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="m9 6.75 0 15" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="m9 14.25 13.5 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>

After

Width:  |  Height:  |  Size: 853 B

View file

@ -38,7 +38,7 @@ type HeadingLabelProps = {
level?: never,
grey?: boolean } & HeadingBaseProps & React.LabelHTMLAttributes<HTMLLabelElement>
export const Heading6Styles = clsx('text-xs font-semibold tracking-normal');
export const Heading6Styles = clsx('text-sm font-medium tracking-normal');
export const Heading6StylesGrey = clsx(
Heading6Styles,
'text-grey-900 dark:text-grey-500'

View file

@ -6,7 +6,7 @@ export interface DesktopChromeProps {
const DesktopChrome: React.FC<DesktopChromeProps & React.HTMLAttributes<HTMLDivElement>> = ({children, ...props}) => {
return (
<div className='flex h-full w-full flex-col px-5' {...props}>
<div className='flex h-full w-full flex-col px-8' {...props}>
<div className="h-full w-full overflow-hidden rounded-t-[4px] shadow-sm">
{children}
</div>

View file

@ -92,7 +92,7 @@ const ColorIndicator: React.FC<ColorIndicatorProps> = ({title, value, swatches,
))}
</div>
{picker &&
<button aria-label="Pick color" className="relative h-6 w-6 cursor-pointer rounded-full border border-grey-200 dark:border-grey-800" type="button" onClick={onTogglePicker}>
<button aria-label="Pick color" className="relative h-8 w-8 cursor-pointer rounded-full border border-grey-200 dark:border-grey-800" type="button" onClick={onTogglePicker}>
<div className='absolute inset-0 rounded-full bg-[conic-gradient(hsl(360,100%,50%),hsl(315,100%,50%),hsl(270,100%,50%),hsl(225,100%,50%),hsl(180,100%,50%),hsl(135,100%,50%),hsl(90,100%,50%),hsl(45,100%,50%),hsl(0,100%,50%))]' />
{value && !selectedSwatch && (
<div className="absolute inset-[3px] overflow-hidden rounded-full border border-white dark:border-grey-950" style={{backgroundColor: value}}>

View file

@ -5,8 +5,8 @@ import Heading from '../Heading';
export interface FormProps {
title?: string;
grouped?: boolean;
gap?: 'none' | 'sm' | 'md' | 'lg';
margins?: 'none' | 'sm' | 'md' | 'lg';
gap?: 'none' | 'xs' | 'sm' | 'md' | 'lg';
margins?: 'none' | 'xs' | 'sm' | 'md' | 'lg';
marginTop?: boolean;
marginBottom?: boolean;
className?: string;
@ -28,6 +28,7 @@ const Form: React.FC<FormProps> = ({
}) => {
let classes = clsx(
'flex flex-col',
(gap === 'xs' && 'gap-4'),
(gap === 'sm' && 'gap-6'),
(gap === 'md' && 'gap-8'),
(gap === 'lg' && 'gap-11')
@ -41,8 +42,8 @@ const Form: React.FC<FormProps> = ({
classes = clsx(
classes,
(margins === 'sm' && 'mb-7'),
(margins === 'md' && 'mb-11'),
(margins === 'lg' && 'mb-14')
(margins === 'md' && 'mb-10'),
(margins === 'lg' && 'mb-12')
);
}
@ -50,8 +51,8 @@ const Form: React.FC<FormProps> = ({
classes = clsx(
classes,
(margins === 'sm' && 'mt-7'),
(margins === 'md' && 'mt-11'),
(margins === 'lg' && 'mt-14')
(margins === 'md' && 'mt-10'),
(margins === 'lg' && 'mt-12')
);
}
@ -78,7 +79,7 @@ const Form: React.FC<FormProps> = ({
}
return (
<div className={classes + className}>
<div className={clsx(classes, className)}>
{children}
</div>
);

View file

@ -1,6 +1,6 @@
import clsx from 'clsx';
import React, {useId, useMemo, useEffect} from 'react';
import ReactSelect, {ClearIndicatorProps, DropdownIndicatorProps, GroupBase, OptionProps, OptionsOrGroups, Props, components} from 'react-select';
import ReactSelect, {ClearIndicatorProps, DropdownIndicatorProps, GroupBase, MenuPlacement, OptionProps, OptionsOrGroups, Props, components} from 'react-select';
import AsyncSelect from 'react-select/async';
import {useFocusContext} from '../../providers/DesignSystemProvider';
import Heading from '../Heading';
@ -82,7 +82,7 @@ const ClearIndicator: React.FC<ClearIndicatorProps<SelectOption, false>> = props
const Option: React.FC<OptionProps<SelectOption, false>> = ({children, ...optionProps}) => (
<components.Option {...optionProps}>
<span data-testid="select-option" data-value={optionProps.data.value}>{children}</span>
<span className={optionProps.isSelected ? 'relative flex w-full items-center justify-between gap-2' : ''} data-testid="select-option" data-value={optionProps.data.value}>{children}{optionProps.isSelected && <span><Icon name='check' size={14} /></span>}</span>
{optionProps.data.hint && <span className="block text-xs text-grey-700 dark:text-grey-300">{optionProps.data.hint}</span>}
</components.Option>
);
@ -190,7 +190,13 @@ const Select: React.FC<SelectProps> = ({
control: () => customClasses.control,
placeholder: () => customClasses.placeHolder,
menu: () => customClasses.menu,
option: () => customClasses.option,
/* eslint-disable @typescript-eslint/no-explicit-any */
option: (state: any) => {
if (state.data.className) {
return clsx(customClasses.option, state.data.className);
}
return customClasses.option;
},
noOptionsMessage: () => customClasses.noOptionsMessage,
groupHeading: () => customClasses.groupHeading,
clearIndicator: () => customClasses.clearIndicator
@ -206,7 +212,8 @@ const Select: React.FC<SelectProps> = ({
unstyled: true,
onChange: onSelect,
onFocus: handleFocus,
onBlur: handleBlur
onBlur: handleBlur,
menuPlacement: 'auto' as MenuPlacement
};
const select = (

View file

@ -28,7 +28,7 @@ const ToggleGroup: React.FC<ToggleGroupProps> = ({children, gap = 'md', classNam
}
className = clsx(
'flex flex-col gap-3',
'flex flex-col',
gapClass,
className
);

View file

@ -28,7 +28,7 @@ const PageHeader: React.FC<PageHeaderProps> = ({
children
}) => {
const containerClasses = clsx(
'z-50 h-22 min-h-[92px] p-8 px-6 tablet:px-12',
'z-50 h-22 min-h-[92px] p-8',
!children && 'flex items-center justify-between gap-3',
sticky && 'sticky top-0',
containerClassName

View file

@ -220,11 +220,11 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
preview = (
<div className={containerClasses}>
{previewToolbar && <header className="relative flex h-[74px] shrink-0 items-center justify-center px-3 py-5" data-testid="design-toolbar">
{leftToolbar && <div className='absolute left-5 flex h-full items-center'>
{previewToolbar && <header className="relative flex h-[80px] shrink-0 items-center justify-center px-8 py-5" data-testid="design-toolbar">
{leftToolbar && <div className='absolute left-8 flex h-full items-center'>
{toolbarLeft}
</div>}
{rightToolbar && <div className='absolute right-5 flex h-full items-center'>
{rightToolbar && <div className='absolute right-8 flex h-full items-center'>
{toolbarRight}
{viewSiteButton}
</div>}
@ -287,7 +287,7 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
{sidebarButtons ? sidebarButtons : <ButtonGroup buttons={buttons} /> }
</div>
)}
<div className={`${!sidebarHeader ? 'absolute inset-x-0 bottom-0 top-[74px] grow' : ''} ${sidebarPadding && 'p-7 pt-0'} flex flex-col justify-between overflow-y-auto ${sidebarContentClasses && sidebarContentClasses}`}>
<div className={`${!sidebarHeader ? 'absolute inset-x-0 bottom-0 top-[80px] grow' : ''} ${sidebarPadding && 'p-7 pt-0'} flex flex-col justify-between overflow-y-auto ${sidebarContentClasses && sidebarContentClasses}`}>
{sidebar}
</div>
</div>

View file

@ -4,6 +4,28 @@
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import url(https://fonts.bunny.net/css?family=cardo:400,700);
@import url(https://fonts.bunny.net/css?family=manrope:300,500,700);
@import url(https://fonts.bunny.net/css?family=merriweather:300,700);
@import url(https://fonts.bunny.net/css?family=nunito:400,600,700);
@import url(https://fonts.bunny.net/css?family=old-standard-tt:400,700);
@import url(https://fonts.bunny.net/css?family=prata:400);
@import url(https://fonts.bunny.net/css?family=roboto:400,500,700);
@import url(https://fonts.bunny.net/css?family=rufina:400,500,700);
@import url(https://fonts.bunny.net/css?family=tenor-sans:400);
@import url(https://fonts.bunny.net/css?family=space-grotesk:700);
@import url(https://fonts.bunny.net/css?family=chakra-petch:400);
@import url(https://fonts.bunny.net/css?family=noto-sans:400,700);
@import url(https://fonts.bunny.net/css?family=poppins:400,700);
@import url(https://fonts.bunny.net/css?family=fira-sans:400,700);
@import url(https://fonts.bunny.net/css?family=inter:400,700);
@import url(https://fonts.bunny.net/css?family=noto-serif:400,700);
@import url(https://fonts.bunny.net/css?family=lora:400,700);
@import url(https://fonts.bunny.net/css?family=ibm-plex-serif:400,700);
@import url(https://fonts.bunny.net/css?family=space-mono:400,700);
@import url(https://fonts.bunny.net/css?family=fira-mono:400,700);
@import url(https://fonts.bunny.net/css?family=jetbrains-mono:400,700);
/* Defaults */
@layer base {
/* This just serves as a placeholder; we actually load Inter from a font file in Ember admin */

View file

@ -89,11 +89,31 @@ module.exports = {
}
},
fontFamily: {
cardo: 'Cardo',
manrope: 'Manrope',
merriweather: 'Merriweather',
nunito: 'Nunito',
'tenor-sans': 'Tenor Sans',
'old-standard-tt': 'Old Standard TT',
prata: 'Prata',
roboto: 'Roboto',
rufina: 'Rufina',
inter: 'Inter',
sans: 'Inter, -apple-system, BlinkMacSystemFont, avenir next, avenir, helvetica neue, helvetica, ubuntu, roboto, noto, segoe ui, arial, sans-serif',
serif: 'Georgia, serif',
mono: 'Consolas, Liberation Mono, Menlo, Courier, monospace',
inherit: 'inherit'
inherit: 'inherit',
'space-grotesk': 'Space Grotesk',
'chakra-petch': 'Chakra Petch',
'noto-sans': 'Noto Sans',
poppins: 'Poppins',
'fira-sans': 'Fira Sans',
'noto-serif': 'Noto Serif',
lora: 'Lora',
'ibm-plex-serif': 'IBM Plex Serif',
'space-mono': 'Space Mono',
'fira-mono': 'Fira Mono',
'jetbrains-mono': 'JetBrains Mono'
},
boxShadow: {
DEFAULT: '0 0 1px rgba(0,0,0,.05), 0 5px 18px rgba(0,0,0,.08)',

View file

@ -327,6 +327,14 @@
{
"key": "support_email_address",
"value": "support@example.com"
},
{
"key": "heading_font",
"value": null
},
{
"key": "body_font",
"value": null
}
],
"meta": {

View file

@ -49,6 +49,7 @@
"@testing-library/react": "14.3.1",
"@tryghost/admin-x-design-system": "0.0.0",
"@tryghost/admin-x-framework": "0.0.0",
"@tryghost/custom-fonts": "0.0.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@types/validator": "13.12.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View file

@ -0,0 +1,18 @@
import React, {ReactNode} from 'react';
import useFeatureFlag from '../hooks/useFeatureFlag';
type BehindFeatureFlagProps = {
flag: string
children: ReactNode
};
const BehindFeatureFlag: React.FC<BehindFeatureFlagProps> = ({flag, children}) => {
const enabled = useFeatureFlag(flag);
if (!enabled) {
return null;
}
return <>{children}</>;
};
export default BehindFeatureFlag;

View file

@ -167,6 +167,7 @@ const Sidebar: React.FC = () => {
<SettingNavSection isVisible={checkVisible(Object.values(siteSearchKeywords).flat())} title="Site">
<NavItem icon='palette' keywords={siteSearchKeywords.design} navid='design' title="Design & branding" onClick={handleSectionClick} />
<NavItem icon='layout-2-col' keywords={siteSearchKeywords.theme} navid='theme' title="Theme" onClick={handleSectionClick} />
<NavItem icon='navigation' keywords={siteSearchKeywords.navigation} navid='navigation' title="Navigation" onClick={handleSectionClick} />
<NavItem icon='megaphone' keywords={siteSearchKeywords.announcementBar} navid='announcement-bar' title="Announcement bar" onClick={handleSectionClick} />
</SettingNavSection>

View file

@ -4,7 +4,11 @@ import {useRouting} from '@tryghost/admin-x-framework/routing';
import {useScrollSection} from '../hooks/useScrollSection';
import {useSearch} from './providers/SettingsAppProvider';
const TopLevelGroup: React.FC<Omit<SettingGroupProps, 'isVisible' | 'highlight'> & {keywords: string[]}> = ({keywords, navid, ...props}) => {
interface TopLevelGroupProps extends Omit<SettingGroupProps, 'isVisible' | 'highlight'> {
keywords: string[];
}
const TopLevelGroup: React.FC<TopLevelGroupProps> = ({keywords, navid, children, ...props}) => {
const {checkVisible, noResult} = useSearch();
const {route} = useRouting();
const [highlight, setHighlight] = useState(false);
@ -12,17 +16,38 @@ const TopLevelGroup: React.FC<Omit<SettingGroupProps, 'isVisible' | 'highlight'>
useEffect(() => {
setHighlight(route === navid);
if (route === navid) {
const timer = setTimeout(() => setHighlight(false), 2000);
return () => clearTimeout(timer);
}
}, [route, navid]);
useEffect(() => {
if (highlight) {
setTimeout(() => {
setHighlight(false);
}, 2000);
}
}, [highlight]);
const hasImageChild = React.Children.toArray(children).some(
child => React.isValidElement(child) && child.type === 'img'
);
return <Base ref={ref} highlight={highlight} isVisible={checkVisible(keywords) || noResult} navid={navid} {...props} />;
const wrappedChildren = hasImageChild ? (
<div className="-mx-5 -mb-5 overflow-hidden rounded-b-xl bg-grey-50 md:-mx-7 md:-mb-7">
{React.Children.map(children, child => (React.isValidElement<React.ImgHTMLAttributes<HTMLImageElement>>(child) && child.type === 'img'
? React.cloneElement(child, {
className: `${child.props.className || ''} h-full w-full rounded-b-xl`.trim()
})
: child)
)}
</div>
) : children;
return (
<Base
ref={ref}
highlight={highlight}
isVisible={checkVisible(keywords) || noResult}
navid={navid}
{...props}
>
{wrappedChildren}
</Base>
);
};
export default TopLevelGroup;

View file

@ -60,9 +60,9 @@ const features = [{
description: 'Enables new comment features',
flag: 'commentImprovements'
}, {
title: 'Staff 2FA',
description: 'Enables email verification for staff logins',
flag: 'staff2fa'
title: 'Custom Fonts',
description: 'Enables new custom font settings',
flag: 'customFonts'
}];
const AlphaFeatures: React.FC = () => {

View file

@ -76,14 +76,14 @@ const LookAndFeel: React.FC<{
return <div className='mt-7'><Form>
<Toggle
checked={Boolean(portalButton)}
direction='rtl'
label='Show portal button'
labelStyle='heading'
onChange={e => updateSetting('portal_button', e.target.checked)}
/>
<Select
options={portalButtonOptions}
selectedOption={portalButtonOptions.find(option => option.value === portalButtonStyle)}
title='Portal button style'
title='Button style'
onSelect={option => updateSetting('portal_button_style', option?.value || null)}
/>
{portalButtonStyle?.toString()?.includes('icon') &&

View file

@ -118,15 +118,15 @@ const SignupOptions: React.FC<{
return <div className='mt-7'><Form>
<Toggle
checked={Boolean(portalName)}
direction='rtl'
disabled={isDisabled}
label='Display name in signup form'
labelStyle='heading'
onChange={e => updateSetting('portal_name', e.target.checked)}
/>
<CheckboxGroup
checkboxes={tiersCheckboxes}
title='Tiers available at signup'
title='Available tiers'
/>
{arePaidTiersVisible && (
@ -152,7 +152,7 @@ const SignupOptions: React.FC<{
}
}
]}
title='Prices available at signup'
title='Available prices'
/>
{(portalPlans.includes('yearly') && portalPlans.includes('monthly')) &&
<Select

View file

@ -0,0 +1,42 @@
import React from 'react';
import TopLevelGroup from '../../TopLevelGroup';
import {Button, SettingGroupContent, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {Theme, useBrowseThemes} from '@tryghost/admin-x-framework/api/themes';
import {useRouting} from '@tryghost/admin-x-framework/routing';
const ChangeTheme: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
const {data: themesData} = useBrowseThemes();
const activeTheme = themesData?.themes.find((theme: Theme) => theme.active);
const openPreviewModal = () => {
updateRoute('design/change-theme');
};
const values = (
<SettingGroupContent
values={[
{
heading: 'Active theme',
key: 'active-theme',
value: activeTheme ? `${activeTheme.name} (v${activeTheme.package?.version || '1.0'})` : 'Loading...'
}
]}
/>
);
return (
<TopLevelGroup
customButtons={<Button className='mt-[-5px]' color='clear' label='Change theme' size='sm' onClick={openPreviewModal}/>}
description="Browse and install official themes or upload one"
keywords={keywords}
navid='theme'
testId='theme'
title="Theme"
>
{values}
</TopLevelGroup>
);
};
export default withErrorBoundary(ChangeTheme, 'Branding and design');

View file

@ -1,53 +1,49 @@
import BrandSettings, {BrandSettingValues} from './designAndBranding/BrandSettings';
import GlobalSettings, {GlobalSettingValues} from './designAndBranding/GlobalSettings';
import React, {useEffect, useState} from 'react';
import ThemePreview from './designAndBranding/ThemePreview';
import ThemeSettings from './designAndBranding/ThemeSettings';
import useQueryParams from '../../../hooks/useQueryParams';
import {CustomThemeSetting, useBrowseCustomThemeSettings, useEditCustomThemeSettings} from '@tryghost/admin-x-framework/api/customThemeSettings';
import {Icon, PreviewModalContent, StickyFooter, Tab, TabView} from '@tryghost/admin-x-design-system';
import {PreviewModalContent, Tab, TabView} from '@tryghost/admin-x-design-system';
import {Setting, SettingValue, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings';
import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site';
import {useBrowsePosts} from '@tryghost/admin-x-framework/api/posts';
import {useBrowseThemes} from '@tryghost/admin-x-framework/api/themes';
import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
import {useGlobalData} from '../../providers/GlobalDataProvider';
import {useRouting} from '@tryghost/admin-x-framework/routing';
const Sidebar: React.FC<{
brandSettings: BrandSettingValues
globalSettings: GlobalSettingValues
themeSettingSections: Array<{id: string, title: string, settings: CustomThemeSetting[]}>
updateBrandSetting: (key: string, value: SettingValue) => void
updateGlobalSetting: (key: string, value: SettingValue) => void
updateThemeSetting: (updated: CustomThemeSetting) => void
onTabChange: (id: string) => void
handleSave: () => Promise<boolean>
}> = ({
brandSettings,
globalSettings,
themeSettingSections,
updateBrandSetting,
updateGlobalSetting,
updateThemeSetting,
onTabChange,
handleSave
onTabChange
}) => {
const {updateRoute} = useRouting();
const [selectedTab, setSelectedTab] = useState('brand');
const {data: {themes} = {}} = useBrowseThemes();
const refParam = useQueryParams().getParam('ref');
const activeTheme = themes?.find(theme => theme.active);
const [selectedTab, setSelectedTab] = useState('global');
const tabs: Tab[] = [
{
id: 'brand',
title: 'Brand',
contents: <BrandSettings updateSetting={updateBrandSetting} values={brandSettings} />
},
...themeSettingSections.map(({id, title, settings}) => ({
id,
title,
contents: <ThemeSettings settings={settings} updateSetting={updateThemeSetting} />
}))
id: 'global',
title: 'Global',
contents: <GlobalSettings updateSetting={updateGlobalSetting} values={globalSettings} />
}
];
if (themeSettingSections.length > 0) {
tabs.push({
id: 'theme-settings',
title: 'Theme settings',
contents: <ThemeSettings sections={themeSettingSections} updateSetting={updateThemeSetting} />
});
}
const handleTabChange = (id: string) => {
setSelectedTab(id);
onTabChange(id);
@ -55,27 +51,13 @@ const Sidebar: React.FC<{
return (
<div className='flex h-full flex-col justify-between'>
<div className='p-7' data-testid="design-setting-tabs">
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={handleTabChange} />
<div className='grow p-7 pt-2' data-testid="design-setting-tabs">
{tabs.length > 1 ?
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={handleTabChange} />
:
<GlobalSettings updateSetting={updateGlobalSetting} values={globalSettings} />
}
</div>
<StickyFooter height={74}>
<div className='w-full px-7'>
<button className='group flex w-full items-center justify-between text-sm font-medium opacity-80 transition-all hover:opacity-100' data-testid='change-theme' type='button' onClick={async () => {
await handleSave();
if (refParam) {
updateRoute(`design/change-theme?ref=${refParam}`);
} else {
updateRoute('design/change-theme');
}
}}>
<div className='text-left'>
<div className='font-semibold'>Change theme</div>
<div className='font-sm text-grey-700'>Current theme: {activeTheme?.name} - v{activeTheme?.package.version}</div>
</div>
<Icon className='mr-2 transition-all group-hover:translate-x-2 dark:text-white' name='chevron-right' size='sm' />
</button>
</div>
</StickyFooter>
</div>
);
};
@ -132,7 +114,7 @@ const DesignModal: React.FC = () => {
}
}, [setFormState, themeSettings]);
const updateBrandSetting = (key: string, value: SettingValue) => {
const updateGlobalSetting = (key: string, value: SettingValue) => {
updateForm(state => ({...state, settings: state.settings.map(setting => (
setting.key === key ? {...setting, value, dirty: true} : setting
))}));
@ -144,7 +126,7 @@ const DesignModal: React.FC = () => {
))}));
};
const [description, accentColor, icon, logo, coverImage] = getSettingValues(formState.settings, ['description', 'accent_color', 'icon', 'logo', 'cover_image']) as string[];
const [description, accentColor, icon, logo, coverImage, headingFont, bodyFont] = getSettingValues(formState.settings, ['description', 'accent_color', 'icon', 'logo', 'cover_image', 'heading_font', 'body_font']) as string[];
const themeSettingGroups = (formState.themeSettings || []).reduce((groups, setting) => {
const group = (setting.group === 'homepage' || setting.group === 'post') ? setting.group : 'site-wide';
@ -201,16 +183,18 @@ const DesignModal: React.FC = () => {
icon,
logo,
coverImage,
themeSettings: formState.themeSettings
themeSettings: formState.themeSettings,
headingFont,
bodyFont
}}
url={selectedTabURL}
/>;
const sidebarContent =
<Sidebar
brandSettings={{description, accentColor, icon, logo, coverImage}}
globalSettings={{description, accentColor, icon, logo, coverImage, headingFont, bodyFont}}
handleSave={handleSave}
themeSettingSections={themeSettingSections}
updateBrandSetting={updateBrandSetting}
updateGlobalSetting={updateGlobalSetting}
updateThemeSetting={updateThemeSetting}
onTabChange={onTabChange}
/>;

View file

@ -1,3 +1,4 @@
import DesignSettingsImg from '../../../assets/images/design-settings.png';
import React from 'react';
import TopLevelGroup from '../../TopLevelGroup';
import {Button, withErrorBoundary} from '@tryghost/admin-x-design-system';
@ -12,12 +13,13 @@ const DesignSetting: React.FC<{ keywords: string[] }> = ({keywords}) => {
return (
<TopLevelGroup
customButtons={<Button className='mt-[-5px]' color='clear' label='Customize' size='sm' onClick={openPreviewModal}/>}
description="Customize the theme, colors, and layout of your site"
description="Customize the style and layout of your site"
keywords={keywords}
navid='design'
testId='design'
title="Design & branding"
/>
title="Design & branding">
<img src={DesignSettingsImg} />
</TopLevelGroup>
);
};

View file

@ -1,4 +1,5 @@
import AnnouncementBar from './AnnouncementBar';
import ChangeTheme from './ChangeTheme';
import DesignSetting from './DesignSetting';
import Navigation from './Navigation';
import React from 'react';
@ -6,6 +7,7 @@ import SearchableSection from '../../SearchableSection';
export const searchKeywords = {
design: ['site', 'logo', 'cover', 'colors', 'fonts', 'background', 'themes', 'appearance', 'style', 'design & branding', 'design and branding'],
theme: ['theme', 'template', 'upload'],
navigation: ['site', 'navigation', 'menus', 'primary', 'secondary', 'links'],
announcementBar: ['site', 'announcement bar', 'important', 'banner']
};
@ -15,6 +17,7 @@ const SiteSettings: React.FC = () => {
<>
<SearchableSection keywords={Object.values(searchKeywords).flat()} title="Site">
<DesignSetting keywords={searchKeywords.design} />
<ChangeTheme keywords={searchKeywords.theme} />
<Navigation keywords={searchKeywords.navigation} />
<AnnouncementBar keywords={searchKeywords.announcementBar} />
</SearchableSection>

View file

@ -6,7 +6,7 @@ import React, {useEffect, useState} from 'react';
import ThemeInstalledModal from './theme/ThemeInstalledModal';
import ThemePreview from './theme/ThemePreview';
import useQueryParams from '../../../hooks/useQueryParams';
import {Breadcrumbs, Button, ConfirmationModal, FileUpload, LimitModal, Modal, PageHeader, TabView, showToast} from '@tryghost/admin-x-design-system';
import {Button, ConfirmationModal, FileUpload, LimitModal, Modal, PageHeader, TabView, showToast} from '@tryghost/admin-x-design-system';
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
import {InstalledTheme, Theme, ThemesInstallResponseType, isDefaultOrLegacyTheme, useActivateTheme, useBrowseThemes, useInstallTheme, useUploadTheme} from '@tryghost/admin-x-framework/api/themes';
import {JSONError} from '@tryghost/admin-x-framework/errors';
@ -54,11 +54,11 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
setCurrentTab,
themes
}) => {
const modal = useModal();
const {updateRoute} = useRouting();
const {mutateAsync: uploadTheme} = useUploadTheme();
const limiter = useLimiter();
const handleError = useHandleError();
const refParam = useQueryParams().getParam('ref');
const [uploadConfig, setUploadConfig] = useState<{enabled: boolean; error?: string}>();
@ -80,11 +80,7 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
}, [limiter]);
const onClose = () => {
if (refParam) {
updateRoute(`design/edit?ref=${refParam}`);
} else {
updateRoute('design/edit');
}
updateRoute('/');
};
const onThemeUpload = async (file: File) => {
@ -169,7 +165,7 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
title,
prompt,
fatalErrors,
onRetry: async (modal) => {
onRetry: async () => {
modal?.remove();
handleUpload();
}
@ -219,17 +215,18 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
};
const left =
<Breadcrumbs
activeItemClassName='hidden md:!block md:!visible'
itemClassName='hidden md:!block md:!visible'
items={[
{label: 'Design', onClick: onClose},
{label: 'Change theme'}
<div className='hidden md:!visible md:!block'>
<TabView
border={false}
selectedTab={currentTab}
tabs={[
{id: 'official', title: 'Official themes'},
{id: 'installed', title: 'Installed'}
]}
separatorClassName='hidden md:!block md:!visible'
backIcon
onBack={onClose}
/>;
onTabChange={(id: string) => {
setCurrentTab(id);
}} />
</div>;
const handleUpload = () => {
if (uploadConfig?.enabled) {
@ -250,19 +247,11 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
const right =
<div className='flex items-center gap-14'>
<div className='hidden md:!visible md:!block'>
<TabView
border={false}
selectedTab={currentTab}
tabs={[
{id: 'official', title: 'Official themes'},
{id: 'installed', title: 'Installed'}
]}
onTabChange={(id: string) => {
setCurrentTab(id);
}} />
</div>
<div className='flex items-center gap-3'>
<Button label='Close' onClick={() => {
modal.remove();
onClose();
}} />
<Button color='black' label='Upload theme' loading={isUploading} onClick={handleUpload} />
</div>
</div>;
@ -372,11 +361,7 @@ const ChangeThemeModal: React.FC<ChangeThemeModalProps> = ({source, themeRef}) =
});
}
confirmModal?.remove();
if (refParam) {
updateRoute(`design/edit?ref=${refParam}`);
} else {
updateRoute('design/edit');
}
updateRoute('');
} catch (e) {
handleError(e);
}
@ -457,11 +442,7 @@ const ChangeThemeModal: React.FC<ChangeThemeModalProps> = ({source, themeRef}) =
prompt,
installedTheme: installedTheme!,
onActivate: () => {
if (refParam) {
updateRoute(`design/edit?ref=${refParam}`);
} else {
updateRoute('design/edit');
}
updateRoute('');
}
});
};
@ -470,11 +451,7 @@ const ChangeThemeModal: React.FC<ChangeThemeModalProps> = ({source, themeRef}) =
return (
<Modal
afterClose={() => {
if (refParam) {
updateRoute(`design/edit?ref=${refParam}`);
} else {
updateRoute('design/edit');
}
updateRoute('');
}}
animate={false}
cancelLabel=''
@ -500,7 +477,7 @@ const ChangeThemeModal: React.FC<ChangeThemeModalProps> = ({source, themeRef}) =
setSelectedTheme(null);
}}
onClose={() => {
updateRoute('design/edit');
updateRoute('');
}}
onInstall={onInstall} />
}

View file

@ -1,181 +0,0 @@
import React, {useRef, useState} from 'react';
import UnsplashSelector from '../../../selectors/UnsplashSelector';
import usePinturaEditor from '../../../../hooks/usePinturaEditor';
import {APIError} from '@tryghost/admin-x-framework/errors';
import {ColorPickerField, Heading, Hint, ImageUpload, SettingGroupContent, TextField, debounce} from '@tryghost/admin-x-design-system';
import {SettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
import {useFramework} from '@tryghost/admin-x-framework';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
export interface BrandSettingValues {
description: string
accentColor: string
icon: string | null
logo: string | null
coverImage: string | null
}
const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key: string, value: SettingValue) => void }> = ({values,updateSetting}) => {
const {mutateAsync: uploadImage} = useUploadImage();
const [siteDescription, setSiteDescription] = useState(values.description);
const {settings} = useGlobalData();
const [unsplashEnabled] = getSettingValues<boolean>(settings, ['unsplash']);
const [showUnsplash, setShowUnsplash] = useState<boolean>(false);
const {unsplashConfig} = useFramework();
const handleError = useHandleError();
const updateDescriptionDebouncedRef = useRef(
debounce((value: string) => {
updateSetting('description', value);
}, 500)
);
const editor = usePinturaEditor();
return (
<div className='mt-7'>
<SettingGroupContent>
<TextField
key='site-description'
hint='Used in your theme, meta data and search results'
maxLength={200}
title='Site description'
value={siteDescription}
onChange={(event) => {
// Immediately update the local state
setSiteDescription(event.target.value);
// Debounce the updateSetting call
updateDescriptionDebouncedRef.current(event.target.value);
}}
/>
<ColorPickerField
debounceMs={200}
direction='rtl'
title={<Heading className='mt-[3px]' grey={true} level={6}>Accent color</Heading>}
value={values.accentColor}
alwaysOpen
// we debounce this because the color picker fires a lot of events.
onChange={value => updateSetting('accent_color', value)}
/>
<div className={`flex justify-between ${values.icon ? 'items-start ' : 'items-end'}`}>
<div>
<Heading grey={(values.icon ? true : false)} level={6}>Publication icon</Heading>
<Hint className='mr-5 max-w-[160px]'>A square, social icon, at least 60x60px</Hint>
</div>
<div className='flex gap-3'>
<ImageUpload
deleteButtonClassName='!top-1 !right-1'
editButtonClassName='!top-1 !right-1'
height={values.icon ? '66px' : '36px'}
id='logo'
imageBWCheckedBg={true}
imageURL={values.icon || ''}
width={values.icon ? '66px' : '150px'}
onDelete={() => updateSetting('icon', null)}
onUpload={async (file) => {
try {
updateSetting('icon', getImageUrl(await uploadImage({file})));
} catch (e) {
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>
Upload icon
</ImageUpload>
</div>
</div>
<div>
<Heading className='mb-2' grey={(values.logo ? true : false)} level={6}>Publication logo</Heading>
<ImageUpload
deleteButtonClassName='!top-1 !right-1'
height='80px'
id='site-logo'
imageBWCheckedBg={true}
imageFit='contain'
imageURL={values.logo || ''}
onDelete={() => updateSetting('logo', null)}
onUpload={async (file) => {
try {
updateSetting('logo', getImageUrl(await uploadImage({file})));
} catch (e) {
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>
Upload logo
</ImageUpload>
</div>
<div>
<Heading className='mb-2' grey={(values.coverImage ? true : false)} level={6}>Publication cover</Heading>
<ImageUpload
deleteButtonClassName='!top-1 !right-1'
editButtonClassName='!top-1 !right-10'
height='180px'
id='cover'
imageURL={values.coverImage || ''}
openUnsplash={() => setShowUnsplash(true)}
pintura={
{
isEnabled: editor.isEnabled,
openEditor: async () => editor.openEditor({
image: values.coverImage || '',
handleSave: async (file:File) => {
try {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
}
}
})
}
}
unsplashButtonClassName='!top-1 !right-1 z-50'
unsplashEnabled={unsplashEnabled}
onDelete={() => updateSetting('cover_image', null)}
onUpload={async (file: any) => {
try {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
} catch (e) {
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>
Upload cover
</ImageUpload>
{
showUnsplash && unsplashConfig && unsplashEnabled && (
<UnsplashSelector
unsplashProviderConfig={unsplashConfig}
onClose={() => {
setShowUnsplash(false);
}}
onImageInsert={(image) => {
if (image.src) {
updateSetting('cover_image', image.src);
}
setShowUnsplash(false);
}}
/>
)
}
</div>
</SettingGroupContent>
</div>
);
};
export default BrandSettings;

View file

@ -0,0 +1,265 @@
import BehindFeatureFlag from '../../../BehindFeatureFlag';
import React, {useState} from 'react';
import UnsplashSelector from '../../../selectors/UnsplashSelector';
import clsx from 'clsx';
import usePinturaEditor from '../../../../hooks/usePinturaEditor';
import {APIError} from '@tryghost/admin-x-framework/errors';
import {CUSTOM_FONTS, getCSSFriendlyFontClassName} from '@tryghost/custom-fonts';
import {ColorPickerField, Form, Hint, ImageUpload, Select} from '@tryghost/admin-x-design-system';
import {SettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
import {useFramework} from '@tryghost/admin-x-framework';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
import type {BodyFont, HeadingFont} from '@tryghost/custom-fonts';
type BodyFontOption = {
value: BodyFont | typeof DEFAULT_FONT,
label: BodyFont | typeof DEFAULT_FONT,
className?: string
};
type HeadingFontOption = {
value: HeadingFont | typeof DEFAULT_FONT,
label: HeadingFont | typeof DEFAULT_FONT,
className?: string
};
export interface GlobalSettingValues {
description: string
accentColor: string
icon: string | null
logo: string | null
coverImage: string | null
headingFont: string
bodyFont: string
}
/**
* All custom fonts are maintained in the @tryghost/custom-fonts package.
* If you need to change a font, you'll need to update the @tryghost/custom-fonts package.
*/
const DEFAULT_FONT = 'Theme default';
const GlobalSettings: React.FC<{ values: GlobalSettingValues, updateSetting: (key: string, value: SettingValue) => void }> = ({values,updateSetting}) => {
const {mutateAsync: uploadImage} = useUploadImage();
const {settings} = useGlobalData();
const [unsplashEnabled] = getSettingValues<boolean>(settings, ['unsplash']);
const [showUnsplash, setShowUnsplash] = useState<boolean>(false);
const {unsplashConfig} = useFramework();
const handleError = useHandleError();
const editor = usePinturaEditor();
const [headingFont, setHeadingFont] = useState(values.headingFont || DEFAULT_FONT);
const [bodyFont, setBodyFont] = useState(values.bodyFont || DEFAULT_FONT);
const fontClassName = (fontName: string, heading: boolean = true) => {
return clsx(getCSSFriendlyFontClassName(fontName), heading && 'font-bold');
};
// Populate the heading and body font options
const customHeadingFonts: HeadingFontOption[] = CUSTOM_FONTS.heading.map((x) => {
let className = fontClassName(x, true);
return {label: x, value: x, className};
});
customHeadingFonts.unshift({label: DEFAULT_FONT, value: DEFAULT_FONT, className: 'font-sans font-normal'});
const customBodyFonts: BodyFontOption[] = CUSTOM_FONTS.body.map((x) => {
let className = fontClassName(x, false);
return {label: x, value: x, className};
});
customBodyFonts.unshift({label: DEFAULT_FONT, value: DEFAULT_FONT, className: 'font-sans font-normal'});
const selectFont = (fontName: string, heading: boolean) => {
if (fontName === DEFAULT_FONT) {
return '';
}
return fontClassName(fontName, heading);
};
const selectedHeadingFont = {label: headingFont, value: headingFont};
const selectedBodyFont = {label: bodyFont, value: bodyFont};
return (
<>
<Form className='mt-4' gap='sm' margins='lg' title=''>
<ColorPickerField
debounceMs={200}
direction='rtl'
testId='accent-color-picker'
title={<div>Accent color</div>}
value={values.accentColor}
// we debounce this because the color picker fires a lot of events.
onChange={value => updateSetting('accent_color', value)}
/>
<div className='flex items-start justify-between'>
<div>
<div>Publication icon</div>
<Hint className='!mt-0 mr-5 max-w-[160px]'>A square, social icon, at least 60x60px</Hint>
</div>
<div className='flex gap-3'>
<ImageUpload
deleteButtonClassName='!top-1 !right-1'
editButtonClassName='!top-1 !right-1'
height={values.icon ? '66px' : '36px'}
id='logo'
imageBWCheckedBg={true}
imageURL={values.icon || ''}
width={values.icon ? '66px' : '160px'}
onDelete={() => updateSetting('icon', null)}
onUpload={async (file) => {
try {
updateSetting('icon', getImageUrl(await uploadImage({file})));
} catch (e) {
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>
Upload icon
</ImageUpload>
</div>
</div>
<div className={`flex items-start justify-between ${values.icon && 'mt-2'}`}>
<div>
<div>Publication logo</div>
<Hint className='!mt-0 mr-5 max-w-[160px]'>Appears usually in the main header of your theme</Hint>
</div>
<div>
<ImageUpload
deleteButtonClassName='!top-1 !right-1'
height='60px'
id='site-logo'
imageBWCheckedBg={true}
imageFit='contain'
imageURL={values.logo || ''}
width='160px'
onDelete={() => updateSetting('logo', null)}
onUpload={async (file) => {
try {
updateSetting('logo', getImageUrl(await uploadImage({file})));
} catch (e) {
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>
Upload logo
</ImageUpload>
</div>
</div>
<div className='mt-2 flex items-start justify-between'>
<div>
<div>Publication cover</div>
<Hint className='!mt-0 mr-5 max-w-[160px]'>Usually as a large banner image on your index pages</Hint>
</div>
<ImageUpload
deleteButtonClassName='!top-1 !right-1'
editButtonClassName='!top-1 !right-10'
height='95px'
id='cover'
imageURL={values.coverImage || ''}
openUnsplash={() => setShowUnsplash(true)}
pintura={
{
isEnabled: editor.isEnabled,
openEditor: async () => editor.openEditor({
image: values.coverImage || '',
handleSave: async (file:File) => {
try {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
}
}
})
}
}
unsplashButtonClassName='!bg-transparent !h-6 !top-1.5 !w-6 !right-1.5 z-50'
unsplashEnabled={unsplashEnabled}
width='160px'
onDelete={() => updateSetting('cover_image', null)}
onUpload={async (file: any) => {
try {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
} catch (e) {
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>
Upload cover
</ImageUpload>
{
showUnsplash && unsplashConfig && unsplashEnabled && (
<UnsplashSelector
unsplashProviderConfig={unsplashConfig}
onClose={() => {
setShowUnsplash(false);
}}
onImageInsert={(image) => {
if (image.src) {
updateSetting('cover_image', image.src);
}
setShowUnsplash(false);
}}
/>
)
}
</div>
</Form>
<BehindFeatureFlag flag="customFonts">
<Form className='-mt-4' gap='sm' margins='lg' title='Typography'>
<Select
className={selectFont(selectedHeadingFont.label, true)}
hint={''}
menuShouldScrollIntoView={true}
options={customHeadingFonts}
selectedOption={selectedHeadingFont}
testId='heading-font-select'
title={'Heading font'}
onSelect={(option) => {
if (option?.value === DEFAULT_FONT) {
setHeadingFont(DEFAULT_FONT);
updateSetting('heading_font', '');
} else {
setHeadingFont(option?.value || '');
updateSetting('heading_font', option?.value || '');
}
}}
/>
<Select
className={selectFont(selectedBodyFont.label, false)}
hint={''}
maxMenuHeight={200}
menuPosition='fixed'
menuShouldScrollIntoView={true}
options={customBodyFonts}
selectedOption={selectedBodyFont}
testId='body-font-select'
title={'Body font'}
onSelect={(option) => {
if (option?.value === DEFAULT_FONT) {
setBodyFont(DEFAULT_FONT);
updateSetting('body_font', '');
} else {
setBodyFont(option?.value || '');
updateSetting('body_font', option?.value || '');
}
}}
/>
</Form>
</BehindFeatureFlag>
</>
);
};
export default GlobalSettings;

View file

@ -3,17 +3,19 @@ import React, {useCallback} from 'react';
import {CustomThemeSetting, hiddenCustomThemeSettingValue} from '@tryghost/admin-x-framework/api/customThemeSettings';
import {isCustomThemeSettingVisible} from '../../../../utils/isCustomThemeSettingsVisible';
type BrandSettings = {
type GlobalSettings = {
description: string;
accentColor: string;
icon: string;
logo: string;
coverImage: string;
themeSettings?: Array<CustomThemeSetting & { dirty?: boolean }>;
bodyFont: string;
headingFont: string;
}
interface ThemePreviewProps {
settings: BrandSettings
settings: GlobalSettings
url: string
}
@ -23,14 +25,18 @@ function getPreviewData({
icon,
logo,
coverImage,
themeSettings
themeSettings,
bodyFont,
headingFont
}: {
description: string;
accentColor: string;
icon: string;
logo: string;
coverImage: string;
themeSettings?: Array<CustomThemeSetting & { dirty?: boolean }>,
themeSettings?: Array<CustomThemeSetting & { dirty?: boolean }>;
bodyFont: string;
headingFont: string;
}) {
// Don't render twice while theme settings are loading
if (!themeSettings) {
@ -44,6 +50,8 @@ function getPreviewData({
params.append('icon', icon);
params.append('logo', logo);
params.append('cover', coverImage);
params.append('bf', bodyFont);
params.append('hf', headingFont);
const custom: {
[key: string]: string | typeof hiddenCustomThemeSettingValue;
} = {};

View file

@ -0,0 +1,99 @@
import React, {useEffect, useState} from 'react';
import {ColorPickerField, Heading, Hint, ImageUpload, Select, TextField, Toggle} from '@tryghost/admin-x-design-system';
import {CustomThemeSetting} from '@tryghost/admin-x-framework/api/customThemeSettings';
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
import {humanizeSettingKey} from '@tryghost/admin-x-framework/api/settings';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
interface ThemeSettingProps {
setting: CustomThemeSetting;
setSetting: <Setting extends CustomThemeSetting>(value: Setting['value']) => void;
}
const ThemeSetting: React.FC<ThemeSettingProps> = ({setting, setSetting}) => {
const [fieldValues, setFieldValues] = useState<{ [key: string]: string | null }>({});
useEffect(() => {
const valueAsString = setting.value === null ? '' : String(setting.value);
setFieldValues(values => ({...values, [setting.key]: valueAsString}));
}, [setting]);
const handleBlur = (key: string) => {
if (fieldValues[key] !== undefined) {
setSetting(fieldValues[key]);
}
};
const handleChange = (key: string, value: string) => {
setFieldValues(values => ({...values, [key]: value}));
};
const {mutateAsync: uploadImage} = useUploadImage();
const handleError = useHandleError();
const handleImageUpload = async (file: File) => {
try {
const imageUrl = getImageUrl(await uploadImage({file}));
setSetting(imageUrl);
} catch (e) {
handleError(e);
}
};
switch (setting.type) {
case 'text':
return (
<TextField
hint={setting.description}
title={humanizeSettingKey(setting.key)}
value={fieldValues[setting.key] || ''}
onBlur={() => handleBlur(setting.key)}
onChange={event => handleChange(setting.key, event.target.value)}
/>
);
case 'boolean':
return (
<Toggle
checked={setting.value}
direction="rtl"
hint={setting.description}
label={humanizeSettingKey(setting.key)}
onChange={event => setSetting(event.target.checked)}
/>
);
case 'select':
return (
<Select
hint={setting.description}
options={setting.options.map(option => ({label: option, value: option}))}
selectedOption={{label: setting.value, value: setting.value}}
testId={`setting-select-${setting.key}`}
title={humanizeSettingKey(setting.key)}
onSelect={option => setSetting(option?.value || null)}
/>
);
case 'color':
return (
<ColorPickerField
debounceMs={200}
direction='rtl'
hint={setting.description}
title={humanizeSettingKey(setting.key)}
value={setting.value || ''}
onChange={value => setSetting(value)}
/>
);
case 'image':
return <>
<Heading useLabelTag>{humanizeSettingKey(setting.key)}</Heading>
<ImageUpload
height={setting.value ? '100px' : '32px'}
id={`custom-${setting.key}`}
imageURL={setting.value || ''}
onDelete={() => setSetting(null)}
onUpload={file => handleImageUpload(file)}
>Upload image</ImageUpload>
{setting.description && <Hint>{setting.description}</Hint>}
</>;
}
};
export default ThemeSetting;

View file

@ -1,109 +1,50 @@
import React, {useEffect, useState} from 'react';
import {ColorPickerField, Heading, Hint, ImageUpload, Select, SettingGroupContent, TextField, Toggle} from '@tryghost/admin-x-design-system';
import React from 'react';
import ThemeSetting from './ThemeSetting';
import {CustomThemeSetting} from '@tryghost/admin-x-framework/api/customThemeSettings';
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
import {humanizeSettingKey} from '@tryghost/admin-x-framework/api/settings';
import {Form} from '@tryghost/admin-x-design-system';
import {isCustomThemeSettingVisible} from '../../../../utils/isCustomThemeSettingsVisible';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
const ThemeSetting: React.FC<{
setting: CustomThemeSetting,
setSetting: <Setting extends CustomThemeSetting>(value: Setting['value']) => void
}> = ({setting, setSetting}) => {
const [fieldValues, setFieldValues] = useState<{ [key: string]: string | null }>({});
useEffect(() => {
const valueAsString = setting.value === null ? '' : String(setting.value);
setFieldValues(values => ({...values, [setting.key]: valueAsString}));
}, [setting]);
const handleBlur = (key: string) => {
if (fieldValues[key] !== undefined) {
setSetting(fieldValues[key]);
}
};
const handleChange = (key: string, value: string) => {
setFieldValues(values => ({...values, [key]: value}));
};
const {mutateAsync: uploadImage} = useUploadImage();
const handleError = useHandleError();
const handleImageUpload = async (file: File) => {
try {
const imageUrl = getImageUrl(await uploadImage({file}));
setSetting(imageUrl);
} catch (e) {
handleError(e);
}
};
switch (setting.type) {
case 'text':
return (
<TextField
hint={setting.description}
title={humanizeSettingKey(setting.key)}
value={fieldValues[setting.key] || ''}
onBlur={() => handleBlur(setting.key)}
onChange={event => handleChange(setting.key, event.target.value)}
/>
);
case 'boolean':
return (
<Toggle
checked={setting.value}
direction="rtl"
hint={setting.description}
label={humanizeSettingKey(setting.key)}
onChange={event => setSetting(event.target.checked)}
/>
);
case 'select':
return (
<Select
hint={setting.description}
options={setting.options.map(option => ({label: option, value: option}))}
selectedOption={{label: setting.value, value: setting.value}}
testId={`setting-select-${setting.key}`}
title={humanizeSettingKey(setting.key)}
onSelect={option => setSetting(option?.value || null)}
/>
);
case 'color':
return (
<ColorPickerField
debounceMs={200}
direction='rtl'
hint={setting.description}
title={humanizeSettingKey(setting.key)}
value={setting.value || ''}
onChange={value => setSetting(value)}
/>
);
case 'image':
return <>
<Heading useLabelTag>{humanizeSettingKey(setting.key)}</Heading>
<ImageUpload
height={setting.value ? '100px' : '32px'}
id={`custom-${setting.key}`}
imageURL={setting.value || ''}
onDelete={() => setSetting(null)}
onUpload={file => handleImageUpload(file)}
>Upload image</ImageUpload>
{setting.description && <Hint>{setting.description}</Hint>}
</>;
}
};
const ThemeSettings: React.FC<{ settings: CustomThemeSetting[], updateSetting: (setting: CustomThemeSetting) => void }> = ({settings, updateSetting}) => {
// Filter out custom theme settings that should not be visible
const settingsKeyValueObj = settings.reduce((obj, {key, value}) => ({...obj, [key]: value}), {});
const filteredSettings = settings.filter(setting => isCustomThemeSettingVisible(setting, settingsKeyValueObj));
interface ThemeSettingsProps {
sections: Array<{
id: string;
title: string;
settings: CustomThemeSetting[];
}>;
updateSetting: (setting: CustomThemeSetting) => void;
}
const ThemeSettings: React.FC<ThemeSettingsProps> = ({sections, updateSetting}) => {
return (
<SettingGroupContent className='mt-7'>
{filteredSettings.map(setting => <ThemeSetting key={setting.key} setSetting={value => updateSetting({...setting, value} as CustomThemeSetting)} setting={setting} />)}
</SettingGroupContent>
<>
{sections.map((section) => {
const filteredSettings = section.settings.filter(setting => isCustomThemeSettingVisible(setting, section.settings.reduce((obj, {key, value}) => ({...obj, [key]: value}), {}))
);
let previousType: string | undefined;
return (
<Form key={section.id} className='first-of-type:mt-6' gap='xs' margins='lg' title={section.title}>
{filteredSettings.map((setting) => {
let spaceClass = '';
if (setting.type === 'boolean' && previousType !== 'boolean' && previousType !== undefined) {
spaceClass = 'mt-3';
}
if ((setting.type === 'text' || setting.type === 'select') && (previousType === 'text' || previousType === 'select')) {
spaceClass = 'mt-2';
}
previousType = setting.type;
return <div key={setting.key} className={spaceClass}>
<ThemeSetting
key={setting.key}
setSetting={value => updateSetting({...setting, value} as CustomThemeSetting)}
setting={setting}
/>
</div>;
})}
</Form>
);
})}
</>
);
};

View file

@ -34,7 +34,6 @@ const ThemePreview: React.FC<{
isInstalling,
installedTheme,
onBack,
onClose,
onInstall
}) => {
const [previewMode, setPreviewMode] = useState('desktop');
@ -101,7 +100,6 @@ const ThemePreview: React.FC<{
containerClassName='whitespace-nowrap'
itemClassName='hidden md:!block md:!visible'
items={[
{label: 'Design', onClick: onClose},
{label: 'Change theme', onClick: onBack},
{label: selectedTheme.name}
]}
@ -163,7 +161,7 @@ const ThemePreview: React.FC<{
return (
<div className='absolute inset-0 z-[100]'>
<PageHeader containerClassName='bg-grey-50 dark:bg-black z-[100]' left={left} right={right} sticky={false} />
<div className='flex h-[calc(100%-74px)] grow flex-col items-center justify-center bg-grey-50 dark:bg-black'>
<div className='flex h-[calc(100%-92px)] grow flex-col items-center justify-center bg-grey-50 dark:bg-black'>
{previewMode === 'desktop' ?
<DesktopChrome>
<iframe

View file

@ -1,4 +1,11 @@
import {chooseOptionInSelect, mockApi, mockSitePreview, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance';
import {
chooseOptionInSelect,
mockApi,
mockSitePreview,
responseFixtures,
toggleLabsFlag,
updatedSettingsResponse
} from '@tryghost/admin-x-framework/test/acceptance';
import {expect, test} from '@playwright/test';
import {globalDataRequests} from '../../utils/acceptance';
@ -45,16 +52,6 @@ test.describe('Design settings', async () => {
await modal.getByRole('button', {name: 'Desktop'}).click();
await expect(modal.getByTestId('preview-mobile')).not.toBeVisible();
// Switching preview based on settings tab
await modal.getByTestId('design-setting-tabs').getByRole('tab', {name: 'Homepage'}).click();
await expect(modal.frameLocator('[data-testid="theme-preview"] iframe[data-visible=true]').getByText('homepage preview')).toHaveCount(1);
await modal.getByTestId('design-setting-tabs').getByRole('tab', {name: 'Post'}).click();
await expect(modal.frameLocator('[data-testid="theme-preview"] iframe[data-visible=true]').getByText('post preview')).toHaveCount(1);
});
test('Warns when leaving without saving', async ({page}) => {
@ -75,7 +72,9 @@ test.describe('Design settings', async () => {
const modal = page.getByTestId('design-modal');
await modal.getByLabel('Site description').fill('new description');
const accentColorPicker = modal.getByTestId('accent-color-picker');
await accentColorPicker.getByRole('button').click();
await accentColorPicker.getByRole('textbox').fill('#cd5786');
// set timeout of 500ms to wait for the debounce
await page.waitForTimeout(1000);
await modal.getByRole('button', {name: 'Close'}).click();
@ -90,7 +89,7 @@ test.describe('Design settings', async () => {
await section.getByRole('button', {name: 'Customize'}).click();
await modal.getByTestId('design-setting-tabs').getByRole('tab', {name: 'Post'}).click();
await modal.getByTestId('design-setting-tabs').getByRole('tab', {name: 'Theme settings'}).click();
await modal.getByLabel('Email signup text').fill('test');
@ -128,17 +127,19 @@ test.describe('Design settings', async () => {
await expect(modal.frameLocator('[data-testid="theme-preview"] iframe[data-visible=true]').getByText('homepage preview')).toHaveCount(1);
await modal.getByLabel('Site description').fill('new description');
const accentColorPicker = modal.getByTestId('accent-color-picker');
await accentColorPicker.getByRole('button').click();
await accentColorPicker.getByRole('textbox').fill('#cd5786');
await expect(modal.getByTestId('toggle-unsplash-button')).toBeVisible();
// set timeout of 500ms to wait for the debounce
await page.waitForTimeout(1000);
await modal.getByRole('button', {name: 'Save'}).click();
expect(lastPreviewRequest.previewHeader).toMatch(/&d=new\+description&/);
expect(lastPreviewRequest.previewHeader).toMatch(/c=\%23cd5786\&d/);
expect(lastApiRequests.editSettings?.body).toEqual({
settings: [
{key: 'description', value: 'new description'}
{key: 'accent_color', value: '#cd5786'}
]
});
});
@ -177,7 +178,7 @@ test.describe('Design settings', async () => {
const modal = page.getByTestId('design-modal');
await modal.getByRole('tab', {name: 'Site wide'}).click();
await modal.getByRole('tab', {name: 'Theme settings'}).click();
await chooseOptionInSelect(modal.getByTestId('setting-select-navigation_layout'), 'Logo in the middle');
await modal.getByRole('button', {name: 'Save'}).click();
@ -215,12 +216,16 @@ test.describe('Design settings', async () => {
const modal = page.getByTestId('design-modal');
await expect(modal.getByTestId('design-setting-tabs').getByRole('tab', {name: 'Brand'})).toBeVisible();
await expect(modal.getByTestId('design-setting-tabs').getByRole('tab', {name: 'Site wide'})).toBeHidden();
await expect(modal.getByTestId('design-setting-tabs').getByRole('tab', {name: 'Homepage'})).toBeHidden();
await expect(modal.getByTestId('design-setting-tabs').getByRole('tab', {name: 'Post'})).toBeHidden();
const designSettingTabs = modal.getByTestId('design-setting-tabs');
await expect(designSettingTabs.getByRole('tab', {name: 'Global'})).toBeHidden();
await expect(designSettingTabs.getByRole('tab', {name: 'Theme settings'})).toBeHidden();
// The tabs are not visible, but the global settings are still rendered
await expect(designSettingTabs.getByTestId('accent-color-picker')).toBeVisible();
const expectedEncoded = new URLSearchParams([['custom', JSON.stringify({})]]).toString();
expect(lastPreviewRequest.previewHeader).toMatch(new RegExp(`&${expectedEncoded.replace(/\+/g, '\\+')}`));
});
@ -265,7 +270,7 @@ test.describe('Design settings', async () => {
const modal = page.getByTestId('design-modal');
await modal.getByRole('tab', {name: 'Site wide'}).click();
await modal.getByRole('tab', {name: 'Theme settings'}).click();
const showFeaturedPostsCustomThemeSetting = modal.getByLabel('Show featured posts');
@ -288,4 +293,99 @@ test.describe('Design settings', async () => {
]
});
});
test('Custom fonts', async ({page}) => {
toggleLabsFlag('customFonts', true);
await mockApi({page, requests: {
...globalDataRequests,
browseCustomThemeSettings: {method: 'GET', path: '/custom_theme_settings/', response: {
custom_theme_settings: []
}},
browseLatestPost: {method: 'GET', path: /^\/posts\/.+limit=1/, response: responseFixtures.latestPost}
}});
const lastPreviewRequest = await mockSitePreview({
page,
url: responseFixtures.site.site.url,
response: '<html><head><style></style></head><body><div>homepage preview</div></body></html>'
});
await page.goto('/');
const section = page.getByTestId('design');
await section.getByRole('button', {name: 'Customize'}).click();
const modal = page.getByTestId('design-modal');
const designSettingTabs = modal.getByTestId('design-setting-tabs');
await expect(designSettingTabs.getByTestId('accent-color-picker')).toBeVisible();
await expect(designSettingTabs.getByText('Typography')).toBeVisible();
await expect(designSettingTabs.getByTestId('heading-font-select')).toBeVisible();
await expect(designSettingTabs.getByTestId('body-font-select')).toBeVisible();
// select a different heading font
const headingFontSelect = designSettingTabs.getByTestId('heading-font-select');
await headingFontSelect.click();
await headingFontSelect.getByText('Cardo').click();
// select a different body font
const bodyFontSelect = designSettingTabs.getByTestId('body-font-select');
await bodyFontSelect.click();
await bodyFontSelect.getByText('Inter').click();
const expectedEncoded = new URLSearchParams([['bf', 'Inter'], ['hf', 'Cardo']]).toString();
// Preview should have the new fonts
await expect(lastPreviewRequest.previewHeader).toMatch(new RegExp(`&${expectedEncoded.replace(/\+/g, '\\+')}`));
});
test('Custom fonts setting back to default', async ({page}) => {
toggleLabsFlag('customFonts', true);
await mockApi({page, requests: {
...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: updatedSettingsResponse([
{key: 'heading_font', value: 'Caro'},
{key: 'body_font', value: 'Inter'}
])},
browseCustomThemeSettings: {method: 'GET', path: '/custom_theme_settings/', response: {
custom_theme_settings: []
}},
browseLatestPost: {method: 'GET', path: /^\/posts\/.+limit=1/, response: responseFixtures.latestPost}
}});
const lastPreviewRequest = await mockSitePreview({
page,
url: responseFixtures.site.site.url,
response: '<html><head><style></style></head><body><div>homepage preview</div></body></html>'
});
await page.goto('/');
const section = page.getByTestId('design');
await section.getByRole('button', {name: 'Customize'}).click();
const modal = page.getByTestId('design-modal');
// The fonts should be set to the values in the settings
await expect(modal.getByTestId('heading-font-select')).toHaveText('Caro');
await expect(modal.getByTestId('body-font-select')).toHaveText('Inter');
const designSettingTabs = modal.getByTestId('design-setting-tabs');
// select a different heading font
const headingFontSelect = designSettingTabs.getByTestId('heading-font-select');
await headingFontSelect.click();
await headingFontSelect.getByText('Theme default').click();
// select a different body font
const bodyFontSelect = designSettingTabs.getByTestId('body-font-select');
await bodyFontSelect.click();
await bodyFontSelect.getByText('Theme default').click();
const expectedEncoded = new URLSearchParams([['bf', ''], ['hf', '']]).toString();
// Preview should have the old fonts back
await expect(lastPreviewRequest.previewHeader).toMatch(new RegExp(`&${expectedEncoded.replace(/\+/g, '\\+')}`));
});
});

View file

@ -27,13 +27,9 @@ test.describe('Theme settings', async () => {
await page.goto('/');
const designSection = page.getByTestId('design');
const themeSection = page.getByTestId('theme');
await designSection.getByRole('button', {name: 'Customize'}).click();
const designModal = page.getByTestId('design-modal');
await designModal.getByTestId('change-theme').click();
await themeSection.getByRole('button', {name: 'Change theme'}).click();
const modal = page.getByTestId('theme-modal');
@ -77,13 +73,9 @@ test.describe('Theme settings', async () => {
await page.goto('/');
const designSection = page.getByTestId('design');
const themeSection = page.getByTestId('theme');
await designSection.getByRole('button', {name: 'Customize'}).click();
const designModal = page.getByTestId('design-modal');
await designModal.getByTestId('change-theme').click();
await themeSection.getByRole('button', {name: 'Change theme'}).click();
const modal = page.getByTestId('theme-modal');
@ -142,13 +134,9 @@ test.describe('Theme settings', async () => {
await page.goto('/');
const designSection = page.getByTestId('design');
const themeSection = page.getByTestId('theme');
await designSection.getByRole('button', {name: 'Customize'}).click();
const designModal = page.getByTestId('design-modal');
await designModal.getByTestId('change-theme').click();
await themeSection.getByRole('button', {name: 'Change theme'}).click();
const modal = page.getByTestId('theme-modal');
@ -193,13 +181,9 @@ test.describe('Theme settings', async () => {
await page.goto('/');
const designSection = page.getByTestId('design');
const themeSection = page.getByTestId('theme');
await designSection.getByRole('button', {name: 'Customize'}).click();
const designModal = page.getByTestId('design-modal');
await designModal.getByTestId('change-theme').click();
await themeSection.getByRole('button', {name: 'Change theme'}).click();
const modal = page.getByTestId('theme-modal');
@ -236,13 +220,9 @@ test.describe('Theme settings', async () => {
await page.goto('/');
const designSection = page.getByTestId('design');
const themeSection = page.getByTestId('theme');
await designSection.getByRole('button', {name: 'Customize'}).click();
const designModal = page.getByTestId('design-modal');
await designModal.getByTestId('change-theme').click();
await themeSection.getByRole('button', {name: 'Change theme'}).click();
const modal = page.getByTestId('theme-modal');

View file

@ -22,7 +22,12 @@ export default (function viteConfig() {
}
},
optimizeDeps: {
include: ['@tryghost/kg-unsplash-selector']
include: ['@tryghost/kg-unsplash-selector', '@tryghost/custom-fonts']
}
},
build: {
commonjsOptions: {
include: [/ghost\/custom-fonts/]
}
}
});

View file

@ -239,6 +239,8 @@ export default class ThemeManagementService extends Service {
params.append('icon', this.settings.icon);
params.append('logo', this.settings.logo);
params.append('cover', this.settings.coverImage);
params.append('bf', this.settings.bodyFont);
params.append('hf', this.settings.headingFont);
if (this.settings.announcementContent) {
params.append('announcement', this.settings.announcementContent);

View file

@ -2,8 +2,14 @@
// Usage: `{{body_class}}`
//
// Output classes for the body element
const {labs, settingsCache} = require('../services/proxy');
const {generateCustomFontBodyClass, isValidCustomFont, isValidCustomHeadingFont} = require('@tryghost/custom-fonts');
const {SafeString} = require('../services/handlebars');
/**
* @typedef {import('@tryghost/custom-fonts').FontSelection} FontSelection
*/
// We use the name body_class to match the helper for consistency
module.exports = function body_class(options) { // eslint-disable-line camelcase
let classes = [];
@ -39,6 +45,32 @@ module.exports = function body_class(options) { // eslint-disable-line camelcase
classes.push('paged');
}
if (labs.isSet('customFonts')) {
// Check if if the request is for a site preview, in which case we **always** use the custom font values
// from the passed in data, even when they're empty strings or settings cache has values.
const isSitePreview = options.data.site._preview;
// Taking the fonts straight from the passed in data, as they can't be used from the
// settings cache for the theme preview until the settings are saved. Once saved,
// we need to use the settings cache to provide the correct CSS injection.
const headingFont = isSitePreview ? options.data.site.heading_font : settingsCache.get('heading_font');
const bodyFont = isSitePreview ? options.data.site.body_font : settingsCache.get('body_font');
if ((typeof headingFont === 'string' && isValidCustomHeadingFont(headingFont)) ||
(typeof bodyFont === 'string' && isValidCustomFont(bodyFont))) {
/** @type FontSelection */
const fontSelection = {};
if (headingFont) {
fontSelection.heading = headingFont;
}
if (bodyFont) {
fontSelection.body = bodyFont;
}
const customBodyClasses = generateCustomFontBodyClass(fontSelection);
classes.push(new SafeString(customBodyClasses));
}
}
classes = classes.join(' ').trim();
return new SafeString(classes);

View file

@ -4,7 +4,7 @@
// Outputs scripts and other assets at the top of a Ghost theme
const {labs, metaData, settingsCache, config, blogIcon, urlUtils, getFrontendKey} = require('../services/proxy');
const {escapeExpression, SafeString} = require('../services/handlebars');
const {generateCustomFontCss, isValidCustomFont, isValidCustomHeadingFont} = require('@tryghost/custom-fonts');
// BAD REQUIRE
// @TODO fix this require
const {cardAssets} = require('../services/assets-minification');
@ -15,6 +15,10 @@ const debug = require('@tryghost/debug')('ghost_head');
const templateStyles = require('./tpl/styles');
const {getFrontendAppConfig, getDataAttributes} = require('../utils/frontend-apps');
/**
* @typedef {import('@tryghost/custom-fonts').FontSelection} FontSelection
*/
const {get: getMetaData, getAssetUrl} = metaData;
function writeMetaTag(property, content, type) {
@ -339,6 +343,31 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam
if (config.get('tinybird') && config.get('tinybird:tracker') && config.get('tinybird:tracker:scriptUrl')) {
head.push(getTinybirdTrackerScript(dataRoot));
}
if (labs.isSet('customFonts')) {
// Check if if the request is for a site preview, in which case we **always** use the custom font values
// from the passed in data, even when they're empty strings or settings cache has values.
const isSitePreview = options.data.site._preview;
// Taking the fonts straight from the passed in data, as they can't be used from the
// settings cache for the theme preview until the settings are saved. Once saved,
// we need to use the settings cache to provide the correct CSS injection.
const headingFont = isSitePreview ? options.data.site.heading_font : settingsCache.get('heading_font');
const bodyFont = isSitePreview ? options.data.site.body_font : settingsCache.get('body_font');
if ((typeof headingFont === 'string' && isValidCustomHeadingFont(headingFont)) ||
(typeof bodyFont === 'string' && isValidCustomFont(bodyFont))) {
/** @type FontSelection */
const fontSelection = {};
if (headingFont) {
fontSelection.heading = headingFont;
}
if (bodyFont) {
fontSelection.body = bodyFont;
}
const customCSS = generateCustomFontCss(fontSelection);
head.push(new SafeString(customCSS));
}
}
}
debug('end');

View file

@ -22,7 +22,9 @@ function getPreviewData(previewHeader, customThemeSettingKeys = []) {
logo: 'logo',
cover: 'cover_image',
custom: 'custom',
d: 'description'
d: 'description',
bf: 'body_font',
hf: 'heading_font'
};
let opts = new URLSearchParams(previewHeader);

View file

@ -46,7 +46,8 @@ const ALPHA_FEATURES = [
'adminXDemo',
'contentVisibility',
'commentImprovements',
'staff2fa'
'staff2fa',
'customFonts'
];
module.exports.GA_KEYS = [...GA_FEATURES];

View file

@ -80,6 +80,7 @@
"@tryghost/debug": "0.1.32",
"@tryghost/domain-events": "0.0.0",
"@tryghost/donations": "0.0.0",
"@tryghost/custom-fonts": "0.0.0",
"@tryghost/dynamic-routing-events": "0.0.0",
"@tryghost/email-analytics-provider-mailgun": "0.0.0",
"@tryghost/email-analytics-service": "0.0.0",

View file

@ -20,6 +20,7 @@ Object {
"collectionsCard": true,
"commentImprovements": true,
"contentVisibility": true,
"customFonts": true,
"editorExcerpt": true,
"emailCustomization": true,
"i18n": true,

View file

@ -959,6 +959,602 @@ Object {
}
`;
exports[`{{ghost_head}} helper custom fonts can handle preview being set and custom font keys missing 1 1`] = `
Object {
"rendered": "<meta name=\\"description\\" content=\\"all about our site\\">
<link rel=\\"canonical\\" href=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"referrer\\" content=\\"no-referrer-when-downgrade\\">
<link rel=\\"amphtml\\" href=\\"http://127.0.0.1:2369/post/amp/\\">
<meta property=\\"og:site_name\\" content=\\"Ghost\\">
<meta property=\\"og:type\\" content=\\"article\\">
<meta property=\\"og:title\\" content=\\"Custom Facebook title\\">
<meta property=\\"og:description\\" content=\\"Custom Facebook description\\">
<meta property=\\"og:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta property=\\"og:image\\" content=\\"http://127.0.0.1:2369/content/images/test-og-image.png\\">
<meta property=\\"article:published_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:modified_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:author\\" content=\\"https://www.facebook.com/testuser\\">
<meta name=\\"twitter:card\\" content=\\"summary_large_image\\">
<meta name=\\"twitter:title\\" content=\\"Custom Twitter title\\">
<meta name=\\"twitter:description\\" content=\\"Custom Twitter description\\">
<meta name=\\"twitter:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"twitter:image\\" content=\\"http://127.0.0.1:2369/content/images/test-twitter-image.png\\">
<meta name=\\"twitter:label1\\" content=\\"Written by\\">
<meta name=\\"twitter:data1\\" content=\\"name\\">
<meta name=\\"twitter:creator\\" content=\\"@testuser\\">
<script type=\\"application/ld+json\\">
{
\\"@context\\": \\"https://schema.org\\",
\\"@type\\": \\"Article\\",
\\"publisher\\": {
\\"@type\\": \\"Organization\\",
\\"name\\": \\"Ghost\\",
\\"url\\": \\"http://127.0.0.1:2369/\\",
\\"logo\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/favicon.ico\\"
}
},
\\"author\\": {
\\"@type\\": \\"Person\\",
\\"name\\": \\"name\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-author-image.png\\"
},
\\"url\\": \\"https://mysite.com/fakeauthor/\\",
\\"sameAs\\": [
\\"http://authorwebsite.com\\",
\\"https://www.facebook.com/testuser\\",
\\"https://twitter.com/testuser\\"
]
},
\\"headline\\": \\"About\\",
\\"url\\": \\"http://127.0.0.1:2369/post/\\",
\\"datePublished\\": \\"1970-01-01T00:00:00.000Z\\",
\\"dateModified\\": \\"1970-01-01T00:00:00.000Z\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-image-about.png\\"
},
\\"description\\": \\"all about our site\\",
\\"mainEntityOfPage\\": \\"http://127.0.0.1:2369/post/\\"
}
</script>
<meta name=\\"generator\\" content=\\"Ghost 0.3\\">
<link rel=\\"alternate\\" type=\\"application/rss+xml\\" title=\\"Ghost\\" href=\\"http://localhost:65530/rss/\\">
<script defer src=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/sodo-search.min.js\\" data-key=\\"xyz\\" data-styles=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/main.css\\" data-sodo-search=\\"http://127.0.0.1:2369/\\" data-locale=\\"en\\" crossorigin=\\"anonymous\\"></script>
<link href=\\"http://127.0.0.1:2369/webmentions/receive/\\" rel=\\"webmention\\">",
}
`;
exports[`{{ghost_head}} helper custom fonts does not include custom font when invalid 1 1`] = `
Object {
"rendered": "<meta name=\\"description\\" content=\\"all about our site\\">
<link rel=\\"canonical\\" href=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"referrer\\" content=\\"no-referrer-when-downgrade\\">
<link rel=\\"amphtml\\" href=\\"http://127.0.0.1:2369/post/amp/\\">
<meta property=\\"og:site_name\\" content=\\"Ghost\\">
<meta property=\\"og:type\\" content=\\"article\\">
<meta property=\\"og:title\\" content=\\"Custom Facebook title\\">
<meta property=\\"og:description\\" content=\\"Custom Facebook description\\">
<meta property=\\"og:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta property=\\"og:image\\" content=\\"http://127.0.0.1:2369/content/images/test-og-image.png\\">
<meta property=\\"article:published_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:modified_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:author\\" content=\\"https://www.facebook.com/testuser\\">
<meta name=\\"twitter:card\\" content=\\"summary_large_image\\">
<meta name=\\"twitter:title\\" content=\\"Custom Twitter title\\">
<meta name=\\"twitter:description\\" content=\\"Custom Twitter description\\">
<meta name=\\"twitter:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"twitter:image\\" content=\\"http://127.0.0.1:2369/content/images/test-twitter-image.png\\">
<meta name=\\"twitter:label1\\" content=\\"Written by\\">
<meta name=\\"twitter:data1\\" content=\\"name\\">
<meta name=\\"twitter:creator\\" content=\\"@testuser\\">
<script type=\\"application/ld+json\\">
{
\\"@context\\": \\"https://schema.org\\",
\\"@type\\": \\"Article\\",
\\"publisher\\": {
\\"@type\\": \\"Organization\\",
\\"name\\": \\"Ghost\\",
\\"url\\": \\"http://127.0.0.1:2369/\\",
\\"logo\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/favicon.ico\\"
}
},
\\"author\\": {
\\"@type\\": \\"Person\\",
\\"name\\": \\"name\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-author-image.png\\"
},
\\"url\\": \\"https://mysite.com/fakeauthor/\\",
\\"sameAs\\": [
\\"http://authorwebsite.com\\",
\\"https://www.facebook.com/testuser\\",
\\"https://twitter.com/testuser\\"
]
},
\\"headline\\": \\"About\\",
\\"url\\": \\"http://127.0.0.1:2369/post/\\",
\\"datePublished\\": \\"1970-01-01T00:00:00.000Z\\",
\\"dateModified\\": \\"1970-01-01T00:00:00.000Z\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-image-about.png\\"
},
\\"description\\": \\"all about our site\\",
\\"mainEntityOfPage\\": \\"http://127.0.0.1:2369/post/\\"
}
</script>
<meta name=\\"generator\\" content=\\"Ghost 0.3\\">
<link rel=\\"alternate\\" type=\\"application/rss+xml\\" title=\\"Ghost\\" href=\\"http://localhost:65530/rss/\\">
<script defer src=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/sodo-search.min.js\\" data-key=\\"xyz\\" data-styles=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/main.css\\" data-sodo-search=\\"http://127.0.0.1:2369/\\" data-locale=\\"en\\" crossorigin=\\"anonymous\\"></script>
<link href=\\"http://127.0.0.1:2369/webmentions/receive/\\" rel=\\"webmention\\">",
}
`;
exports[`{{ghost_head}} helper custom fonts does not include custom font when not set 1 1`] = `
Object {
"rendered": "<meta name=\\"description\\" content=\\"all about our site\\">
<link rel=\\"canonical\\" href=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"referrer\\" content=\\"no-referrer-when-downgrade\\">
<link rel=\\"amphtml\\" href=\\"http://127.0.0.1:2369/post/amp/\\">
<meta property=\\"og:site_name\\" content=\\"Ghost\\">
<meta property=\\"og:type\\" content=\\"article\\">
<meta property=\\"og:title\\" content=\\"Custom Facebook title\\">
<meta property=\\"og:description\\" content=\\"Custom Facebook description\\">
<meta property=\\"og:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta property=\\"og:image\\" content=\\"http://127.0.0.1:2369/content/images/test-og-image.png\\">
<meta property=\\"article:published_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:modified_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:author\\" content=\\"https://www.facebook.com/testuser\\">
<meta name=\\"twitter:card\\" content=\\"summary_large_image\\">
<meta name=\\"twitter:title\\" content=\\"Custom Twitter title\\">
<meta name=\\"twitter:description\\" content=\\"Custom Twitter description\\">
<meta name=\\"twitter:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"twitter:image\\" content=\\"http://127.0.0.1:2369/content/images/test-twitter-image.png\\">
<meta name=\\"twitter:label1\\" content=\\"Written by\\">
<meta name=\\"twitter:data1\\" content=\\"name\\">
<meta name=\\"twitter:creator\\" content=\\"@testuser\\">
<script type=\\"application/ld+json\\">
{
\\"@context\\": \\"https://schema.org\\",
\\"@type\\": \\"Article\\",
\\"publisher\\": {
\\"@type\\": \\"Organization\\",
\\"name\\": \\"Ghost\\",
\\"url\\": \\"http://127.0.0.1:2369/\\",
\\"logo\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/favicon.ico\\"
}
},
\\"author\\": {
\\"@type\\": \\"Person\\",
\\"name\\": \\"name\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-author-image.png\\"
},
\\"url\\": \\"https://mysite.com/fakeauthor/\\",
\\"sameAs\\": [
\\"http://authorwebsite.com\\",
\\"https://www.facebook.com/testuser\\",
\\"https://twitter.com/testuser\\"
]
},
\\"headline\\": \\"About\\",
\\"url\\": \\"http://127.0.0.1:2369/post/\\",
\\"datePublished\\": \\"1970-01-01T00:00:00.000Z\\",
\\"dateModified\\": \\"1970-01-01T00:00:00.000Z\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-image-about.png\\"
},
\\"description\\": \\"all about our site\\",
\\"mainEntityOfPage\\": \\"http://127.0.0.1:2369/post/\\"
}
</script>
<meta name=\\"generator\\" content=\\"Ghost 0.3\\">
<link rel=\\"alternate\\" type=\\"application/rss+xml\\" title=\\"Ghost\\" href=\\"http://localhost:65530/rss/\\">
<script defer src=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/sodo-search.min.js\\" data-key=\\"xyz\\" data-styles=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/main.css\\" data-sodo-search=\\"http://127.0.0.1:2369/\\" data-locale=\\"en\\" crossorigin=\\"anonymous\\"></script>
<link href=\\"http://127.0.0.1:2369/webmentions/receive/\\" rel=\\"webmention\\">",
}
`;
exports[`{{ghost_head}} helper custom fonts does not inject custom fonts when preview is set and default font was selected (empty string) 1 1`] = `
Object {
"rendered": "<meta name=\\"description\\" content=\\"all about our site\\">
<link rel=\\"canonical\\" href=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"referrer\\" content=\\"no-referrer-when-downgrade\\">
<link rel=\\"amphtml\\" href=\\"http://127.0.0.1:2369/post/amp/\\">
<meta property=\\"og:site_name\\" content=\\"Ghost\\">
<meta property=\\"og:type\\" content=\\"article\\">
<meta property=\\"og:title\\" content=\\"Custom Facebook title\\">
<meta property=\\"og:description\\" content=\\"Custom Facebook description\\">
<meta property=\\"og:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta property=\\"og:image\\" content=\\"http://127.0.0.1:2369/content/images/test-og-image.png\\">
<meta property=\\"article:published_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:modified_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:author\\" content=\\"https://www.facebook.com/testuser\\">
<meta name=\\"twitter:card\\" content=\\"summary_large_image\\">
<meta name=\\"twitter:title\\" content=\\"Custom Twitter title\\">
<meta name=\\"twitter:description\\" content=\\"Custom Twitter description\\">
<meta name=\\"twitter:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"twitter:image\\" content=\\"http://127.0.0.1:2369/content/images/test-twitter-image.png\\">
<meta name=\\"twitter:label1\\" content=\\"Written by\\">
<meta name=\\"twitter:data1\\" content=\\"name\\">
<meta name=\\"twitter:creator\\" content=\\"@testuser\\">
<script type=\\"application/ld+json\\">
{
\\"@context\\": \\"https://schema.org\\",
\\"@type\\": \\"Article\\",
\\"publisher\\": {
\\"@type\\": \\"Organization\\",
\\"name\\": \\"Ghost\\",
\\"url\\": \\"http://127.0.0.1:2369/\\",
\\"logo\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/favicon.ico\\"
}
},
\\"author\\": {
\\"@type\\": \\"Person\\",
\\"name\\": \\"name\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-author-image.png\\"
},
\\"url\\": \\"https://mysite.com/fakeauthor/\\",
\\"sameAs\\": [
\\"http://authorwebsite.com\\",
\\"https://www.facebook.com/testuser\\",
\\"https://twitter.com/testuser\\"
]
},
\\"headline\\": \\"About\\",
\\"url\\": \\"http://127.0.0.1:2369/post/\\",
\\"datePublished\\": \\"1970-01-01T00:00:00.000Z\\",
\\"dateModified\\": \\"1970-01-01T00:00:00.000Z\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-image-about.png\\"
},
\\"description\\": \\"all about our site\\",
\\"mainEntityOfPage\\": \\"http://127.0.0.1:2369/post/\\"
}
</script>
<meta name=\\"generator\\" content=\\"Ghost 0.3\\">
<link rel=\\"alternate\\" type=\\"application/rss+xml\\" title=\\"Ghost\\" href=\\"http://localhost:65530/rss/\\">
<script defer src=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/sodo-search.min.js\\" data-key=\\"xyz\\" data-styles=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/main.css\\" data-sodo-search=\\"http://127.0.0.1:2369/\\" data-locale=\\"en\\" crossorigin=\\"anonymous\\"></script>
<link href=\\"http://127.0.0.1:2369/webmentions/receive/\\" rel=\\"webmention\\">",
}
`;
exports[`{{ghost_head}} helper custom fonts includes custom font when set in options data object 1 1`] = `
Object {
"rendered": "<meta name=\\"description\\" content=\\"all about our site\\">
<link rel=\\"canonical\\" href=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"referrer\\" content=\\"no-referrer-when-downgrade\\">
<link rel=\\"amphtml\\" href=\\"http://127.0.0.1:2369/post/amp/\\">
<meta property=\\"og:site_name\\" content=\\"Ghost\\">
<meta property=\\"og:type\\" content=\\"article\\">
<meta property=\\"og:title\\" content=\\"Custom Facebook title\\">
<meta property=\\"og:description\\" content=\\"Custom Facebook description\\">
<meta property=\\"og:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta property=\\"og:image\\" content=\\"http://127.0.0.1:2369/content/images/test-og-image.png\\">
<meta property=\\"article:published_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:modified_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:author\\" content=\\"https://www.facebook.com/testuser\\">
<meta name=\\"twitter:card\\" content=\\"summary_large_image\\">
<meta name=\\"twitter:title\\" content=\\"Custom Twitter title\\">
<meta name=\\"twitter:description\\" content=\\"Custom Twitter description\\">
<meta name=\\"twitter:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"twitter:image\\" content=\\"http://127.0.0.1:2369/content/images/test-twitter-image.png\\">
<meta name=\\"twitter:label1\\" content=\\"Written by\\">
<meta name=\\"twitter:data1\\" content=\\"name\\">
<meta name=\\"twitter:creator\\" content=\\"@testuser\\">
<script type=\\"application/ld+json\\">
{
\\"@context\\": \\"https://schema.org\\",
\\"@type\\": \\"Article\\",
\\"publisher\\": {
\\"@type\\": \\"Organization\\",
\\"name\\": \\"Ghost\\",
\\"url\\": \\"http://127.0.0.1:2369/\\",
\\"logo\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/favicon.ico\\"
}
},
\\"author\\": {
\\"@type\\": \\"Person\\",
\\"name\\": \\"name\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-author-image.png\\"
},
\\"url\\": \\"https://mysite.com/fakeauthor/\\",
\\"sameAs\\": [
\\"http://authorwebsite.com\\",
\\"https://www.facebook.com/testuser\\",
\\"https://twitter.com/testuser\\"
]
},
\\"headline\\": \\"About\\",
\\"url\\": \\"http://127.0.0.1:2369/post/\\",
\\"datePublished\\": \\"1970-01-01T00:00:00.000Z\\",
\\"dateModified\\": \\"1970-01-01T00:00:00.000Z\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-image-about.png\\"
},
\\"description\\": \\"all about our site\\",
\\"mainEntityOfPage\\": \\"http://127.0.0.1:2369/post/\\"
}
</script>
<meta name=\\"generator\\" content=\\"Ghost 0.3\\">
<link rel=\\"alternate\\" type=\\"application/rss+xml\\" title=\\"Ghost\\" href=\\"http://localhost:65530/rss/\\">
<script defer src=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/sodo-search.min.js\\" data-key=\\"xyz\\" data-styles=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/main.css\\" data-sodo-search=\\"http://127.0.0.1:2369/\\" data-locale=\\"en\\" crossorigin=\\"anonymous\\"></script>
<link href=\\"http://127.0.0.1:2369/webmentions/receive/\\" rel=\\"webmention\\">
<style>@import url(https://fonts.bunny.net/css?family=space-grotesk:700);@import url(https://fonts.bunny.net/css?family=poppins:400,500,600);:root {--gh-font-heading: Space Grotesk;--gh-font-body: Poppins;}</style>",
}
`;
exports[`{{ghost_head}} helper custom fonts includes custom font when set in options data object and preview is set 1 1`] = `
Object {
"rendered": "<meta name=\\"description\\" content=\\"all about our site\\">
<link rel=\\"canonical\\" href=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"referrer\\" content=\\"no-referrer-when-downgrade\\">
<link rel=\\"amphtml\\" href=\\"http://127.0.0.1:2369/post/amp/\\">
<meta property=\\"og:site_name\\" content=\\"Ghost\\">
<meta property=\\"og:type\\" content=\\"article\\">
<meta property=\\"og:title\\" content=\\"Custom Facebook title\\">
<meta property=\\"og:description\\" content=\\"Custom Facebook description\\">
<meta property=\\"og:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta property=\\"og:image\\" content=\\"http://127.0.0.1:2369/content/images/test-og-image.png\\">
<meta property=\\"article:published_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:modified_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:author\\" content=\\"https://www.facebook.com/testuser\\">
<meta name=\\"twitter:card\\" content=\\"summary_large_image\\">
<meta name=\\"twitter:title\\" content=\\"Custom Twitter title\\">
<meta name=\\"twitter:description\\" content=\\"Custom Twitter description\\">
<meta name=\\"twitter:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"twitter:image\\" content=\\"http://127.0.0.1:2369/content/images/test-twitter-image.png\\">
<meta name=\\"twitter:label1\\" content=\\"Written by\\">
<meta name=\\"twitter:data1\\" content=\\"name\\">
<meta name=\\"twitter:creator\\" content=\\"@testuser\\">
<script type=\\"application/ld+json\\">
{
\\"@context\\": \\"https://schema.org\\",
\\"@type\\": \\"Article\\",
\\"publisher\\": {
\\"@type\\": \\"Organization\\",
\\"name\\": \\"Ghost\\",
\\"url\\": \\"http://127.0.0.1:2369/\\",
\\"logo\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/favicon.ico\\"
}
},
\\"author\\": {
\\"@type\\": \\"Person\\",
\\"name\\": \\"name\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-author-image.png\\"
},
\\"url\\": \\"https://mysite.com/fakeauthor/\\",
\\"sameAs\\": [
\\"http://authorwebsite.com\\",
\\"https://www.facebook.com/testuser\\",
\\"https://twitter.com/testuser\\"
]
},
\\"headline\\": \\"About\\",
\\"url\\": \\"http://127.0.0.1:2369/post/\\",
\\"datePublished\\": \\"1970-01-01T00:00:00.000Z\\",
\\"dateModified\\": \\"1970-01-01T00:00:00.000Z\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-image-about.png\\"
},
\\"description\\": \\"all about our site\\",
\\"mainEntityOfPage\\": \\"http://127.0.0.1:2369/post/\\"
}
</script>
<meta name=\\"generator\\" content=\\"Ghost 0.3\\">
<link rel=\\"alternate\\" type=\\"application/rss+xml\\" title=\\"Ghost\\" href=\\"http://localhost:65530/rss/\\">
<script defer src=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/sodo-search.min.js\\" data-key=\\"xyz\\" data-styles=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/main.css\\" data-sodo-search=\\"http://127.0.0.1:2369/\\" data-locale=\\"en\\" crossorigin=\\"anonymous\\"></script>
<link href=\\"http://127.0.0.1:2369/webmentions/receive/\\" rel=\\"webmention\\">
<style>@import url(https://fonts.bunny.net/css?family=space-grotesk:700);@import url(https://fonts.bunny.net/css?family=poppins:400,500,600);:root {--gh-font-heading: Space Grotesk;--gh-font-body: Poppins;}</style>",
}
`;
exports[`{{ghost_head}} helper custom fonts includes custom font when set in settings cache 1 1`] = `
Object {
"rendered": "<meta name=\\"description\\" content=\\"all about our site\\">
<link rel=\\"canonical\\" href=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"referrer\\" content=\\"no-referrer-when-downgrade\\">
<link rel=\\"amphtml\\" href=\\"http://127.0.0.1:2369/post/amp/\\">
<meta property=\\"og:site_name\\" content=\\"Ghost\\">
<meta property=\\"og:type\\" content=\\"article\\">
<meta property=\\"og:title\\" content=\\"Custom Facebook title\\">
<meta property=\\"og:description\\" content=\\"Custom Facebook description\\">
<meta property=\\"og:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta property=\\"og:image\\" content=\\"http://127.0.0.1:2369/content/images/test-og-image.png\\">
<meta property=\\"article:published_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:modified_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:author\\" content=\\"https://www.facebook.com/testuser\\">
<meta name=\\"twitter:card\\" content=\\"summary_large_image\\">
<meta name=\\"twitter:title\\" content=\\"Custom Twitter title\\">
<meta name=\\"twitter:description\\" content=\\"Custom Twitter description\\">
<meta name=\\"twitter:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"twitter:image\\" content=\\"http://127.0.0.1:2369/content/images/test-twitter-image.png\\">
<meta name=\\"twitter:label1\\" content=\\"Written by\\">
<meta name=\\"twitter:data1\\" content=\\"name\\">
<meta name=\\"twitter:creator\\" content=\\"@testuser\\">
<script type=\\"application/ld+json\\">
{
\\"@context\\": \\"https://schema.org\\",
\\"@type\\": \\"Article\\",
\\"publisher\\": {
\\"@type\\": \\"Organization\\",
\\"name\\": \\"Ghost\\",
\\"url\\": \\"http://127.0.0.1:2369/\\",
\\"logo\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/favicon.ico\\"
}
},
\\"author\\": {
\\"@type\\": \\"Person\\",
\\"name\\": \\"name\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-author-image.png\\"
},
\\"url\\": \\"https://mysite.com/fakeauthor/\\",
\\"sameAs\\": [
\\"http://authorwebsite.com\\",
\\"https://www.facebook.com/testuser\\",
\\"https://twitter.com/testuser\\"
]
},
\\"headline\\": \\"About\\",
\\"url\\": \\"http://127.0.0.1:2369/post/\\",
\\"datePublished\\": \\"1970-01-01T00:00:00.000Z\\",
\\"dateModified\\": \\"1970-01-01T00:00:00.000Z\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-image-about.png\\"
},
\\"description\\": \\"all about our site\\",
\\"mainEntityOfPage\\": \\"http://127.0.0.1:2369/post/\\"
}
</script>
<meta name=\\"generator\\" content=\\"Ghost 0.3\\">
<link rel=\\"alternate\\" type=\\"application/rss+xml\\" title=\\"Ghost\\" href=\\"http://localhost:65530/rss/\\">
<script defer src=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/sodo-search.min.js\\" data-key=\\"xyz\\" data-styles=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/main.css\\" data-sodo-search=\\"http://127.0.0.1:2369/\\" data-locale=\\"en\\" crossorigin=\\"anonymous\\"></script>
<link href=\\"http://127.0.0.1:2369/webmentions/receive/\\" rel=\\"webmention\\">
<style>undefined;@import url(https://fonts.bunny.net/css?family=lora:400,700);:root {--gh-font-heading: Playfair Display;--gh-font-body: Lora;}</style>",
}
`;
exports[`{{ghost_head}} helper custom fonts includes custom font when set in settings cache and no preview 1 1`] = `
Object {
"rendered": "<meta name=\\"description\\" content=\\"all about our site\\">
<link rel=\\"canonical\\" href=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"referrer\\" content=\\"no-referrer-when-downgrade\\">
<link rel=\\"amphtml\\" href=\\"http://127.0.0.1:2369/post/amp/\\">
<meta property=\\"og:site_name\\" content=\\"Ghost\\">
<meta property=\\"og:type\\" content=\\"article\\">
<meta property=\\"og:title\\" content=\\"Custom Facebook title\\">
<meta property=\\"og:description\\" content=\\"Custom Facebook description\\">
<meta property=\\"og:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta property=\\"og:image\\" content=\\"http://127.0.0.1:2369/content/images/test-og-image.png\\">
<meta property=\\"article:published_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:modified_time\\" content=\\"1970-01-01T00:00:00.000Z\\">
<meta property=\\"article:author\\" content=\\"https://www.facebook.com/testuser\\">
<meta name=\\"twitter:card\\" content=\\"summary_large_image\\">
<meta name=\\"twitter:title\\" content=\\"Custom Twitter title\\">
<meta name=\\"twitter:description\\" content=\\"Custom Twitter description\\">
<meta name=\\"twitter:url\\" content=\\"http://127.0.0.1:2369/post/\\">
<meta name=\\"twitter:image\\" content=\\"http://127.0.0.1:2369/content/images/test-twitter-image.png\\">
<meta name=\\"twitter:label1\\" content=\\"Written by\\">
<meta name=\\"twitter:data1\\" content=\\"name\\">
<meta name=\\"twitter:creator\\" content=\\"@testuser\\">
<script type=\\"application/ld+json\\">
{
\\"@context\\": \\"https://schema.org\\",
\\"@type\\": \\"Article\\",
\\"publisher\\": {
\\"@type\\": \\"Organization\\",
\\"name\\": \\"Ghost\\",
\\"url\\": \\"http://127.0.0.1:2369/\\",
\\"logo\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/favicon.ico\\"
}
},
\\"author\\": {
\\"@type\\": \\"Person\\",
\\"name\\": \\"name\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-author-image.png\\"
},
\\"url\\": \\"https://mysite.com/fakeauthor/\\",
\\"sameAs\\": [
\\"http://authorwebsite.com\\",
\\"https://www.facebook.com/testuser\\",
\\"https://twitter.com/testuser\\"
]
},
\\"headline\\": \\"About\\",
\\"url\\": \\"http://127.0.0.1:2369/post/\\",
\\"datePublished\\": \\"1970-01-01T00:00:00.000Z\\",
\\"dateModified\\": \\"1970-01-01T00:00:00.000Z\\",
\\"image\\": {
\\"@type\\": \\"ImageObject\\",
\\"url\\": \\"http://127.0.0.1:2369/content/images/test-image-about.png\\"
},
\\"description\\": \\"all about our site\\",
\\"mainEntityOfPage\\": \\"http://127.0.0.1:2369/post/\\"
}
</script>
<meta name=\\"generator\\" content=\\"Ghost 0.3\\">
<link rel=\\"alternate\\" type=\\"application/rss+xml\\" title=\\"Ghost\\" href=\\"http://localhost:65530/rss/\\">
<script defer src=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/sodo-search.min.js\\" data-key=\\"xyz\\" data-styles=\\"https://cdn.jsdelivr.net/ghost/sodo-search@~[[VERSION]]/umd/main.css\\" data-sodo-search=\\"http://127.0.0.1:2369/\\" data-locale=\\"en\\" crossorigin=\\"anonymous\\"></script>
<link href=\\"http://127.0.0.1:2369/webmentions/receive/\\" rel=\\"webmention\\">
<style>undefined;@import url(https://fonts.bunny.net/css?family=lora:400,700);:root {--gh-font-heading: Playfair Display;--gh-font-body: Lora;}</style>",
}
`;
exports[`{{ghost_head}} helper includes tinybird tracker script when config is set Sets tb_post_uuid on post page 1 1`] = `
Object {
"rendered": "<link rel=\\"canonical\\" href=\\"http://127.0.0.1:2369/post/\\">

View file

@ -1,9 +1,14 @@
const should = require('should');
const themeList = require('../../../../core/server/services/themes/list');
const sinon = require('sinon');
// Stuff we are testing
const body_class = require('../../../../core/frontend/helpers/body_class');
// Stubs
const proxy = require('../../../../core/frontend/services/proxy');
const {settingsCache, labs} = proxy;
describe('{{body_class}} helper', function () {
let options = {};
before(function () {
@ -155,4 +160,115 @@ describe('{{body_class}} helper', function () {
rendered.string.should.equal('page-template page-about');
});
});
describe('custom fonts', function () {
let settingsCacheStub;
let labsStub;
function callBodyClassWithContext(context, self) {
options.data.root.context = context;
return body_class.call(
self,
options
);
}
beforeEach(function () {
labsStub = sinon.stub(labs, 'isSet').withArgs('customFonts').returns(true);
settingsCacheStub = sinon.stub(settingsCache, 'get');
options = {
data: {
root: {
context: [],
settings: {active_theme: 'casper'}
},
site: {}
}
};
});
afterEach(function () {
sinon.restore();
});
it('includes custom font for post when set in options data object', function () {
options.data.site.heading_font = 'Space Grotesk';
options.data.site.body_font = 'Noto Sans';
options.data.site._preview = 'test';
const rendered = callBodyClassWithContext(
['post'],
{relativeUrl: '/my-awesome-post/', post: {tags: [{slug: 'foo'}, {slug: 'bar'}]}}
);
rendered.string.should.equal('post-template tag-foo tag-bar gh-font-heading-space-grotesk gh-font-body-noto-sans');
});
it('includes custom font for post when set in settings cache and no preview', function () {
settingsCacheStub.withArgs('heading_font').returns('Space Grotesk');
settingsCacheStub.withArgs('body_font').returns('Noto Sans');
const rendered = callBodyClassWithContext(
['post'],
{relativeUrl: '/my-awesome-post/', post: {tags: [{slug: 'foo'}, {slug: 'bar'}]}}
);
rendered.string.should.equal('post-template tag-foo tag-bar gh-font-heading-space-grotesk gh-font-body-noto-sans');
});
it('does not include custom font classes when custom fonts are not enabled', function () {
labsStub.withArgs('customFonts').returns(false);
const rendered = callBodyClassWithContext(
['post'],
{relativeUrl: '/my-awesome-post/', post: {tags: [{slug: 'foo'}, {slug: 'bar'}]}}
);
rendered.string.should.equal('post-template tag-foo tag-bar');
});
it('includes custom font classes for home page when set in options data object', function () {
options.data.site.heading_font = 'Space Grotesk';
options.data.site.body_font = '';
options.data.site._preview = 'test';
const rendered = callBodyClassWithContext(
['home'],
{relativeUrl: '/'}
);
rendered.string.should.equal('home-template gh-font-heading-space-grotesk');
});
it('does not inject custom fonts when preview is set and default font was selected (empty string)', function () {
// The site has fonts set up, but we override them with Theme default fonts (empty string)
settingsCacheStub.withArgs('heading_font').returns('Space Grotesk');
settingsCacheStub.withArgs('body_font').returns('Noto Sans');
options.data.site.heading_font = '';
options.data.site.body_font = '';
options.data.site._preview = 'test';
const rendered = callBodyClassWithContext(
['home'],
{relativeUrl: '/'}
);
rendered.string.should.equal('home-template');
});
it('can handle preview being set and custom font keys missing', function () {
options.data.site._preview = 'test';
// The site has fonts set up, but we override them with Theme default fonts (empty string)
settingsCacheStub.withArgs('heading_font').returns('Space Grotesk');
settingsCacheStub.withArgs('body_font').returns('Noto Sans');
const rendered = callBodyClassWithContext(
['post'],
{relativeUrl: '/my-awesome-post/', post: {tags: [{slug: 'foo'}, {slug: 'bar'}]}}
);
rendered.string.should.equal('post-template tag-foo tag-bar');
});
});
});

View file

@ -1125,6 +1125,153 @@ describe('{{ghost_head}} helper', function () {
});
});
describe('custom fonts', function () {
it('includes custom font when set in options data object and preview is set', async function () {
sinon.stub(labs, 'isSet').withArgs('customFonts').returns(true);
const renderObject = {
post: posts[1]
};
const templateOptions = {
site: {
heading_font: 'Space Grotesk',
body_font: 'Poppins',
_preview: 'test'
}
};
await testGhostHead(testUtils.createHbsResponse({
templateOptions,
renderObject: renderObject,
locals: {
relativeUrl: '/post/',
context: ['post'],
safeVersion: '0.3'
}
}));
});
it('includes custom font when set in settings cache and no preview', async function () {
sinon.stub(labs, 'isSet').withArgs('customFonts').returns(true);
settingsCache.get.withArgs('heading_font').returns('Playfair Display');
settingsCache.get.withArgs('body_font').returns('Lora');
const renderObject = {
post: posts[1]
};
await testGhostHead(testUtils.createHbsResponse({
templateOptions: {site: {}},
renderObject: renderObject,
locals: {
relativeUrl: '/post/',
context: ['post'],
safeVersion: '0.3'
}
}));
});
it('does not include custom font when not set', async function () {
sinon.stub(labs, 'isSet').withArgs('customFonts').returns(true);
settingsCache.get.withArgs('heading_font').returns(null);
settingsCache.get.withArgs('body_font').returns('');
const renderObject = {
post: posts[1]
};
await testGhostHead(testUtils.createHbsResponse({
templateOptions: {site: {}},
renderObject,
locals: {
relativeUrl: '/post/',
context: ['post'],
safeVersion: '0.3'
}
}));
});
it('does not include custom font when invalid', async function () {
sinon.stub(labs, 'isSet').withArgs('customFonts').returns(true);
settingsCache.get.withArgs('heading_font').returns(null);
settingsCache.get.withArgs('body_font').returns('Wendy Sans');
const templateOptions = {
site: {
heading_font: 'Comic Sans',
body_font: ''
}
};
const renderObject = {
post: posts[1]
};
await testGhostHead(testUtils.createHbsResponse({
templateOptions,
renderObject,
locals: {
relativeUrl: '/post/',
context: ['post'],
safeVersion: '0.3'
}
}));
});
it('does not inject custom fonts when preview is set and default font was selected (empty string)', async function () {
sinon.stub(labs, 'isSet').withArgs('customFonts').returns(true);
// The site has fonts set up, but we override them with Theme default fonts (empty string)
settingsCache.get.withArgs('heading_font').returns('Playfair Display');
settingsCache.get.withArgs('body_font').returns('Lora');
const renderObject = {
post: posts[1]
};
await testGhostHead(testUtils.createHbsResponse({
templateOptions: {site: {
heading_font: '',
body_font: '',
_preview: 'test'
}},
renderObject,
locals: {
relativeUrl: '/post/',
context: ['post'],
safeVersion: '0.3'
}
}));
});
it('can handle preview being set and custom font keys missing', async function () {
sinon.stub(labs, 'isSet').withArgs('customFonts').returns(true);
// The site has fonts set up, but we override them with Theme default fonts (empty string)
settingsCache.get.withArgs('heading_font').returns('Playfair Display');
settingsCache.get.withArgs('body_font').returns('Lora');
const renderObject = {
post: posts[1]
};
await testGhostHead(testUtils.createHbsResponse({
templateOptions: {site: {
// No keys for custom fonts set
_preview: 'test'
}},
renderObject,
locals: {
relativeUrl: '/post/',
context: ['post'],
safeVersion: '0.3'
}
}));
});
});
describe('members scripts', function () {
it('includes portal when members enabled', async function () {
settingsCache.get.withArgs('members_enabled').returns(true);

View file

@ -0,0 +1,7 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View file

@ -0,0 +1,23 @@
# Custom Fonts
Custom fonts mapping module
## Usage
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests

View file

@ -0,0 +1,43 @@
{
"name": "@tryghost/custom-fonts",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/custom-fonts",
"author": "Ghost Foundation",
"private": true,
"main": "./build/cjs/index.js",
"module": "./build/esm/index.js",
"types": "./build/types/index.d.ts",
"exports": {
".": {
"import": "./build/esm/index.js",
"require": "./build/cjs/index.js",
"types": "./build/types/index.d.ts"
}
},
"scripts": {
"dev": "tsc --watch --preserveWatchOutput --sourceMap",
"build": "yarn build:cjs && yarn build:esm && yarn build:ts",
"build:types": "tsc -p tsconfig.json --emitDeclarationOnly --declaration --declarationDir ./build/types",
"build:cjs": "tsc -p tsconfig.json --outDir ./build/cjs --module CommonJS",
"build:esm": "tsc -p tsconfig.esm.json --outDir ./build/esm --module ES2020",
"build:ts": "yarn build:cjs && yarn build:esm && yarn build:types",
"prepare": "yarn build",
"test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura mocha -r ts-node/register './test/**/*.test.ts'",
"test": "yarn test:ts && yarn test:unit",
"test:ts": "tsc --noEmit",
"lint:code": "eslint src/ --ext .ts --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache"
},
"files": [
"build"
],
"devDependencies": {
"c8": "10.1.2",
"mocha": "10.7.3",
"sinon": "19.0.2",
"ts-node": "10.9.2",
"typescript": "5.6.2"
},
"dependencies": {}
}

View file

@ -0,0 +1,218 @@
export type BodyFont = 'Fira Mono' | 'Fira Sans' | 'IBM Plex Serif' | 'Inter' | 'JetBrains Mono' | 'Lora' | 'Manrope' | 'Merriweather' | 'Nunito' | 'Noto Sans' | 'Noto Serif' | 'Poppins' | 'Roboto' | 'Space Mono';
export type HeadingFont = 'Cardo' | 'Chakra Petch' | 'Old Standard TT' | 'Prata' | 'Rufina' | 'Space Grotesk' | 'Tenor Sans' | BodyFont;
export type CustomFonts = {heading: HeadingFont[], body: BodyFont[]};
export type FontSelection = {
heading?: HeadingFont,
body?: BodyFont
};
export const CUSTOM_FONTS: CustomFonts = {
heading: [
'Cardo',
'Chakra Petch',
'Fira Mono',
'Fira Sans',
'IBM Plex Serif',
'Inter',
'JetBrains Mono',
'Lora',
'Manrope',
'Merriweather',
'Noto Sans',
'Noto Serif',
'Nunito',
'Old Standard TT',
'Poppins',
'Prata',
'Roboto',
'Rufina',
'Space Grotesk',
'Space Mono',
'Tenor Sans'
],
body: [
'Fira Mono',
'Fira Sans',
'IBM Plex Serif',
'Inter',
'JetBrains Mono',
'Lora',
'Manrope',
'Merriweather',
'Noto Sans',
'Noto Serif',
'Nunito',
'Poppins',
'Roboto',
'Space Mono'
]
};
const classFontNames = {
Cardo: 'cardo',
Manrope: 'manrope',
Merriweather: 'merriweather',
Nunito: 'nunito',
'Old Standard TT': 'old-standard-tt',
Prata: 'prata',
Roboto: 'roboto',
Rufina: 'rufina',
'Tenor Sans': 'tenor-sans',
'Space Grotesk': 'space-grotesk',
'Chakra Petch': 'chakra-petch',
'Noto Sans': 'noto-sans',
Poppins: 'poppins',
'Fira Sans': 'fira-sans',
Inter: 'inter',
'Noto Serif': 'noto-serif',
Lora: 'lora',
'IBM Plex Serif': 'ibm-plex-serif',
'Space Mono': 'space-mono',
'Fira Mono': 'fira-mono',
'JetBrains Mono': 'jetbrains-mono'
};
export function generateCustomFontCss(fonts: FontSelection) {
let fontImports: string = '';
let fontCSS: string = '';
const importStrings = {
Cardo: {
url: '@import url(https://fonts.bunny.net/css?family=cardo:400,700)'
},
Manrope: {
url: '@import url(https://fonts.bunny.net/css?family=manrope:300,500,700)'
},
Merriweather: {
url: '@import url(https://fonts.bunny.net/css?family=merriweather:300,700)'
},
Nunito: {
url: '@import url(https://fonts.bunny.net/css?family=nunito:400,600,700)'
},
'Old Standard TT': {
url: '@import url(https://fonts.bunny.net/css?family=old-standard-tt:400,700)'
},
Prata: {
url: '@import url(https://fonts.bunny.net/css?family=prata:400)'
},
Roboto: {
url: '@import url(https://fonts.bunny.net/css?family=roboto:400,500,700)'
},
Rufina: {
url: '@import url(https://fonts.bunny.net/css?family=rufina:400,500,700)'
},
'Tenor Sans': {
url: '@import url(https://fonts.bunny.net/css?family=tenor-sans:400)'
},
'Space Grotesk': {
url: '@import url(https://fonts.bunny.net/css?family=space-grotesk:700)'
},
'Chakra Petch': {
url: '@import url(https://fonts.bunny.net/css?family=chakra-petch:400)'
},
'Noto Sans': {
url: '@import url(https://fonts.bunny.net/css?family=noto-sans:400,700)'
},
Poppins: {
url: '@import url(https://fonts.bunny.net/css?family=poppins:400,500,600)'
},
'Fira Sans': {
url: '@import url(https://fonts.bunny.net/css?family=fira-sans:400,500,600)'
},
Inter: {
url: '@import url(https://fonts.bunny.net/css?family=inter:400,500,600)'
},
'Noto Serif': {
url: '@import url(https://fonts.bunny.net/css?family=noto-serif:400,700)'
},
Lora: {
url: '@import url(https://fonts.bunny.net/css?family=lora:400,700)'
},
'IBM Plex Serif': {
url: '@import url(https://fonts.bunny.net/css?family=ibm-plex-serif:400,500,600)'
},
'Space Mono': {
url: '@import url(https://fonts.bunny.net/css?family=space-mono:400,700)'
},
'Fira Mono': {
url: '@import url(https://fonts.bunny.net/css?family=fira-mono:400,700)'
},
'JetBrains Mono': {
url: '@import url(https://fonts.bunny.net/css?family=jetbrains-mono:400,700)'
}
};
if (fonts?.heading && fonts?.body && fonts?.heading === fonts?.body) {
fontImports = `${importStrings[fonts?.heading]?.url};`;
} else {
fontImports = '';
if (fonts?.heading) {
fontImports += `${importStrings[fonts?.heading]?.url};`;
}
if (fonts?.body) {
fontImports += `${importStrings[fonts?.body]?.url};`;
}
}
if (fonts?.body || fonts?.heading) {
fontCSS = ':root {';
if (fonts?.heading) {
fontCSS += `--gh-font-heading: ${fonts.heading};`;
}
if (fonts?.body) {
fontCSS += `--gh-font-body: ${fonts.body};`;
}
fontCSS += '}';
}
return `<style>${fontImports}${fontCSS}</style>`;
}
export function generateCustomFontBodyClass(fonts: FontSelection) {
let bodyClass = '';
if (fonts?.heading) {
bodyClass += getCustomFontClassName({font: fonts.heading, heading: true});
if (fonts?.body) {
bodyClass += ' ';
}
}
if (fonts?.body) {
bodyClass += getCustomFontClassName({font: fonts.body, heading: false});
}
return bodyClass;
}
export function getCSSFriendlyFontClassName(font: string) {
return classFontNames[font as keyof typeof classFontNames] || '';
}
export function getCustomFontClassName({font, heading}: {font: string, heading: boolean}) {
const cssFriendlyFontClassName = getCSSFriendlyFontClassName(font);
if (!cssFriendlyFontClassName) {
return '';
}
return `gh-font-${heading ? 'heading' : 'body'}-${cssFriendlyFontClassName}`;
}
export function getCustomFonts(): CustomFonts {
return CUSTOM_FONTS;
}
export function isValidCustomFont(font: string): font is BodyFont {
return CUSTOM_FONTS.body.includes(font as BodyFont);
}
export function isValidCustomHeadingFont(font: string): font is HeadingFont {
return CUSTOM_FONTS.heading.includes(font as HeadingFont);
}

View file

@ -0,0 +1,7 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View file

@ -0,0 +1,124 @@
import assert from 'assert/strict';
import * as customFonts from '../src/index';
describe('Custom Fonts', function () {
describe('getCustomFonts', function () {
it('returns the correct custom fonts', function () {
const fonts = customFonts.getCustomFonts();
assert.equal(fonts, customFonts.CUSTOM_FONTS);
});
});
describe('getCSSFriendlyFontClassName', function () {
it('returns the correct class name for a font', function () {
assert.equal(customFonts.getCSSFriendlyFontClassName('Inter'), 'inter');
});
it('returns the correct class name for a font with a space', function () {
assert.equal(customFonts.getCSSFriendlyFontClassName('Fira Sans'), 'fira-sans');
});
it('returns empty string for an invalid font', function () {
assert.equal(customFonts.getCSSFriendlyFontClassName('Invalid Font'), '');
});
});
describe('getCustomFontClassName', function () {
it('returns the correct class name for a valid heading font', function () {
assert.equal(customFonts.getCustomFontClassName({font: 'Space Grotesk', heading: true}), 'gh-font-heading-space-grotesk');
});
it('returns the correct class name for a valid body font', function () {
assert.equal(customFonts.getCustomFontClassName({font: 'Noto Sans', heading: false}), 'gh-font-body-noto-sans');
});
it('returns empty string for an invalid font', function () {
assert.equal(customFonts.getCustomFontClassName({font: 'Invalid Font', heading: true}), '');
});
});
describe('isValidCustomFont', function () {
it('returns true for valid body fonts', function () {
assert.equal(customFonts.isValidCustomFont('Inter'), true);
assert.equal(customFonts.isValidCustomFont('Fira Sans'), true);
});
it('returns false for invalid fonts', function () {
assert.equal(customFonts.isValidCustomFont('Invalid Font'), false);
});
});
describe('isValidCustomHeadingFont', function () {
it('returns true for valid heading fonts', function () {
assert.equal(customFonts.isValidCustomHeadingFont('Space Grotesk'), true);
assert.equal(customFonts.isValidCustomHeadingFont('Lora'), true);
});
it('returns false for invalid heading fonts', function () {
assert.equal(customFonts.isValidCustomHeadingFont('Invalid Font'), false);
assert.equal(customFonts.isValidCustomHeadingFont('Comic Sans'), false);
});
});
describe('generateCustomFontCss', function () {
it('returns correct CSS for single font', function () {
const result = customFonts.generateCustomFontCss({body: 'Noto Sans'});
assert.equal(result.includes('@import url(https://fonts.bunny.net/css?family=noto-sans:400,700);'), true, 'Includes the correct import for the body font');
assert.equal(result.includes(':root {--gh-font-body: Noto Sans;}'), true, 'Includes the correct CSS for the body font');
assert.equal(result.includes('--gh-font-heading'), false, 'Does not include CSS for the title font');
});
it('returns correct CSS for different heading and body fonts', function () {
const result = customFonts.generateCustomFontCss({heading: 'Chakra Petch', body: 'Poppins'});
assert.equal(result.includes('@import url(https://fonts.bunny.net/css?family=chakra-petch:400);'), true, 'Includes the correct import for the heading font');
assert.equal(result.includes('@import url(https://fonts.bunny.net/css?family=poppins:400,500,600);'), true, 'Includes the correct import for the body font');
assert.equal(result.includes(':root {--gh-font-heading: Chakra Petch;--gh-font-body: Poppins;}'), true, 'Includes the correct CSS for the body and heading fonts');
});
it('returns correct CSS with only one import for equal heading and body fonts', function () {
const result = customFonts.generateCustomFontCss({heading: 'Lora', body: 'Lora'});
assert.equal(result, '<style>@import url(https://fonts.bunny.net/css?family=lora:400,700);:root {--gh-font-heading: Lora;--gh-font-body: Lora;}</style>', 'Includes the correct CSS with only one import for equal heading and body fonts');
});
it('generates CSS when only body font is provided', function () {
const result = customFonts.generateCustomFontCss({body: 'Noto Sans'});
assert.equal(result.includes(':root {'), true, 'Includes :root selector when only body font is provided');
assert.equal(result.includes('--gh-font-body: Noto Sans;'), true, 'Includes body font CSS');
assert.equal(result.includes('--gh-font-heading'), false, 'Does not include heading font CSS when not provided');
});
it('generates CSS when only heading font is provided', function () {
const result = customFonts.generateCustomFontCss({heading: 'Space Grotesk'});
assert.equal(result.includes(':root {'), true, 'Includes :root selector when only heading font is provided');
assert.equal(result.includes('--gh-font-heading: Space Grotesk;'), true, 'Includes heading font CSS');
assert.equal(result.includes('--gh-font-body'), false, 'Does not include body font CSS when not provided');
});
});
describe('generateCustomFontBodyClass', function () {
it('returns the correct class for a single font', function () {
const result = customFonts.generateCustomFontBodyClass({body: 'Noto Sans'});
assert.equal(result, 'gh-font-body-noto-sans', 'Returns the correct class for a single font');
});
it('returns the correct class for different heading and body fonts', function () {
const result = customFonts.generateCustomFontBodyClass({heading: 'JetBrains Mono', body: 'Poppins'});
assert.equal(result, 'gh-font-heading-jetbrains-mono gh-font-body-poppins', 'Returns the correct class for different heading and body fonts');
});
it('returns the correct class with only a heading font', function () {
const result = customFonts.generateCustomFontBodyClass({heading: 'Lora'});
assert.equal(result, 'gh-font-heading-lora', 'Returns the correct class with only a heading font');
});
it('returns empty string with no fonts', function () {
const result = customFonts.generateCustomFontBodyClass({});
assert.equal(result, '', 'Returns an empty string with no fonts');
});
});
});

View file

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ES2020",
"outDir": "./build/esm",
"verbatimModuleSyntax": true,
"isolatedModules": true
},
"include": ["src/**/*"]
}

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"incremental": true,
"rootDir": "src",
"target": "ES2020",
"module": "CommonJS",
"outDir": "./build",
"declaration": true,
"declarationDir": "./build",
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}