aboutsummaryrefslogtreecommitdiff
path: root/packages/markdown/remark/src
diff options
context:
space:
mode:
authorGravatar PolyWolf <31190026+p0lyw0lf@users.noreply.github.com> 2025-02-26 05:15:35 -0500
committerGravatar GitHub <noreply@github.com> 2025-02-26 10:15:35 +0000
commit1e11f5e8b722b179e382f3c792cd961b2b51f61b (patch)
tree32671113f3e93c92e4c3462f89298352b31c3e3f /packages/markdown/remark/src
parent797a9480b23303329dd618633194cbfb3dccb459 (diff)
downloadastro-1e11f5e8b722b179e382f3c792cd961b2b51f61b.tar.gz
astro-1e11f5e8b722b179e382f3c792cd961b2b51f61b.tar.zst
astro-1e11f5e8b722b179e382f3c792cd961b2b51f61b.zip
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>
Diffstat (limited to 'packages/markdown/remark/src')
-rw-r--r--packages/markdown/remark/src/index.ts15
-rw-r--r--packages/markdown/remark/src/rehype-images.ts54
-rw-r--r--packages/markdown/remark/src/remark-collect-images.ts56
-rw-r--r--packages/markdown/remark/src/types.ts20
4 files changed, 92 insertions, 53 deletions
diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts
index de13523fe..d1b6035e4 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<MarkdownProcessor> {
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 11d33df9c..92043b5e3 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 f09f1c580..062fabc45 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<string>();
- visit(tree, ['image', 'imageReference'], (node: Image | ImageReference) => {
+ const localImagePaths = new Set<string>();
+ const remoteImagePaths = new Set<string>();
+ 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 e6a9d362b..91dd00607 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<string, any>;
};
}
@@ -39,6 +41,9 @@ export interface ShikiConfig
extends Pick<CreateShikiHighlighterOptions, 'langs' | 'theme' | 'themes' | 'langAlias'>,
Pick<ShikiHighlighterHighlightOptions, 'defaultColor' | 'wrap' | 'transformers'> {}
+/**
+ * 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<string, any>;
};
}