Skip to content

Commit 1e11f5e

Browse files
p0lyw0lfematipicosarah11918ascorbic
authored
feat: Pass remote Markdown images through image service (#13254)
Co-authored-by: Sarah Rainsberger <[email protected]> Co-authored-by: ematipico <[email protected]> Co-authored-by: sarah11918 <[email protected]> Co-authored-by: ascorbic <[email protected]>
1 parent 797a948 commit 1e11f5e

File tree

25 files changed

+378
-178
lines changed

25 files changed

+378
-178
lines changed

.changeset/quiet-birds-joke.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@astrojs/internal-helpers': minor
3+
---
4+
5+
Adds remote URL filtering utilities
6+
7+
This adds logic to filter remote URLs so that it can be used by both `astro` and `@astrojs/markdown-remark`.

.changeset/shy-bats-exist.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Adds the ability to process and optimize remote images in Markdown files
6+
7+
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.
8+
9+
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.
10+
11+
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.

.changeset/tiny-cows-march.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@astrojs/mdx': minor
3+
---
4+
5+
Adds the ability to process and optimize remote images in Markdown syntax in MDX files.
6+
7+
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.
8+
9+
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.
10+
11+
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.

.changeset/warm-planes-swim.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@astrojs/markdown-remark': minor
3+
---
4+
5+
Adds remote image optimization in Markdown
6+
7+
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.
8+
9+
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.
10+
11+
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.

packages/astro/src/assets/endpoint/generic.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// @ts-expect-error
22
import { imageConfig } from 'astro:assets';
33
import { isRemotePath } from '@astrojs/internal-helpers/path';
4+
import { isRemoteAllowed } from '@astrojs/internal-helpers/remote';
45
import * as mime from 'mrmime';
56
import type { APIRoute } from '../../types/public/common.js';
67
import { getConfiguredImageService } from '../internal.js';
78
import { etag } from '../utils/etag.js';
8-
import { isRemoteAllowed } from '../utils/remotePattern.js';
99

1010
async function loadRemoteImage(src: URL, headers: Headers) {
1111
try {

packages/astro/src/assets/endpoint/node.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
66
// @ts-expect-error
77
import { assetsDir, imageConfig, outDir } from 'astro:assets';
88
import { isRemotePath, removeQueryString } from '@astrojs/internal-helpers/path';
9+
import { isRemoteAllowed } from '@astrojs/internal-helpers/remote';
910
import * as mime from 'mrmime';
1011
import type { APIRoute } from '../../types/public/common.js';
1112
import { getConfiguredImageService } from '../internal.js';
1213
import { etag } from '../utils/etag.js';
13-
import { isRemoteAllowed } from '../utils/remotePattern.js';
1414

1515
function replaceFileSystemReferences(src: string) {
1616
return os.platform().includes('win32') ? src.replace(/^\/@fs\//, '') : src.replace(/^\/@fs/, '');

packages/astro/src/assets/services/service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isRemoteAllowed } from '@astrojs/internal-helpers/remote';
12
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
23
import { isRemotePath, joinPaths } from '../../core/path.js';
34
import type { AstroConfig } from '../../types/public/config.js';
@@ -9,7 +10,6 @@ import type {
910
UnresolvedSrcSetValue,
1011
} from '../types.js';
1112
import { isESMImportedImage, isRemoteImage } from '../utils/imageKind.js';
12-
import { isRemoteAllowed } from '../utils/remotePattern.js';
1313

1414
export type ImageService = LocalImageService | ExternalImageService;
1515

packages/astro/src/assets/utils/index.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,6 @@ export { emitESMImage } from './node/emitAsset.js';
22
export { isESMImportedImage, isRemoteImage } from './imageKind.js';
33
export { imageMetadata } from './metadata.js';
44
export { getOrigQueryParams } from './queryParams.js';
5-
export {
6-
isRemoteAllowed,
7-
matchHostname,
8-
matchPathname,
9-
matchPattern,
10-
matchPort,
11-
matchProtocol,
12-
type RemotePattern,
13-
} from './remotePattern.js';
145
export { hashTransform, propsToFilename } from './transformToPath.js';
156
export { inferRemoteSize } from './remoteProbe.js';
167
export { makeSvgComponent } from './svg.js';

packages/astro/src/content/runtime.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -414,13 +414,23 @@ async function updateImageReferencesInBody(html: string, fileName: string) {
414414
for (const [_full, imagePath] of html.matchAll(CONTENT_LAYER_IMAGE_REGEX)) {
415415
try {
416416
const decodedImagePath = JSON.parse(imagePath.replaceAll('&#x22;', '"'));
417-
const id = imageSrcToImportId(decodedImagePath.src, fileName);
418417

419-
const imported = imageAssetMap.get(id);
420-
if (!id || imageObjects.has(id) || !imported) {
421-
continue;
418+
let image: GetImageResult;
419+
if (URL.canParse(decodedImagePath.src)) {
420+
// Remote image, pass through without resolving import
421+
// We know we should resolve this remote image because either:
422+
// 1. It was collected with the remark-collect-images plugin, which respects the astro image configuration,
423+
// 2. OR it was manually injected by another plugin, and we should respect that.
424+
image = await getImage(decodedImagePath);
425+
} else {
426+
const id = imageSrcToImportId(decodedImagePath.src, fileName);
427+
428+
const imported = imageAssetMap.get(id);
429+
if (!id || imageObjects.has(id) || !imported) {
430+
continue;
431+
}
432+
image = await getImage({ ...decodedImagePath, src: imported });
422433
}
423-
const image: GetImageResult = await getImage({ ...decodedImagePath, src: imported });
424434
imageObjects.set(imagePath, image);
425435
} catch {
426436
throw new Error(`Failed to parse image reference: ${imagePath}`);

packages/astro/src/types/public/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import type { OutgoingHttpHeaders } from 'node:http';
2+
import type {
3+
RemotePattern
4+
} from '@astrojs/internal-helpers/remote';
25
import type {
36
RehypePlugins,
47
RemarkPlugins,
@@ -8,7 +11,6 @@ import type {
811
import type { BuiltinDriverName, BuiltinDriverOptions, Driver, Storage } from 'unstorage';
912
import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite';
1013
import type { ImageFit, ImageLayout } from '../../assets/types.js';
11-
import type { RemotePattern } from '../../assets/utils/remotePattern.js';
1214
import type { SvgRenderMode } from '../../assets/utils/svg.js';
1315
import type { AssetsPrefix } from '../../core/app/types.js';
1416
import type { AstroConfigType } from '../../core/config/schema.js';

packages/astro/src/types/public/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export type * from './manifest.js';
1414
export type { AstroIntegrationLogger } from '../../core/logger/core.js';
1515
export type { ToolbarServerHelpers } from '../../runtime/client/dev-toolbar/helpers.js';
1616

17+
export type {
18+
RemotePattern,
19+
} from '@astrojs/internal-helpers/remote';
1720
export type {
1821
MarkdownHeading,
1922
RehypePlugins,
@@ -35,7 +38,6 @@ export type {
3538
ImageTransform,
3639
UnresolvedImageTransform,
3740
} from '../../assets/types.js';
38-
export type { RemotePattern } from '../../assets/utils/remotePattern.js';
3941
export type { AssetsPrefix, SSRManifest } from '../../core/app/types.js';
4042
export type {
4143
AstroCookieGetOptions,

packages/astro/src/vite-plugin-markdown/content-entry-type.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ export const markdownContentEntryType: ContentEntryType = {
1818
handlePropagation: true,
1919

2020
async getRenderFunction(config) {
21-
const processor = await createMarkdownProcessor(config.markdown);
21+
const processor = await createMarkdownProcessor({
22+
image: config.image,
23+
...config.markdown,
24+
});
2225
return async function renderToString(entry) {
2326
// Process markdown even if it's empty as remark/rehype plugins may add content or frontmatter dynamically
2427
const result = await processor.render(entry.body ?? '', {
@@ -28,7 +31,10 @@ export const markdownContentEntryType: ContentEntryType = {
2831
});
2932
return {
3033
html: result.code,
31-
metadata: result.metadata,
34+
metadata: {
35+
...result.metadata,
36+
imagePaths: result.metadata.localImagePaths.concat(result.metadata.remoteImagePaths),
37+
},
3238
};
3339
};
3440
},

packages/astro/src/vite-plugin-markdown/images.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
export type MarkdownImagePath = { raw: string; safeName: string };
22

3-
export function getMarkdownCodeForImages(imagePaths: MarkdownImagePath[], html: string) {
3+
export function getMarkdownCodeForImages(
4+
localImagePaths: MarkdownImagePath[],
5+
remoteImagePaths: string[],
6+
html: string,
7+
) {
48
return `
59
import { getImage } from "astro:assets";
6-
${imagePaths
10+
${localImagePaths
711
.map((entry) => `import Astro__${entry.safeName} from ${JSON.stringify(entry.raw)};`)
812
.join('\n')}
913
1014
const images = async function(html) {
1115
const imageSources = {};
12-
${imagePaths
16+
${localImagePaths
1317
.map((entry) => {
1418
const rawUrl = JSON.stringify(entry.raw);
1519
return `{
@@ -29,6 +33,25 @@ export function getMarkdownCodeForImages(imagePaths: MarkdownImagePath[], html:
2933
}`;
3034
})
3135
.join('\n')}
36+
${remoteImagePaths
37+
.map((raw) => {
38+
const rawUrl = JSON.stringify(raw);
39+
return `{
40+
const regex = new RegExp('__ASTRO_IMAGE_="([^"]*' + ${rawUrl.replace(
41+
/[.*+?^${}()|[\]\\]/g,
42+
'\\\\$&',
43+
)} + '[^"]*)"', 'g');
44+
let match;
45+
let occurrenceCounter = 0;
46+
while ((match = regex.exec(html)) !== null) {
47+
const matchKey = ${rawUrl} + '_' + occurrenceCounter;
48+
const props = JSON.parse(match[1].replace(/&#x22;/g, '"'));
49+
imageSources[matchKey] = await getImage(props);
50+
occurrenceCounter++;
51+
}
52+
}`;
53+
})
54+
.join('\n')}
3255
return imageSources;
3356
};
3457

packages/astro/src/vite-plugin-markdown/index.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug
6060

6161
// Lazily initialize the Markdown processor
6262
if (!processor) {
63-
processor = createMarkdownProcessor(settings.config.markdown);
63+
processor = createMarkdownProcessor({
64+
image: settings.config.image,
65+
...settings.config.markdown,
66+
});
6467
}
6568

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

7780
let html = renderResult.code;
78-
const { headings, imagePaths: rawImagePaths, frontmatter } = renderResult.metadata;
81+
const {
82+
headings,
83+
localImagePaths: rawLocalImagePaths,
84+
remoteImagePaths,
85+
frontmatter,
86+
} = renderResult.metadata;
7987

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

8492
// Resolve all the extracted images from the content
85-
const imagePaths: MarkdownImagePath[] = [];
86-
for (const imagePath of rawImagePaths) {
87-
imagePaths.push({
93+
const localImagePaths: MarkdownImagePath[] = [];
94+
for (const imagePath of rawLocalImagePaths) {
95+
localImagePaths.push({
8896
raw: imagePath,
8997
safeName: shorthash(imagePath),
9098
});
@@ -108,8 +116,8 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug
108116
109117
${
110118
// Only include the code relevant to `astro:assets` if there's images in the file
111-
imagePaths.length > 0
112-
? getMarkdownCodeForImages(imagePaths, html)
119+
localImagePaths.length > 0 || remoteImagePaths.length > 0
120+
? getMarkdownCodeForImages(localImagePaths, remoteImagePaths, html)
113121
: `const html = () => ${JSON.stringify(html)};`
114122
}
115123

packages/astro/test/units/assets/remote-pattern.test.js renamed to packages/astro/test/units/remote-pattern.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import {
66
matchPattern,
77
matchPort,
88
matchProtocol,
9-
} from '../../../dist/assets/utils/remotePattern.js';
9+
} from '@astrojs/internal-helpers/remote';
1010

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

0 commit comments

Comments
 (0)