From 1e11f5e8b722b179e382f3c792cd961b2b51f61b Mon Sep 17 00:00:00 2001 From: PolyWolf <31190026+p0lyw0lf@users.noreply.github.com> Date: Wed, 26 Feb 2025 05:15:35 -0500 Subject: [PATCH] feat: Pass remote Markdown images through image service (#13254) Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> Co-authored-by: ematipico <602478+ematipico@users.noreply.github.com> Co-authored-by: sarah11918 <5098874+sarah11918@users.noreply.github.com> Co-authored-by: ascorbic <213306+ascorbic@users.noreply.github.com> --- .changeset/quiet-birds-joke.md | 7 + .changeset/shy-bats-exist.md | 11 ++ .changeset/tiny-cows-march.md | 11 ++ .changeset/warm-planes-swim.md | 11 ++ packages/astro/src/assets/endpoint/generic.ts | 2 +- packages/astro/src/assets/endpoint/node.ts | 2 +- packages/astro/src/assets/services/service.ts | 2 +- packages/astro/src/assets/utils/index.ts | 9 -- packages/astro/src/content/runtime.ts | 20 ++- packages/astro/src/types/public/config.ts | 4 +- packages/astro/src/types/public/index.ts | 4 +- .../content-entry-type.ts | 10 +- .../astro/src/vite-plugin-markdown/images.ts | 29 +++- .../astro/src/vite-plugin-markdown/index.ts | 22 ++- .../units/{assets => }/remote-pattern.test.js | 4 +- .../mdx/src/rehype-images-to-component.ts | 151 ++++++++++-------- packages/internal-helpers/package.json | 4 + .../src/remote.ts} | 56 ++++--- packages/markdown/remark/package.json | 1 + packages/markdown/remark/src/index.ts | 15 +- packages/markdown/remark/src/rehype-images.ts | 54 ++++--- .../remark/src/remark-collect-images.ts | 56 ++++--- packages/markdown/remark/src/types.ts | 20 ++- .../remark/test/remark-collect-images.test.js | 48 +++++- pnpm-lock.yaml | 3 + 25 files changed, 378 insertions(+), 178 deletions(-) create mode 100644 .changeset/quiet-birds-joke.md create mode 100644 .changeset/shy-bats-exist.md create mode 100644 .changeset/tiny-cows-march.md create mode 100644 .changeset/warm-planes-swim.md rename packages/astro/test/units/{assets => }/remote-pattern.test.js (96%) rename packages/{astro/src/assets/utils/remotePattern.ts => internal-helpers/src/remote.ts} (59%) diff --git a/.changeset/quiet-birds-joke.md b/.changeset/quiet-birds-joke.md new file mode 100644 index 000000000000..37b175850916 --- /dev/null +++ b/.changeset/quiet-birds-joke.md @@ -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`. diff --git a/.changeset/shy-bats-exist.md b/.changeset/shy-bats-exist.md new file mode 100644 index 000000000000..25ac954ef2af --- /dev/null +++ b/.changeset/shy-bats-exist.md @@ -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 `` tag instead. Note that images located in your `public/` folder are still never processed. diff --git a/.changeset/tiny-cows-march.md b/.changeset/tiny-cows-march.md new file mode 100644 index 000000000000..95682c85b0a7 --- /dev/null +++ b/.changeset/tiny-cows-march.md @@ -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 `` tag instead. Note that images located in your `public/` folder are still never processed. diff --git a/.changeset/warm-planes-swim.md b/.changeset/warm-planes-swim.md new file mode 100644 index 000000000000..f710f1a6e3a3 --- /dev/null +++ b/.changeset/warm-planes-swim.md @@ -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. diff --git a/packages/astro/src/assets/endpoint/generic.ts b/packages/astro/src/assets/endpoint/generic.ts index f8924134b271..d71d06987073 100644 --- a/packages/astro/src/assets/endpoint/generic.ts +++ b/packages/astro/src/assets/endpoint/generic.ts @@ -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 { diff --git a/packages/astro/src/assets/endpoint/node.ts b/packages/astro/src/assets/endpoint/node.ts index 4b18deb38202..991d7171f3ec 100644 --- a/packages/astro/src/assets/endpoint/node.ts +++ b/packages/astro/src/assets/endpoint/node.ts @@ -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/, ''); diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts index ee3bcb587f8f..4bb643a9cdfe 100644 --- a/packages/astro/src/assets/services/service.ts +++ b/packages/astro/src/assets/services/service.ts @@ -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'; @@ -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; diff --git a/packages/astro/src/assets/utils/index.ts b/packages/astro/src/assets/utils/index.ts index 3fae182000fa..d937048b5fa6 100644 --- a/packages/astro/src/assets/utils/index.ts +++ b/packages/astro/src/assets/utils/index.ts @@ -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'; diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts index 019a4c8b12cf..52aaec642ab4 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -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('"', '"')); - 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}`); diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 84dc3fd83484..3bb0e58819e9 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -1,4 +1,7 @@ import type { OutgoingHttpHeaders } from 'node:http'; +import type { + RemotePattern +} from '@astrojs/internal-helpers/remote'; import type { RehypePlugins, RemarkPlugins, @@ -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'; diff --git a/packages/astro/src/types/public/index.ts b/packages/astro/src/types/public/index.ts index fae134bbeb01..53b67aa3f94b 100644 --- a/packages/astro/src/types/public/index.ts +++ b/packages/astro/src/types/public/index.ts @@ -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, @@ -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, diff --git a/packages/astro/src/vite-plugin-markdown/content-entry-type.ts b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts index 6f248853f51e..a8832ef35630 100644 --- a/packages/astro/src/vite-plugin-markdown/content-entry-type.ts +++ b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts @@ -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 ?? '', { @@ -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), + }, }; }; }, diff --git a/packages/astro/src/vite-plugin-markdown/images.ts b/packages/astro/src/vite-plugin-markdown/images.ts index d0ed625358ff..b99d1af233b2 100644 --- a/packages/astro/src/vite-plugin-markdown/images.ts +++ b/packages/astro/src/vite-plugin-markdown/images.ts @@ -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 `{ @@ -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(/"/g, '"')); + imageSources[matchKey] = await getImage(props); + occurrenceCounter++; + } + }`; + }) + .join('\n')} return imageSources; }; diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index 8876250f9765..9cd080a0302d 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -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, { @@ -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 ? '' : ''; // 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), }); @@ -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)};` } diff --git a/packages/astro/test/units/assets/remote-pattern.test.js b/packages/astro/test/units/remote-pattern.test.js similarity index 96% rename from packages/astro/test/units/assets/remote-pattern.test.js rename to packages/astro/test/units/remote-pattern.test.js index 0961ffd05232..95879d89b6e2 100644 --- a/packages/astro/test/units/assets/remote-pattern.test.js +++ b/packages/astro/test/units/remote-pattern.test.js @@ -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/'); diff --git a/packages/integrations/mdx/src/rehype-images-to-component.ts b/packages/integrations/mdx/src/rehype-images-to-component.ts index c903ae511b47..d6e5308c4bbf 100644 --- a/packages/integrations/mdx/src/rehype-images-to-component.ts +++ b/packages/integrations/mdx/src/rehype-images-to-component.ts @@ -73,84 +73,105 @@ function getImageComponentAttributes(props: Properties): MdxJsxAttribute[] { export function rehypeImageToComponent() { return function (tree: Root, file: VFile) { - if (!file.data.astro?.imagePaths?.length) return; + if (!file.data.astro?.localImagePaths?.length && !file.data.astro?.remoteImagePaths?.length) + return; const importsStatements: MdxjsEsm[] = []; const importedImages = new Map(); visit(tree, 'element', (node, index, parent) => { - if (!file.data.astro?.imagePaths?.length || node.tagName !== 'img' || !node.properties.src) - return; + if (node.tagName !== 'img' || !node.properties.src) return; const src = decodeURI(String(node.properties.src)); - if (!file.data.astro.imagePaths?.includes(src)) return; - - let importName = importedImages.get(src); - - if (!importName) { - importName = `__${importedImages.size}_${src.replace(/\W/g, '_')}__`; - - importsStatements.push({ - type: 'mdxjsEsm', - value: '', - data: { - estree: { - type: 'Program', - sourceType: 'module', - body: [ - { - type: 'ImportDeclaration', - source: { - type: 'Literal', - value: src, - raw: JSON.stringify(src), - }, - specifiers: [ - { - type: 'ImportDefaultSpecifier', - local: { type: 'Identifier', name: importName }, + const isLocalImage = file.data.astro?.localImagePaths?.includes(src); + const isRemoteImage = file.data.astro?.remoteImagePaths?.includes(src); + + let element: MdxJsxFlowElementHast; + if (isLocalImage) { + let importName = importedImages.get(src); + + if (!importName) { + importName = `__${importedImages.size}_${src.replace(/\W/g, '_')}__`; + + importsStatements.push({ + type: 'mdxjsEsm', + value: '', + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ImportDeclaration', + source: { + type: 'Literal', + value: src, + raw: JSON.stringify(src), }, - ], - }, - ], + specifiers: [ + { + type: 'ImportDefaultSpecifier', + local: { type: 'Identifier', name: importName }, + }, + ], + }, + ], + }, }, - }, - }); - importedImages.set(src, importName); - } - - // Build a component that's equivalent to - const componentElement: MdxJsxFlowElementHast = { - name: ASTRO_IMAGE_ELEMENT, - type: 'mdxJsxFlowElement', - attributes: [ - ...getImageComponentAttributes(node.properties), - { - name: 'src', - type: 'mdxJsxAttribute', - value: { - type: 'mdxJsxAttributeValueExpression', - value: importName, - data: { - estree: { - type: 'Program', - sourceType: 'module', - comments: [], - body: [ - { - type: 'ExpressionStatement', - expression: { type: 'Identifier', name: importName }, - }, - ], + }); + importedImages.set(src, importName); + } + + // Build a component that's equivalent to + element = { + name: ASTRO_IMAGE_ELEMENT, + type: 'mdxJsxFlowElement', + attributes: [ + ...getImageComponentAttributes(node.properties), + { + name: 'src', + type: 'mdxJsxAttribute', + value: { + type: 'mdxJsxAttributeValueExpression', + value: importName, + data: { + estree: { + type: 'Program', + sourceType: 'module', + comments: [], + body: [ + { + type: 'ExpressionStatement', + expression: { type: 'Identifier', name: importName }, + }, + ], + }, }, }, }, - }, - ], - children: [], - }; + ], + children: [], + }; + } else if (isRemoteImage) { + // Build a component that's equivalent to + element = { + name: ASTRO_IMAGE_ELEMENT, + type: 'mdxJsxFlowElement', + attributes: [ + ...getImageComponentAttributes(node.properties), + { + name: 'src', + type: 'mdxJsxAttribute', + value: src, + }, + ], + children: [], + }; + } else { + return; + } - parent!.children.splice(index!, 1, componentElement); + parent!.children.splice(index!, 1, element); }); // Add all the import statements to the top of the file for the images diff --git a/packages/internal-helpers/package.json b/packages/internal-helpers/package.json index 5a3960e06c49..aa58997cd2a5 100644 --- a/packages/internal-helpers/package.json +++ b/packages/internal-helpers/package.json @@ -13,6 +13,7 @@ "bugs": "https://github.com/withastro/astro/issues", "exports": { "./path": "./dist/path.js", + "./remote": "./dist/remote.js", "./fs": "./dist/fs.js" }, "typesVersions": { @@ -20,6 +21,9 @@ "path": [ "./dist/path.d.ts" ], + "remote": [ + "./dist/remote.d.ts" + ], "fs": [ "./dist/fs.d.ts" ] diff --git a/packages/astro/src/assets/utils/remotePattern.ts b/packages/internal-helpers/src/remote.ts similarity index 59% rename from packages/astro/src/assets/utils/remotePattern.ts rename to packages/internal-helpers/src/remote.ts index d3e832573a7f..0023deff843e 100644 --- a/packages/astro/src/assets/utils/remotePattern.ts +++ b/packages/internal-helpers/src/remote.ts @@ -1,6 +1,3 @@ -import { isRemotePath } from '@astrojs/internal-helpers/path'; -import type { AstroConfig } from '../../types/public/config.js'; - export type RemotePattern = { hostname?: string; pathname?: string; @@ -25,19 +22,25 @@ export function matchProtocol(url: URL, protocol?: string) { return !protocol || protocol === url.protocol.slice(0, -1); } -export function matchHostname(url: URL, hostname?: string, allowWildcard?: boolean) { +export function matchHostname( + url: URL, + hostname?: string, + allowWildcard?: boolean, +) { if (!hostname) { return true; - } else if (!allowWildcard || !hostname.startsWith('*')) { + } else if (!allowWildcard || !hostname.startsWith("*")) { return hostname === url.hostname; - } else if (hostname.startsWith('**.')) { + } else if (hostname.startsWith("**.")) { const slicedHostname = hostname.slice(2); // ** length - return slicedHostname !== url.hostname && url.hostname.endsWith(slicedHostname); - } else if (hostname.startsWith('*.')) { + return ( + slicedHostname !== url.hostname && url.hostname.endsWith(slicedHostname) + ); + } else if (hostname.startsWith("*.")) { const slicedHostname = hostname.slice(1); // * length const additionalSubdomains = url.hostname - .replace(slicedHostname, '') - .split('.') + .replace(slicedHostname, "") + .split(".") .filter(Boolean); return additionalSubdomains.length === 1; } @@ -45,19 +48,25 @@ export function matchHostname(url: URL, hostname?: string, allowWildcard?: boole return false; } -export function matchPathname(url: URL, pathname?: string, allowWildcard?: boolean) { +export function matchPathname( + url: URL, + pathname?: string, + allowWildcard?: boolean, +) { if (!pathname) { return true; - } else if (!allowWildcard || !pathname.endsWith('*')) { + } else if (!allowWildcard || !pathname.endsWith("*")) { return pathname === url.pathname; - } else if (pathname.endsWith('/**')) { + } else if (pathname.endsWith("/**")) { const slicedPathname = pathname.slice(0, -2); // ** length - return slicedPathname !== url.pathname && url.pathname.startsWith(slicedPathname); - } else if (pathname.endsWith('/*')) { + return ( + slicedPathname !== url.pathname && url.pathname.startsWith(slicedPathname) + ); + } else if (pathname.endsWith("/*")) { const slicedPathname = pathname.slice(0, -1); // * length const additionalPathChunks = url.pathname - .replace(slicedPathname, '') - .split('/') + .replace(slicedPathname, "") + .split("/") .filter(Boolean); return additionalPathChunks.length === 1; } @@ -68,11 +77,16 @@ export function matchPathname(url: URL, pathname?: string, allowWildcard?: boole export function isRemoteAllowed( src: string, { - domains = [], - remotePatterns = [], - }: Partial>, + domains, + remotePatterns, + }: { + domains: string[]; + remotePatterns: RemotePattern[]; + }, ): boolean { - if (!isRemotePath(src)) return false; + if (!URL.canParse(src)) { + return false; + } const url = new URL(src); return ( diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index 8e9e142a625e..5ad883a735ae 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -32,6 +32,7 @@ "test": "astro-scripts test \"test/**/*.test.js\"" }, "dependencies": { + "@astrojs/internal-helpers": "workspace:*", "@astrojs/prism": "workspace:*", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index de13523fe13c..d1b6035e4adf 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -1,4 +1,8 @@ -import type { AstroMarkdownOptions, MarkdownProcessor } from './types.js'; +import type { + AstroMarkdownOptions, + AstroMarkdownProcessorOptions, + MarkdownProcessor, +} from './types.js'; import { loadPlugins } from './load-plugins.js'; import { rehypeHeadingIds } from './rehype-collect-headings.js'; @@ -59,7 +63,7 @@ const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK); * Create a markdown preprocessor to render multiple markdown files */ export async function createMarkdownProcessor( - opts?: AstroMarkdownOptions, + opts?: AstroMarkdownProcessorOptions, ): Promise { const { syntaxHighlight = markdownConfigDefaults.syntaxHighlight, @@ -93,7 +97,7 @@ export async function createMarkdownProcessor( if (!isPerformanceBenchmark) { // Apply later in case user plugins resolve relative image paths - parser.use(remarkCollectImages); + parser.use(remarkCollectImages, opts?.image); } // Remark -> Rehype @@ -118,7 +122,7 @@ export async function createMarkdownProcessor( } // Images / Assets support - parser.use(rehypeImages()); + parser.use(rehypeImages); // Headings if (!isPerformanceBenchmark) { @@ -152,7 +156,8 @@ export async function createMarkdownProcessor( code: String(result.value), metadata: { headings: result.data.astro?.headings ?? [], - imagePaths: result.data.astro?.imagePaths ?? [], + localImagePaths: result.data.astro?.localImagePaths ?? [], + remoteImagePaths: result.data.astro?.remoteImagePaths ?? [], frontmatter: result.data.astro?.frontmatter ?? {}, }, }; diff --git a/packages/markdown/remark/src/rehype-images.ts b/packages/markdown/remark/src/rehype-images.ts index 11d33df9c15b..92043b5e3ad8 100644 --- a/packages/markdown/remark/src/rehype-images.ts +++ b/packages/markdown/remark/src/rehype-images.ts @@ -1,32 +1,44 @@ +import type { Properties, Root } from 'hast'; import { visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; export function rehypeImages() { - return () => - function (tree: any, file: VFile) { - const imageOccurrenceMap = new Map(); + return function (tree: Root, file: VFile) { + if (!file.data.astro?.localImagePaths?.length && !file.data.astro?.remoteImagePaths?.length) { + // No images to transform, nothing to do. + return; + } - visit(tree, (node) => { - if (node.type !== 'element') return; - if (node.tagName !== 'img') return; + const imageOccurrenceMap = new Map(); - if (node.properties?.src) { - node.properties.src = decodeURI(node.properties.src); + visit(tree, 'element', (node) => { + if (node.tagName !== 'img') return; + if (typeof node.properties?.src !== 'string') return; - if (file.data.astro?.imagePaths?.includes(node.properties.src)) { - const { ...props } = node.properties; + const src = decodeURI(node.properties.src); + let newProperties: Properties; - // Initialize or increment occurrence count for this image - const index = imageOccurrenceMap.get(node.properties.src) || 0; - imageOccurrenceMap.set(node.properties.src, index + 1); + if (file.data.astro?.localImagePaths?.includes(src)) { + // Override the original `src` with the new, decoded `src` that Astro will better understand. + newProperties = { ...node.properties, src }; + } else if (file.data.astro?.remoteImagePaths?.includes(src)) { + newProperties = { + // By default, markdown images won't have width and height set. However, just in case another user plugin does set these, we should respect them. + inferSize: 'width' in node.properties && 'height' in node.properties ? undefined : true, + ...node.properties, + src, + }; + } else { + // Not in localImagePaths or remoteImagePaths, we should not transform. + return; + } - node.properties['__ASTRO_IMAGE_'] = JSON.stringify({ ...props, index }); + // Initialize or increment occurrence count for this image + const index = imageOccurrenceMap.get(node.properties.src) || 0; + imageOccurrenceMap.set(node.properties.src, index + 1); - Object.keys(props).forEach((prop) => { - delete node.properties[prop]; - }); - } - } - }); - }; + // Set a special property on the image so later Astro code knows to process this image. + node.properties = { __ASTRO_IMAGE_: JSON.stringify({ ...newProperties, index }) }; + }); + }; } diff --git a/packages/markdown/remark/src/remark-collect-images.ts b/packages/markdown/remark/src/remark-collect-images.ts index f09f1c580a3f..062fabc455c6 100644 --- a/packages/markdown/remark/src/remark-collect-images.ts +++ b/packages/markdown/remark/src/remark-collect-images.ts @@ -1,42 +1,48 @@ -import type { Image, ImageReference } from 'mdast'; +import type { Root } from 'mdast'; import { definitions } from 'mdast-util-definitions'; import { visit } from 'unist-util-visit'; import type { VFile } from 'vfile'; +import { isRemoteAllowed } from '@astrojs/internal-helpers/remote'; +import type { AstroMarkdownProcessorOptions } from './types.js'; -export function remarkCollectImages() { - return function (tree: any, vfile: VFile) { +export function remarkCollectImages(opts: AstroMarkdownProcessorOptions['image']) { + const domains = opts?.domains ?? []; + const remotePatterns = opts?.remotePatterns ?? []; + + return function (tree: Root, vfile: VFile) { if (typeof vfile?.path !== 'string') return; const definition = definitions(tree); - const imagePaths = new Set(); - visit(tree, ['image', 'imageReference'], (node: Image | ImageReference) => { + const localImagePaths = new Set(); + const remoteImagePaths = new Set(); + visit(tree, (node) => { + let url: string | undefined; if (node.type === 'image') { - if (shouldOptimizeImage(node.url)) imagePaths.add(decodeURI(node.url)); - } - if (node.type === 'imageReference') { + url = decodeURI(node.url); + } else if (node.type === 'imageReference') { const imageDefinition = definition(node.identifier); if (imageDefinition) { - if (shouldOptimizeImage(imageDefinition.url)) - imagePaths.add(decodeURI(imageDefinition.url)); + url = decodeURI(imageDefinition.url); + } + } + + if (!url) return; + + if (URL.canParse(url)) { + if (isRemoteAllowed(url, { domains, remotePatterns })) { + remoteImagePaths.add(url); } + } else if (!url.startsWith('/')) { + // If: + // + not a valid URL + // + AND not an absolute path + // Then it's a local image. + localImagePaths.add(url); } }); vfile.data.astro ??= {}; - vfile.data.astro.imagePaths = Array.from(imagePaths); + vfile.data.astro.localImagePaths = Array.from(localImagePaths); + vfile.data.astro.remoteImagePaths = Array.from(remoteImagePaths); }; } - -function shouldOptimizeImage(src: string) { - // Optimize anything that is NOT external or an absolute path to `public/` - return !isValidUrl(src) && !src.startsWith('/'); -} - -function isValidUrl(str: string): boolean { - try { - new URL(str); - return true; - } catch { - return false; - } -} diff --git a/packages/markdown/remark/src/types.ts b/packages/markdown/remark/src/types.ts index e6a9d362bc06..91dd00607527 100644 --- a/packages/markdown/remark/src/types.ts +++ b/packages/markdown/remark/src/types.ts @@ -3,6 +3,7 @@ import type * as mdast from 'mdast'; import type { Options as RemarkRehypeOptions } from 'remark-rehype'; import type { BuiltinTheme } from 'shiki'; import type * as unified from 'unified'; +import type { RemotePattern } from '@astrojs/internal-helpers/remote'; import type { CreateShikiHighlighterOptions, ShikiHighlighterHighlightOptions } from './shiki.js'; export type { Node } from 'unist'; @@ -11,7 +12,8 @@ declare module 'vfile' { interface DataMap { astro: { headings?: MarkdownHeading[]; - imagePaths?: string[]; + localImagePaths?: string[]; + remoteImagePaths?: string[]; frontmatter?: Record; }; } @@ -39,6 +41,9 @@ export interface ShikiConfig extends Pick, Pick {} +/** + * Configuration options that end up in the markdown section of AstroConfig + */ export interface AstroMarkdownOptions { syntaxHighlight?: 'shiki' | 'prism' | false; shikiConfig?: ShikiConfig; @@ -49,6 +54,16 @@ export interface AstroMarkdownOptions { smartypants?: boolean; } +/** + * Extra configuration options from other parts of AstroConfig that get injected into this plugin + */ +export interface AstroMarkdownProcessorOptions extends AstroMarkdownOptions { + image?: { + domains?: string[]; + remotePatterns?: RemotePattern[]; + }; +} + export interface MarkdownProcessor { render: ( content: string, @@ -67,7 +82,8 @@ export interface MarkdownProcessorRenderResult { code: string; metadata: { headings: MarkdownHeading[]; - imagePaths: string[]; + localImagePaths: string[]; + remoteImagePaths: string[]; frontmatter: Record; }; } diff --git a/packages/markdown/remark/test/remark-collect-images.test.js b/packages/markdown/remark/test/remark-collect-images.test.js index 669bee595fa5..e7ac08918d86 100644 --- a/packages/markdown/remark/test/remark-collect-images.test.js +++ b/packages/markdown/remark/test/remark-collect-images.test.js @@ -6,7 +6,7 @@ describe('collect images', async () => { let processor; before(async () => { - processor = await createMarkdownProcessor(); + processor = await createMarkdownProcessor({ image: { domains: ['example.com'] } }); }); it('should collect inline image paths', async () => { @@ -15,7 +15,7 @@ describe('collect images', async () => { const { code, - metadata: { imagePaths }, + metadata: { localImagePaths, remoteImagePaths }, } = await processor.render(markdown, { fileURL }); assert.equal( @@ -23,20 +23,56 @@ describe('collect images', async () => { '

Hello

', ); - assert.deepEqual(imagePaths, ['./img.png']); + assert.deepEqual(localImagePaths, ['./img.png']); + assert.deepEqual(remoteImagePaths, []); + }); + + it('should collect allowed remote image paths', async () => { + const markdown = `Hello ![inline remote image url](https://example.com/example.png)`; + const fileURL = 'file.md'; + + const { + code, + metadata: { localImagePaths, remoteImagePaths }, + } = await processor.render(markdown, { fileURL }); + assert.equal( + code, + `

Hello

`, + ); + + assert.deepEqual(localImagePaths, []); + assert.deepEqual(remoteImagePaths, ['https://example.com/example.png']); + }); + + it('should not collect other remote image paths', async () => { + const markdown = `Hello ![inline remote image url](https://google.com/google.png)`; + const fileURL = 'file.md'; + + const { + code, + metadata: { localImagePaths, remoteImagePaths }, + } = await processor.render(markdown, { fileURL }); + assert.equal( + code, + `

Hello inline remote image url

`, + ); + + assert.deepEqual(localImagePaths, []); + assert.deepEqual(remoteImagePaths, []); }); it('should add image paths from definition', async () => { - const markdown = `Hello ![image ref][img-ref]\n\n[img-ref]: ./img.webp`; + const markdown = `Hello ![image ref][img-ref] ![remote image ref][remote-img-ref]\n\n[img-ref]: ./img.webp\n[remote-img-ref]: https://example.com/example.jpg`; const fileURL = 'file.md'; const { code, metadata } = await processor.render(markdown, { fileURL }); assert.equal( code, - '

Hello

', + '

Hello

', ); - assert.deepEqual(metadata.imagePaths, ['./img.webp']); + assert.deepEqual(metadata.localImagePaths, ['./img.webp']); + assert.deepEqual(metadata.remoteImagePaths, ['https://example.com/example.jpg']); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faf1453c0464..1c1226241d47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6067,6 +6067,9 @@ importers: packages/markdown/remark: dependencies: + '@astrojs/internal-helpers': + specifier: workspace:* + version: link:../../internal-helpers '@astrojs/prism': specifier: workspace:* version: link:../../astro-prism