0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-10 23:01:26 -05:00

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>
This commit is contained in:
PolyWolf 2025-02-26 05:15:35 -05:00 committed by GitHub
parent 797a9480b2
commit 1e11f5e8b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 374 additions and 174 deletions

View file

@ -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`.

View file

@ -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 `<img>` tag instead. Note that images located in your `public/` folder are still never processed.

View file

@ -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 `<img/>` tag instead. Note that images located in your `public/` folder are still never processed.

View file

@ -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.

View file

@ -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 {

View file

@ -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/, '');

View file

@ -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;

View file

@ -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';

View file

@ -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('&#x22;', '"'));
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;
}
const image: GetImageResult = await getImage({ ...decodedImagePath, src: imported });
image = await getImage({ ...decodedImagePath, src: imported });
}
imageObjects.set(imagePath, image);
} catch {
throw new Error(`Failed to parse image reference: ${imagePath}`);

View file

@ -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';

View file

@ -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,

View file

@ -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),
},
};
};
},

View file

@ -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(/&#x22;/g, '"'));
imageSources[matchKey] = await getImage(props);
occurrenceCounter++;
}
}`;
})
.join('\n')}
return imageSources;
};

View file

@ -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 ? '<meta charset="utf-8">' : '';
// 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)};`
}

View file

@ -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/');

View file

@ -73,18 +73,21 @@ 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<string, string>();
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;
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) {
@ -120,7 +123,7 @@ export function rehypeImageToComponent() {
}
// Build a component that's equivalent to <Image src={importName} {...attributes} />
const componentElement: MdxJsxFlowElementHast = {
element = {
name: ASTRO_IMAGE_ELEMENT,
type: 'mdxJsxFlowElement',
attributes: [
@ -149,8 +152,26 @@ export function rehypeImageToComponent() {
],
children: [],
};
} else if (isRemoteImage) {
// Build a component that's equivalent to <Image src={url} {...attributes} />
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

View file

@ -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"
]

View file

@ -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<Pick<AstroConfig['image'], 'domains' | 'remotePatterns'>>,
domains,
remotePatterns,
}: {
domains: string[];
remotePatterns: RemotePattern[];
},
): boolean {
if (!isRemotePath(src)) return false;
if (!URL.canParse(src)) {
return false;
}
const url = new URL(src);
return (

View file

@ -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",

View file

@ -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 ?? {},
},
};

View file

@ -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) {
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;
}
const imageOccurrenceMap = new Map();
visit(tree, (node) => {
if (node.type !== 'element') return;
visit(tree, 'element', (node) => {
if (node.tagName !== 'img') return;
if (typeof node.properties?.src !== 'string') return;
if (node.properties?.src) {
node.properties.src = decodeURI(node.properties.src);
const src = decodeURI(node.properties.src);
let newProperties: Properties;
if (file.data.astro?.imagePaths?.includes(node.properties.src)) {
const { ...props } = node.properties;
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;
}
// Initialize or increment occurrence count for this image
const index = imageOccurrenceMap.get(node.properties.src) || 0;
imageOccurrenceMap.set(node.properties.src, index + 1);
node.properties['__ASTRO_IMAGE_'] = JSON.stringify({ ...props, index });
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 }) };
});
};
}

View file

@ -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;
}
}

View file

@ -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>;
};
}

View file

@ -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']);
});
});

3
pnpm-lock.yaml generated
View file

@ -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