Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat: Pass remote Markdown images through image service #13254

Merged
merged 17 commits into from
Feb 26, 2025
7 changes: 7 additions & 0 deletions .changeset/quiet-birds-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astrojs/internal-helpers': minor
---

Adds remote URL filtering utilities

This adds logic to filter remote URLs so that it can be used by both `astro` and `@astrojs/markdown-remark`.
11 changes: 11 additions & 0 deletions .changeset/shy-bats-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'astro': minor
---

Adds the ability to process and optimize remote images in Markdown files

Previously, Astro only allowed local images to be optimized when included using `![]()` syntax in plain Markdown files. Astro's image service could only display remote images without any processing.

Now, Astro's image service can also optimize remote images written in standard Markdown syntax. This allows you to enjoy the benefits of Astro's image processing when your images are stored externally, for example in a CMS or digital asset manager.

No additional configuration is required to use this feature! Any existing remote images written in Markdown will now automatically be optimized. To opt-out of this processing, write your images in Markdown using the HTML `<img>` tag instead. Note that images located in your `public/` folder are still never processed.
11 changes: 11 additions & 0 deletions .changeset/tiny-cows-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@astrojs/mdx': minor
---

Adds the ability to process and optimize remote images in Markdown syntax in MDX files.

Previously, Astro only allowed local images to be optimized when included using `![]()` syntax. Astro's image service could only display remote images without any processing.

Now, Astro's image service can also optimize remote images written in standard Markdown syntax. This allows you to enjoy the benefits of Astro's image processing when your images are stored externally, for example in a CMS or digital asset manager.

No additional configuration is required to use this feature! Any existing remote images written in Markdown will now automatically be optimized. To opt-out of this processing, write your images in Markdown using the JSX `<img/>` tag instead. Note that images located in your `public/` folder are still never processed.
11 changes: 11 additions & 0 deletions .changeset/warm-planes-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@astrojs/markdown-remark': minor
---

Adds remote image optimization in Markdown

Previously, an internal remark plugin only looked for images in `![]()` syntax that referred to a relative file path. This meant that only local images stored in `src/` were passed through to an internal rehype plugin that would transform them for later processing by Astro's image service.

Now, the plugins recognize and transform both local and remote images using this syntax. Only [authorized remote images specified in your config](https://docs.astro.build/en/guides/images/#authorizing-remote-images) are transformed; remote images from other sources will not be processed.

While not configurable at this time, this process outputs two separate metadata fields (`localImagePaths` and `remoteImagePaths`) which allow for the possibility of controlling the behavior of each type of image separately in the future.
2 changes: 1 addition & 1 deletion packages/astro/src/assets/endpoint/generic.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// @ts-expect-error
import { imageConfig } from 'astro:assets';
import { isRemotePath } from '@astrojs/internal-helpers/path';
import { isRemoteAllowed } from '@astrojs/internal-helpers/remote';
import * as mime from 'mrmime';
import type { APIRoute } from '../../types/public/common.js';
import { getConfiguredImageService } from '../internal.js';
import { etag } from '../utils/etag.js';
import { isRemoteAllowed } from '../utils/remotePattern.js';

async function loadRemoteImage(src: URL, headers: Headers) {
try {
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/assets/endpoint/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
// @ts-expect-error
import { assetsDir, imageConfig, outDir } from 'astro:assets';
import { isRemotePath, removeQueryString } from '@astrojs/internal-helpers/path';
import { isRemoteAllowed } from '@astrojs/internal-helpers/remote';
import * as mime from 'mrmime';
import type { APIRoute } from '../../types/public/common.js';
import { getConfiguredImageService } from '../internal.js';
import { etag } from '../utils/etag.js';
import { isRemoteAllowed } from '../utils/remotePattern.js';

function replaceFileSystemReferences(src: string) {
return os.platform().includes('win32') ? src.replace(/^\/@fs\//, '') : src.replace(/^\/@fs/, '');
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/assets/services/service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isRemoteAllowed } from '@astrojs/internal-helpers/remote';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import { isRemotePath, joinPaths } from '../../core/path.js';
import type { AstroConfig } from '../../types/public/config.js';
Expand All @@ -9,7 +10,6 @@ import type {
UnresolvedSrcSetValue,
} from '../types.js';
import { isESMImportedImage, isRemoteImage } from '../utils/imageKind.js';
import { isRemoteAllowed } from '../utils/remotePattern.js';

export type ImageService = LocalImageService | ExternalImageService;

Expand Down
9 changes: 0 additions & 9 deletions packages/astro/src/assets/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,6 @@ export { emitESMImage } from './node/emitAsset.js';
export { isESMImportedImage, isRemoteImage } from './imageKind.js';
export { imageMetadata } from './metadata.js';
export { getOrigQueryParams } from './queryParams.js';
export {
isRemoteAllowed,
matchHostname,
matchPathname,
matchPattern,
matchPort,
matchProtocol,
type RemotePattern,
} from './remotePattern.js';
export { hashTransform, propsToFilename } from './transformToPath.js';
export { inferRemoteSize } from './remoteProbe.js';
export { makeSvgComponent } from './svg.js';
20 changes: 15 additions & 5 deletions packages/astro/src/content/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,13 +414,23 @@ async function updateImageReferencesInBody(html: string, fileName: string) {
for (const [_full, imagePath] of html.matchAll(CONTENT_LAYER_IMAGE_REGEX)) {
try {
const decodedImagePath = JSON.parse(imagePath.replaceAll('&#x22;', '"'));
const id = imageSrcToImportId(decodedImagePath.src, fileName);

const imported = imageAssetMap.get(id);
if (!id || imageObjects.has(id) || !imported) {
continue;
let image: GetImageResult;
if (URL.canParse(decodedImagePath.src)) {
// Remote image, pass through without resolving import
// We know we should resolve this remote image because either:
// 1. It was collected with the remark-collect-images plugin, which respects the astro image configuration,
// 2. OR it was manually injected by another plugin, and we should respect that.
image = await getImage(decodedImagePath);
} else {
const id = imageSrcToImportId(decodedImagePath.src, fileName);

const imported = imageAssetMap.get(id);
if (!id || imageObjects.has(id) || !imported) {
continue;
}
image = await getImage({ ...decodedImagePath, src: imported });
}
const image: GetImageResult = await getImage({ ...decodedImagePath, src: imported });
imageObjects.set(imagePath, image);
} catch {
throw new Error(`Failed to parse image reference: ${imagePath}`);
Expand Down
4 changes: 3 additions & 1 deletion packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { OutgoingHttpHeaders } from 'node:http';
import type {
RemotePattern
} from '@astrojs/internal-helpers/remote';
import type {
RehypePlugins,
RemarkPlugins,
Expand All @@ -8,7 +11,6 @@ import type {
import type { BuiltinDriverName, BuiltinDriverOptions, Driver, Storage } from 'unstorage';
import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite';
import type { ImageFit, ImageLayout } from '../../assets/types.js';
import type { RemotePattern } from '../../assets/utils/remotePattern.js';
import type { SvgRenderMode } from '../../assets/utils/svg.js';
import type { AssetsPrefix } from '../../core/app/types.js';
import type { AstroConfigType } from '../../core/config/schema.js';
Expand Down
4 changes: 3 additions & 1 deletion packages/astro/src/types/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export type * from './manifest.js';
export type { AstroIntegrationLogger } from '../../core/logger/core.js';
export type { ToolbarServerHelpers } from '../../runtime/client/dev-toolbar/helpers.js';

export type {
RemotePattern,
} from '@astrojs/internal-helpers/remote';
export type {
MarkdownHeading,
RehypePlugins,
Expand All @@ -35,7 +38,6 @@ export type {
ImageTransform,
UnresolvedImageTransform,
} from '../../assets/types.js';
export type { RemotePattern } from '../../assets/utils/remotePattern.js';
export type { AssetsPrefix, SSRManifest } from '../../core/app/types.js';
export type {
AstroCookieGetOptions,
Expand Down
10 changes: 8 additions & 2 deletions packages/astro/src/vite-plugin-markdown/content-entry-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export const markdownContentEntryType: ContentEntryType = {
handlePropagation: true,

async getRenderFunction(config) {
const processor = await createMarkdownProcessor(config.markdown);
const processor = await createMarkdownProcessor({
image: config.image,
...config.markdown,
});
return async function renderToString(entry) {
// Process markdown even if it's empty as remark/rehype plugins may add content or frontmatter dynamically
const result = await processor.render(entry.body ?? '', {
Expand All @@ -28,7 +31,10 @@ export const markdownContentEntryType: ContentEntryType = {
});
return {
html: result.code,
metadata: result.metadata,
metadata: {
...result.metadata,
imagePaths: result.metadata.localImagePaths.concat(result.metadata.remoteImagePaths),
},
};
};
},
Expand Down
29 changes: 26 additions & 3 deletions packages/astro/src/vite-plugin-markdown/images.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
export type MarkdownImagePath = { raw: string; safeName: string };

export function getMarkdownCodeForImages(imagePaths: MarkdownImagePath[], html: string) {
export function getMarkdownCodeForImages(
localImagePaths: MarkdownImagePath[],
remoteImagePaths: string[],
html: string,
) {
return `
import { getImage } from "astro:assets";
${imagePaths
${localImagePaths
.map((entry) => `import Astro__${entry.safeName} from ${JSON.stringify(entry.raw)};`)
.join('\n')}

const images = async function(html) {
const imageSources = {};
${imagePaths
${localImagePaths
.map((entry) => {
const rawUrl = JSON.stringify(entry.raw);
return `{
Expand All @@ -29,6 +33,25 @@ export function getMarkdownCodeForImages(imagePaths: MarkdownImagePath[], html:
}`;
})
.join('\n')}
${remoteImagePaths
.map((raw) => {
const rawUrl = JSON.stringify(raw);
return `{
const regex = new RegExp('__ASTRO_IMAGE_="([^"]*' + ${rawUrl.replace(
/[.*+?^${}()|[\]\\]/g,
'\\\\$&',
)} + '[^"]*)"', 'g');
let match;
let occurrenceCounter = 0;
while ((match = regex.exec(html)) !== null) {
const matchKey = ${rawUrl} + '_' + occurrenceCounter;
const props = JSON.parse(match[1].replace(/&#x22;/g, '"'));
imageSources[matchKey] = await getImage(props);
occurrenceCounter++;
}
}`;
})
.join('\n')}
return imageSources;
};

Expand Down
22 changes: 15 additions & 7 deletions packages/astro/src/vite-plugin-markdown/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug

// Lazily initialize the Markdown processor
if (!processor) {
processor = createMarkdownProcessor(settings.config.markdown);
processor = createMarkdownProcessor({
image: settings.config.image,
...settings.config.markdown,
});
}

const renderResult = await (await processor).render(raw.content, {
Expand All @@ -75,16 +78,21 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug
}

let html = renderResult.code;
const { headings, imagePaths: rawImagePaths, frontmatter } = renderResult.metadata;
const {
headings,
localImagePaths: rawLocalImagePaths,
remoteImagePaths,
frontmatter,
} = renderResult.metadata;

// Add default charset for markdown pages
const isMarkdownPage = isPage(fileURL, settings);
const charset = isMarkdownPage ? '<meta charset="utf-8">' : '';

// Resolve all the extracted images from the content
const imagePaths: MarkdownImagePath[] = [];
for (const imagePath of rawImagePaths) {
imagePaths.push({
const localImagePaths: MarkdownImagePath[] = [];
for (const imagePath of rawLocalImagePaths) {
localImagePaths.push({
raw: imagePath,
safeName: shorthash(imagePath),
});
Expand All @@ -108,8 +116,8 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug

${
// Only include the code relevant to `astro:assets` if there's images in the file
imagePaths.length > 0
? getMarkdownCodeForImages(imagePaths, html)
localImagePaths.length > 0 || remoteImagePaths.length > 0
? getMarkdownCodeForImages(localImagePaths, remoteImagePaths, html)
: `const html = () => ${JSON.stringify(html)};`
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import {
matchPattern,
matchPort,
matchProtocol,
} from '../../../dist/assets/utils/remotePattern.js';
} from '@astrojs/internal-helpers/remote';

describe('astro/src/assets/utils/remotePattern', () => {
describe('remote-pattern', () => {
const url1 = new URL('https://docs.astro.build/en/getting-started');
const url2 = new URL('http://preview.docs.astro.build:8080/');
const url3 = new URL('https://astro.build/');
Expand Down
Loading