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:
parent
96239d31a6
commit
c1ce322e86
52 changed files with 2184 additions and 481 deletions
|
@ -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 |
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}}>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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)',
|
||||
|
|
|
@ -327,6 +327,14 @@
|
|||
{
|
||||
"key": "support_email_address",
|
||||
"value": "support@example.com"
|
||||
},
|
||||
{
|
||||
"key": "heading_font",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"key": "body_font",
|
||||
"value": null
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
|
|
|
@ -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",
|
||||
|
|
BIN
apps/admin-x-settings/src/assets/images/design-settings.png
Normal file
BIN
apps/admin-x-settings/src/assets/images/design-settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 41 KiB |
18
apps/admin-x-settings/src/components/BehindFeatureFlag.tsx
Normal file
18
apps/admin-x-settings/src/components/BehindFeatureFlag.tsx
Normal 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;
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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') &&
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
|
@ -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}
|
||||
/>;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
} = {};
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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, '\\+')}`));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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/]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -46,7 +46,8 @@ const ALPHA_FEATURES = [
|
|||
'adminXDemo',
|
||||
'contentVisibility',
|
||||
'commentImprovements',
|
||||
'staff2fa'
|
||||
'staff2fa',
|
||||
'customFonts'
|
||||
];
|
||||
|
||||
module.exports.GA_KEYS = [...GA_FEATURES];
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -20,6 +20,7 @@ Object {
|
|||
"collectionsCard": true,
|
||||
"commentImprovements": true,
|
||||
"contentVisibility": true,
|
||||
"customFonts": true,
|
||||
"editorExcerpt": true,
|
||||
"emailCustomization": true,
|
||||
"i18n": true,
|
||||
|
|
|
@ -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/\\">
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
7
ghost/custom-fonts/.eslintrc.js
Normal file
7
ghost/custom-fonts/.eslintrc.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
23
ghost/custom-fonts/README.md
Normal file
23
ghost/custom-fonts/README.md
Normal 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
|
||||
|
43
ghost/custom-fonts/package.json
Normal file
43
ghost/custom-fonts/package.json
Normal 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": {}
|
||||
}
|
218
ghost/custom-fonts/src/index.ts
Normal file
218
ghost/custom-fonts/src/index.ts
Normal 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);
|
||||
}
|
7
ghost/custom-fonts/test/.eslintrc.js
Normal file
7
ghost/custom-fonts/test/.eslintrc.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
124
ghost/custom-fonts/test/index.test.ts
Normal file
124
ghost/custom-fonts/test/index.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
10
ghost/custom-fonts/tsconfig.esm.json
Normal file
10
ghost/custom-fonts/tsconfig.esm.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ES2020",
|
||||
"outDir": "./build/esm",
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
17
ghost/custom-fonts/tsconfig.json
Normal file
17
ghost/custom-fonts/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
Loading…
Add table
Reference in a new issue