diff --git a/apps/admin/vite-ember-assets.ts b/apps/admin/vite-ember-assets.ts
index 0341c0ac7c3..0be1f079802 100644
--- a/apps/admin/vite-ember-assets.ts
+++ b/apps/admin/vite-ember-assets.ts
@@ -4,6 +4,7 @@ import fs from 'fs';
import sirv from 'sirv';
const GHOST_ADMIN_PATH = path.resolve(__dirname, '../../ghost/core/core/built/admin');
+const GHOST_ADMIN_DIST = path.resolve(__dirname, '../../ghost/admin/dist');
function isAbsoluteUrl(url: string): boolean {
return url.startsWith('http://') ||
@@ -29,12 +30,14 @@ export function emberAssetsPlugin() {
transformIndexHtml: {
order: 'post',
handler() {
- // Path to the Ghost admin index.html file
- const indexPath = path.resolve(GHOST_ADMIN_PATH, 'index.html');
+ // Read from Ember's own build output (not the combined output
+ // in built/admin which gets overwritten by closeBundle and would
+ // accumulate duplicate path prefixes on repeated builds)
+ const indexPath = path.resolve(GHOST_ADMIN_DIST, 'index.html');
try {
const indexContent = fs.readFileSync(indexPath, 'utf-8');
const base = config.base || '/';
-
+
// Extract stylesheets
const styleRegex = /]*rel="stylesheet"[^>]*href="([^"]*)"[^>]*>/g;
const styles: HtmlTagDescriptor[] = [];
diff --git a/ghost/admin/app/components/admin-x/admin-x-component.js b/ghost/admin/app/components/admin-x/admin-x-component.js
index 6b87ea698a9..407252d82e8 100644
--- a/ghost/admin/app/components/admin-x/admin-x-component.js
+++ b/ghost/admin/app/components/admin-x/admin-x-component.js
@@ -1,6 +1,7 @@
import * as Sentry from '@sentry/ember';
import Component from '@glimmer/component';
import React, {Suspense} from 'react';
+import assetBase from 'ghost-admin/utils/asset-base';
import config from 'ghost-admin/config/environment';
import fetch from 'fetch';
import fetchKoenigLexical from 'ghost-admin/utils/fetch-koenig-lexical';
@@ -57,7 +58,7 @@ export const importComponent = async (packageName) => {
throw new Error(`Missing config for ${packageName}. Add it in asset delivery.`);
}
- const baseUrl = (config.cdnUrl ? `${config.cdnUrl}assets/` : ghostPaths().assetRootWithHost);
+ const baseUrl = `${assetBase()}assets/`;
let url = new URL(`${baseUrl}${relativePath}/${config[`${configKey}Filename`]}?v=${config[`${configKey}Hash`]}`);
const customUrl = config[`${configKey}CustomUrl`];
diff --git a/ghost/admin/app/helpers/parse-history-event.js b/ghost/admin/app/helpers/parse-history-event.js
index 47b43b06d4c..2db3dc91f6b 100644
--- a/ghost/admin/app/helpers/parse-history-event.js
+++ b/ghost/admin/app/helpers/parse-history-event.js
@@ -1,10 +1,7 @@
import Helper from '@ember/component/helper';
-import config from 'ghost-admin/config/environment';
-import {inject as service} from '@ember/service';
+import assetBase from 'ghost-admin/utils/asset-base';
export default class ParseHistoryEvent extends Helper {
- @service ghostPaths;
-
compute([ev]) {
const action = getAction(ev);
const actionIcon = getActionIcon(ev);
@@ -14,7 +11,7 @@ export default class ParseHistoryEvent extends Helper {
const actor = getActor(ev);
const actorLinkTarget = getActorLinkTarget(ev);
- const assetRoot = (config.cdnUrl ? '' : this.ghostPaths.assetRoot.replace(/\/$/, ''));
+ const assetRoot = `${assetBase()}assets`;
const actorIcon = getActorIcon(ev, assetRoot);
return {
diff --git a/ghost/admin/app/models/user.js b/ghost/admin/app/models/user.js
index 38599453dd4..a07186446fc 100644
--- a/ghost/admin/app/models/user.js
+++ b/ghost/admin/app/models/user.js
@@ -1,6 +1,6 @@
import BaseModel from './base';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
-import config from 'ghost-admin/config/environment';
+import assetBase from 'ghost-admin/utils/asset-base';
import {attr, hasMany} from '@ember-data/model';
import {computed} from '@ember/object';
import {equal, or} from '@ember/object/computed';
@@ -96,17 +96,17 @@ export default BaseModel.extend(ValidationEngine, {
}
}),
- profileImageUrl: computed('ghostPaths.assetRoot', 'profileImage', function () {
+ profileImageUrl: computed('profileImage', function () {
// keep path separate so asset rewriting correctly picks it up
let defaultImage = '/img/user-image.png';
- let defaultPath = (config.cdnUrl ? '' : this.ghostPaths.assetRoot.replace(/\/$/, '')) + defaultImage;
+ let defaultPath = `${assetBase()}assets${defaultImage}`;
return this.profileImage || defaultPath;
}),
- coverImageUrl: computed('ghostPaths.assetRoot', 'coverImage', function () {
+ coverImageUrl: computed('coverImage', function () {
// keep path separate so asset rewriting correctly picks it up
let defaultImage = '/img/user-cover.png';
- let defaultPath = (config.cdnUrl ? '' : this.ghostPaths.assetRoot.replace(/\/$/, '')) + defaultImage;
+ let defaultPath = `${assetBase()}assets${defaultImage}`;
return this.coverImage || defaultPath;
}),
diff --git a/ghost/admin/app/services/lazy-loader.js b/ghost/admin/app/services/lazy-loader.js
index 6a3a543df17..c2562df19f0 100644
--- a/ghost/admin/app/services/lazy-loader.js
+++ b/ghost/admin/app/services/lazy-loader.js
@@ -1,12 +1,12 @@
import RSVP from 'rsvp';
import Service, {inject as service} from '@ember/service';
+import assetBase from 'ghost-admin/utils/asset-base';
import classic from 'ember-classic-decorator';
import config from 'ghost-admin/config/environment';
@classic
export default class LazyLoaderService extends Service {
@service ajax;
- @service ghostPaths;
// This is needed so we can disable it in unit tests
testing = undefined;
@@ -35,7 +35,7 @@ export default class LazyLoaderService extends Service {
let script = document.createElement('script');
script.type = 'text/javascript';
script.async = true;
- script.src = `${config.cdnUrl ? '' : this.ghostPaths.adminRoot}${url}`;
+ script.src = `${assetBase()}${url}`;
let el = document.getElementsByTagName('script')[0];
el.parentNode.insertBefore(script, el);
@@ -63,7 +63,7 @@ export default class LazyLoaderService extends Service {
let link = document.createElement('link');
link.id = `${key}-styles`;
link.rel = alternate ? 'alternate stylesheet' : 'stylesheet';
- link.href = `${config.cdnUrl ? '' : this.ghostPaths.adminRoot}${url}`;
+ link.href = `${assetBase()}${url}`;
link.onload = () => {
link.onload = null;
if (alternate) {
diff --git a/ghost/admin/app/utils/asset-base.js b/ghost/admin/app/utils/asset-base.js
new file mode 100644
index 00000000000..db6f8b3b550
--- /dev/null
+++ b/ghost/admin/app/utils/asset-base.js
@@ -0,0 +1,50 @@
+import ghostPaths from 'ghost-admin/utils/ghost-paths';
+
+let _assetBase = null;
+
+/**
+ * Resolve the asset base URL from script elements in the given document root.
+ * Exported for direct testing — callers should use the default export instead.
+ *
+ * @param {Document} doc The document to search for script tags
+ * @returns {string} Absolute URL with trailing slash
+ */
+export function resolveAssetBase(doc) {
+ // Find the Ember app script — its src tells us where assets are served from.
+ // The browser always resolves script.src to an absolute URL.
+ // Matches both non-fingerprinted (ghost.js) and fingerprinted (ghost-{hash}.js).
+ const script = doc.querySelector('script[src*="assets/ghost"]');
+
+ if (script && script.src) {
+ try {
+ const url = new URL(script.src);
+ const assetsIdx = url.pathname.indexOf('/assets/');
+ if (assetsIdx > 0) {
+ return `${url.origin}${url.pathname.substring(0, assetsIdx)}/`;
+ }
+ } catch (e) {
+ // Fall through to ghostPaths
+ }
+ }
+
+ // Fallback: absolute URL from the current origin so new URL() never throws
+ return `${window.location.origin}${ghostPaths().adminRoot}`;
+}
+
+/**
+ * Derives the asset base URL from where the Ember scripts were loaded.
+ * If loaded from a CDN, returns the CDN base. If local, returns the admin root.
+ *
+ * Always returns an absolute URL (with origin and trailing slash) so callers
+ * can safely pass the result to `new URL()`. Examples:
+ * CDN: "https://assets.ghost.io/admin-forward/"
+ * Local: "http://localhost:2368/ghost/"
+ */
+export default function assetBase() {
+ if (_assetBase !== null) {
+ return _assetBase;
+ }
+
+ _assetBase = resolveAssetBase(document);
+ return _assetBase;
+}
diff --git a/ghost/admin/app/utils/fetch-koenig-lexical.js b/ghost/admin/app/utils/fetch-koenig-lexical.js
index 382824ef782..f4d13edf268 100644
--- a/ghost/admin/app/utils/fetch-koenig-lexical.js
+++ b/ghost/admin/app/utils/fetch-koenig-lexical.js
@@ -1,15 +1,12 @@
+import assetBase from 'ghost-admin/utils/asset-base';
import config from 'ghost-admin/config/environment';
-import ghostPaths from 'ghost-admin/utils/ghost-paths';
export default async function fetchKoenigLexical() {
if (window['@tryghost/koenig-lexical']) {
return window['@tryghost/koenig-lexical'];
}
- // If we pass an editor URL (the env var from the dev script), use that
- // Else, if we pass a CDN URL, use that
- // Else, use the asset root from the ghostPaths util
- const baseUrl = (config.editorUrl || (config.cdnUrl ? `${config.cdnUrl}assets/koenig-lexical/` : `${ghostPaths().assetRootWithHost}koenig-lexical/`));
+ const baseUrl = (config.editorUrl || `${assetBase()}assets/koenig-lexical/`);
const url = new URL(`${baseUrl}${config.editorFilename}?v=${config.editorHash}`);
if (url.protocol === 'http:') {
diff --git a/ghost/admin/config/environment.js b/ghost/admin/config/environment.js
index d77a92194fd..beb9bba2507 100644
--- a/ghost/admin/config/environment.js
+++ b/ghost/admin/config/environment.js
@@ -5,7 +5,6 @@ module.exports = function (environment) {
let ENV = {
modulePrefix: 'ghost-admin',
environment,
- cdnUrl: process.env.GHOST_CDN_URL || '',
editorUrl: process.env.EDITOR_URL || '',
rootURL: '',
locationType: 'trailing-hash',
diff --git a/ghost/admin/tests/integration/services/lazy-loader-test.js b/ghost/admin/tests/integration/services/lazy-loader-test.js
index 2117989ab29..da61432d418 100644
--- a/ghost/admin/tests/integration/services/lazy-loader-test.js
+++ b/ghost/admin/tests/integration/services/lazy-loader-test.js
@@ -1,4 +1,5 @@
import Pretender from 'pretender';
+import assetBase from 'ghost-admin/utils/asset-base';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupTest} from 'ember-mocha';
@@ -7,9 +8,6 @@ describe('Integration: Service: lazy-loader', function () {
setupTest();
let server;
- let ghostPaths = {
- adminRoot: '/assets/'
- };
beforeEach(function () {
server = new Pretender();
@@ -23,28 +21,29 @@ describe('Integration: Service: lazy-loader', function () {
let subject = this.owner.lookup('service:lazy-loader');
subject.setProperties({
- ghostPaths,
scriptPromises: {},
testing: false
});
+ const expectedSrc = `${assetBase()}lazy-test.js`;
+
// first load should add script element
await subject.loadScript('test', 'lazy-test.js')
.then(() => {})
.catch(() => {});
expect(
- document.querySelectorAll('script[src="/assets/lazy-test.js"]').length,
+ document.querySelectorAll(`script[src="${expectedSrc}"]`).length,
'no of script tags on first load'
).to.equal(1);
// second load should not add another script element
- await subject.loadScript('test', '/assets/lazy-test.js')
+ await subject.loadScript('test', 'lazy-test.js')
.then(() => { })
.catch(() => { });
expect(
- document.querySelectorAll('script[src="/assets/lazy-test.js"]').length,
+ document.querySelectorAll(`script[src="${expectedSrc}"]`).length,
'no of script tags on second load'
).to.equal(1);
});
@@ -53,14 +52,15 @@ describe('Integration: Service: lazy-loader', function () {
let subject = this.owner.lookup('service:lazy-loader');
subject.setProperties({
- ghostPaths,
testing: false
});
+ const expectedHref = `${assetBase()}style.css`;
+
return subject.loadStyle('testing', 'style.css').catch(() => {
- // we add a catch handler here because `/assets/style.css` doesn't exist
+ // we add a catch handler here because the style.css doesn't exist
expect(document.querySelectorAll('#testing-styles').length).to.equal(1);
- expect(document.querySelector('#testing-styles').getAttribute('href')).to.equal('/assets/style.css');
+ expect(document.querySelector('#testing-styles').getAttribute('href')).to.equal(expectedHref);
});
});
});
diff --git a/ghost/admin/tests/unit/utils/asset-base-test.js b/ghost/admin/tests/unit/utils/asset-base-test.js
new file mode 100644
index 00000000000..d63d1311756
--- /dev/null
+++ b/ghost/admin/tests/unit/utils/asset-base-test.js
@@ -0,0 +1,94 @@
+import {describe, it} from 'mocha';
+import {expect} from 'chai';
+import {resolveAssetBase} from 'ghost-admin/utils/asset-base';
+
+/**
+ * Create a minimal document fragment containing a