diff --git a/.changeset/two-beans-dress.md b/.changeset/two-beans-dress.md new file mode 100644 index 000000000000..7865e80e8a75 --- /dev/null +++ b/.changeset/two-beans-dress.md @@ -0,0 +1,8 @@ +--- +'astro': minor +'@astrojs/image': patch +--- + +Add `build.assetsPrefix` option for CDN support. If set, all Astro-generated asset links will be prefixed with it. For example, setting it to `https://cdn.example.com` would generate `https://cdn.example.com/_astro/penguin.123456.png` links. + +Also adds `import.meta.env.ASSETS_PREFIX` environment variable that can be used to manually create asset links not handled by Astro. diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index e9ac3bc23dfa..c1f80665fa64 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -624,6 +624,29 @@ export interface AstroUserConfig { * ``` */ assets?: string; + /** + * @docs + * @name build.assetsPrefix + * @type {string} + * @default `undefined` + * @version 2.2.0 + * @description + * Specifies the prefix for Astro-generated asset links. This can be used if assets are served from a different domain than the current site. + * + * For example, if this is set to `https://cdn.example.com`, assets will be fetched from `https://cdn.example.com/_astro/...` (regardless of the `base` option). + * You would need to upload the files in `./dist/_astro/` to `https://cdn.example.com/_astro/` to serve the assets. + * The process varies depending on how the third-party domain is hosted. + * To rename the `_astro` path, specify a new directory in `build.assets`. + * + * ```js + * { + * build: { + * assetsPrefix: 'https://cdn.example.com' + * } + * } + * ``` + */ + assetsPrefix?: string; /** * @docs * @name build.serverEntry diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 3512303f3f6a..828dcf4e60b9 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -8,7 +8,11 @@ import type * as vite from 'vite'; import { normalizePath } from 'vite'; import type { AstroPluginOptions, ImageTransform } from '../@types/astro'; import { error } from '../core/logger/core.js'; -import { joinPaths, prependForwardSlash } from '../core/path.js'; +import { + appendForwardSlash, + joinPaths, + prependForwardSlash, +} from '../core/path.js'; import { VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js'; import { isESMImportedImage } from './internal.js'; import { isLocalService } from './services/service.js'; @@ -174,7 +178,11 @@ export default function assets({ globalThis.astroAsset.staticImages.set(hash, { path: filePath, options: options }); } - return prependForwardSlash(joinPaths(settings.config.base, filePath)); + if (settings.config.build.assetsPrefix) { + return joinPaths(settings.config.build.assetsPrefix, filePath); + } else { + return prependForwardSlash(joinPaths(settings.config.base, filePath)); + } }; }, async buildEnd() { @@ -202,7 +210,10 @@ export default function assets({ const [full, hash, postfix = ''] = match; const file = this.getFileName(hash); - const outputFilepath = normalizePath(resolvedConfig.base + file + postfix); + const prefix = settings.config.build.assetsPrefix + ? appendForwardSlash(settings.config.build.assetsPrefix) + : resolvedConfig.base; + const outputFilepath = prefix + normalizePath(file + postfix); s.overwrite(match.index, match.index + full.length, outputFilepath); } diff --git a/packages/astro/src/content/vite-plugin-content-assets.ts b/packages/astro/src/content/vite-plugin-content-assets.ts index 6d5e6a31a2e1..d0695f158661 100644 --- a/packages/astro/src/content/vite-plugin-content-assets.ts +++ b/packages/astro/src/content/vite-plugin-content-assets.ts @@ -8,7 +8,7 @@ import type { AstroBuildPlugin } from '../core/build/plugin.js'; import type { StaticBuildOptions } from '../core/build/types'; import type { ModuleLoader } from '../core/module-loader/loader.js'; import { createViteLoader } from '../core/module-loader/vite.js'; -import { prependForwardSlash } from '../core/path.js'; +import { joinPaths, prependForwardSlash } from '../core/path.js'; import { getStylesForURL } from '../core/render/dev/css.js'; import { getScriptsForURL } from '../core/render/dev/scripts.js'; import { @@ -71,7 +71,11 @@ export function astroContentAssetPropagationPlugin({ 'development' ); - const hoistedScripts = await getScriptsForURL(pathToFileURL(basePath), devModuleLoader); + const hoistedScripts = await getScriptsForURL( + pathToFileURL(basePath), + settings.config.root, + devModuleLoader + ); return { code: code @@ -106,8 +110,13 @@ export function astroConfigBuildPlugin( }, 'build:post': ({ ssrOutputs, clientOutputs, mutate }) => { const outputs = ssrOutputs.flatMap((o) => o.output); - const prependBase = (src: string) => - prependForwardSlash(npath.posix.join(options.settings.config.base, src)); + const prependBase = (src: string) => { + if (options.settings.config.build.assetsPrefix) { + return joinPaths(options.settings.config.build.assetsPrefix, src); + } else { + return prependForwardSlash(joinPaths(options.settings.config.base, src)); + } + }; for (const chunk of outputs) { if ( chunk.type === 'chunk' && diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index a89db40ad136..e78c75bb7730 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -32,7 +32,11 @@ import { AstroError } from '../errors/index.js'; import { debug, info } from '../logger/core.js'; import { createEnvironment, createRenderContext, renderPage } from '../render/index.js'; import { callGetStaticPaths } from '../render/route-cache.js'; -import { createLinkStylesheetElementSet, createModuleScriptsSet } from '../render/ssr-element.js'; +import { + createAssetLink, + createLinkStylesheetElementSet, + createModuleScriptsSet, +} from '../render/ssr-element.js'; import { createRequest } from '../request.js'; import { matchRoute } from '../routing/match.js'; import { getOutputFilename } from '../util.js'; @@ -351,10 +355,15 @@ async function generatePath( debug('build', `Generating: ${pathname}`); - const links = createLinkStylesheetElementSet(linkIds, settings.config.base); + const links = createLinkStylesheetElementSet( + linkIds, + settings.config.base, + settings.config.build.assetsPrefix + ); const scripts = createModuleScriptsSet( hoistedScripts ? [hoistedScripts] : [], - settings.config.base + settings.config.base, + settings.config.build.assetsPrefix ); if (settings.scripts.some((script) => script.stage === 'page')) { @@ -362,7 +371,11 @@ async function generatePath( if (typeof hashedFilePath !== 'string') { throw new Error(`Cannot find the built path for ${PAGE_SCRIPT_ID}`); } - const src = prependForwardSlash(npath.posix.join(settings.config.base, hashedFilePath)); + const src = createAssetLink( + hashedFilePath, + settings.config.base, + settings.config.build.assetsPrefix + ); scripts.add({ props: { type: 'module', src }, children: '', @@ -403,7 +416,11 @@ async function generatePath( } throw new Error(`Cannot find the built path for ${specifier}`); } - return prependForwardSlash(npath.posix.join(settings.config.base, hashedFilePath)); + return createAssetLink( + hashedFilePath, + settings.config.base, + settings.config.build.assetsPrefix + ); }, routeCache, site: settings.config.site diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts index 78a1217e045c..d3776cd51800 100644 --- a/packages/astro/src/core/build/plugins/plugin-ssr.ts +++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from 'url'; import { runHookBuildSsr } from '../../../integrations/index.js'; import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; import { pagesVirtualModuleId } from '../../app/index.js'; -import { removeLeadingForwardSlash, removeTrailingForwardSlash } from '../../path.js'; +import { joinPaths, prependForwardSlash } from '../../path.js'; import { serializeRouteData } from '../../routing/index.js'; import { addRollupInput } from '../add-rollup-input.js'; import { getOutFile, getOutFolder } from '../common.js'; @@ -134,8 +134,13 @@ function buildManifest( staticFiles.push(entryModules[PAGE_SCRIPT_ID]); } - const bareBase = removeTrailingForwardSlash(removeLeadingForwardSlash(settings.config.base)); - const joinBase = (pth: string) => (bareBase ? bareBase + '/' + pth : pth); + const prefixAssetPath = (pth: string) => { + if (settings.config.build.assetsPrefix) { + return joinPaths(settings.config.build.assetsPrefix, pth); + } else { + return prependForwardSlash(joinPaths(settings.config.base, pth)); + } + }; for (const pageData of eachPrerenderedPageData(internals)) { if (!pageData.route.pathname) continue; @@ -165,7 +170,7 @@ function buildManifest( const scripts: SerializedRouteInfo['scripts'] = []; if (pageData.hoistedScript) { const hoistedValue = pageData.hoistedScript.value; - const value = hoistedValue.endsWith('.js') ? joinBase(hoistedValue) : hoistedValue; + const value = hoistedValue.endsWith('.js') ? prefixAssetPath(hoistedValue) : hoistedValue; scripts.unshift( Object.assign({}, pageData.hoistedScript, { value, @@ -177,11 +182,11 @@ function buildManifest( scripts.push({ type: 'external', - value: joinBase(src), + value: prefixAssetPath(src), }); } - const links = sortedCSS(pageData).map((pth) => joinBase(pth)); + const links = sortedCSS(pageData).map((pth) => prefixAssetPath(pth)); routes.push({ file: '', @@ -212,7 +217,7 @@ function buildManifest( componentMetadata: Array.from(internals.componentMetadata), renderers: [], entryModules, - assets: staticFiles.map((s) => settings.config.base + s), + assets: staticFiles.map(prefixAssetPath), }; return ssrManifest; diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 5b374ce33ec3..1ba6fd829cde 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -97,6 +97,7 @@ export const AstroConfigSchema = z.object({ .default(ASTRO_CONFIG_DEFAULTS.build.server) .transform((val) => new URL(val)), assets: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.assets), + assetsPrefix: z.string().optional(), serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry), }) .optional() @@ -222,6 +223,7 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: URL) { .default(ASTRO_CONFIG_DEFAULTS.build.server) .transform((val) => new URL(val, fileProtocolRoot)), assets: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.assets), + assetsPrefix: z.string().optional(), serverEntry: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.build.serverEntry), }) .optional() diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 54a8ce016fd9..e658a0c76be3 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -26,6 +26,7 @@ import astroScannerPlugin from '../vite-plugin-scanner/index.js'; import astroScriptsPlugin from '../vite-plugin-scripts/index.js'; import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js'; import { vitePluginSSRManifest } from '../vite-plugin-ssr-manifest/index.js'; +import { joinPaths } from './path.js'; interface CreateViteOptions { settings: AstroSettings; @@ -174,6 +175,20 @@ export async function createVite( }, }; + // If the user provides a custom assets prefix, make sure assets handled by Vite + // are prefixed with it too. This uses one of it's experimental features, but it + // has been stable for a long time now. + const assetsPrefix = settings.config.build.assetsPrefix; + if (assetsPrefix) { + commonConfig.experimental = { + renderBuiltUrl(filename, { type }) { + if (type === 'asset') { + return joinPaths(assetsPrefix, filename); + } + }, + }; + } + // Merge configs: we merge vite configuration objects together in the following order, // where future values will override previous values. // 1. common vite config diff --git a/packages/astro/src/core/dev/container.ts b/packages/astro/src/core/dev/container.ts index 161a3bdf61f7..cdc52c7e19ca 100644 --- a/packages/astro/src/core/dev/container.ts +++ b/packages/astro/src/core/dev/container.ts @@ -86,11 +86,6 @@ export async function createContainer(params: CreateContainerParams = {}): Promi optimizeDeps: { include: rendererClientEntries, }, - define: { - 'import.meta.env.BASE_URL': settings.config.base - ? JSON.stringify(settings.config.base) - : 'undefined', - }, }, { settings, logging, mode: 'dev', command: 'dev', fs } ); diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts index 8d26f2e3f5a5..51920e8003a7 100644 --- a/packages/astro/src/core/render/dev/index.ts +++ b/packages/astro/src/core/render/dev/index.ts @@ -76,7 +76,7 @@ interface GetScriptsAndStylesParams { async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams) { // Add hoisted script tags - const scripts = await getScriptsForURL(filePath, env.loader); + const scripts = await getScriptsForURL(filePath, env.settings.config.root, env.loader); // Inject HMR scripts if (isPage(filePath, env.settings) && env.mode === 'development') { diff --git a/packages/astro/src/core/render/dev/scripts.ts b/packages/astro/src/core/render/dev/scripts.ts index 14f8616eed9c..186332ab2ae8 100644 --- a/packages/astro/src/core/render/dev/scripts.ts +++ b/packages/astro/src/core/render/dev/scripts.ts @@ -2,30 +2,31 @@ import type { SSRElement } from '../../../@types/astro'; import type { PluginMetadata as AstroPluginMetadata } from '../../../vite-plugin-astro/types'; import type { ModuleInfo, ModuleLoader } from '../../module-loader/index'; -import { viteID } from '../../util.js'; +import { rootRelativePath, viteID } from '../../util.js'; import { createModuleScriptElementWithSrc } from '../ssr-element.js'; import { crawlGraph } from './vite.js'; export async function getScriptsForURL( filePath: URL, + root: URL, loader: ModuleLoader ): Promise> { const elements = new Set(); const rootID = viteID(filePath); const modInfo = loader.getModuleInfo(rootID); - addHoistedScripts(elements, modInfo); + addHoistedScripts(elements, modInfo, root); for await (const moduleNode of crawlGraph(loader, rootID, true)) { const id = moduleNode.id; if (id) { const info = loader.getModuleInfo(id); - addHoistedScripts(elements, info); + addHoistedScripts(elements, info, root); } } return elements; } -function addHoistedScripts(set: Set, info: ModuleInfo | null) { +function addHoistedScripts(set: Set, info: ModuleInfo | null, root: URL) { if (!info?.meta?.astro) { return; } @@ -33,7 +34,8 @@ function addHoistedScripts(set: Set, info: ModuleInfo | null) { let id = info.id; const astro = info?.meta?.astro as AstroPluginMetadata['astro']; for (let i = 0; i < astro.scripts.length; i++) { - const scriptId = `${id}?astro&type=script&index=${i}&lang.ts`; + let scriptId = `${id}?astro&type=script&index=${i}&lang.ts`; + scriptId = rootRelativePath(root, scriptId); const element = createModuleScriptElementWithSrc(scriptId); set.add(element); } diff --git a/packages/astro/src/core/render/ssr-element.ts b/packages/astro/src/core/render/ssr-element.ts index 36fbca9e8572..2ebcf7bb85dd 100644 --- a/packages/astro/src/core/render/ssr-element.ts +++ b/packages/astro/src/core/render/ssr-element.ts @@ -1,37 +1,48 @@ import slashify from 'slash'; import type { SSRElement } from '../../@types/astro'; -import { appendForwardSlash, removeLeadingForwardSlash } from '../../core/path.js'; +import { joinPaths, prependForwardSlash } from '../../core/path.js'; -function getRootPath(base?: string): string { - return appendForwardSlash(new URL(base || '/', 'http://localhost/').pathname); -} - -function joinToRoot(href: string, base?: string): string { - const rootPath = getRootPath(base); - const normalizedHref = slashify(href); - return appendForwardSlash(rootPath) + removeLeadingForwardSlash(normalizedHref); +export function createAssetLink(href: string, base?: string, assetsPrefix?: string): string { + if (assetsPrefix) { + return joinPaths(assetsPrefix, slashify(href)); + } else if (base) { + return prependForwardSlash(joinPaths(base, slashify(href))); + } else { + return href; + } } -export function createLinkStylesheetElement(href: string, base?: string): SSRElement { +export function createLinkStylesheetElement( + href: string, + base?: string, + assetsPrefix?: string +): SSRElement { return { props: { rel: 'stylesheet', - href: joinToRoot(href, base), + href: createAssetLink(href, base, assetsPrefix), }, children: '', }; } -export function createLinkStylesheetElementSet(hrefs: string[], base?: string) { - return new Set(hrefs.map((href) => createLinkStylesheetElement(href, base))); +export function createLinkStylesheetElementSet( + hrefs: string[], + base?: string, + assetsPrefix?: string +) { + return new Set( + hrefs.map((href) => createLinkStylesheetElement(href, base, assetsPrefix)) + ); } export function createModuleScriptElement( script: { type: 'inline' | 'external'; value: string }, - base?: string + base?: string, + assetsPrefix?: string ): SSRElement { if (script.type === 'external') { - return createModuleScriptElementWithSrc(script.value, base); + return createModuleScriptElementWithSrc(script.value, base, assetsPrefix); } else { return { props: { @@ -42,11 +53,15 @@ export function createModuleScriptElement( } } -export function createModuleScriptElementWithSrc(src: string, site?: string): SSRElement { +export function createModuleScriptElementWithSrc( + src: string, + base?: string, + assetsPrefix?: string +): SSRElement { return { props: { type: 'module', - src: joinToRoot(src, site), + src: createAssetLink(src, base, assetsPrefix), }, children: '', }; @@ -54,14 +69,20 @@ export function createModuleScriptElementWithSrc(src: string, site?: string): SS export function createModuleScriptElementWithSrcSet( srces: string[], - site?: string + site?: string, + assetsPrefix?: string ): Set { - return new Set(srces.map((src) => createModuleScriptElementWithSrc(src, site))); + return new Set( + srces.map((src) => createModuleScriptElementWithSrc(src, site, assetsPrefix)) + ); } export function createModuleScriptsSet( scripts: { type: 'inline' | 'external'; value: string }[], - base?: string + base?: string, + assetsPrefix?: string ): Set { - return new Set(scripts.map((script) => createModuleScriptElement(script, base))); + return new Set( + scripts.map((script) => createModuleScriptElement(script, base, assetsPrefix)) + ); } diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts index 51abe62c1eda..ddc20bff57b7 100644 --- a/packages/astro/src/core/util.ts +++ b/packages/astro/src/core/util.ts @@ -151,14 +151,14 @@ export function relativeToSrcDir(config: AstroConfig, idOrUrl: URL | string) { return id.slice(slash(fileURLToPath(config.srcDir)).length); } -export function rootRelativePath(config: AstroConfig, idOrUrl: URL | string) { +export function rootRelativePath(root: URL, idOrUrl: URL | string) { let id: string; if (typeof idOrUrl !== 'string') { id = unwrapId(viteID(idOrUrl)); } else { id = idOrUrl; } - return prependForwardSlash(id.slice(normalizePath(fileURLToPath(config.root)).length)); + return prependForwardSlash(id.slice(normalizePath(fileURLToPath(root)).length)); } export function emoji(char: string, fallback: string) { diff --git a/packages/astro/src/vite-plugin-env/index.ts b/packages/astro/src/vite-plugin-env/index.ts index 2540299fa338..ab881600052e 100644 --- a/packages/astro/src/vite-plugin-env/index.ts +++ b/packages/astro/src/vite-plugin-env/index.ts @@ -40,6 +40,9 @@ function getPrivateEnv( privateEnv.SITE = astroConfig.site ? JSON.stringify(astroConfig.site) : 'undefined'; privateEnv.SSR = JSON.stringify(true); privateEnv.BASE_URL = astroConfig.base ? JSON.stringify(astroConfig.base) : 'undefined'; + privateEnv.ASSETS_PREFIX = astroConfig.build.assetsPrefix + ? JSON.stringify(astroConfig.build.assetsPrefix) + : 'undefined'; return privateEnv; } @@ -60,6 +63,18 @@ export default function envVitePlugin({ settings }: EnvPluginOptions): vite.Plug return { name: 'astro:vite-plugin-env', enforce: 'pre', + config() { + return { + define: { + 'import.meta.env.BASE_URL': astroConfig.base + ? JSON.stringify(astroConfig.base) + : 'undefined', + 'import.meta.env.ASSETS_PREFIX': astroConfig.build.assetsPrefix + ? JSON.stringify(astroConfig.build.assetsPrefix) + : 'undefined', + }, + }; + }, configResolved(resolvedConfig) { viteConfig = resolvedConfig; }, diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index 1afeca8f7673..c3f08f2c4e15 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -128,7 +128,7 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu (entry) => `'${entry.raw}': await getImageSafely((await import("${entry.raw}")).default, "${ entry.raw - }", "${rootRelativePath(settings.config, entry.resolved)}")` + }", "${rootRelativePath(settings.config.root, entry.resolved)}")` )} } diff --git a/packages/astro/test/astro-assets-prefix.test.js b/packages/astro/test/astro-assets-prefix.test.js new file mode 100644 index 000000000000..7cbfe274cfc8 --- /dev/null +++ b/packages/astro/test/astro-assets-prefix.test.js @@ -0,0 +1,110 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import testAdapter from './test-adapter.js'; +import { loadFixture } from './test-utils.js'; + +const assetsPrefix = 'http://localhost:4321'; +const assetsPrefixRegex = /^http:\/\/localhost:4321\/_astro\/.*/; + +// Asset prefix for CDN support +describe('Assets Prefix - Static', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-assets-prefix/', + }); + await fixture.build(); + }); + + it('all stylesheets should start with assetPrefix', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + const stylesheets = $('link[rel="stylesheet"]'); + stylesheets.each((i, el) => { + expect(el.attribs.href).to.match(assetsPrefixRegex); + }); + }); + + it('image src start with assetsPrefix', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + const imgAsset = $('#image-asset'); + expect(imgAsset.attr('src')).to.match(assetsPrefixRegex); + }); + + it('react component astro-island should import from assetsPrefix', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + const island = $('astro-island'); + expect(island.attr('component-url')).to.match(assetsPrefixRegex); + expect(island.attr('renderer-url')).to.match(assetsPrefixRegex); + }); + + it('import.meta.env.ASSETS_PREFIX works', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + const env = $('#assets-prefix-env'); + expect(env.text()).to.equal(assetsPrefix); + }); + + it('markdown image src start with assetsPrefix', async () => { + const html = await fixture.readFile('/markdown/index.html'); + const $ = cheerio.load(html); + const imgAsset = $('img'); + expect(imgAsset.attr('src')).to.match(assetsPrefixRegex); + }); + + it('content collections image src start with assetsPrefix', async () => { + const html = await fixture.readFile('/blog/index.html'); + const $ = cheerio.load(html); + const imgAsset = $('img'); + expect(imgAsset.attr('src')).to.match(assetsPrefixRegex); + }); +}); + +describe('Assets Prefix - Server', () => { + let app; + + before(async () => { + const fixture = await loadFixture({ + root: './fixtures/astro-assets-prefix/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('all stylesheets should start with assetPrefix', async () => { + const request = new Request('http://example.com/custom-base/'); + const response = await app.render(request); + expect(response.status).to.equal(200); + const html = await response.text(); + const $ = cheerio.load(html); + const stylesheets = $('link[rel="stylesheet"]'); + stylesheets.each((i, el) => { + expect(el.attribs.href).to.match(assetsPrefixRegex); + }); + }); + + it('image src start with assetsPrefix', async () => { + const request = new Request('http://example.com/custom-base/'); + const response = await app.render(request); + expect(response.status).to.equal(200); + const html = await response.text(); + const $ = cheerio.load(html); + const imgAsset = $('#image-asset'); + expect(imgAsset.attr('src')).to.match(assetsPrefixRegex); + }); + + it('markdown image src start with assetsPrefix', async () => { + const request = new Request('http://example.com/custom-base/markdown/'); + const response = await app.render(request); + expect(response.status).to.equal(200); + const html = await response.text(); + const $ = cheerio.load(html); + const imgAsset = $('img'); + expect(imgAsset.attr('src')).to.match(assetsPrefixRegex); + }); +}); diff --git a/packages/astro/test/fixtures/astro-assets-prefix/astro.config.mjs b/packages/astro/test/fixtures/astro-assets-prefix/astro.config.mjs new file mode 100644 index 000000000000..869cf811b06d --- /dev/null +++ b/packages/astro/test/fixtures/astro-assets-prefix/astro.config.mjs @@ -0,0 +1,15 @@ +import { defineConfig } from 'astro/config'; +import react from '@astrojs/react' + +// https://astro.build/config +export default defineConfig({ + // test custom base to make sure things work + base: '/custom-base', + integrations: [react()], + build: { + assetsPrefix: 'http://localhost:4321' + }, + experimental: { + assets: true + } +}); diff --git a/packages/astro/test/fixtures/astro-assets-prefix/package.json b/packages/astro/test/fixtures/astro-assets-prefix/package.json new file mode 100644 index 000000000000..7aa9dd8ecb62 --- /dev/null +++ b/packages/astro/test/fixtures/astro-assets-prefix/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/astro-assets-prefix", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/react": "workspace:*", + "astro": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/packages/astro/test/fixtures/astro-assets-prefix/src/assets/penguin1.jpg b/packages/astro/test/fixtures/astro-assets-prefix/src/assets/penguin1.jpg new file mode 100644 index 000000000000..6c5dcd37a30a Binary files /dev/null and b/packages/astro/test/fixtures/astro-assets-prefix/src/assets/penguin1.jpg differ diff --git a/packages/astro/test/fixtures/astro-assets-prefix/src/components/Counter.jsx b/packages/astro/test/fixtures/astro-assets-prefix/src/components/Counter.jsx new file mode 100644 index 000000000000..56c220522605 --- /dev/null +++ b/packages/astro/test/fixtures/astro-assets-prefix/src/components/Counter.jsx @@ -0,0 +1,11 @@ +import React, { useState } from 'react'; + +export default function Counter() { + const [count, setCount] = useState(0); + return ( +
+
Count: {count}
+ +
+ ); +} diff --git a/packages/astro/test/fixtures/astro-assets-prefix/src/content/blog/my-post.md b/packages/astro/test/fixtures/astro-assets-prefix/src/content/blog/my-post.md new file mode 100644 index 000000000000..82c1bbb8622b --- /dev/null +++ b/packages/astro/test/fixtures/astro-assets-prefix/src/content/blog/my-post.md @@ -0,0 +1,6 @@ +--- +title: My Post +cover: ../../assets/penguin1.jpg +--- + +Hello world diff --git a/packages/astro/test/fixtures/astro-assets-prefix/src/content/config.ts b/packages/astro/test/fixtures/astro-assets-prefix/src/content/config.ts new file mode 100644 index 000000000000..d82c39786f0c --- /dev/null +++ b/packages/astro/test/fixtures/astro-assets-prefix/src/content/config.ts @@ -0,0 +1,12 @@ +import { defineCollection, z, image } from "astro:content"; + +const blogCollection = defineCollection({ + schema: z.object({ + title: z.string(), + cover: image(), + }), +}); + +export const collections = { + blog: blogCollection, +}; diff --git a/packages/astro/test/fixtures/astro-assets-prefix/src/pages/blog.astro b/packages/astro/test/fixtures/astro-assets-prefix/src/pages/blog.astro new file mode 100644 index 000000000000..13729ec0abbc --- /dev/null +++ b/packages/astro/test/fixtures/astro-assets-prefix/src/pages/blog.astro @@ -0,0 +1,16 @@ +--- +import { Image } from "astro:assets"; +import { getCollection } from "astro:content"; +const allBlogPosts = await getCollection("blog"); +--- + +{ + allBlogPosts.map((post) => ( +
+ cover +

+ {post.data.title} +

+
+ )) +} diff --git a/packages/astro/test/fixtures/astro-assets-prefix/src/pages/index.astro b/packages/astro/test/fixtures/astro-assets-prefix/src/pages/index.astro new file mode 100644 index 000000000000..67f6e97fa56c --- /dev/null +++ b/packages/astro/test/fixtures/astro-assets-prefix/src/pages/index.astro @@ -0,0 +1,23 @@ +--- +import { Image } from 'astro:assets' +import p1Image from '../assets/penguin1.jpg'; +import Counter from '../components/Counter.jsx'; +--- + + + + Assets Prefix + + +

I am red

+ penguin + penguin + +

{import.meta.env.ASSETS_PREFIX}

+ + + diff --git a/packages/astro/test/fixtures/astro-assets-prefix/src/pages/markdown.md b/packages/astro/test/fixtures/astro-assets-prefix/src/pages/markdown.md new file mode 100644 index 000000000000..20f623657f31 --- /dev/null +++ b/packages/astro/test/fixtures/astro-assets-prefix/src/pages/markdown.md @@ -0,0 +1,5 @@ +# Assets Prefix + +Relative image has assetsPrefix + +![Relative image](../assets/penguin1.jpg) diff --git a/packages/astro/test/space-in-folder-name.test.js b/packages/astro/test/space-in-folder-name.test.js index 2a59418ff2c0..402c797d5a04 100644 --- a/packages/astro/test/space-in-folder-name.test.js +++ b/packages/astro/test/space-in-folder-name.test.js @@ -27,7 +27,7 @@ describe('Projects with a space in the folder name', () => { const html = await fixture.fetch('/').then((r) => r.text()); const $ = cheerio.load(html); - expect($('script[src*="space in folder name"]')).to.have.a.lengthOf(1); + expect($('script[src*="/src/pages/index.astro"]')).to.have.a.lengthOf(1); }); }); }); diff --git a/packages/integrations/image/src/build/ssg.ts b/packages/integrations/image/src/build/ssg.ts index 41fc510d77d8..2a6976d76adc 100644 --- a/packages/integrations/image/src/build/ssg.ts +++ b/packages/integrations/image/src/build/ssg.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { SSRImageService, TransformOptions } from '../loaders/index.js'; import { debug, info, warn, type LoggerLevel } from '../utils/logger.js'; -import { isRemoteImage } from '../utils/paths.js'; +import { isRemoteImage, prependForwardSlash } from '../utils/paths.js'; import { ImageCache } from './cache.js'; async function loadLocalImage(src: string | URL) { @@ -135,10 +135,15 @@ export async function ssgBuild({ // tracks the cache duration for the original source image let expires = 0; - // Vite will prefix a hashed image with the base path, we need to strip this - // off to find the actual file relative to /dist - if (config.base && src.startsWith(config.base)) { - src = src.substring(config.base.length - +config.base.endsWith('/')); + // Strip leading assetsPrefix or base added by addStaticImage + if (config.build.assetsPrefix) { + if (src.startsWith(config.build.assetsPrefix)) { + src = prependForwardSlash(src.slice(config.build.assetsPrefix.length)); + } + } else if (config.base) { + if (src.startsWith(config.base)) { + src = prependForwardSlash(src.slice(config.base.length)); + } } if (isRemoteImage(src)) { diff --git a/packages/integrations/image/src/index.ts b/packages/integrations/image/src/index.ts index 0a101724e36e..671faad5c173 100644 --- a/packages/integrations/image/src/index.ts +++ b/packages/integrations/image/src/index.ts @@ -130,7 +130,11 @@ export default function integration(options: IntegrationOptions = {}): AstroInte // Doing this here makes sure that base is ignored when building // staticImages to /dist, but the rendered HTML will include the // base prefix for `src`. - return prependForwardSlash(joinPaths(_config.base, _buildConfig.assets, filename)); + if (_config.build.assetsPrefix) { + return joinPaths(_config.build.assetsPrefix, _buildConfig.assets, filename); + } else { + return prependForwardSlash(joinPaths(_config.base, _buildConfig.assets, filename)); + } } // Helpers for building static images should only be available for SSG diff --git a/packages/integrations/image/src/vite-plugin-astro-image.ts b/packages/integrations/image/src/vite-plugin-astro-image.ts index b721578a5992..bf5078fb30e1 100644 --- a/packages/integrations/image/src/vite-plugin-astro-image.ts +++ b/packages/integrations/image/src/vite-plugin-astro-image.ts @@ -9,6 +9,7 @@ import type { Plugin, ResolvedConfig } from 'vite'; import type { IntegrationOptions } from './index.js'; import type { InputFormat } from './loaders/index.js'; import { metadata } from './utils/metadata.js'; +import { appendForwardSlash } from './utils/paths.js'; export interface ImageMetadata { src: string; @@ -118,7 +119,10 @@ export function createPlugin(config: AstroConfig, options: Required { + fixture = await loadFixture({ root: './fixtures/assets-prefix/' }); + await fixture.build(); + }); + + it('images src has assets prefix', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + const img = $('#social-jpg'); + expect(img.attr('src')).to.match(assetsPrefixRegex); + }); +}); diff --git a/packages/integrations/image/test/fixtures/assets-prefix/astro.config.mjs b/packages/integrations/image/test/fixtures/assets-prefix/astro.config.mjs new file mode 100644 index 000000000000..e5a629ed0164 --- /dev/null +++ b/packages/integrations/image/test/fixtures/assets-prefix/astro.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from 'astro/config'; +import image from '@astrojs/image'; + +// https://astro.build/config +export default defineConfig({ + integrations: [image()], + build: { + assetsPrefix: 'http://localhost:4321', + } +}); diff --git a/packages/integrations/image/test/fixtures/assets-prefix/package.json b/packages/integrations/image/test/fixtures/assets-prefix/package.json new file mode 100644 index 000000000000..a72317c84d12 --- /dev/null +++ b/packages/integrations/image/test/fixtures/assets-prefix/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/image-assets-prefix", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/image": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/image/test/fixtures/assets-prefix/src/assets/social.png b/packages/integrations/image/test/fixtures/assets-prefix/src/assets/social.png new file mode 100644 index 000000000000..1399856f1f31 Binary files /dev/null and b/packages/integrations/image/test/fixtures/assets-prefix/src/assets/social.png differ diff --git a/packages/integrations/image/test/fixtures/assets-prefix/src/pages/index.astro b/packages/integrations/image/test/fixtures/assets-prefix/src/pages/index.astro new file mode 100644 index 000000000000..b66a202be055 --- /dev/null +++ b/packages/integrations/image/test/fixtures/assets-prefix/src/pages/index.astro @@ -0,0 +1,13 @@ +--- +import socialJpg from '../assets/social.png'; +import { Image } from '@astrojs/image/components'; +--- + + + + + + + social-jpg + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 163ddc2fd59e..02cf70e70ec0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1301,6 +1301,18 @@ importers: dependencies: astro: link:../../.. + packages/astro/test/fixtures/astro-assets-prefix: + specifiers: + '@astrojs/react': workspace:* + astro: workspace:* + react: ^18.2.0 + react-dom: ^18.2.0 + dependencies: + '@astrojs/react': link:../../../../integrations/react + astro: link:../../.. + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + packages/astro/test/fixtures/astro-attrs: specifiers: '@astrojs/react': workspace:* @@ -2958,6 +2970,14 @@ importers: sharp: 0.31.3 vite: 4.1.2 + packages/integrations/image/test/fixtures/assets-prefix: + specifiers: + '@astrojs/image': workspace:* + astro: workspace:* + dependencies: + '@astrojs/image': link:../../.. + astro: link:../../../../../astro + packages/integrations/image/test/fixtures/background-color-image: specifiers: '@astrojs/image': workspace:*