From b004b2c759396bf6ff450e48c49fbf9a78d23bfd Mon Sep 17 00:00:00 2001 From: John O'Nolan Date: Sun, 3 May 2026 19:15:36 -0700 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20theme=20editor=20a?= =?UTF-8?q?ccess=20and=20save=20routing=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The routed editor now has to enforce theme entitlement checks itself so direct URLs cannot bypass plan limits, and rename/save-as flows need to preserve file invariants and the addressable editor route to avoid silent corruption or stale refresh targets. --- .../settings/site/design-and-theme-modal.tsx | 112 +- .../site/theme/theme-code-editor-modal.tsx | 1314 +++++++++++++++++ 2 files changed, 1403 insertions(+), 23 deletions(-) create mode 100644 apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx diff --git a/apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx b/apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx index 09afa63e7a2..875bf402963 100644 --- a/apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx @@ -1,20 +1,42 @@ import ChangeThemeModal from './theme-modal'; import DesignModal from './design-modal'; import NiceModal, {useModal} from '@ebay/nice-modal-react'; -import React, {useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; +import ThemeCodeEditorModal from './theme/theme-code-editor-modal'; import {LimitModal} from '@tryghost/admin-x-design-system'; import {type RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; import {useCheckThemeLimitError} from '../../../hooks/use-check-theme-limit-error'; +const getEditingThemeName = (path: string) => { + if (!path.startsWith('theme/edit/')) { + return null; + } + + const encodedThemeName = path.replace('theme/edit/', '').split('?')[0]; + + return encodedThemeName ? decodeURIComponent(encodedThemeName) : null; +}; + const DesignAndThemeModal: React.FC = ({pathName}) => { const modal = useModal(); - const {updateRoute} = useRouting(); + const {route, updateRoute} = useRouting(); + const currentPath = route || pathName; const [themeChangeError, setThemeChangeError] = useState(null); const [isCheckingLimit, setIsCheckingLimit] = useState(false); const [isCheckingInstallation, setIsCheckingInstallation] = useState(false); + const [editorThemeError, setEditorThemeError] = useState(null); + const [isCheckingEditorLimit, setIsCheckingEditorLimit] = useState(false); const {checkThemeLimitError, isThemeLimitCheckReady, noThemeChangesAllowed, isThemeLimited} = useCheckThemeLimitError(); const [installationAllowed, setInstallationAllowed] = useState(null); const [hasCheckedInstallation, setHasCheckedInstallation] = useState(false); + const editingThemeName = getEditingThemeName(currentPath); + + const showThemeLimitModal = useCallback((error: string) => { + NiceModal.show(LimitModal, { + prompt: error, + onOk: () => updateRoute({route: '/pro', isExternal: true}) + }); + }, [updateRoute]); useEffect(() => { const checkIfThemeChangeAllowed = async () => { @@ -33,30 +55,73 @@ const DesignAndThemeModal: React.FC = ({pathName}) => { // Show limit modal immediately if there's an error if (error) { - NiceModal.show(LimitModal, { - prompt: error, - onOk: () => updateRoute({route: '/pro', isExternal: true}) - }); + showThemeLimitModal(error); modal.remove(); // Close the current modal } }; - if (pathName === 'design/change-theme' && isThemeLimitCheckReady) { + if (currentPath === 'design/change-theme' && isThemeLimitCheckReady) { checkIfThemeChangeAllowed(); } else { setThemeChangeError(null); setIsCheckingLimit(false); } - }, [checkThemeLimitError, isThemeLimitCheckReady, pathName, modal, updateRoute, noThemeChangesAllowed]); + }, [checkThemeLimitError, currentPath, isThemeLimitCheckReady, modal, noThemeChangesAllowed, showThemeLimitModal]); // Reset states when pathName changes useEffect(() => { - if (pathName !== 'theme/install') { + if (currentPath !== 'theme/install') { setHasCheckedInstallation(false); setInstallationAllowed(null); setIsCheckingInstallation(false); } - }, [pathName]); + }, [currentPath]); + + useEffect(() => { + let isCancelled = false; + + const checkThemeEditorAccess = async () => { + if (!editingThemeName) { + setEditorThemeError(null); + setIsCheckingEditorLimit(false); + return; + } + + if (!isThemeLimitCheckReady) { + setIsCheckingEditorLimit(true); + return; + } + + if (!isThemeLimited) { + setEditorThemeError(null); + setIsCheckingEditorLimit(false); + return; + } + + setIsCheckingEditorLimit(true); + + const error = await checkThemeLimitError(editingThemeName.toLowerCase()); + + if (isCancelled) { + return; + } + + setEditorThemeError(error); + setIsCheckingEditorLimit(false); + + if (error) { + showThemeLimitModal(error); + modal.remove(); + updateRoute('theme'); + } + }; + + void checkThemeEditorAccess(); + + return () => { + isCancelled = true; + }; + }, [checkThemeLimitError, editingThemeName, isThemeLimitCheckReady, isThemeLimited, modal, showThemeLimitModal, updateRoute]); // Check theme installation limits useEffect(() => { @@ -79,21 +144,16 @@ const DesignAndThemeModal: React.FC = ({pathName}) => { // Immediately prevent any installation attempts setInstallationAllowed(false); - const limitModalConfig = { - prompt: error, - onOk: () => updateRoute({route: '/pro', isExternal: true}) - }; - if (noThemeChangesAllowed) { // Single theme - show limit modal and redirect to /theme - NiceModal.show(LimitModal, limitModalConfig); + showThemeLimitModal(error); // Clear URL parameters window.history.replaceState({}, '', window.location.pathname + window.location.hash.split('?')[0]); modal.remove(); updateRoute('theme'); } else { // Multiple themes allowed - show limit modal and then redirect - NiceModal.show(LimitModal, limitModalConfig); + showThemeLimitModal(error); modal.remove(); // Don't redirect to change-theme modal - just stay on current route // This prevents both modals from being visible at the same time @@ -103,7 +163,7 @@ const DesignAndThemeModal: React.FC = ({pathName}) => { const checkThemeInstallation = async () => { // Early return if not on theme/install path - if (pathName !== 'theme/install') { + if (currentPath !== 'theme/install') { setIsCheckingInstallation(false); return; } @@ -140,7 +200,7 @@ const DesignAndThemeModal: React.FC = ({pathName}) => { const error = await checkThemeLimitError(themeName); // Double-check again after async operation - if (pathName !== 'theme/install') { + if (currentPath !== 'theme/install') { setIsCheckingInstallation(false); return; } @@ -160,11 +220,11 @@ const DesignAndThemeModal: React.FC = ({pathName}) => { }; checkThemeInstallation(); - }, [pathName, isThemeLimitCheckReady, checkThemeLimitError, noThemeChangesAllowed, isThemeLimited, modal, updateRoute]); + }, [checkThemeLimitError, currentPath, isThemeLimitCheckReady, noThemeChangesAllowed, isThemeLimited, modal, showThemeLimitModal, updateRoute]); - if (pathName === 'design/edit') { + if (currentPath === 'design/edit') { return ; - } else if (pathName === 'design/change-theme') { + } else if (currentPath === 'design/change-theme') { // Don't show the change theme modal if we're still checking limits or if there's // a theme limit error if (isCheckingLimit || themeChangeError) { @@ -172,7 +232,7 @@ const DesignAndThemeModal: React.FC = ({pathName}) => { } return ; - } else if (pathName === 'theme/install') { + } else if (currentPath === 'theme/install') { // Always wait for the installation check to complete // This prevents any race conditions if (!hasCheckedInstallation || !isThemeLimitCheckReady || isCheckingInstallation || installationAllowed === null) { @@ -200,6 +260,12 @@ const DesignAndThemeModal: React.FC = ({pathName}) => { // Installation is allowed, render the ChangeThemeModal with the source and ref return ; + } else if (editingThemeName) { + if (isCheckingEditorLimit || editorThemeError) { + return null; + } + + return ; } else { modal.remove(); } diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx new file mode 100644 index 00000000000..8b3360129e4 --- /dev/null +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx @@ -0,0 +1,1314 @@ +import CodeMirror, {EditorView} from '@uiw/react-codemirror'; +import InvalidThemeModal, {type FatalErrors} from './invalid-theme-modal'; +import NiceModal, {useModal} from '@ebay/nice-modal-react'; +import React, {useEffect, useMemo, useState} from 'react'; +import ThemeInstalledModal from './theme-installed-modal'; +import { + ChevronDown, + ChevronRight, + CircleDot, + FileCode2, + Folder, + FolderOpen, + Pencil, + Plus, + Save, + TextWrap, + Trash2, + Undo2, + X +} from 'lucide-react'; +import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system'; +import { + cloneThemeFiles, + createFolderRenameMap, + extractThemeArchive, + getExtension, + getThemeChanges, + isDefaultThemeName, + isEditablePath, + normaliseRelativePath, + packThemeArchive +} from './theme-editor-utils'; +import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; +import {oneDark} from '@codemirror/theme-one-dark'; +import {search} from '@codemirror/search'; +import {useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; +import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {useQueryClient} from '@tanstack/react-query'; +import {useRouting} from '@tryghost/admin-x-framework/routing'; +import type {ThemeChange, ThemeEditorFile} from './theme-editor-utils'; +import type {ThemesInstallResponseType} from '@tryghost/admin-x-framework/api/themes'; + +type SelectedNode = + | {type: 'file'; path: string} + | {type: 'dir'; path: string} + | null; + +type TreeNode = { + type: 'dir' | 'file'; + name: string; + path: string; + editable?: boolean; + children?: Map; +}; + +type ReviewItem = { + path: string; + editable: boolean; + status: ThemeChange['status']; + before: string | null; + after: string | null; +}; + +const buildTree = (files: Record) => { + const root: TreeNode = { + type: 'dir', + name: '', + path: '', + children: new Map() + }; + + for (const path of Object.keys(files).sort()) { + const segments = path.split('/'); + let cursor = root; + + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + const isLast = index === segments.length - 1; + + if (isLast) { + cursor.children!.set(segment, { + type: 'file', + name: segment, + path, + editable: files[path].editable + }); + continue; + } + + const dirPath = `${segments.slice(0, index + 1).join('/')}/`; + const existing = cursor.children!.get(segment); + + if (existing?.type === 'dir') { + cursor = existing; + continue; + } + + const next: TreeNode = { + type: 'dir', + name: segment, + path: dirPath, + children: new Map() + }; + + cursor.children!.set(segment, next); + cursor = next; + } + } + + return root; +}; + +const sortTreeNodes = (nodes: TreeNode[]) => { + return nodes.sort((left, right) => { + if (left.type !== right.type) { + return left.type === 'dir' ? -1 : 1; + } + + return left.name.localeCompare(right.name); + }); +}; + +const getLanguageExtension = (path: string) => { + const extension = getExtension(path); + + switch (extension) { + case 'css': + case 'scss': + case 'sass': + case 'less': + return import('@codemirror/lang-css').then(module => module.css()); + case 'js': + case 'cjs': + case 'mjs': + return import('@codemirror/lang-javascript').then(module => module.javascript()); + case 'json': + return import('@codemirror/lang-json').then(module => module.json()); + case 'md': + case 'markdown': + return import('@codemirror/lang-markdown').then(module => module.markdown()); + case 'yaml': + case 'yml': + return import('@codemirror/lang-yaml').then(module => module.yaml()); + case 'hbs': + case 'handlebars': + case 'html': + case 'htm': + case 'svg': + case 'xml': + return import('@codemirror/lang-html').then(module => module.html()); + default: + return import('@codemirror/lang-html').then(module => module.html()); + } +}; + +const getLanguageLabel = (path: string) => { + const extension = getExtension(path); + + if (!extension) { + return 'text'; + } + + switch (extension) { + case 'hbs': + return 'handlebars'; + case 'htm': + return 'html'; + case 'yml': + return 'yaml'; + default: + return extension; + } +}; + +const getDefaultSelection = (files: Record): SelectedNode => { + if (files['package.json']?.editable) { + return {type: 'file', path: 'package.json'}; + } + + const nextEditable = Object.keys(files).sort().find(path => files[path].editable); + + if (nextEditable) { + return {type: 'file', path: nextEditable}; + } + + const firstPath = Object.keys(files).sort()[0]; + + return firstPath ? {type: 'file', path: firstPath} : null; +}; + +const getParentDirectories = (path: string) => { + const segments = path.split('/'); + const directories: string[] = ['']; + + for (let index = 0; index < segments.length - 1; index += 1) { + directories.push(`${segments.slice(0, index + 1).join('/')}/`); + } + + return directories; +}; + +const buildReviewItems = ({ + baseFiles, + currentFiles, + changes +}: { + baseFiles: Record; + currentFiles: Record; + changes: ThemeChange[]; +}) => { + return changes.map((change) => { + const baseFile = baseFiles[change.path]; + const currentFile = currentFiles[change.path]; + + return { + path: change.path, + editable: change.editable, + status: change.status, + before: baseFile?.editable ? (baseFile.content ?? '') : null, + after: currentFile?.editable ? (currentFile.content ?? '') : null + }; + }); +}; + +const formatReviewSummary = (reviewItems: ReviewItem[]) => { + const added = reviewItems.filter(item => item.status === 'added').length; + const modified = reviewItems.filter(item => item.status === 'modified').length; + const deleted = reviewItems.filter(item => item.status === 'deleted').length; + + return `${modified} modified, ${added} added, ${deleted} deleted`; +}; + +const getReturnRouteFromHash = () => { + const hash = window.location.hash.substring(1); + const domain = `${window.location.protocol}//${window.location.hostname}`; + const url = new URL(hash || '/', domain); + + return url.searchParams.get('from'); +}; + +const buildThemeEditorRoute = (themeName: string, fromRoute: string | null) => { + const nextRoute = `theme/edit/${encodeURIComponent(themeName)}`; + + if (fromRoute === null) { + return nextRoute; + } + + return `${nextRoute}?from=${encodeURIComponent(fromRoute)}`; +}; + +const wouldRenameBinaryFileToEditable = (file: ThemeEditorFile, nextPath: string) => { + return !file.editable && isEditablePath(nextPath); +}; + +const iconButtonClass = 'inline-flex h-7 w-7 items-center justify-center rounded-full border border-[#2f333b] bg-transparent text-[#c8ccd3] transition-colors hover:bg-[#1f2228] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4a9eff] disabled:cursor-not-allowed disabled:opacity-50'; +const toolbarButtonClass = 'inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-[13px] font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4a9eff] disabled:cursor-not-allowed disabled:opacity-50'; +const ghostButtonClass = `${toolbarButtonClass} border-[#2f333b] bg-transparent text-[#e6e7ea] hover:bg-[#1f2228]`; +const primaryButtonClass = `${toolbarButtonClass} border-transparent bg-green text-black hover:bg-green-400`; +const wrapToggleClass = (active: boolean) => `inline-flex h-5 w-5 items-center justify-center rounded-sm text-[#c8ccd3] transition-opacity hover:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4a9eff] ${active ? 'opacity-100' : 'opacity-60'}`; +const fileActionButtonClass = 'inline-flex h-5 w-5 items-center justify-center rounded-sm text-[#c8ccd3] transition-opacity hover:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4a9eff] disabled:cursor-not-allowed disabled:opacity-30'; +const editorSelectionTheme = EditorView.theme({ + '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { + backgroundColor: '#355070' + }, + '.cm-content ::selection': { + color: '#ffffff' + } +}, {dark: true}); + +type ThemeEditorConfirmModalProps = { + title: string; + prompt: React.ReactNode; + cancelLabel?: string; + okLabel?: string; + okColor?: 'black' | 'red' | 'green' | 'outline'; +}; + +const ThemeEditorConfirmModal = NiceModal.create(({ + title, + prompt, + cancelLabel = 'Cancel', + okLabel = 'OK', + okColor = 'black' +}) => { + const modal = useModal(); + + const closeWithResult = (result: boolean) => { + modal.resolve(result); + modal.remove(); + }; + + return ( + closeWithResult(false)} + onOk={() => closeWithResult(true)} + > +
+ {prompt} +
+
+ ); +}); + +type ThemeEditorInputModalProps = { + title: string; + prompt?: React.ReactNode; + fieldTitle: string; + initialValue: string; + placeholder?: string; + cancelLabel?: string; + okLabel?: string; +}; + +const ThemeEditorInputModal = NiceModal.create(({ + title, + prompt, + fieldTitle, + initialValue, + placeholder, + cancelLabel = 'Cancel', + okLabel = 'Continue' +}) => { + const modal = useModal(); + const [value, setValue] = useState(initialValue); + + const closeWithResult = (result: string | null) => { + modal.resolve(result); + modal.remove(); + }; + + return ( + closeWithResult(null)} + onOk={() => closeWithResult(value)} + > +
+ {prompt} + setValue(event.target.value)} + /> +
+
+ ); +}); + +const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { + const modal = useModal(); + const {updateRoute} = useRouting(); + const queryClient = useQueryClient(); + const handleError = useHandleError(); + const {data: themesData} = useBrowseThemes(); + + const [currentThemeName, setCurrentThemeName] = useState(themeName); + const [baseFiles, setBaseFiles] = useState>({}); + const [currentFiles, setCurrentFiles] = useState>({}); + const [rootPrefix, setRootPrefix] = useState(''); + const [selectedNode, setSelectedNode] = useState(null); + const [expandedDirectories, setExpandedDirectories] = useState>(new Set([''])); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [loadError, setLoadError] = useState(null); + const [isReviewOpen, setIsReviewOpen] = useState(false); + const [isTextWrapEnabled, setIsTextWrapEnabled] = useState(false); + const [selectedReviewPath, setSelectedReviewPath] = useState(null); + const [editorExtensions, setEditorExtensions] = useState | typeof oneDark | typeof editorSelectionTheme | typeof EditorView.lineWrapping | Awaited>>>([]); + + useEffect(() => { + let isMounted = true; + + const loadTheme = async () => { + setIsLoading(true); + setLoadError(null); + + try { + const {apiRoot} = getGhostPaths(); + const response = await fetch(`${apiRoot}/themes/${encodeURIComponent(themeName)}/download/`, { + credentials: 'include', + headers: { + Accept: 'application/zip, application/octet-stream, */*' + } + }); + + if (!response.ok) { + throw new Error(`Failed to load theme "${themeName}" (${response.status})`); + } + + const archive = await response.arrayBuffer(); + const snapshot = await extractThemeArchive(archive); + const nextFiles = cloneThemeFiles(snapshot.files); + const nextSelection = getDefaultSelection(nextFiles); + + if (!isMounted) { + return; + } + + setCurrentThemeName(themeName); + setBaseFiles(cloneThemeFiles(snapshot.files)); + setCurrentFiles(nextFiles); + setRootPrefix(snapshot.rootPrefix); + setSelectedNode(nextSelection); + setExpandedDirectories(new Set(nextSelection?.type === 'file' ? getParentDirectories(nextSelection.path) : [''])); + } catch (error) { + if (!isMounted) { + return; + } + + setLoadError(error instanceof Error ? error.message : 'Failed to load theme'); + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + loadTheme(); + + return () => { + isMounted = false; + }; + }, [themeName]); + + useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = previousOverflow; + }; + }, []); + + const changes = useMemo(() => getThemeChanges({baseFiles, currentFiles}), [baseFiles, currentFiles]); + const changesMap = useMemo(() => new Map(changes.map(change => [change.path, change.status])), [changes]); + const reviewItems = useMemo(() => buildReviewItems({baseFiles, currentFiles, changes}), [baseFiles, currentFiles, changes]); + const selectedFile = selectedNode?.type === 'file' ? currentFiles[selectedNode.path] : null; + const selectedReviewItem = reviewItems.find(item => item.path === selectedReviewPath) || reviewItems[0] || null; + + useEffect(() => { + if (!selectedReviewItem) { + setSelectedReviewPath(null); + return; + } + + if (!selectedReviewPath || !reviewItems.some(item => item.path === selectedReviewPath)) { + setSelectedReviewPath(selectedReviewItem.path); + } + }, [reviewItems, selectedReviewItem, selectedReviewPath]); + + useEffect(() => { + let isMounted = true; + + if (!selectedFile?.editable) { + setEditorExtensions([]); + return () => { + isMounted = false; + }; + } + + Promise.all([oneDark, getLanguageExtension(selectedFile.path)]).then((extensions) => { + if (isMounted) { + setEditorExtensions([ + search({top: true}), + ...extensions, + ...(isTextWrapEnabled ? [EditorView.lineWrapping] : []), + editorSelectionTheme + ]); + } + }).catch(() => { + if (isMounted) { + setEditorExtensions([ + search({top: true}), + oneDark, + ...(isTextWrapEnabled ? [EditorView.lineWrapping] : []), + editorSelectionTheme + ]); + } + }); + + return () => { + isMounted = false; + }; + }, [isTextWrapEnabled, selectedFile]); + + const requestConfirmation = async ({ + title, + prompt, + cancelLabel, + okLabel, + okColor + }: ThemeEditorConfirmModalProps) => { + const confirmed = await NiceModal.show(ThemeEditorConfirmModal, { + title, + prompt, + cancelLabel, + okLabel, + okColor + }) as boolean | undefined; + + return Boolean(confirmed); + }; + + const requestInput = async ({ + title, + prompt, + fieldTitle, + initialValue, + placeholder, + cancelLabel, + okLabel + }: ThemeEditorInputModalProps) => { + return await NiceModal.show(ThemeEditorInputModal, { + title, + prompt, + fieldTitle, + initialValue, + placeholder, + cancelLabel, + okLabel + }) as string | null; + }; + + const closeEditor = async () => { + if (changes.length > 0) { + const shouldDiscard = await requestConfirmation({ + title: 'Discard changes?', + prompt: 'You have unsaved theme changes. Close the editor and discard them?', + okLabel: 'Discard changes', + okColor: 'red' + }); + + if (!shouldDiscard) { + return; + } + } + + modal.remove(); + updateRoute(getReturnRouteFromHash() ?? 'design/change-theme'); + }; + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { + event.preventDefault(); + void handleSave(); + return; + } + + if (event.key !== 'Escape') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + + window.addEventListener('keydown', handleKeydown, true); + + return () => { + window.removeEventListener('keydown', handleKeydown, true); + }; + }); + + const ensurePathExpanded = (path: string) => { + setExpandedDirectories((current) => { + const next = new Set(current); + + for (const dir of getParentDirectories(path)) { + next.add(dir); + } + + return next; + }); + }; + + const setFilesAndSelection = (files: Record, nextSelection?: SelectedNode) => { + setCurrentFiles(files); + + if (nextSelection) { + setSelectedNode(nextSelection); + if (nextSelection.type === 'file') { + ensurePathExpanded(nextSelection.path); + } + return; + } + + if (selectedNode?.type === 'file' && files[selectedNode.path]) { + return; + } + + setSelectedNode(getDefaultSelection(files)); + }; + + const handleCreateFile = async () => { + const requestedPath = await requestInput({ + title: 'Create file', + fieldTitle: 'File path', + initialValue: 'partials/new-file.hbs', + okLabel: 'Create file', + prompt: 'Add a new editable file to this theme.' + }); + const nextPath = requestedPath ? normaliseRelativePath(requestedPath) : null; + + if (!nextPath) { + return; + } + + if (!isEditablePath(nextPath)) { + showToast({ + type: 'error', + title: 'Only text files can be created here', + message: 'Use a text-based theme file extension such as .hbs, .css, .js, or .json.' + }); + return; + } + + if (currentFiles[nextPath]) { + showToast({ + type: 'error', + title: 'File already exists' + }); + return; + } + + const nextFiles = { + ...currentFiles, + [nextPath]: { + path: nextPath, + editable: true, + content: '', + binary: null, + date: new Date(), + unixPermissions: null, + dosPermissions: null + } + }; + + setFilesAndSelection(nextFiles, {type: 'file', path: nextPath}); + }; + + const handleRenameSelected = async () => { + if (!selectedNode) { + return; + } + + const defaultValue = selectedNode.type === 'dir' + ? selectedNode.path.replace(/\/$/, '') + : selectedNode.path; + const promptLabel = selectedNode.type === 'dir' ? 'Rename folder to' : 'Rename file to'; + const requestedPath = await requestInput({ + title: selectedNode.type === 'dir' ? 'Rename folder' : 'Rename file', + fieldTitle: 'Path', + initialValue: defaultValue, + okLabel: 'Rename', + prompt: promptLabel + }); + const nextPath = requestedPath ? normaliseRelativePath(requestedPath) : null; + + if (!nextPath || nextPath === defaultValue) { + return; + } + + if (selectedNode.type === 'file') { + const fileToRename = currentFiles[selectedNode.path]; + + if (!isEditablePath(nextPath) && fileToRename.editable) { + showToast({ + type: 'error', + title: 'Text files must keep a text file extension' + }); + return; + } + + if (wouldRenameBinaryFileToEditable(fileToRename, nextPath)) { + showToast({ + type: 'error', + title: 'Binary files cannot be renamed to a text file', + message: 'Rename this file with a non-text extension to keep its contents intact.' + }); + return; + } + + if (currentFiles[nextPath]) { + showToast({ + type: 'error', + title: 'A file with that name already exists' + }); + return; + } + + const nextFiles = {...currentFiles}; + const file = nextFiles[selectedNode.path]; + delete nextFiles[selectedNode.path]; + nextFiles[nextPath] = { + ...file, + path: nextPath, + editable: isEditablePath(nextPath) + }; + + setFilesAndSelection(nextFiles, {type: 'file', path: nextPath}); + return; + } + + const nextDirectoryPath = `${nextPath}/`; + + if (nextDirectoryPath.startsWith(selectedNode.path)) { + showToast({ + type: 'error', + title: 'A folder cannot be renamed inside itself' + }); + return; + } + + const conflictingPath = Object.keys(currentFiles).find(path => path.startsWith(nextDirectoryPath)); + + if (conflictingPath) { + showToast({ + type: 'error', + title: 'A folder with that path already exists' + }); + return; + } + + const nextFiles = createFolderRenameMap({ + files: currentFiles, + oldPrefix: selectedNode.path, + newPrefix: nextDirectoryPath + }); + + setFilesAndSelection(nextFiles, {type: 'dir', path: nextDirectoryPath}); + setExpandedDirectories((current) => { + const next = new Set(); + + for (const directory of current) { + if (!directory.startsWith(selectedNode.path)) { + next.add(directory); + continue; + } + + next.add(`${nextDirectoryPath}${directory.slice(selectedNode.path.length)}`); + } + + return next; + }); + }; + + const handleDeleteSelected = async () => { + if (!selectedNode) { + return; + } + + if (selectedNode.type === 'file') { + const confirmed = await requestConfirmation({ + title: 'Delete file', + prompt: <>Delete {selectedNode.path} from this theme?, + okLabel: 'Delete', + okColor: 'red' + }); + + if (!confirmed) { + return; + } + + const nextFiles = {...currentFiles}; + delete nextFiles[selectedNode.path]; + setFilesAndSelection(nextFiles); + return; + } + + const matchingPaths = Object.keys(currentFiles).filter(path => path.startsWith(selectedNode.path)); + + if (!matchingPaths.length) { + return; + } + + const confirmed = await requestConfirmation({ + title: 'Delete folder', + prompt: <>Delete {matchingPaths.length} file{matchingPaths.length === 1 ? '' : 's'} from {selectedNode.path}?, + okLabel: 'Delete', + okColor: 'red' + }); + + if (!confirmed) { + return; + } + + const nextFiles = {...currentFiles}; + + for (const path of matchingPaths) { + delete nextFiles[path]; + } + + setFilesAndSelection(nextFiles); + }; + + const handleRevertPath = (path: string) => { + const baseFile = baseFiles[path]; + const currentFile = currentFiles[path]; + const nextFiles = {...currentFiles}; + + if (!baseFile && currentFile) { + delete nextFiles[path]; + } else if (baseFile) { + nextFiles[path] = { + ...baseFile, + binary: baseFile.binary ? new Uint8Array(baseFile.binary) : null + }; + } + + setFilesAndSelection(nextFiles); + }; + + const requestSaveAsThemeName = async () => { + const requestedName = await requestInput({ + title: 'Save as new theme', + fieldTitle: 'Theme name', + initialValue: `${currentThemeName}-edited`, + okLabel: 'Continue', + prompt: 'Default themes cannot be overwritten. Save your edits as a new theme instead.' + }); + const nextName = requestedName?.trim().toLowerCase(); + + if (!nextName) { + return null; + } + + if (nextName.includes('/') || nextName.includes('\\')) { + showToast({ + type: 'error', + title: 'Theme names cannot contain slashes' + }); + return null; + } + + if (isDefaultThemeName(nextName)) { + showToast({ + type: 'error', + title: 'Built-in themes cannot be overwritten' + }); + return null; + } + + return nextName; + }; + + const handleSave = async () => { + if (isSaving) { + return; + } + + if (changes.length === 0) { + showToast({ + type: 'info', + title: 'No changes to save' + }); + return; + } + + const previousThemeName = currentThemeName; + const nextThemeName = isDefaultThemeName(previousThemeName) ? await requestSaveAsThemeName() : previousThemeName; + + if (!nextThemeName) { + return; + } + + const isSaveAs = nextThemeName !== previousThemeName; + const themeExists = themesData?.themes.some(theme => theme.name === nextThemeName) || false; + const confirmMessage = isSaveAs + ? `Save your edits as "${nextThemeName}"?` + : `Upload ${changes.length} changed file${changes.length === 1 ? '' : 's'} and replace "${previousThemeName}"?`; + + const confirmedSave = await requestConfirmation({ + title: isSaveAs ? 'Save theme as new copy' : 'Update theme', + prompt: confirmMessage, + okLabel: isSaveAs ? 'Save theme' : 'Replace theme' + }); + + if (!confirmedSave) { + return; + } + + if (isSaveAs && themeExists) { + const confirmedOverwrite = await requestConfirmation({ + title: 'Overwrite theme', + prompt: <>{nextThemeName} already exists. Do you want to overwrite it?, + okLabel: 'Overwrite', + okColor: 'red' + }); + + if (!confirmedOverwrite) { + return; + } + } + + setIsSaving(true); + + try { + const blob = await packThemeArchive({ + files: currentFiles, + rootPrefix + }); + + const formData = new FormData(); + formData.append('file', blob, `${nextThemeName}.zip`); + + const response = await fetch(`${getGhostPaths().apiRoot}/themes/upload/`, { + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json' + }, + body: formData + }); + + const data = await response.json().catch(() => null) as ThemesInstallResponseType & {errors?: FatalErrors}; + + if (!response.ok) { + if (response.status === 422 && data?.errors) { + NiceModal.show(InvalidThemeModal, { + title: 'Invalid Theme', + prompt: <>Fix the validation errors below and try saving again., + fatalErrors: data.errors as FatalErrors + }); + return; + } + + throw new Error((data as {errors?: Array<{message?: string}>} | null)?.errors?.[0]?.message || 'Theme upload failed.'); + } + + const uploadedTheme = data.themes[0]; + const returnRoute = getReturnRouteFromHash(); + + setBaseFiles(cloneThemeFiles(currentFiles)); + + if (isSaveAs) { + setCurrentThemeName(nextThemeName); + setRootPrefix(rootPrefix ? `${nextThemeName}/` : ''); + updateRoute(buildThemeEditorRoute(nextThemeName, returnRoute)); + } + + await queryClient.invalidateQueries(['ThemesResponseType']); + + if (isSaveAs || uploadedTheme.errors?.length || uploadedTheme.warnings?.length) { + NiceModal.show(ThemeInstalledModal, { + title: isSaveAs ? 'Theme saved' : 'Theme updated', + prompt: <>{uploadedTheme.name} saved successfully., + installedTheme: uploadedTheme + }); + } else { + showToast({ + type: 'success', + title: 'Theme saved', + message:
{uploadedTheme.name} has been updated.
+ }); + } + } catch (error) { + handleError(error); + } finally { + setIsSaving(false); + } + }; + + const openFile = (path: string) => { + setSelectedNode({type: 'file', path}); + ensurePathExpanded(path); + }; + + const renderTreeNode = (node: TreeNode, depth = 0): React.ReactNode => { + if (node.type === 'file') { + const isSelected = selectedNode?.type === 'file' && selectedNode.path === node.path; + const changeStatus = changesMap.get(node.path); + + return ( + + ); + } + + const isExpanded = expandedDirectories.has(node.path); + const isSelected = selectedNode?.type === 'dir' && selectedNode.path === node.path; + const children = sortTreeNodes(Array.from(node.children?.values() || [])); + + return ( +
+ {node.path && ( + + )} + {(node.path === '' || isExpanded) && ( +
+ {children.map(child => renderTreeNode(child, node.path ? depth + 1 : depth))} +
+ )} +
+ ); + }; + + const selectedFileStatus = selectedFile ? changesMap.get(selectedFile.path) : null; + const reviewSummary = formatReviewSummary(reviewItems); + + return ( +
{ + if (event.target === event.currentTarget) { + closeEditor(); + } + }} + > +
+
+
+

Edit theme

+ {currentThemeName} +
+ {changes.length > 0 && ( + + )} +
+ + +
+ + {loadError && ( +
+ {loadError} +
+ )} + +
+ + +
+
+ {selectedFile ? ( + <> + {selectedFile.path} + + {getLanguageLabel(selectedFile.path)} + + {selectedFileStatus && ( + + )} +
+ {selectedFile.editable && ( + + )} + + ) : ( + No file selected + )} +
+ +
+ {!selectedNode && !isLoading && ( +
+ Select a file from the tree to start editing. +
+ )} + + {selectedNode?.type === 'dir' && ( +
+ Folder selected. Choose a file to edit, or rename or delete the folder from the file pane. +
+ )} + + {selectedFile && !selectedFile.editable && ( +
+ This file cannot be edited in the browser. +
+ )} + + {selectedFile?.editable && ( + { + setCurrentFiles(files => ({ + ...files, + [selectedFile.path]: { + ...files[selectedFile.path], + content: value + } + })); + }} + /> + )} +
+
+
+ + {isReviewOpen && ( +
setIsReviewOpen(false)}> +
event.stopPropagation()}> +
+
+

All changes

+

{reviewSummary}

+
+ +
+ +
+
+ {reviewItems.map(item => ( + + ))} + {reviewItems.length === 0 && ( +
No unsaved changes.
+ )} +
+ +
+ {selectedReviewItem ? ( + <> +
+
+
{selectedReviewItem.path}
+
+ {selectedReviewItem.editable ? 'Text file preview' : 'Binary file'} +
+
+ {selectedReviewItem.status !== 'deleted' && ( + + )} + +
+ + {!selectedReviewItem.editable ? ( +
+ Binary files are kept intact in the archive. Open or revert the change from here, but binary contents are not shown. +
+ ) : selectedReviewItem.status === 'added' ? ( +
+
After
+
{selectedReviewItem.after ?? ''}
+
+ ) : selectedReviewItem.status === 'deleted' ? ( +
+
Before
+
{selectedReviewItem.before ?? ''}
+
+ ) : ( +
+
+
Before
+
{selectedReviewItem.before ?? ''}
+
+
+
After
+
{selectedReviewItem.after ?? ''}
+
+
+ )} + + ) : ( +
+ Select a changed file to review it. +
+ )} +
+
+
+
+ )} +
+
+ ); +}; + +export default ThemeCodeEditorModal; From 27b54020fea647130dedce9dd06b8dce6a402a9b Mon Sep 17 00:00:00 2001 From: John O'Nolan Date: Sun, 3 May 2026 19:15:55 -0700 Subject: [PATCH 02/10] Added theme editor acceptance coverage Updated the theme settings acceptance suite to match the current editor copy and flows, and added coverage for active-theme entry, return routing, direct-route gating, and non-editable file messaging so editor regressions are caught in one place. --- .../test/acceptance/site/theme.test.ts | 204 ++++++++++++++++-- 1 file changed, 188 insertions(+), 16 deletions(-) diff --git a/apps/admin-x-settings/test/acceptance/site/theme.test.ts b/apps/admin-x-settings/test/acceptance/site/theme.test.ts index 98f5a3e9219..b8e42784ddb 100644 --- a/apps/admin-x-settings/test/acceptance/site/theme.test.ts +++ b/apps/admin-x-settings/test/acceptance/site/theme.test.ts @@ -1,5 +1,63 @@ +import path from 'path'; import {expect, test} from '@playwright/test'; import {expectExternalNavigate, globalDataRequests, limitRequests, mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance'; +import {readFileSync} from 'fs'; +import type {Page} from '@playwright/test'; + +const themeEditorZip = readFileSync(path.join(__dirname, '../../utils/responses/theme.zip')); + +const customThemesLimitConfig = (allowlist: string[], error: string) => ({ + ...globalDataRequests.browseConfig, + response: { + config: { + ...responseFixtures.config.config, + hostSettings: { + limits: { + customThemes: { + allowlist, + error + } + } + } + } + } +}); + +const themeDownloadRequest = (themeName: string) => ({ + method: 'GET' as const, + path: `/themes/${themeName}/download/`, + response: '', + rawResponse: themeEditorZip, + responseHeaders: {'content-type': 'application/zip'} +}); + +async function openChangeThemeModal(page: Page) { + await page.goto('/#/settings/theme'); + await page.getByTestId('theme').getByRole('button', {name: 'Change theme'}).click(); + + return page.getByTestId('theme-modal'); +} + +async function openInstalledThemeEditor(page: Page, themeName: string) { + const modal = await openChangeThemeModal(page); + await modal.getByRole('tab', {name: 'Installed'}).click(); + + const themeListItem = modal.getByTestId('theme-list-item').filter({hasText: new RegExp(themeName, 'i')}); + await themeListItem.getByRole('button', {name: 'Menu'}).click(); + await page.getByTestId('popover-content').getByRole('button', {name: 'Edit code'}).click(); + + return page.getByTestId('theme-code-editor-modal'); +} + +async function openActiveThemeEditorFromSettings(page: Page) { + await page.goto('/#/settings'); + + const themeSection = page.getByTestId('theme'); + await themeSection.getByRole('button', {name: 'Menu'}).click(); + await page.getByTestId('popover-content').getByRole('button', {name: 'Edit code'}).click(); + + return page.getByTestId('theme-code-editor-modal'); +} test.describe('Theme settings', async () => { test('Browsing and installing default themes', async ({page}) => { @@ -189,6 +247,86 @@ test.describe('Theme settings', async () => { expect(lastApiRequests.uploadTheme).toBeTruthy(); }); + test('Supports editing and saving a custom theme in browser', async ({page}) => { + const {lastApiRequests} = await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: themeDownloadRequest('edition'), + uploadTheme: { + method: 'POST', + path: '/themes/upload/', + response: { + themes: [{ + name: 'edition', + package: { + name: 'Edition', + version: '1.0.0' + }, + active: true, + templates: [] + }] + } + } + }}); + + const editorModal = await openInstalledThemeEditor(page, 'edition'); + await expect(editorModal).toBeVisible(); + await expect(editorModal).toContainText('Edit theme'); + await expect(editorModal).toContainText('edition'); + + const codeEditor = editorModal.locator('.cm-content'); + await codeEditor.click(); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.insertText('{"name":"edition","version":"1.0.0"}\n'); + + await editorModal.getByRole('button', {name: 'Save'}).click(); + await page.getByTestId('theme-editor-confirm-modal').getByRole('button', {name: 'Replace theme'}).click(); + + await expect(page.getByTestId('toast-success')).toHaveText(/Theme saved/i); + expect(lastApiRequests.downloadTheme?.url).toMatch(/\/themes\/edition\/download/); + expect(lastApiRequests.uploadTheme?.url).toMatch(/\/themes\/upload\//); + }); + + test('Saves built-in themes as a new theme name', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: themeDownloadRequest('casper'), + uploadTheme: { + method: 'POST', + path: '/themes/upload/', + response: { + themes: [{ + name: 'casper-edited', + package: { + name: 'Casper Edited', + version: '1.0.0' + }, + active: false, + templates: [] + }] + } + } + }}); + + const editorModal = await openInstalledThemeEditor(page, 'casper'); + await expect(editorModal).toBeVisible(); + + const codeEditor = editorModal.locator('.cm-content'); + await codeEditor.click(); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.insertText('{"name":"casper","version":"1.0.0"}\n'); + + await editorModal.getByRole('button', {name: 'Save'}).click(); + const inputModal = page.getByTestId('theme-editor-input-modal'); + await inputModal.getByLabel('Theme name').fill('casper-edited'); + await inputModal.getByRole('button', {name: 'Continue'}).click(); + await page.getByTestId('theme-editor-confirm-modal').getByRole('button', {name: 'Save theme'}).click(); + + await expect(page).toHaveURL(/#\/settings\/theme\/edit\/casper-edited/); + await expect(editorModal).toContainText('casper-edited'); + }); + test('Limits uploading new themes and redirect to /pro', async ({page}) => { await mockApi({page, requests: { ...globalDataRequests, @@ -396,22 +534,7 @@ test.describe('Theme settings', async () => { ...globalDataRequests, ...limitRequests, browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, - browseConfig: { - ...globalDataRequests.browseConfig, - response: { - config: { - ...responseFixtures.config.config, - hostSettings: { - limits: { - customThemes: { - allowlist: ['casper'], - error: 'Upgrade to use custom themes' - } - } - } - } - } - } + browseConfig: customThemesLimitConfig(['casper'], 'Upgrade to use custom themes') }}); // Navigate directly to the change theme route @@ -428,6 +551,55 @@ test.describe('Theme settings', async () => { await expect(page.getByTestId('theme-modal')).not.toBeVisible(); }); + test('Opens the editor from the active theme overflow menu and returns to settings on close', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: themeDownloadRequest('edition') + }}); + + const editorModal = await openActiveThemeEditorFromSettings(page); + + await expect(editorModal).toBeVisible(); + await expect(page).toHaveURL(/#\/settings\/theme\/edit\/edition/); + + await editorModal.getByRole('button', {name: 'Close'}).click(); + + await expect(page).toHaveURL(/#\/settings$/); + await expect(page.getByTestId('theme')).toBeVisible(); + }); + + test('Prevents direct access to theme editor route when editing is limited', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + ...limitRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + browseConfig: customThemesLimitConfig(['casper'], 'Upgrade to use custom themes') + }}); + + await page.goto('/#/settings/theme/edit/edition'); + + await page.waitForSelector('[data-testid="limit-modal"]', {timeout: 10000}); + + await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to use custom themes/); + await expect(page.getByTestId('theme-code-editor-modal')).not.toBeVisible(); + }); + + test('Allows selecting a non-editable file and shows the browser-edit message', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: themeDownloadRequest('edition') + }}); + + const editorModal = await openInstalledThemeEditor(page, 'edition'); + + await editorModal.getByRole('button', {name: '.DS_Store'}).click(); + + await expect(editorModal).toContainText('This file cannot be edited in the browser.'); + await expect(editorModal.locator('.cm-editor')).toHaveCount(0); + }); + test('Theme install route works without limits', async ({page}) => { await mockApi({page, requests: { ...globalDataRequests, From 0d76394ea3ce60bbfa08fb47f8322721257cdf9c Mon Sep 17 00:00:00 2001 From: John O'Nolan Date: Sun, 3 May 2026 19:44:05 -0700 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9C=A8=20Added=20a=20built-in=20theme?= =?UTF-8?q?=20code=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a Ghost-native browser editor for installed themes so theme changes can happen directly inside settings instead of requiring a separate local workflow. The implementation keeps the existing theme upload/download contract, adds native routing and entry points, preserves binary assets during ZIP round-trips, and includes route/archive hardening so invalid URLs or oversized archives fail safely rather than breaking the settings UI. --- apps/admin-x-framework/src/test/acceptance.ts | 19 +- apps/admin-x-settings/package.json | 8 + .../components/providers/settings-router.tsx | 1 + .../components/settings/site/change-theme.tsx | 79 +++- .../settings/site/design-and-theme-modal.tsx | 44 +- .../site/theme/advanced-theme-settings.tsx | 29 +- .../site/theme/theme-code-editor-modal.tsx | 10 + .../settings/site/theme/theme-editor-utils.ts | 380 ++++++++++++++++++ .../test/acceptance/site/theme.test.ts | 54 +++ .../unit/utils/theme-editor-utils.test.ts | 286 +++++++++++++ pnpm-lock.yaml | 109 ++++- 11 files changed, 991 insertions(+), 28 deletions(-) create mode 100644 apps/admin-x-settings/src/components/settings/site/theme/theme-editor-utils.ts create mode 100644 apps/admin-x-settings/test/unit/utils/theme-editor-utils.test.ts diff --git a/apps/admin-x-framework/src/test/acceptance.ts b/apps/admin-x-framework/src/test/acceptance.ts index 3e6e79994b4..fb6ff9f50e2 100644 --- a/apps/admin-x-framework/src/test/acceptance.ts +++ b/apps/admin-x-framework/src/test/acceptance.ts @@ -44,6 +44,7 @@ interface MockRequestConfig { method: string; path: string | RegExp; response: unknown; + rawResponse?: string | ArrayBuffer | Uint8Array | Buffer; responseStatus?: number; responseHeaders?: {[key: string]: string}; } @@ -208,6 +209,22 @@ export function createMockRequests(overrides: Record export async function mockApi>({page, requests, options = {}}: {page: Page, requests: Requests, options?: {useActivityPub?: boolean}}) { const lastApiRequests: {[key in keyof Requests]?: RequestRecord} = {}; + const getResponseBody = (matchingMock: MockRequestConfig) => { + if (typeof matchingMock.rawResponse === 'string' || Buffer.isBuffer(matchingMock.rawResponse)) { + return matchingMock.rawResponse; + } + + if (matchingMock.rawResponse instanceof ArrayBuffer) { + return Buffer.from(matchingMock.rawResponse); + } + + if (matchingMock.rawResponse instanceof Uint8Array) { + return Buffer.from(matchingMock.rawResponse); + } + + return typeof matchingMock.response === 'string' ? matchingMock.response : JSON.stringify(matchingMock.response); + }; + const namedRequests = Object.entries(requests).reduce( (array, [key, value]) => array.concat({name: key, ...value}), [] as Array @@ -260,7 +277,7 @@ export async function mockApi await route.fulfill({ status: matchingMock.responseStatus || 200, - body: typeof matchingMock.response === 'string' ? matchingMock.response : JSON.stringify(matchingMock.response), + body: getResponseBody(matchingMock), headers: matchingMock.responseHeaders || {} }); }); diff --git a/apps/admin-x-settings/package.json b/apps/admin-x-settings/package.json index 3f930a0496e..c4db239720f 100644 --- a/apps/admin-x-settings/package.json +++ b/apps/admin-x-settings/package.json @@ -40,7 +40,14 @@ "preview": "vite preview" }, "dependencies": { + "@codemirror/theme-one-dark": "6.1.3", + "@codemirror/lang-css": "6.3.1", "@codemirror/lang-html": "6.4.11", + "@codemirror/lang-javascript": "6.2.4", + "@codemirror/lang-json": "6.0.2", + "@codemirror/lang-markdown": "6.3.4", + "@codemirror/search": "6.6.0", + "@codemirror/lang-yaml": "6.1.2", "@dnd-kit/sortable": "7.0.2", "@ebay/nice-modal-react": "1.2.13", "@sentry/react": "7.120.4", @@ -53,6 +60,7 @@ "@tryghost/timezone-data": "0.4.18", "@uiw/react-codemirror": "4.25.2", "clsx": "2.1.1", + "jszip": "^3.10.1", "lucide-react": "0.577.0", "mingo": "2.5.3", "react": "18.3.1", diff --git a/apps/admin-x-settings/src/components/providers/settings-router.tsx b/apps/admin-x-settings/src/components/providers/settings-router.tsx index ed226a33f99..1e8984d886d 100644 --- a/apps/admin-x-settings/src/components/providers/settings-router.tsx +++ b/apps/admin-x-settings/src/components/providers/settings-router.tsx @@ -7,6 +7,7 @@ export const modalPaths: {[key: string]: ModalName} = { 'design/change-theme': 'DesignAndThemeModal', 'design/edit': 'DesignAndThemeModal', 'theme/install': 'DesignAndThemeModal', // this is a special route, because it can install a theme directly from the Ghost Marketplace + 'theme/edit/:name': 'DesignAndThemeModal', 'navigation/edit': 'NavigationModal', 'staff/invite': 'InviteUserModal', 'staff/:slug/social-links': 'UserDetailModal', diff --git a/apps/admin-x-settings/src/components/settings/site/change-theme.tsx b/apps/admin-x-settings/src/components/settings/site/change-theme.tsx index 1f864ba5bf1..59f4f40952c 100644 --- a/apps/admin-x-settings/src/components/settings/site/change-theme.tsx +++ b/apps/admin-x-settings/src/components/settings/site/change-theme.tsx @@ -1,8 +1,9 @@ import NiceModal from '@ebay/nice-modal-react'; import React, {useEffect, useState} from 'react'; import TopLevelGroup from '../../top-level-group'; -import {Button, LimitModal, SettingGroupContent, withErrorBoundary} from '@tryghost/admin-x-design-system'; -import {type Theme, useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; +import {Button, Heading, LimitModal, Menu, SettingGroupContent, withErrorBoundary} from '@tryghost/admin-x-design-system'; +import {type Theme, isDefaultOrLegacyTheme, useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; +import {downloadFile, getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useCheckThemeLimitError} from '../../../hooks/use-check-theme-limit-error'; import {useRouting} from '@tryghost/admin-x-framework/routing'; @@ -10,7 +11,7 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({keywords}) => { const [themeLimitError, setThemeLimitError] = useState(null); const [isCheckingLimit, setIsCheckingLimit] = useState(false); const {checkThemeLimitError} = useCheckThemeLimitError(); - const {updateRoute} = useRouting(); + const {route, updateRoute} = useRouting(); const {data: themesData} = useBrowseThemes(); const activeTheme = themesData?.themes.find((theme: Theme) => theme.active); @@ -41,21 +42,73 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({keywords}) => { } }; + const openThemeEditor = async () => { + if (!activeTheme) { + return; + } + + const limitError = await checkThemeLimitError(isDefaultOrLegacyTheme(activeTheme) ? '.' : activeTheme.name); + + if (limitError) { + NiceModal.show(LimitModal, { + prompt: limitError, + onOk: () => updateRoute({route: '/pro', isExternal: true}) + }); + return; + } + + updateRoute(`theme/edit/${encodeURIComponent(activeTheme.name)}?from=${encodeURIComponent(route ?? '')}`); + }; + + const downloadTheme = () => { + if (!activeTheme) { + return; + } + + const {apiRoot} = getGhostPaths(); + downloadFile(`${apiRoot}/themes/${activeTheme.name}/download`); + }; + + const themeMenuItems = [ + { + id: 'edit-code', + label: 'Edit code', + onClick: openThemeEditor + }, + { + id: 'download', + label: 'Download', + onClick: downloadTheme + } + ]; + const values = ( - + +
+ Active theme +
+
{activeTheme ? `${activeTheme.name} (v${activeTheme.package?.version || '1.0'})` : 'Loading...'}
+
+ +
+
+
+
); return ( } + customButtons={ +