aboutsummaryrefslogtreecommitdiff
path: root/packages/markdown
diff options
context:
space:
mode:
Diffstat (limited to 'packages/markdown')
-rw-r--r--packages/markdown/remark/package.json1
-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
-rw-r--r--packages/markdown/remark/test/remark-collect-images.test.js48
6 files changed, 135 insertions, 59 deletions
diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json
index 8e9e142a6..5ad883a73 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 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>;
};
}
diff --git a/packages/markdown/remark/test/remark-collect-images.test.js b/packages/markdown/remark/test/remark-collect-images.test.js
index 669bee595..e7ac08918 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 () => {
'<p>Hello <img __ASTRO_IMAGE_="{&#x22;src&#x22;:&#x22;./img.png&#x22;,&#x22;alt&#x22;:&#x22;inline image url&#x22;,&#x22;index&#x22;:0}"></p>',
);
- 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,
+ `<p>Hello <img __ASTRO_IMAGE_="{&#x22;inferSize&#x22;:true,&#x22;src&#x22;:&#x22;https://example.com/example.png&#x22;,&#x22;alt&#x22;:&#x22;inline remote image url&#x22;,&#x22;index&#x22;:0}"></p>`,
+ );
+
+ 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,
+ `<p>Hello <img src="https://google.com/google.png" alt="inline remote image url"></p>`,
+ );
+
+ 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,
- '<p>Hello <img __ASTRO_IMAGE_="{&#x22;src&#x22;:&#x22;./img.webp&#x22;,&#x22;alt&#x22;:&#x22;image ref&#x22;,&#x22;index&#x22;:0}"></p>',
+ '<p>Hello <img __ASTRO_IMAGE_="{&#x22;src&#x22;:&#x22;./img.webp&#x22;,&#x22;alt&#x22;:&#x22;image ref&#x22;,&#x22;index&#x22;:0}"> <img __ASTRO_IMAGE_="{&#x22;inferSize&#x22;:true,&#x22;src&#x22;:&#x22;https://example.com/example.jpg&#x22;,&#x22;alt&#x22;:&#x22;remote image ref&#x22;,&#x22;index&#x22;:0}"></p>',
);
- assert.deepEqual(metadata.imagePaths, ['./img.webp']);
+ assert.deepEqual(metadata.localImagePaths, ['./img.webp']);
+ assert.deepEqual(metadata.remoteImagePaths, ['https://example.com/example.jpg']);
});
});