diff options
Diffstat (limited to 'packages/markdown')
-rw-r--r-- | packages/markdown/remark/package.json | 1 | ||||
-rw-r--r-- | packages/markdown/remark/src/index.ts | 15 | ||||
-rw-r--r-- | packages/markdown/remark/src/rehype-images.ts | 54 | ||||
-rw-r--r-- | packages/markdown/remark/src/remark-collect-images.ts | 56 | ||||
-rw-r--r-- | packages/markdown/remark/src/types.ts | 20 | ||||
-rw-r--r-- | packages/markdown/remark/test/remark-collect-images.test.js | 48 |
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_="{"src":"./img.png","alt":"inline image url","index":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 `; + const fileURL = 'file.md'; + + const { + code, + metadata: { localImagePaths, remoteImagePaths }, + } = await processor.render(markdown, { fileURL }); + assert.equal( + code, + `<p>Hello <img __ASTRO_IMAGE_="{"inferSize":true,"src":"https://example.com/example.png","alt":"inline remote image url","index":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 `; + 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_="{"src":"./img.webp","alt":"image ref","index":0}"></p>', + '<p>Hello <img __ASTRO_IMAGE_="{"src":"./img.webp","alt":"image ref","index":0}"> <img __ASTRO_IMAGE_="{"inferSize":true,"src":"https://example.com/example.jpg","alt":"remote image ref","index":0}"></p>', ); - assert.deepEqual(metadata.imagePaths, ['./img.webp']); + assert.deepEqual(metadata.localImagePaths, ['./img.webp']); + assert.deepEqual(metadata.remoteImagePaths, ['https://example.com/example.jpg']); }); }); |