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

Updated AdminX to load via ES Modules to enable code splitting (#17971)

refs https://github.com/TryGhost/Product/issues/3349
This commit is contained in:
Jono M 2023-09-07 07:38:20 +01:00 committed by GitHub
parent 8a02d54326
commit 9e45afddb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 199 additions and 136 deletions

View file

@ -71,9 +71,9 @@ if (DASH_DASH_ARGS.includes('admin-x') || DASH_DASH_ARGS.includes('adminx') || D
// https://localhost:41740 {
// reverse_proxy http://localhost:4174
// }
COMMAND_GHOST.env['adminX__url'] = 'https://localhost:41740/admin-x-settings.umd.js';
COMMAND_GHOST.env['adminX__url'] = 'https://localhost:41740/admin-x-settings.js';
} else {
COMMAND_GHOST.env['adminX__url'] = 'http://localhost:4174/admin-x-settings.umd.js';
COMMAND_GHOST.env['adminX__url'] = 'http://localhost:4174/admin-x-settings.js';
}
}

View file

@ -89,6 +89,7 @@
"stylelint": "15.10.3",
"tailwindcss": "3.3.3",
"vite": "4.4.9",
"vite-plugin-css-injected-by-js": "^3.3.0",
"vite-plugin-svgr": "3.2.0",
"vitest": "0.34.3"
}

View file

@ -1,68 +1,15 @@
import CodeMirror, {ReactCodeMirrorProps, ReactCodeMirrorRef} from '@uiw/react-codemirror';
import Heading from '../Heading';
import Hint from '../Hint';
import React, {forwardRef, useId} from 'react';
import clsx from 'clsx';
import {EditorView} from '@codemirror/view';
import {Extension} from '@codemirror/state';
import React, {Suspense, forwardRef} from 'react';
import type {CodeEditorProps} from './CodeEditorView.tsx';
import type {ReactCodeMirrorRef} from '@uiw/react-codemirror';
export interface CodeEditorProps extends Omit<ReactCodeMirrorProps, 'value' | 'onChange'> {
title?: string;
value?: string;
height?: string;
error?: boolean;
hint?: React.ReactNode;
clearBg?: boolean;
extensions: Extension[];
onChange?: (value: string) => void;
}
const theme = EditorView.theme({
'& .cm-scroller': {
fontFamily: 'Consolas, Liberation Mono, Menlo, Courier, monospace'
},
'& .cm-activeLine, & .cm-activeLineGutter': {
backgroundColor: 'transparent'
}
});
const CodeEditor = forwardRef<ReactCodeMirrorRef, CodeEditorProps>(function CodeEditor({
title,
value,
height = '200px',
error,
hint,
clearBg = true,
extensions,
onChange,
...props
}, ref) {
const id = useId();
let styles = clsx(
'peer order-2 overflow-hidden rounded-sm border',
clearBg ? 'bg-transparent' : 'bg-grey-75',
error ? 'border-red' : 'border-grey-500 hover:border-grey-700 focus:border-grey-800',
title && 'mt-2',
height === 'full' && 'h-full'
);
// Imported asynchronously to avoid including CodeMirror in the main bundle
const CodeEditorView = React.lazy(() => import('./CodeEditorView.tsx'));
const CodeEditor = forwardRef<ReactCodeMirrorRef, CodeEditorProps>(function CodeEditor(props, ref) {
return (
<div className={height === 'full' ? 'h-full' : ''}>
<CodeMirror
ref={ref}
className={styles}
extensions={extensions}
height={height === 'full' ? '100%' : height}
theme={theme}
value={value}
onChange={onChange}
{...props}
/>
{title && <Heading className={'order-1 !text-grey-700 peer-focus:!text-black'} htmlFor={id} useLabelTag={true}>{title}</Heading>}
{hint && <Hint className='order-3' color={error ? 'red' : ''}>{hint}</Hint>}
</div>
<Suspense fallback={null}>
<CodeEditorView {...props} ref={ref} />
</Suspense>
);
});

View file

@ -0,0 +1,79 @@
import CodeMirror, {ReactCodeMirrorProps, ReactCodeMirrorRef} from '@uiw/react-codemirror';
import Heading from '../Heading';
import Hint from '../Hint';
import React, {forwardRef, useEffect, useId} from 'react';
import clsx from 'clsx';
import {EditorView} from '@codemirror/view';
import {Extension} from '@codemirror/state';
export interface CodeEditorProps extends Omit<ReactCodeMirrorProps, 'value' | 'onChange' | 'extensions'> {
title?: string;
value?: string;
height?: string;
error?: boolean;
hint?: React.ReactNode;
clearBg?: boolean;
extensions: Array<Extension | Promise<Extension>>;
onChange?: (value: string) => void;
}
const theme = EditorView.theme({
'& .cm-scroller': {
fontFamily: 'Consolas, Liberation Mono, Menlo, Courier, monospace'
},
'& .cm-activeLine, & .cm-activeLineGutter': {
backgroundColor: 'transparent'
}
});
// Meant to be imported asynchronously to avoid including CodeMirror in the main bundle
const CodeEditorView = forwardRef<ReactCodeMirrorRef, CodeEditorProps>(function CodeEditorView({
title,
value,
height = '200px',
error,
hint,
clearBg = true,
extensions,
onChange,
...props
}, ref) {
const id = useId();
const [resolvedExtensions, setResolvedExtensions] = React.useState<Extension[] | null>(null);
useEffect(() => {
Promise.all(extensions).then(setResolvedExtensions);
}, [extensions]);
let styles = clsx(
'peer order-2 overflow-hidden rounded-sm border',
clearBg ? 'bg-transparent' : 'bg-grey-75',
error ? 'border-red' : 'border-grey-500 hover:border-grey-700 focus:border-grey-800',
title && 'mt-2',
height === 'full' && 'h-full'
);
if (!resolvedExtensions) {
return null;
}
return (
<div className={height === 'full' ? 'h-full' : ''}>
<CodeMirror
ref={ref}
className={styles}
extensions={resolvedExtensions}
height={height === 'full' ? '100%' : height}
theme={theme}
value={value}
onChange={onChange}
{...props}
/>
{title && <Heading className={'order-1 !text-grey-700 peer-focus:!text-black'} htmlFor={id} useLabelTag={true}>{title}</Heading>}
{hint && <Hint className='order-3' color={error ? 'red' : ''}>{hint}</Hint>}
</div>
);
});
export default CodeEditorView;

View file

@ -1,28 +1,6 @@
import AddIntegrationModal from '../settings/advanced/integrations/AddIntegrationModal';
import AddNewsletterModal from '../settings/email/newsletters/AddNewsletterModal';
import AddRecommendationModal from '../settings/site/recommendations/AddRecommendationModal';
import AmpModal from '../settings/advanced/integrations/AmpModal';
import AnnouncementBarModal from '../settings/site/AnnouncementBarModal';
import ChangeThemeModal from '../settings/site/ThemeModal';
import CustomIntegrationModal from '../settings/advanced/integrations/CustomIntegrationModal';
import DesignModal from '../settings/site/DesignModal';
import EditRecommendationModal from '../settings/site/recommendations/EditRecommendationModal';
import EmbedSignupFormModal from '../settings/membership/EmbedSignupFormModal';
import FirstpromoterModal from '../settings/advanced/integrations/FirstPromoterModal';
import HistoryModal from '../settings/advanced/HistoryModal';
import InviteUserModal from '../settings/general/InviteUserModal';
import NavigationModal from '../settings/site/NavigationModal';
import NewsletterDetailModal from '../settings/email/newsletters/NewsletterDetailModal';
import NiceModal, {NiceModalHocProps} from '@ebay/nice-modal-react';
import PinturaModal from '../settings/advanced/integrations/PinturaModal';
import PortalModal from '../settings/membership/portal/PortalModal';
import React, {createContext, useCallback, useEffect, useState} from 'react';
import SlackModal from '../settings/advanced/integrations/SlackModal';
import StripeConnectModal from '../settings/membership/stripe/StripeConnectModal';
import TierDetailModal from '../settings/membership/tiers/TierDetailModal';
import UnsplashModal from '../settings/advanced/integrations/UnsplashModal';
import UserDetailModal from '../settings/general/UserDetailModal';
import ZapierModal from '../settings/advanced/integrations/ZapierModal';
export type RouteParams = {[key: string]: string}
@ -57,7 +35,31 @@ export type RoutingModalProps = {
params?: Record<string, string>
}
const modalPaths: {[key: string]: React.FC<NiceModalHocProps & RoutingModalProps>} = {
const AddIntegrationModal = () => import('../settings/advanced/integrations/AddIntegrationModal');
const AddNewsletterModal = () => import('../settings/email/newsletters/AddNewsletterModal');
const AddRecommendationModal = () => import('../settings/site/recommendations/AddRecommendationModal');
const AmpModal = () => import('../settings/advanced/integrations/AmpModal');
const ChangeThemeModal = () => import('../settings/site/ThemeModal');
const CustomIntegrationModal = () => import('../settings/advanced/integrations/CustomIntegrationModal');
const DesignModal = () => import('../settings/site/DesignModal');
const EditRecommendationModal = () => import('../settings/site/recommendations/EditRecommendationModal');
const FirstpromoterModal = () => import('../settings/advanced/integrations/FirstPromoterModal');
const HistoryModal = () => import('../settings/advanced/HistoryModal');
const InviteUserModal = () => import('../settings/general/InviteUserModal');
const NavigationModal = () => import('../settings/site/NavigationModal');
const NewsletterDetailModal = () => import('../settings/email/newsletters/NewsletterDetailModal');
const PinturaModal = () => import('../settings/advanced/integrations/PinturaModal');
const PortalModal = () => import('../settings/membership/portal/PortalModal');
const SlackModal = () => import('../settings/advanced/integrations/SlackModal');
const StripeConnectModal = () => import('../settings/membership/stripe/StripeConnectModal');
const TierDetailModal = () => import('../settings/membership/tiers/TierDetailModal');
const UnsplashModal = () => import('../settings/advanced/integrations/UnsplashModal');
const UserDetailModal = () => import('../settings/general/UserDetailModal');
const ZapierModal = () => import('../settings/advanced/integrations/ZapierModal');
const AnnouncementBarModal = () => import('../settings/site/AnnouncementBarModal');
const EmbedSignupFormModal = () => import('../settings/membership/EmbedSignupFormModal');
const modalPaths: {[key: string]: () => Promise<{default: React.FC<NiceModalHocProps & RoutingModalProps>}>} = {
'design/edit/themes': ChangeThemeModal,
'design/edit': DesignModal,
'navigation/edit': NavigationModal,
@ -119,7 +121,7 @@ const handleNavigation = (scroll: boolean = true) => {
const [path, modal] = Object.entries(modalPaths).find(([modalPath]) => matchRoute(pathName, modalPath)) || [];
if (path && modal) {
NiceModal.show(modal, {params: matchRoute(pathName, path)});
modal().then(({default: component}) => NiceModal.show(component, {params: matchRoute(pathName, path)}));
}
if (scroll) {

View file

@ -2,13 +2,12 @@ import Button from '../../../admin-x-ds/global/Button';
import CodeEditor from '../../../admin-x-ds/global/form/CodeEditor';
import CodeModal from './code/CodeModal';
import NiceModal from '@ebay/nice-modal-react';
import React, {useRef, useState} from 'react';
import React, {useMemo, useRef, useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {ReactCodeMirrorRef} from '@uiw/react-codemirror';
import {getSettingValues} from '../../../api/settings';
import {html} from '@codemirror/lang-html';
const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
@ -28,15 +27,17 @@ const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => {
const headerEditorRef = useRef<ReactCodeMirrorRef>(null);
const footerEditorRef = useRef<ReactCodeMirrorRef>(null);
const html = useMemo(() => import('@codemirror/lang-html').then(module => module.html()), []);
const headerProps = {
extensions: [html()],
extensions: [html],
hint: 'Code here will be injected into the {{ghost_head}} tag on every page of the site',
value: headerContent,
onChange: (value: string) => updateSetting('codeinjection_head', value)
};
const footerProps = {
extensions: [html()],
extensions: [html],
hint: 'Code here will be injected into the {{ghost_foot}} tag on every page of the site',
value: footerContent,
onChange: (value: string) => updateSetting('codeinjection_foot', value)

View file

@ -1,8 +1,7 @@
import CodeEditor from '../../../../admin-x-ds/global/form/CodeEditor';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react';
import {html} from '@codemirror/lang-html';
import React, {useMemo} from 'react';
interface CodeModalProps {
hint?: React.ReactNode;
@ -14,13 +13,15 @@ interface CodeModalProps {
const CodeModal: React.FC<CodeModalProps> = ({hint, value, onChange, afterClose}) => {
const modal = useModal();
const html = useMemo(() => import('@codemirror/lang-html').then(module => module.html()), []);
const onOk = () => {
modal.remove();
afterClose?.();
};
return <Modal afterClose={afterClose} cancelLabel='' okColor='grey' okLabel='Done' size='full' testId='modal-code' onOk={onOk}>
<CodeEditor extensions={[html()]} height='full' hint={hint} value={value} autoFocus onChange={onChange} />
<CodeEditor extensions={[html]} height='full' hint={hint} value={value} autoFocus onChange={onChange} />
</Modal>;
};

View file

@ -1,9 +1,10 @@
import LimitService from '@tryghost/limit-service';
import useStaffUsers from './useStaffUsers';
import {useBrowseMembers} from '../api/members';
import {useBrowseNewsletters} from '../api/newsletters';
import {useEffect, useMemo, useState} from 'react';
import {useGlobalData} from '../components/providers/GlobalDataProvider';
import {useMemo} from 'react';
const limitServiceImport = import('@tryghost/limit-service');
export class LimitError extends Error {
public readonly errorType: string;
@ -48,6 +49,11 @@ interface LimiterLimits {
export const useLimiter = () => {
const {config} = useGlobalData();
const [LimitService, setLimitService] = useState<typeof import('@tryghost/limit-service') | null>(null);
useEffect(() => {
limitServiceImport.then(exports => setLimitService(() => exports.default));
}, []);
const {users, contributorUsers, invites, isLoading} = useStaffUsers();
const {refetch: fetchMembers} = useBrowseMembers({
@ -70,7 +76,7 @@ export const useLimiter = () => {
return useMemo(() => {
const limits = config.hostSettings?.limits as LimiterLimits;
if (!limits || isLoading) {
if (!LimitService || !limits || isLoading) {
return;
}
@ -114,5 +120,5 @@ export const useLimiter = () => {
errorIfWouldGoOverLimit: (limitName: string, metadata: Record<string, unknown> = {}): Promise<void> => limiter.errorIfWouldGoOverLimit(limitName, metadata),
errorIfIsOverLimit: (limitName: string): Promise<void> => limiter.errorIfIsOverLimit(limitName)
};
}, [config.hostSettings?.limits, contributorUsers, fetchMembers, fetchNewsletters, helpLink, invites, isLoading, users]);
}, [LimitService, config.hostSettings?.limits, contributorUsers, fetchMembers, fetchNewsletters, helpLink, invites, isLoading, users]);
};

View file

@ -19,19 +19,19 @@ test.describe('Announcement Bar', async () => {
});
await page.goto('/');
const section = page.getByTestId('announcement-bar');
await section.getByRole('button', {name: 'Customize'}).click();
const modal = page.getByTestId('announcement-bar-modal');
// // Homepage and post preview
await expect(modal.frameLocator('[data-testid="announcement-bar-preview"]').getByText('homepage preview')).toHaveCount(1);
await modal.getByTestId('design-toolbar').getByRole('tab', {name: 'Post'}).click();
await expect(modal.frameLocator('[data-testid="announcement-bar-preview"]').getByText('post preview')).toHaveCount(1);
});
@ -51,9 +51,9 @@ test.describe('Announcement Bar', async () => {
// await page.goto('/');
// const section = page.getByTestId('announcement-bar');
// await section.getByRole('button', {name: 'Customize'}).click();
// const modal = page.getByTestId('announcement-bar-modal');
// await expect(modal.frameLocator('[data-testid="announcement-bar-preview"]').getByText('homepage preview')).toHaveCount(1);
@ -73,26 +73,26 @@ test.describe('Announcement Bar', async () => {
await section.getByRole('button', {name: 'Customize'}).click();
const labelElement = await page.$('label:text("Background color")');
const labelElement = page.locator('label:text("Background color")');
expect(labelElement).not.toBeNull();
await expect(labelElement).toHaveCount(1);
const modal = page.getByTestId('announcement-bar-modal');
// Check the titles of the buttons.
// Get the parent div of the label
const parentDiv = await labelElement?.$('xpath=..');
const parentDiv = labelElement.locator('..');
// Then get the div that follows the label within the parent div
const buttonContainer = await parentDiv?.$('div');
const buttonContainer = parentDiv.locator('div');
const darkButton = await buttonContainer?.$('button[title="Dark"]');
const lightButton = await buttonContainer?.$('button[title="Light"]');
const accentButton = await buttonContainer?.$('button[title="Accent"]');
const darkButton = buttonContainer.locator('button[title="Dark"]');
const lightButton = buttonContainer.locator('button[title="Light"]');
const accentButton = buttonContainer.locator('button[title="Accent"]');
expect(darkButton).not.toBeNull();
expect(lightButton).not.toBeNull();
expect(accentButton).not.toBeNull();
await expect(darkButton).toHaveCount(1);
await expect(lightButton).toHaveCount(1);
await expect(accentButton).toHaveCount(1);
await lightButton?.click();
@ -119,9 +119,9 @@ test.describe('Announcement Bar', async () => {
await section.getByRole('button', {name: 'Customize'}).click();
const labelElement = await page.$('h6:text("Visibility")');
const labelElement = page.locator('h6:text("Visibility")');
expect(labelElement).not.toBeNull();
await expect(labelElement).toHaveCount(1);
const modal = page.getByTestId('announcement-bar-modal');
@ -142,4 +142,4 @@ test.describe('Announcement Bar', async () => {
]
});
});
});
});

View file

@ -1,17 +1,49 @@
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
import pkg from './package.json';
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
import {PluginOption} from 'vite';
import {defineConfig} from 'vitest/config';
import {resolve} from 'path';
const outputFileName = pkg.name[0] === '@' ? pkg.name.slice(pkg.name.indexOf('/') + 1) : pkg.name;
const externalPlugin = ({externals}: { externals: Record<string, string> }): PluginOption => {
return {
name: 'external-globals',
apply: 'build',
enforce: 'pre',
resolveId(id) {
if (Object.keys(externals).includes(id)) {
// Naming convention for IDs that will be resolved by a plugin
return `\0${id}`;
}
},
async load(id) {
const [originalId, externalName] = Object.entries(externals).find(([key]) => id === `\0${key}`) || [];
if (originalId) {
const module = await import(originalId);
return Object.keys(module).map(key => (key === 'default' ? `export default ${externalName};` : `export const ${key} = ${externalName}.${key};`)).join('\n');
}
}
};
};
// https://vitejs.dev/config/
export default (function viteConfig() {
return defineConfig({
plugins: [
svgr(),
react()
react(),
externalPlugin({
externals: {
react: 'React',
'react-dom': 'ReactDOM'
}
}),
cssInjectedByJsPlugin()
],
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
@ -23,8 +55,8 @@ export default (function viteConfig() {
build: {
minify: true,
sourcemap: true,
cssCodeSplit: true,
lib: {
formats: ['es'],
entry: resolve(__dirname, 'src/index.tsx'),
name: pkg.name,
fileName(format) {
@ -35,15 +67,6 @@ export default (function viteConfig() {
return `${outputFileName}.js`;
}
},
rollupOptions: {
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM'
}
}
},
commonjsOptions: {
include: [/packages/, /node_modules/]
}

View file

@ -5,6 +5,7 @@ import config from 'ghost-admin/config/environment';
import {action} from '@ember/object';
import {inject} from 'ghost-admin/decorators/inject';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
// TODO: Long term move asset management directly in AdminX
const officialThemes = [{
@ -213,9 +214,9 @@ const fetchKoenig = function () {
const url = new URL(urlTemplate.replace('{version}', urlVersion));
if (url.protocol === 'http:') {
await import(`http://${url.host}${url.pathname}`);
window['@tryghost/admin-x-settings'] = await import(`http://${url.host}${url.pathname}`);
} else {
await import(`https://${url.host}${url.pathname}`);
window['@tryghost/admin-x-settings'] = await import(`https://${url.host}${url.pathname}`);
}
return window['@tryghost/admin-x-settings'];
@ -264,6 +265,8 @@ export default class AdminXSettings extends Component {
@inject config;
@tracked display = 'none';
@action
onError(error) {
// ensure we're still showing errors in development

View file

@ -31242,7 +31242,7 @@ vite-plugin-commonjs@0.9.0:
magic-string "^0.30.1"
vite-plugin-dynamic-import "^1.5.0"
vite-plugin-css-injected-by-js@3.3.0:
vite-plugin-css-injected-by-js@3.3.0, vite-plugin-css-injected-by-js@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/vite-plugin-css-injected-by-js/-/vite-plugin-css-injected-by-js-3.3.0.tgz#c19480a9e42a95c5bced976a9dde1446f9bd91ff"
integrity sha512-xG+jyHNCmUqi/TXp6q88wTJGeAOrNLSyUUTp4qEQ9QZLGcHWQQsCsSSKa59rPMQr8sOzfzmWDd8enGqfH/dBew==