Skip to content

Replaced cdnUrl config with script-origin asset base derivation#26555

Open
rob-ghost wants to merge 1 commit intomainfrom
feat/ghost-cdn-url-meta-tag
Open

Replaced cdnUrl config with script-origin asset base derivation#26555
rob-ghost wants to merge 1 commit intomainfrom
feat/ghost-cdn-url-meta-tag

Conversation

@rob-ghost
Copy link
Contributor

@rob-ghost rob-ghost commented Feb 24, 2026

ref https://linear.app/ghost/issue/ENG-1326

Summary

  • Replaced Ember's build-time cdnUrl config with a runtime assetBase() utility that derives the CDN base from where the Ember scripts were loaded — the same principle ES modules use for relative imports
  • Removed the ghost-cdn-url meta tag and bridge script from index.html (no longer needed)
  • Fixed vite-ember-assets plugin reading from the combined output directory instead of the Ember dist, which caused ././assets/ path prefixes to accumulate on repeated builds

Background

Ghost(Pro) currently bakes absolute CDN URLs into the admin build at CI time via GHOST_CDN_URL, then string-replaces them per environment at deploy time. The new cd.yml flow (Ghost-Moya #135, merged) replaces this with a simpler approach: build once with relative paths, then rewrite only index.html at deploy time with sed.

Vite's ES modules handle this automatically — import.meta.url reflects where the module was loaded from, so dynamic imports resolve to CDN if the entry script was loaded from CDN. Ember uses classic AMD scripts with no module-relative resolution, so it previously needed a cdnUrl config value to construct absolute URLs for lazy-loaded assets.

Rather than shimming cdnUrl through a meta tag, this PR makes Ember work the same way as Vite: derive the asset base from where the scripts were loaded.

Details

assetBase() utility (ghost/admin/app/utils/asset-base.js): Finds the Ember <script> element in the DOM, reads its src (always resolved to an absolute URL by the browser), and extracts the base path up to /assets/. If the script loaded from CDN (because sed rewrote index.html), the result is the CDN base. If local, it's the local admin root. The result is memoized. Falls back to ghostPaths().adminRoot if no matching script is found.

Replaced 6 duplicated conditionals: lazy-loader.js, parse-history-event.js, admin-x-component.js, user.js, and fetch-koenig-lexical.js all had config.cdnUrl ? ... : ghostPaths()... patterns. Each is now a single assetBase() call with unused imports removed.

Removed cdnUrl from config/environment.js: No longer needed at runtime. GHOST_CDN_URL still works at build time for ember-cli-build.js (fingerprint.prepend, publicAssetURL) — self-hosters who set it get the same behaviour as before.

././ fix: The vite-ember-assets plugin was reading Ember asset paths from ghost/core/core/built/admin/index.html — the combined output directory that gets overwritten by the plugin's own closeBundle hook. On each build, paths that already had ./ got another ./ prepended. Now reads from ghost/admin/dist/index.html (the Ember build's own output), producing clean ./assets/ paths.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6b1f371 and dd70141.

📒 Files selected for processing (9)
  • apps/admin/vite-ember-assets.ts
  • ghost/admin/app/components/admin-x/admin-x-component.js
  • ghost/admin/app/helpers/parse-history-event.js
  • ghost/admin/app/models/user.js
  • ghost/admin/app/services/lazy-loader.js
  • ghost/admin/app/utils/asset-base.js
  • ghost/admin/app/utils/fetch-koenig-lexical.js
  • ghost/admin/config/environment.js
  • ghost/admin/tests/unit/utils/asset-base-test.js
💤 Files with no reviewable changes (1)
  • ghost/admin/config/environment.js
🚧 Files skipped from review as they are similar to previous changes (5)
  • ghost/admin/app/helpers/parse-history-event.js
  • ghost/admin/app/utils/fetch-koenig-lexical.js
  • ghost/admin/app/components/admin-x/admin-x-component.js
  • ghost/admin/app/models/user.js
  • ghost/admin/app/services/lazy-loader.js

Walkthrough

Introduces a new utility assetBase() with exported resolveAssetBase(doc) that computes and memoizes the admin asset base URL. Replaces previous config.cdnUrl / ghostPaths-based asset-root logic across admin components, helpers, models, services, and utilities to use assetBase(). Removes cdnUrl from admin ENV, updates apps/admin/vite-ember-assets.ts, and adds unit tests for asset-base.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the primary change: replacing the build-time cdnUrl config with a runtime assetBase() utility that derives the asset base from script origin.
Description check ✅ Passed The description comprehensively explains the changes, background, and rationale for this refactoring, clearly relating to all aspects of the changeset.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/ghost-cdn-url-meta-tag

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
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/index.html (1)

17-30: Guard decode/parse to avoid hard failure on malformed meta content.

If the env meta tag is ever malformed (e.g., partial rewrite), decodeURIComponent/JSON.parse will throw and can stop the script. A small guard keeps the boot resilient.

🔧 Suggested defensive guard
     (function() {
       var c = document.querySelector('meta[name="ghost-cdn-url"]');
       if (c && c.content) {
         var m = document.querySelector('meta[name="ghost-admin/config/environment"]');
         if (m) {
-          var d = JSON.parse(decodeURIComponent(m.content));
-          d.cdnUrl = c.content;
-          m.content = encodeURIComponent(JSON.stringify(d));
+          try {
+            var d = JSON.parse(decodeURIComponent(m.content));
+            d.cdnUrl = c.content;
+            m.content = encodeURIComponent(JSON.stringify(d));
+          } catch (e) {
+            // no-op: keep original content if malformed
+          }
         }
       }
     })();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/index.html` around lines 17 - 30, Guard the decode/parse of the
env meta content to avoid hard failures: in the inline IIFE that queries meta
elements (variables c and m) and reads m.content, validate m.content is present
and wrap the decodeURIComponent + JSON.parse steps in a try/catch (or
conditional check) so malformed or empty content is skipped; only set d.cdnUrl
and update m.content if parsing succeeds, and log or silently ignore parse
errors to keep the boot resilient.
🤖 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/index.html`:
- Around line 17-30: Guard the decode/parse of the env meta content to avoid
hard failures: in the inline IIFE that queries meta elements (variables c and m)
and reads m.content, validate m.content is present and wrap the
decodeURIComponent + JSON.parse steps in a try/catch (or conditional check) so
malformed or empty content is skipped; only set d.cdnUrl and update m.content if
parsing succeeds, and log or silently ignore parse errors to keep the boot
resilient.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1dc579a and e5a2df9.

📒 Files selected for processing (2)
  • apps/admin/index.html
  • apps/admin/vite-ember-assets.ts

@rob-ghost rob-ghost force-pushed the feat/ghost-cdn-url-meta-tag branch from e5a2df9 to 542240d Compare February 26, 2026 12:08
@rob-ghost rob-ghost changed the title Added ghost-cdn-url meta tag and fixed ././ build output Replaced cdnUrl config with script-origin asset base derivation Feb 26, 2026
Copy link
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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/admin/app/services/lazy-loader.js`:
- Around line 1-4: The init() method in lazy-loader.js references
config.environment but the config import was removed causing a ReferenceError at
runtime; restore the import of the app environment config (import config from
the app's environment module—the same module previously removed) at the top of
the file so that the config identifier used in init() is defined. Ensure the
imported name is exactly config and no other symbol is renamed so init() can
read config.environment without changes.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e5a2df9 and 542240d.

📒 Files selected for processing (8)
  • apps/admin/vite-ember-assets.ts
  • ghost/admin/app/components/admin-x/admin-x-component.js
  • ghost/admin/app/helpers/parse-history-event.js
  • ghost/admin/app/models/user.js
  • ghost/admin/app/services/lazy-loader.js
  • ghost/admin/app/utils/asset-base.js
  • ghost/admin/app/utils/fetch-koenig-lexical.js
  • ghost/admin/config/environment.js
💤 Files with no reviewable changes (1)
  • ghost/admin/config/environment.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/admin/vite-ember-assets.ts

@rob-ghost rob-ghost force-pushed the feat/ghost-cdn-url-meta-tag branch from 542240d to 78029fa Compare February 26, 2026 12:15
Copy link
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)
ghost/admin/app/helpers/parse-history-event.js (1)

14-14: Normalize base before appending assets for safer URL construction.

Line 14 assumes assetBase() always ends with /. Normalizing here avoids malformed paths if fallback formatting ever changes.

💡 Proposed hardening
-        const assetRoot = `${assetBase()}assets`;
+        const assetRoot = `${assetBase().replace(/\/?$/, '/')}assets`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/admin/app/helpers/parse-history-event.js` at line 14, The current line
builds assetRoot by blindly concatenating assetBase() and "assets", which breaks
if assetBase() lacks a trailing slash; update the construction so you first
normalize the base returned by assetBase() to ensure it ends with a single '/'
(e.g., check assetBase() with endsWith('/') and append '/' if missing or strip
duplicate slashes) and then append "assets", replacing the use of assetBase()
directly in the template literal that defines assetRoot so assetRoot always
becomes a valid URL/string regardless of assetBase() formatting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/admin/app/helpers/parse-history-event.js`:
- Line 14: The current line builds assetRoot by blindly concatenating
assetBase() and "assets", which breaks if assetBase() lacks a trailing slash;
update the construction so you first normalize the base returned by assetBase()
to ensure it ends with a single '/' (e.g., check assetBase() with endsWith('/')
and append '/' if missing or strip duplicate slashes) and then append "assets",
replacing the use of assetBase() directly in the template literal that defines
assetRoot so assetRoot always becomes a valid URL/string regardless of
assetBase() formatting.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 542240d and 78029fa.

📒 Files selected for processing (8)
  • apps/admin/vite-ember-assets.ts
  • ghost/admin/app/components/admin-x/admin-x-component.js
  • ghost/admin/app/helpers/parse-history-event.js
  • ghost/admin/app/models/user.js
  • ghost/admin/app/services/lazy-loader.js
  • ghost/admin/app/utils/asset-base.js
  • ghost/admin/app/utils/fetch-koenig-lexical.js
  • ghost/admin/config/environment.js
💤 Files with no reviewable changes (1)
  • ghost/admin/config/environment.js
🚧 Files skipped from review as they are similar to previous changes (5)
  • ghost/admin/app/services/lazy-loader.js
  • ghost/admin/app/models/user.js
  • ghost/admin/app/utils/fetch-koenig-lexical.js
  • ghost/admin/app/utils/asset-base.js
  • ghost/admin/app/components/admin-x/admin-x-component.js

Copy link
Member

@jonatansberg jonatansberg left a comment

Choose a reason for hiding this comment

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

Overall implementation looks reasonable. My main concerns would be around how this will interact with the existing Ghost Moya flows that try to do path replace and our release flows outside of the new CD setup.

It should probably be fine, but it would be nice to double check that before we merge this and it goes out into production for everyone.

@rob-ghost rob-ghost force-pushed the feat/ghost-cdn-url-meta-tag branch from 78029fa to 6b1f371 Compare March 2, 2026 16:58
Copy link
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)
ghost/admin/tests/unit/utils/asset-base-test.js (1)

53-80: Consider one more cache-busting variant test (?v= / #...).

This is optional, but adding one URL-with-query/hash case would harden regression coverage for rewritten script URLs.

Optional test addition
 describe('script detection', function () {
+    it('extracts base when script URL contains query/hash', function () {
+        const doc = docWithScript('http://localhost:2368/ghost/assets/ghost-abc123.js?v=1#entry');
+        const result = resolveAssetBase(doc);
+
+        expect(result).to.equal('http://localhost:2368/ghost/');
+    });
+
     it('extracts base from a non-fingerprinted script (ghost.js)', function () {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/admin/tests/unit/utils/asset-base-test.js` around lines 53 - 80, Add a
test in asset-base-test.js to cover cache-busting URL variants by calling
resolveAssetBase with a document created via docWithScript that includes a
script src containing a query string and/or hash (e.g.,
'.../ghost-abc123.js?v=1#hash') and asserting the returned base still produces a
valid absolute URL usable with new URL() (same pattern as the existing
"script-derived result works with new URL()" test); reference resolveAssetBase
and docWithScript to locate where to add the new it(...) case and mirror the
expectations used for koenigUrl.href or the new URL() no-throw assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/admin/tests/unit/utils/asset-base-test.js`:
- Around line 53-80: Add a test in asset-base-test.js to cover cache-busting URL
variants by calling resolveAssetBase with a document created via docWithScript
that includes a script src containing a query string and/or hash (e.g.,
'.../ghost-abc123.js?v=1#hash') and asserting the returned base still produces a
valid absolute URL usable with new URL() (same pattern as the existing
"script-derived result works with new URL()" test); reference resolveAssetBase
and docWithScript to locate where to add the new it(...) case and mirror the
expectations used for koenigUrl.href or the new URL() no-throw assertion.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 78029fa and 6b1f371.

📒 Files selected for processing (9)
  • apps/admin/vite-ember-assets.ts
  • ghost/admin/app/components/admin-x/admin-x-component.js
  • ghost/admin/app/helpers/parse-history-event.js
  • ghost/admin/app/models/user.js
  • ghost/admin/app/services/lazy-loader.js
  • ghost/admin/app/utils/asset-base.js
  • ghost/admin/app/utils/fetch-koenig-lexical.js
  • ghost/admin/config/environment.js
  • ghost/admin/tests/unit/utils/asset-base-test.js
💤 Files with no reviewable changes (1)
  • ghost/admin/config/environment.js
🚧 Files skipped from review as they are similar to previous changes (6)
  • ghost/admin/app/utils/fetch-koenig-lexical.js
  • ghost/admin/app/helpers/parse-history-event.js
  • ghost/admin/app/components/admin-x/admin-x-component.js
  • ghost/admin/app/services/lazy-loader.js
  • ghost/admin/app/utils/asset-base.js
  • apps/admin/vite-ember-assets.ts

ref https://linear.app/ghost/issue/ENG-1326/

Ember's dynamic asset loading (lazy-loader, admin-x components, Koenig
editor, user avatars) previously relied on a build-time `cdnUrl` config
to construct absolute CDN URLs. This required piping GHOST_CDN_URL
through environment.js into an encoded meta tag.

Instead, derive the asset base at runtime from the Ember script's own
URL — the same principle ES modules use for relative imports. If the
script loaded from a CDN (via index.html sed rewrite), dynamic loads
inherit the CDN origin. If local, they inherit the local path.

New `assetBase()` utility replaces 6 duplicated cdnUrl conditionals.
Also fixes the vite-ember-assets build to read from ghost/admin/dist.
@rob-ghost rob-ghost force-pushed the feat/ghost-cdn-url-meta-tag branch from 6b1f371 to dd70141 Compare March 2, 2026 17:04
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.

2 participants