Skip to content

✨ Added a built-in theme code editor#27656

Merged
ErisDS merged 15 commits into
TryGhost:mainfrom
JohnONolan:codex/theme-editor-core
May 14, 2026
Merged

✨ Added a built-in theme code editor#27656
ErisDS merged 15 commits into
TryGhost:mainfrom
JohnONolan:codex/theme-editor-core

Conversation

@JohnONolan
Copy link
Copy Markdown
Member

@JohnONolan JohnONolan commented May 4, 2026

What

This adds a Ghost-native theme code editor inside settings.

Based on community work originally done here by @muratcorlu:
https://github.com/synapsmedia/ghost-theme-editor

  • adds editor entry points from the installed themes list and active theme settings
  • supports browser-based editing of text theme files while preserving binary assets through ZIP round-trips
  • adds create, rename, delete, revert, save, and save-as flows for built-in themes
  • hardens the editor against malformed direct routes and oversized or hostile archives
  • adds acceptance and unit coverage for the editor flows and ZIP utilities

CleanShot 2026-05-03 at 19 49 46@2x
CleanShot 2026-05-03 at 19 48 54@2x

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 4, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Hidden review stack artifact

Walkthrough

Adds a browser-based theme editor and utilities: new default-exported ThemeCodeEditorModal and a theme-editor-utils module (types, ZIP extraction/packing, diffing, path normalization, rename mapping, archive limits, and errors). UI and routing updated to support theme/edit/:name (modalPaths, change-theme menu, advanced-theme-settings, design-and-theme-modal) with centralized theme-limit checks and limit-modal flows. Package dependencies added CodeMirror packages and jszip. Tests: new acceptance and unit suites for editor and archive behavior. Test infra: MockRequestConfig gained an optional rawResponse and mockApi fulfillment was updated to honor it.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding a built-in theme code editor to Ghost settings.
Description check ✅ Passed The description is directly related to the changeset, clearly outlining what was added, the features implemented, and providing context with community attribution and visual examples.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx (1)

129-141: ⚡ Quick win

Extract the editor-entry routing logic into a shared helper/hook.

This handleEditCode flow duplicates the same limit-check + route-building logic in apps/admin-x-settings/src/components/settings/site/change-theme.tsx. Centralizing it will prevent entry-point drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx`
around lines 129 - 141, handleEditCode duplicates the same limit-check and
route-building flow found in change-theme.tsx; extract this into a shared helper
or hook (e.g., openThemeEditor or useThemeEditorEntry) that encapsulates calling
checkThemeLimitError(isDefaultOrLegacyTheme(...) ? '.' : theme.name), showing
LimitModal with the same onOk handler, and calling updateRoute with the encoded
path; replace the local handleEditCode in advanced-theme-settings.tsx and the
equivalent code in change-theme.tsx to call the new helper, exporting it from a
common module so both components reuse the exact same logic and avoid drift.
apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx (2)

568-590: ⚡ Quick win

Consider adding dependencies or using a ref for the keyboard handler.

This useEffect has no dependency array, causing the event listener to be added and removed on every render. While this ensures handleSave always uses current values, it's inefficient.

A cleaner approach would be to use a ref for the handler or wrap handleSave in useCallback:

♻️ Suggested refactor using ref pattern
+const handleSaveRef = useRef(handleSave);
+useEffect(() => {
+    handleSaveRef.current = handleSave;
+});
+
 useEffect(() => {
     const handleKeydown = (event: KeyboardEvent) => {
         if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
             event.preventDefault();
-            void handleSave();
+            void handleSaveRef.current();
             return;
         }

         if (event.key !== 'Escape') {
             return;
         }

         event.preventDefault();
         event.stopPropagation();
         event.stopImmediatePropagation();
     };

     window.addEventListener('keydown', handleKeydown, true);

     return () => {
         window.removeEventListener('keydown', handleKeydown, true);
     };
-});
+}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx`
around lines 568 - 590, The effect attaching handleKeydown is missing a
dependency array causing add/remove on every render; change it to stable
behavior by either (a) wrapping handleSave in useCallback and providing a proper
dependency array to useEffect, or (b) keep handleSave as-is but move the handler
into a ref (e.g., handlerRef) and read handlerRef.current inside handleKeydown
so useEffect can pass an empty dependency array; update the effect that defines
handleKeydown and the cleanup removeEventListener accordingly to reference the
stable handler.

943-958: 💤 Low value

Consider defensive check for malformed success response.

If response.json() fails but response.ok is true (unlikely edge case of server returning invalid JSON on success), accessing data.themes[0] on line 958 would throw.

🛡️ Optional defensive fix
 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.');
 }

+if (!data?.themes?.[0]) {
+    throw new Error('Invalid response from server.');
+}
+
 const uploadedTheme = data.themes[0];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx`
around lines 943 - 958, The success path assumes valid JSON and a populated
themes array, but data can be null or malformed (response.json() may fail), so
after the response.ok check and before using data.themes[0] validate that data
is non-null and that Array.isArray(data.themes) and data.themes.length>0; if the
check fails, throw a descriptive Error (or show an error modal similar to the
existing error handling) so uploadedTheme is never read from an undefined value;
apply this validation around the variable named data (typed as
ThemesInstallResponseType) and before assigning uploadedTheme.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/admin-x-settings/src/components/settings/site/theme/theme-editor-utils.ts`:
- Around line 232-237: Reject archive entries with non-normalized or
path-traversal segments: in the extraction loop where zipPath, entry and
displayPath are used (rootPrefix, zipPath.slice(...)), normalize displayPath
using a POSIX-normalize equivalent and then skip/throw for any path that is
absolute (starts with '/'), contains '..' segments or resolves to empty or a
different traversal target (e.g., begins with '../' or contains '/..' or '\\'),
and also reject entries containing '.' segments that change structure (like
'a/./b') rather than trusting zipPath as-is; apply the same validation in the
other extraction loop referenced (the block around lines 260-268) so only
well-normalized, non-traversing paths are extracted or re-packed.
- Around line 153-160: getEntryUncompressedSize currently reads the private
JSZip field _data to get uncompressedSize; replace this brittle approach by
streaming/decompressing the entry via the public JSZip API (e.g.,
ZipObject.nodeStream() or ZipObject.async()) and count bytes from the
decompressed output to determine size, throwing
ThemeArchiveExtractionError('invalid_archive', invalidArchiveMessage) if the
per-entry or running total exceeds configured limits; update any callers that
assumed getEntryUncompressedSize returns a numeric size to await the
streaming/counting operation, remove reliance on ThemeArchiveEntry._data, and
keep the same error type/message when aborting on size violations.

---

Nitpick comments:
In
`@apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx`:
- Around line 129-141: handleEditCode duplicates the same limit-check and
route-building flow found in change-theme.tsx; extract this into a shared helper
or hook (e.g., openThemeEditor or useThemeEditorEntry) that encapsulates calling
checkThemeLimitError(isDefaultOrLegacyTheme(...) ? '.' : theme.name), showing
LimitModal with the same onOk handler, and calling updateRoute with the encoded
path; replace the local handleEditCode in advanced-theme-settings.tsx and the
equivalent code in change-theme.tsx to call the new helper, exporting it from a
common module so both components reuse the exact same logic and avoid drift.

In
`@apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx`:
- Around line 568-590: The effect attaching handleKeydown is missing a
dependency array causing add/remove on every render; change it to stable
behavior by either (a) wrapping handleSave in useCallback and providing a proper
dependency array to useEffect, or (b) keep handleSave as-is but move the handler
into a ref (e.g., handlerRef) and read handlerRef.current inside handleKeydown
so useEffect can pass an empty dependency array; update the effect that defines
handleKeydown and the cleanup removeEventListener accordingly to reference the
stable handler.
- Around line 943-958: The success path assumes valid JSON and a populated
themes array, but data can be null or malformed (response.json() may fail), so
after the response.ok check and before using data.themes[0] validate that data
is non-null and that Array.isArray(data.themes) and data.themes.length>0; if the
check fails, throw a descriptive Error (or show an error modal similar to the
existing error handling) so uploadedTheme is never read from an undefined value;
apply this validation around the variable named data (typed as
ThemesInstallResponseType) and before assigning uploadedTheme.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 758a245a-bca3-4220-9731-cc55a7734da3

📥 Commits

Reviewing files that changed from the base of the PR and between b08cf75 and 403b513.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (10)
  • apps/admin-x-framework/src/test/acceptance.ts
  • apps/admin-x-settings/package.json
  • apps/admin-x-settings/src/components/providers/settings-router.tsx
  • apps/admin-x-settings/src/components/settings/site/change-theme.tsx
  • apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/theme-editor-utils.ts
  • apps/admin-x-settings/test/acceptance/site/theme.test.ts
  • apps/admin-x-settings/test/unit/utils/theme-editor-utils.test.ts

Comment thread apps/admin-x-settings/src/components/settings/site/theme/theme-editor-utils.ts Outdated
@JohnONolan JohnONolan force-pushed the codex/theme-editor-core branch from 403b513 to 3f8f5fc Compare May 4, 2026 02:57
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx (1)

61-89: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Cancel in-flight limit checks when the route changes.

Both async limit effects can still call setState, modal.remove(), and updateRoute() after the user has already navigated elsewhere. In particular, the currentPath !== 'theme/install' check after await is reading the stale value captured when the effect started, so it does not prevent a late response from redirecting a different screen. Mirror the isCancelled pattern you already use in the editor-access effect.

Also applies to: 156-252

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx`
around lines 61 - 89, The async effect in useEffect that defines
checkIfThemeChangeAllowed can continue after navigation and mutate state or call
modal.remove; fix it by implementing the same isCancelled pattern used in the
editor-access effect: introduce a local let isCancelled = false at the top of
the effect, pass that into checkIfThemeChangeAllowed and after every await
(especially after calling await checkThemeLimitError()) check if isCancelled and
return early if true so you don't call setThemeChangeError, setIsCheckingLimit,
showThemeLimitModal, modal.remove or updateRoute when stale; also set
isCancelled = true in the effect cleanup and apply the identical cancellation
check to the other async limit-check effect (the one referenced around the
156-252 region) so all late promises respect currentPath/isThemeLimitCheckReady
changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx`:
- Around line 18-31: The parser currently accepts multi-segment or encoded-slash
names; update the logic around encodedThemeName (derived from
path.slice('theme/edit/'.length)) and the decodeURIComponent call to reject any
names containing a slash either before or after decoding: if encodedThemeName is
empty OR encodedThemeName.includes('/') OR
decodeURIComponent(encodedThemeName).includes('/') then return {themeName: null,
isInvalid: true}; otherwise return the decoded name as before. Apply this change
in the function that computes encodedThemeName/decodes it so malformed routes
are flagged invalid before rendering ThemeCodeEditorModal.

---

Outside diff comments:
In
`@apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx`:
- Around line 61-89: The async effect in useEffect that defines
checkIfThemeChangeAllowed can continue after navigation and mutate state or call
modal.remove; fix it by implementing the same isCancelled pattern used in the
editor-access effect: introduce a local let isCancelled = false at the top of
the effect, pass that into checkIfThemeChangeAllowed and after every await
(especially after calling await checkThemeLimitError()) check if isCancelled and
return early if true so you don't call setThemeChangeError, setIsCheckingLimit,
showThemeLimitModal, modal.remove or updateRoute when stale; also set
isCancelled = true in the effect cleanup and apply the identical cancellation
check to the other async limit-check effect (the one referenced around the
156-252 region) so all late promises respect currentPath/isThemeLimitCheckReady
changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4a4da06f-a2c0-4a57-8b33-bf2ac83968cd

📥 Commits

Reviewing files that changed from the base of the PR and between 403b513 and 3f8f5fc.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (10)
  • apps/admin-x-framework/src/test/acceptance.ts
  • apps/admin-x-settings/package.json
  • apps/admin-x-settings/src/components/providers/settings-router.tsx
  • apps/admin-x-settings/src/components/settings/site/change-theme.tsx
  • apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/theme-editor-utils.ts
  • apps/admin-x-settings/test/acceptance/site/theme.test.ts
  • apps/admin-x-settings/test/unit/utils/theme-editor-utils.test.ts
✅ Files skipped from review due to trivial changes (3)
  • apps/admin-x-settings/src/components/providers/settings-router.tsx
  • apps/admin-x-settings/package.json
  • apps/admin-x-settings/src/components/settings/site/change-theme.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
  • apps/admin-x-framework/src/test/acceptance.ts
  • apps/admin-x-settings/test/unit/utils/theme-editor-utils.test.ts
  • apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/theme-editor-utils.ts

@JohnONolan JohnONolan force-pushed the codex/theme-editor-core branch from 3f8f5fc to 0f262ad Compare May 4, 2026 03:04
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/admin-x-settings/test/acceptance/site/theme.test.ts (1)

54-54: ⚡ Quick win

Escape themeName before constructing RegExp in locator filter.

Using raw themeName in new RegExp(themeName, 'i') can mis-match if special regex characters appear in future fixtures/theme names.

Suggested change
-    const themeListItem = modal.getByTestId('theme-list-item').filter({hasText: new RegExp(themeName, 'i')});
+    const escapedThemeName = themeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+    const themeListItem = modal.getByTestId('theme-list-item').filter({hasText: new RegExp(escapedThemeName, 'i')});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-x-settings/test/acceptance/site/theme.test.ts` at line 54, The
locator construction uses new RegExp(themeName, 'i') which will break if
themeName contains regex metacharacters; update the test to escape regex-special
characters before building the RegExp used in
modal.getByTestId('theme-list-item').filter({ hasText: new RegExp(...) }) — e.g.
add and use an escapeRegExp helper and pass escapeRegExp(themeName) to RegExp
(case-insensitive) when creating the filter for themeListItem.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/admin-x-settings/test/acceptance/site/theme.test.ts`:
- Line 54: The locator construction uses new RegExp(themeName, 'i') which will
break if themeName contains regex metacharacters; update the test to escape
regex-special characters before building the RegExp used in
modal.getByTestId('theme-list-item').filter({ hasText: new RegExp(...) }) — e.g.
add and use an escapeRegExp helper and pass escapeRegExp(themeName) to RegExp
(case-insensitive) when creating the filter for themeListItem.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 38475e38-44aa-47cb-ba36-382c5cfc0bfd

📥 Commits

Reviewing files that changed from the base of the PR and between 0f262ad and 0cb6c60.

📒 Files selected for processing (2)
  • apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx
  • apps/admin-x-settings/test/acceptance/site/theme.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx

@codecov
Copy link
Copy Markdown

codecov Bot commented May 4, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.81%. Comparing base (e51385b) to head (f68c4eb).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #27656      +/-   ##
==========================================
- Coverage   73.84%   73.81%   -0.03%     
==========================================
  Files        1520     1521       +1     
  Lines      128308   128416     +108     
  Branches    15384    15386       +2     
==========================================
+ Hits        94749    94794      +45     
- Misses      32602    32687      +85     
+ Partials      957      935      -22     
Flag Coverage Δ
admin-tests 53.52% <ø> (ø)
e2e-tests 73.81% <ø> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ErisDS
Copy link
Copy Markdown
Member

ErisDS commented May 11, 2026

Theme editor PR — change list

Review of the editor implementation plus the existing theme upload/download endpoints. Verified against the actual code on the branch. Grouped by confidence that the change is worth making.

Worth doing (high confidence)

  • Add an acceptance test for "close with unsaved changes" (theme-code-editor-modal.tsx:550-566)
    Grepping theme.test.ts for Discard / unsaved returns zero matches. The confirmation flow when closing with edits is uncovered.
  • Pin jszip exactly (apps/admin-x-settings/package.json:63)
    "jszip": "^3.10.1" is the only carbon-pinned dep in this package.json. Every other dep uses an exact version. Match the convention.

Real but minor (medium-high confidence)

  • Add a dep array to the keydown useEffect (theme-code-editor-modal.tsx:568-590)
    No deps means the listener re-registers on every render. Functionally fine — handleSave is recreated each render and captures the latest state, which is what Cmd+S needs — but wasteful. Wrap handleSave in useCallback and
    pass [handleSave] as the dep.
  • Clean up Promise.all([oneDark, ...]) (theme-code-editor-modal.tsx:487)
    oneDark is a static extension array, not a thenable. Promise.all wraps it via Promise.resolve() so it works, but it's misleading. Use Promise.all([Promise.resolve(oneDark), getLanguageExtension(...)]) or split into a
    separate await.

Worth considering (real concern, fix path requires a judgement call)

  • Tighten save-as theme name validation (theme-code-editor-modal.tsx:841-872)
    Client only blocks /, , and default theme names. Null bytes, leading dots, names ≥254 chars, and Windows-reserved names pass through to the server. Server's BaseStorage.getSanitizedFileName ([^\w@.] → -) normalises these
    — [ ] not a security hole, but the final name can surprise the user. Replace with a positive allowlist like ^[a-zA-Z0-9][\w@.-]{0,63}$.
  • Split theme-code-editor-modal.tsx
    1,324 lines, with renderTreeNode and two NiceModal definitions inline. The util layer is already separated; UI side should match. Candidate splits: ThemeFileTree, ThemeEditorToolbar, ThemeEditorConfirmModal,
    ThemeEditorInputModal.

Defensive / lower priority

  • Validate the from= query parameter (theme-code-editor-modal.tsx:233-239, 565)
    getReturnRouteFromHash() returns the raw value, which is passed to updateRoute. updateRoute (routing-provider.tsx:119-138) only writes to window.location.hash, so this is not an open redirect — the worst case is landing on
    an unexpected admin route. Constrain to a known-prefix allowlist (design/, theme, theme/install) for belt-and-braces.
  • Wrap new URL(...) in try/catch (theme-code-editor-modal.tsx:233-239)
    new URL(hash || '/', domain) only throws if the hash is unparseable relative to a same-origin base. Practically very low risk; defensive only.
  • Extract recurring hex colours to constants (theme-code-editor-modal.tsx:255-268 and inline Tailwind classes)
    Literal hex values (#15171a, #23262c, #16181c, #355070, etc.) sprinkled through the component. Candidate for a design-token follow-up.

@ErisDS ErisDS force-pushed the codex/theme-editor-core branch from 0cb6c60 to 21726b0 Compare May 11, 2026 14:33
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/admin-x-settings/test/acceptance/site/theme.test.ts (1)

50-59: 💤 Low value

Consider escaping the theme name for regex safety.

The static analysis tool flags new RegExp(themeName, 'i') as a potential ReDoS risk. While this is test code with controlled inputs, escaping special regex characters would be a minor defensive improvement.

♻️ Optional fix
 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')});
+    const escapedName = themeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+    const themeListItem = modal.getByTestId('theme-list-item').filter({hasText: new RegExp(escapedName, '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');
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/admin-x-settings/test/acceptance/site/theme.test.ts` around lines 50 -
59, The filter uses new RegExp(themeName, 'i') which can misinterpret special
regex chars; update openInstalledThemeEditor to escape themeName before
constructing the RegExp (e.g., add a small helper escapeRegExp(name) that
replaces /[-\/\\^$*+?.()|[\]{}]/g with '\\$&' and then use new
RegExp(escapeRegExp(themeName), 'i') when creating the regex for
themeListItem.filter); reference the existing function openInstalledThemeEditor
and the RegExp(themeName, 'i') usage when applying this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/admin-x-settings/test/acceptance/site/theme.test.ts`:
- Around line 50-59: The filter uses new RegExp(themeName, 'i') which can
misinterpret special regex chars; update openInstalledThemeEditor to escape
themeName before constructing the RegExp (e.g., add a small helper
escapeRegExp(name) that replaces /[-\/\\^$*+?.()|[\]{}]/g with '\\$&' and then
use new RegExp(escapeRegExp(themeName), 'i') when creating the regex for
themeListItem.filter); reference the existing function openInstalledThemeEditor
and the RegExp(themeName, 'i') usage when applying this change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 77650f8a-fa33-4401-884b-922f90e8495b

📥 Commits

Reviewing files that changed from the base of the PR and between 0cb6c60 and 21726b0.

📒 Files selected for processing (10)
  • apps/admin-x-framework/src/test/acceptance.ts
  • apps/admin-x-settings/package.json
  • apps/admin-x-settings/src/components/providers/settings-router.tsx
  • apps/admin-x-settings/src/components/settings/site/change-theme.tsx
  • apps/admin-x-settings/src/components/settings/site/design-and-theme-modal.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/theme-editor-utils.ts
  • apps/admin-x-settings/test/acceptance/site/theme.test.ts
  • apps/admin-x-settings/test/unit/utils/theme-editor-utils.test.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/admin-x-settings/src/components/providers/settings-router.tsx
  • apps/admin-x-settings/test/unit/utils/theme-editor-utils.test.ts
  • apps/admin-x-settings/src/components/settings/site/theme/theme-editor-utils.ts
  • apps/admin-x-settings/src/components/settings/site/change-theme.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx
  • apps/admin-x-framework/src/test/acceptance.ts

@ErisDS ErisDS force-pushed the codex/theme-editor-core branch 4 times, most recently from c38f857 to 00c989d Compare May 14, 2026 13:29
JohnONolan and others added 15 commits May 14, 2026 19:27
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.
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.
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.
This follows up on review feedback around hostile archive handling in the browser theme editor.

The extractor now rejects non-normalized archive paths and enforces extracted-size limits using public JSZip read APIs instead of private metadata fields, which keeps the validation stable across dependency updates while preserving the editor hardening added earlier.
This follows up on review feedback about malformed editor routes.

The parser now rejects encoded slashes before opening the editor, and the acceptance suite covers the direct-route fallback so malformed theme edit URLs return to a safe settings state instead of leaking through to the modal flow.
The closeEditor flow triggers a discard confirmation when changes are
pending, but neither cancel nor confirm was exercised by tests. A
review flagged this as a UX path easy to regress silently.

The new test makes an edit then runs both branches: cancel keeps the
editor open with the change intact; confirm tears it down.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Every other dep in this package.json uses an exact version; jszip was
the lone caret-pinned entry, introduced alongside the theme editor.
Aligning it with the convention so future pnpm installs will not
silently bump to a new minor without a deliberate change.

The lockfile diff also includes incidental cleanup pnpm applied on
this install (a stale @tryghost/members-csv@2.0.5 entry that nothing
references, and a couple of optional-dep flag tweaks). These are
unrelated to the version pin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Cmd+S / Escape handler was inside a useEffect with no dep array,
so the global keydown listener was added and removed on every render
of the theme editor — including every keystroke in the CodeMirror
editor, because typing updates currentFiles state.

The listener cannot have handleSave directly in its dep array either:
handleSave closes over currentFiles, changes, and other state, so it
is itself rebuilt on every keystroke. The literal "wrap in useCallback"
fix would not reduce churn meaningfully — useCallback only returns a
stable reference when all its deps are unchanged, and currentFiles
changes per keystroke.

Switched to the latest-ref pattern: the listener registers once on
mount, and reads the freshest handleSave through a ref that is
updated in a follow-up effect after each render. Same Cmd+S behaviour,
no per-keystroke add/remove on the window. The mutable ref bypasses
React's data flow but is the established workaround for "global event
handler outside React scope needs to call into the component".

Long term, React 19.2 (Oct 2025) shipped useEffectEvent as a stable
API that wraps this exact pattern more ergonomically. This codebase
is on React 18.3.1, so the ref pattern is the right fix today; the
listener can migrate to useEffectEvent when admin-x-settings upgrades.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The editor was wrapping [oneDark, getLanguageExtension(path)] in
Promise.all and spreading the result into the extensions array.
oneDark is a static Extension, not a thenable; Promise.all coerced
it via Promise.resolve() so it worked, but the shape of the code
suggested both values were async. The .catch branch already had to
re-list oneDark by itself, which gave away that only the language
extension was actually async.

Only getLanguageExtension is async (a dynamic import for the
CodeMirror language module). Awaiting just that, and listing oneDark
alongside the other static extensions in both the success and error
branches, keeps the Promise types honest and removes the misleading
parallel-load shape.

No behaviour change; same extensions loaded in the same order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The save-as input previously blocked only forward/back slashes and the
two default theme names. Everything else was sent to the server, where
BaseStorage.getSanitizedFileName silently replaces any character
outside [\w@.] with a dash — so a user typing "My Theme!" had their
theme stored as "my-theme-" with no visible explanation.

Replaced the deny-list with a positive allowlist matching
/^[a-z0-9][\w-]{0,63}$/ on the lower-cased input. This rejects null
bytes, leading dots, Unicode characters the server would replace, and
names over 64 chars — all with a single clear error instead of a
silent server-side rename. The default-theme guard stays separate so
"Built-in themes cannot be overwritten" still surfaces specifically.

Dots are deliberately excluded entirely (not just at the start of the
name). Real Ghost themes are kebab-case and have no legitimate use
for dots, while allowing them invites extension confusion ("mytheme.zip"
typed as a name) and Windows strips trailing dots silently when archives
are unzipped on deploy.

Added an acceptance test that exercises both rejection paths:
disallowed characters trigger the new format error, and built-in
theme names trigger the existing specific guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
theme-code-editor-modal.tsx had grown to 1,358 lines with two NiceModal
definitions, the recursive file-tree renderer, the top toolbar, the
full review-overlay modal, and a handful of helper types/constants all
inline. The util layer was already separated; the UI side now matches.

Extracted five sibling files plus a shared style constants module:

- theme-editor-confirm-modal.tsx: ThemeEditorConfirmModal NiceModal
- theme-editor-input-modal.tsx: ThemeEditorInputModal NiceModal
- theme-file-tree.tsx: ThemeFileTree component (also owns the TreeNode
  type, SelectedNode type, buildTree, sortTreeNodes, and the
  fileActionButtonClass style — all only used here)
- theme-editor-toolbar.tsx: ThemeEditorToolbar component (header bar
  with theme name, modified-count badge, Close, Save)
- theme-review-modal.tsx: ThemeReviewModal component for the "All
  changes" overlay, plus the ReviewItem type and the buildReviewItems
  / formatReviewSummary helpers. selectedReviewPath state and its
  auto-select effect moved inside since they are private to the review
  flow — the parent now only needs reviewItems, an onClose callback,
  onOpenInEditor, and onRevert.
- theme-editor-styles.ts: button-class strings shared between the
  toolbar and the review pane

Main file dropped from 1,358 to ~947 lines. Behaviour is unchanged;
the 27 existing theme acceptance tests, 169 admin-x-settings unit
tests, and tsc all pass without modification — these tests already
exercise every extracted surface (typing in the file tree, both
NiceModals via the save/discard/rename flows, Cmd+S and click Save on
the toolbar, opening the review overlay and reverting from it), so
the existing suite is the safety net for this refactor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getReturnRouteFromHash was passing the ?from= query parameter directly
into updateRoute when the editor closed. updateRoute can only navigate
within the admin SPA (it writes to window.location.hash), so this is
not an open redirect — but a crafted URL could still bounce a
logged-in admin onto an unrelated admin route after closing the
editor, which is a phishing-shape concern even if not exploitable on
its own.

Constrained the return route to a small allowlist of prefixes that
cover every legitimate caller (the editor is only ever opened from a
theme/* or design/* route today). Empty from= is kept as a pass-
through because it is the legitimate signal that the editor was
opened from the settings root (/#/settings), and we still want to
return there.

Behaviour:
- ?from missing → null → falls back to the safe default 'design/change-theme'
- ?from= (empty) → '' → returns to /#/settings (unchanged from today)
- ?from=theme[/...] or ?from=design[/...] → returned as-is
- ?from=anything-else → null → falls back to the safe default

Added two acceptance tests pairing the malicious and legitimate
cases. Test-first: the malicious-from= test was failing before the
implementation landed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
new URL(hash, sameOrigin) throws on truly malformed input — e.g. a
hash that starts with "://" or another unparseable protocol-like
prefix. The Ghost admin SPA never produces such hashes today, so this
is purely defensive: a manual hash edit in devtools, a future routing
change, or a partially-decoded value during a race could trip it.
Without a guard, the throw escapes into closeEditor and would crash
the editor unmount.

Wrapped the new URL call in try/catch, returning null on failure so
the caller's existing fallback ('design/change-theme') still applies.

No test added. Reaching the throw branch via the admin UI is not
possible today (the SPA controls hash format), and the alternative —
extracting parseReturnRoute as a pure exported helper just to mock
window.location — is a heavier refactor than the recommendation
called for. If a real failure case turns up, that refactor + a unit
test is the right follow-up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The review recommendation called for extracting recurring hex colours
into local constants. After the earlier component split, the hex
literals are scattered across smaller files but a handful of full
tailwind class strings still repeat within individual files. Those are
the practical extraction targets.

theme-review-modal.tsx (4 / 2 / 2 duplicates):
- previewBlockClass for the diff/preview <pre> element
- previewSectionLabelClass for the "Before"/"After" labels
- previewEmptyStateClass for the "Select a changed file" / binary-file
  placeholders

theme-code-editor-modal.tsx (3 duplicates):
- editorPaneEmptyStateClass for the three right-pane placeholder states
  ("no file selected", "folder selected", "this file cannot be edited")

The hex values themselves stay inlined where they are used in tailwind
arbitrary-value classes. Replacing them with JS constants would break
tailwind's build-time class extraction; the right substitute is design
tokens in the shade design system, which is broader follow-up work
flagged in the original recommendation ("Candidate for a design-token
follow-up") and out of scope here.

No behaviour change; all 29 theme acceptance tests pass without
modification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the new server-side upload caps (PR TryGhost#27903) reject a save, the
response carries a structured Ghost error with a code
(COMPRESSED_TOO_LARGE / ENTRY_TOO_LARGE / TOTAL_TOO_LARGE) and
errorDetails (entryName, observedBytes, limitBytes). The editor was
throwing the parsed message into the generic useHandleError() pipeline,
which collapsed it to "Something went wrong, please try again." The
user had no way to know which file was too large or what the cap was.

Intercept the three size-limit codes before the throw, format the
errorDetails into a user-friendly toast, and skip the generic handler:

- COMPRESSED_TOO_LARGE → "The theme archive is too large to upload
  (max X MB)."
- ENTRY_TOO_LARGE → "The file 'foo.hbs' is too large (max X MB per file)."
- TOTAL_TOO_LARGE → "The theme contents exceed the maximum allowed size
  of X MB."

All other non-422 failures still fall through to the existing throw +
handleError path. The 422 InvalidThemeModal branch (gscan validation
errors) is unchanged.

Test-first: a new acceptance test mocks /themes/upload/ to return a 415
with an ENTRY_TOO_LARGE shape and asserts the toast mentions the
offending file and the limit (not the generic message). The test was
failing before this change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ErisDS ErisDS force-pushed the codex/theme-editor-core branch from 00c989d to f68c4eb Compare May 14, 2026 18:34
@ErisDS ErisDS merged commit 492c39e into TryGhost:main May 14, 2026
45 checks passed
@nuclearpengy
Copy link
Copy Markdown

🚀

ErisDS added a commit that referenced this pull request May 15, 2026
Escapes theme names before constructing the installed-theme Playwright
locator regexp. Follows up on CodeRabbit nitpick from #27656 so future fixture names
with regexp metacharacters match literally.
@filipesmedeiros
Copy link
Copy Markdown
Contributor

@JohnONolan how does this feature interact with file-system themes? I.e. themes stored in the data/ghost/themes directory. Does it update the files on-system? Thanks! :)

@JohnONolan
Copy link
Copy Markdown
Member Author

Yep

@filipesmedeiros
Copy link
Copy Markdown
Contributor

@JohnONolan it also, unfortunately, seems to destroy the .git folder that I had there :/

Is this behaviour intended? It would be super cool if the theme could continue to be a git repo, so that changes made inside the Ghost editor could be pushed to the remote repo!

@JohnONolan
Copy link
Copy Markdown
Member Author

Sounds like a bug - if you can open an issue with repro steps that would be great!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants