[Content] Throw on relative image usage (#5648)
* chore: add rel image error plugin * deps: mdast, mdast types * chore: add rel image throw to mdx * refactor: doc rel image path plugin * fix: respect experimental flag in md remark * chore: changeset * deps: remove mdast package * fix: resolve contentDir from config * fix: apply MDX plugin after user plugins * fix: stub out contentDir
This commit is contained in:
parent
b64081deed
commit
853081d1c8
15 changed files with 151 additions and 2 deletions
7
.changeset/khaki-crabs-develop.md
Normal file
7
.changeset/khaki-crabs-develop.md
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
'@astrojs/mdx': patch
|
||||||
|
'@astrojs/markdown-remark': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Prevent relative image paths in `src/content/`
|
|
@ -4,3 +4,4 @@ export {
|
||||||
} from './vite-plugin-content-assets.js';
|
} from './vite-plugin-content-assets.js';
|
||||||
export { astroContentServerPlugin } from './vite-plugin-content-server.js';
|
export { astroContentServerPlugin } from './vite-plugin-content-server.js';
|
||||||
export { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.js';
|
export { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.js';
|
||||||
|
export { getContentPaths } from './utils.js';
|
||||||
|
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
RouteType,
|
RouteType,
|
||||||
SSRLoadedRenderer,
|
SSRLoadedRenderer,
|
||||||
} from '../../@types/astro';
|
} from '../../@types/astro';
|
||||||
|
import { getContentPaths } from '../../content/index.js';
|
||||||
import { BuildInternals, hasPrerenderedPages } from '../../core/build/internal.js';
|
import { BuildInternals, hasPrerenderedPages } from '../../core/build/internal.js';
|
||||||
import {
|
import {
|
||||||
prependForwardSlash,
|
prependForwardSlash,
|
||||||
|
@ -352,6 +353,8 @@ async function generatePath(
|
||||||
markdown: {
|
markdown: {
|
||||||
...settings.config.markdown,
|
...settings.config.markdown,
|
||||||
isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown,
|
isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown,
|
||||||
|
isExperimentalContentCollections: settings.config.experimental.contentCollections,
|
||||||
|
contentDir: getContentPaths(settings.config).contentDir,
|
||||||
},
|
},
|
||||||
mode: opts.mode,
|
mode: opts.mode,
|
||||||
renderers,
|
renderers,
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { serializeRouteData } from '../routing/index.js';
|
||||||
import { addRollupInput } from './add-rollup-input.js';
|
import { addRollupInput } from './add-rollup-input.js';
|
||||||
import { getOutFile, getOutFolder } from './common.js';
|
import { getOutFile, getOutFolder } from './common.js';
|
||||||
import { eachPrerenderedPageData, eachServerPageData, sortedCSS } from './internal.js';
|
import { eachPrerenderedPageData, eachServerPageData, sortedCSS } from './internal.js';
|
||||||
|
import { getContentPaths } from '../../content/index.js';
|
||||||
|
|
||||||
export const virtualModuleId = '@astrojs-ssr-virtual-entry';
|
export const virtualModuleId = '@astrojs-ssr-virtual-entry';
|
||||||
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
||||||
|
@ -208,6 +209,8 @@ function buildManifest(
|
||||||
markdown: {
|
markdown: {
|
||||||
...settings.config.markdown,
|
...settings.config.markdown,
|
||||||
isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown,
|
isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown,
|
||||||
|
isExperimentalContentCollections: settings.config.experimental.contentCollections,
|
||||||
|
contentDir: getContentPaths(settings.config).contentDir,
|
||||||
},
|
},
|
||||||
pageMap: null as any,
|
pageMap: null as any,
|
||||||
renderers: [],
|
renderers: [],
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { AstroSettings, RuntimeMode } from '../../../@types/astro';
|
import type { AstroSettings, RuntimeMode } from '../../../@types/astro';
|
||||||
|
import { getContentPaths } from '../../../content/index.js';
|
||||||
import type { LogOptions } from '../../logger/core.js';
|
import type { LogOptions } from '../../logger/core.js';
|
||||||
import type { ModuleLoader } from '../../module-loader/index';
|
import type { ModuleLoader } from '../../module-loader/index';
|
||||||
import type { Environment } from '../index';
|
import type { Environment } from '../index';
|
||||||
|
@ -24,6 +25,8 @@ export function createDevelopmentEnvironment(
|
||||||
markdown: {
|
markdown: {
|
||||||
...settings.config.markdown,
|
...settings.config.markdown,
|
||||||
isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown,
|
isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown,
|
||||||
|
isExperimentalContentCollections: settings.config.experimental.contentCollections,
|
||||||
|
contentDir: getContentPaths(settings.config).contentDir,
|
||||||
},
|
},
|
||||||
mode,
|
mode,
|
||||||
// This will be overridden in the dev server
|
// This will be overridden in the dev server
|
||||||
|
|
|
@ -37,7 +37,11 @@ export function createBasicEnvironment(options: CreateBasicEnvironmentArgs): Env
|
||||||
const mode = options.mode ?? 'development';
|
const mode = options.mode ?? 'development';
|
||||||
return createEnvironment({
|
return createEnvironment({
|
||||||
...options,
|
...options,
|
||||||
markdown: options.markdown ?? {},
|
markdown: {
|
||||||
|
...(options.markdown ?? {}),
|
||||||
|
// Stub out, not important for basic rendering
|
||||||
|
contentDir: new URL('file:///src/content/'),
|
||||||
|
},
|
||||||
mode,
|
mode,
|
||||||
renderers: options.renderers ?? [],
|
renderers: options.renderers ?? [],
|
||||||
resolve: options.resolve ?? ((s: string) => Promise.resolve(s)),
|
resolve: options.resolve ?? ((s: string) => Promise.resolve(s)),
|
||||||
|
|
|
@ -4,6 +4,7 @@ import matter from 'gray-matter';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { Plugin, ResolvedConfig, transformWithEsbuild } from 'vite';
|
import { Plugin, ResolvedConfig, transformWithEsbuild } from 'vite';
|
||||||
import type { AstroSettings } from '../@types/astro';
|
import type { AstroSettings } from '../@types/astro';
|
||||||
|
import { getContentPaths } from '../content/index.js';
|
||||||
import { pagesVirtualModuleId } from '../core/app/index.js';
|
import { pagesVirtualModuleId } from '../core/app/index.js';
|
||||||
import { cachedCompilation, CompileProps } from '../core/compile/index.js';
|
import { cachedCompilation, CompileProps } from '../core/compile/index.js';
|
||||||
import { AstroErrorData, MarkdownError } from '../core/errors/index.js';
|
import { AstroErrorData, MarkdownError } from '../core/errors/index.js';
|
||||||
|
@ -162,6 +163,8 @@ export default function markdown({ settings }: AstroPluginOptions): Plugin {
|
||||||
...renderOpts,
|
...renderOpts,
|
||||||
fileURL: fileUrl,
|
fileURL: fileUrl,
|
||||||
isAstroFlavoredMd: true,
|
isAstroFlavoredMd: true,
|
||||||
|
isExperimentalContentCollections: settings.config.experimental.contentCollections,
|
||||||
|
contentDir: getContentPaths(settings.config).contentDir,
|
||||||
} as any);
|
} as any);
|
||||||
let { code: astroResult, metadata } = renderResult;
|
let { code: astroResult, metadata } = renderResult;
|
||||||
const { layout = '', components = '', setup = '', ...content } = frontmatter;
|
const { layout = '', components = '', setup = '', ...content } = frontmatter;
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
|
||||||
import type { Plugin } from 'vite';
|
import type { Plugin } from 'vite';
|
||||||
import { normalizePath } from 'vite';
|
import { normalizePath } from 'vite';
|
||||||
import type { AstroSettings } from '../@types/astro';
|
import type { AstroSettings } from '../@types/astro';
|
||||||
|
import { getContentPaths } from '../content/index.js';
|
||||||
import { AstroErrorData, MarkdownError } from '../core/errors/index.js';
|
import { AstroErrorData, MarkdownError } from '../core/errors/index.js';
|
||||||
import type { LogOptions } from '../core/logger/core.js';
|
import type { LogOptions } from '../core/logger/core.js';
|
||||||
import { warn } from '../core/logger/core.js';
|
import { warn } from '../core/logger/core.js';
|
||||||
|
@ -71,6 +72,8 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
|
||||||
...settings.config.markdown,
|
...settings.config.markdown,
|
||||||
fileURL: new URL(`file://${fileId}`),
|
fileURL: new URL(`file://${fileId}`),
|
||||||
isAstroFlavoredMd: false,
|
isAstroFlavoredMd: false,
|
||||||
|
isExperimentalContentCollections: settings.config.experimental.contentCollections,
|
||||||
|
contentDir: getContentPaths(settings.config).contentDir,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const html = renderResult.code;
|
const html = renderResult.code;
|
||||||
|
|
|
@ -52,6 +52,7 @@
|
||||||
"@types/chai": "^4.3.1",
|
"@types/chai": "^4.3.1",
|
||||||
"@types/estree": "^1.0.0",
|
"@types/estree": "^1.0.0",
|
||||||
"@types/github-slugger": "^1.3.0",
|
"@types/github-slugger": "^1.3.0",
|
||||||
|
"@types/mdast": "^3.0.10",
|
||||||
"@types/mocha": "^9.1.1",
|
"@types/mocha": "^9.1.1",
|
||||||
"@types/yargs-parser": "^21.0.0",
|
"@types/yargs-parser": "^21.0.0",
|
||||||
"astro": "workspace:*",
|
"astro": "workspace:*",
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { rehypeHeadingIds } from '@astrojs/markdown-remark';
|
import { rehypeHeadingIds } from '@astrojs/markdown-remark';
|
||||||
|
import type { Image } from 'mdast';
|
||||||
import { nodeTypes } from '@mdx-js/mdx';
|
import { nodeTypes } from '@mdx-js/mdx';
|
||||||
import type { PluggableList } from '@mdx-js/mdx/lib/core.js';
|
import type { PluggableList } from '@mdx-js/mdx/lib/core.js';
|
||||||
import type { Options as MdxRollupPluginOptions } from '@mdx-js/rollup';
|
import type { Options as MdxRollupPluginOptions } from '@mdx-js/rollup';
|
||||||
import type { AstroConfig, MarkdownAstroData } from 'astro';
|
import type { AstroConfig, MarkdownAstroData } from 'astro';
|
||||||
import type { Literal, MemberExpression } from 'estree';
|
import type { Literal, MemberExpression } from 'estree';
|
||||||
|
import { visit } from 'unist-util-visit';
|
||||||
import { visit as estreeVisit } from 'estree-util-visit';
|
import { visit as estreeVisit } from 'estree-util-visit';
|
||||||
import { bold, yellow } from 'kleur/colors';
|
import { bold, yellow } from 'kleur/colors';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
|
@ -15,7 +17,8 @@ import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js';
|
||||||
import rehypeMetaString from './rehype-meta-string.js';
|
import rehypeMetaString from './rehype-meta-string.js';
|
||||||
import remarkPrism from './remark-prism.js';
|
import remarkPrism from './remark-prism.js';
|
||||||
import remarkShiki from './remark-shiki.js';
|
import remarkShiki from './remark-shiki.js';
|
||||||
import { jsToTreeNode } from './utils.js';
|
import { jsToTreeNode, isRelativePath } from './utils.js';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
export function recmaInjectImportMetaEnvPlugin({
|
export function recmaInjectImportMetaEnvPlugin({
|
||||||
importMetaEnv,
|
importMetaEnv,
|
||||||
|
@ -113,6 +116,34 @@ export function rehypeApplyFrontmatterExport(pageFrontmatter: Record<string, any
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `src/content/` does not support relative image paths.
|
||||||
|
* This plugin throws an error if any are found
|
||||||
|
*/
|
||||||
|
function toRemarkContentRelImageError({ srcDir }: { srcDir: URL }) {
|
||||||
|
const contentDir = new URL('content/', srcDir);
|
||||||
|
return function remarkContentRelImageError() {
|
||||||
|
return (tree: any, vfile: VFile) => {
|
||||||
|
const isContentFile = pathToFileURL(vfile.path).href.startsWith(contentDir.href);
|
||||||
|
if (!isContentFile) return;
|
||||||
|
|
||||||
|
const relImagePaths = new Set<string>();
|
||||||
|
visit(tree, 'image', function raiseError(node: Image) {
|
||||||
|
if (isRelativePath(node.url)) {
|
||||||
|
relImagePaths.add(node.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (relImagePaths.size === 0) return;
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
`Relative image paths are not supported in the content/ directory. Place local images in the public/ directory and use absolute paths (see https://docs.astro.build/en/guides/images/#in-markdown-files):\n` +
|
||||||
|
[...relImagePaths].map((path) => JSON.stringify(path)).join(',\n');
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_REMARK_PLUGINS: PluggableList = [remarkGfm, remarkSmartypants];
|
const DEFAULT_REMARK_PLUGINS: PluggableList = [remarkGfm, remarkSmartypants];
|
||||||
const DEFAULT_REHYPE_PLUGINS: PluggableList = [];
|
const DEFAULT_REHYPE_PLUGINS: PluggableList = [];
|
||||||
|
|
||||||
|
@ -146,6 +177,11 @@ export async function getRemarkPlugins(
|
||||||
}
|
}
|
||||||
|
|
||||||
remarkPlugins = [...remarkPlugins, ...(mdxOptions.remarkPlugins ?? [])];
|
remarkPlugins = [...remarkPlugins, ...(mdxOptions.remarkPlugins ?? [])];
|
||||||
|
|
||||||
|
// Apply last in case user plugins resolve relative image paths
|
||||||
|
if (config.experimental.contentCollections) {
|
||||||
|
remarkPlugins.push(toRemarkContentRelImageError(config));
|
||||||
|
}
|
||||||
return remarkPlugins;
|
return remarkPlugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -95,3 +95,22 @@ export function handleExtendsNotSupported(pluginConfig: any) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Following utils taken from `packages/astro/src/core/path.ts`:
|
||||||
|
|
||||||
|
export function isRelativePath(path: string) {
|
||||||
|
return startsWithDotDotSlash(path) || startsWithDotSlash(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWithDotDotSlash(path: string) {
|
||||||
|
const c1 = path[0];
|
||||||
|
const c2 = path[1];
|
||||||
|
const c3 = path[2];
|
||||||
|
return c1 === '.' && c2 === '.' && c3 === '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWithDotSlash(path: string) {
|
||||||
|
const c1 = path[0];
|
||||||
|
const c2 = path[1];
|
||||||
|
return c1 === '.' && c2 === '/';
|
||||||
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ import remarkPrism from './remark-prism.js';
|
||||||
import scopedStyles from './remark-scoped-styles.js';
|
import scopedStyles from './remark-scoped-styles.js';
|
||||||
import remarkShiki from './remark-shiki.js';
|
import remarkShiki from './remark-shiki.js';
|
||||||
import remarkUnwrap from './remark-unwrap.js';
|
import remarkUnwrap from './remark-unwrap.js';
|
||||||
|
import toRemarkContentRelImageError from './remark-content-rel-image-error.js';
|
||||||
|
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import rehypeStringify from 'rehype-stringify';
|
import rehypeStringify from 'rehype-stringify';
|
||||||
|
@ -42,6 +43,8 @@ export async function renderMarkdown(
|
||||||
remarkRehype = {},
|
remarkRehype = {},
|
||||||
extendDefaultPlugins = false,
|
extendDefaultPlugins = false,
|
||||||
isAstroFlavoredMd = false,
|
isAstroFlavoredMd = false,
|
||||||
|
isExperimentalContentCollections = false,
|
||||||
|
contentDir,
|
||||||
} = opts;
|
} = opts;
|
||||||
const input = new VFile({ value: content, path: fileURL });
|
const input = new VFile({ value: content, path: fileURL });
|
||||||
const scopedClassName = opts.$?.scopedClassName;
|
const scopedClassName = opts.$?.scopedClassName;
|
||||||
|
@ -73,6 +76,11 @@ export async function renderMarkdown(
|
||||||
parser.use([remarkPrism(scopedClassName)]);
|
parser.use([remarkPrism(scopedClassName)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply later in case user plugins resolve relative image paths
|
||||||
|
if (isExperimentalContentCollections) {
|
||||||
|
parser.use([toRemarkContentRelImageError({ contentDir })]);
|
||||||
|
}
|
||||||
|
|
||||||
parser.use([
|
parser.use([
|
||||||
[
|
[
|
||||||
markdownToHtml as any,
|
markdownToHtml as any,
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { Image } from 'mdast';
|
||||||
|
import { visit } from 'unist-util-visit';
|
||||||
|
import { pathToFileURL } from 'url';
|
||||||
|
import type { VFile } from 'vfile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `src/content/` does not support relative image paths.
|
||||||
|
* This plugin throws an error if any are found
|
||||||
|
*/
|
||||||
|
export default function toRemarkContentRelImageError({ contentDir }: { contentDir: URL }) {
|
||||||
|
return function remarkContentRelImageError() {
|
||||||
|
return (tree: any, vfile: VFile) => {
|
||||||
|
const isContentFile = pathToFileURL(vfile.path).href.startsWith(contentDir.href);
|
||||||
|
if (!isContentFile) return;
|
||||||
|
|
||||||
|
const relImagePaths = new Set<string>();
|
||||||
|
visit(tree, 'image', function raiseError(node: Image) {
|
||||||
|
console.log(node.url);
|
||||||
|
if (isRelativePath(node.url)) {
|
||||||
|
relImagePaths.add(node.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (relImagePaths.size === 0) return;
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
`Relative image paths are not supported in the content/ directory. Place local images in the public/ directory and use absolute paths (see https://docs.astro.build/en/guides/images/#in-markdown-files)\n` +
|
||||||
|
[...relImagePaths].map((path) => JSON.stringify(path)).join(',\n');
|
||||||
|
|
||||||
|
// Throw raw string to use `astro:markdown` default formatting
|
||||||
|
throw errorMessage;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Following utils taken from `packages/astro/src/core/path.ts`:
|
||||||
|
|
||||||
|
function isRelativePath(path: string) {
|
||||||
|
return startsWithDotDotSlash(path) || startsWithDotSlash(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWithDotDotSlash(path: string) {
|
||||||
|
const c1 = path[0];
|
||||||
|
const c2 = path[1];
|
||||||
|
const c3 = path[2];
|
||||||
|
return c1 === '.' && c2 === '.' && c3 === '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWithDotSlash(path: string) {
|
||||||
|
const c1 = path[0];
|
||||||
|
const c2 = path[1];
|
||||||
|
return c1 === '.' && c2 === '/';
|
||||||
|
}
|
|
@ -54,6 +54,10 @@ export interface MarkdownRenderingOptions extends AstroMarkdownOptions {
|
||||||
scopedClassName: string | null;
|
scopedClassName: string | null;
|
||||||
};
|
};
|
||||||
isAstroFlavoredMd?: boolean;
|
isAstroFlavoredMd?: boolean;
|
||||||
|
/** Used to prevent relative image imports from `src/content/` */
|
||||||
|
isExperimentalContentCollections?: boolean;
|
||||||
|
/** Used to prevent relative image imports from `src/content/` */
|
||||||
|
contentDir: URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarkdownHeading {
|
export interface MarkdownHeading {
|
||||||
|
|
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
|
@ -2889,6 +2889,7 @@ importers:
|
||||||
'@types/chai': ^4.3.1
|
'@types/chai': ^4.3.1
|
||||||
'@types/estree': ^1.0.0
|
'@types/estree': ^1.0.0
|
||||||
'@types/github-slugger': ^1.3.0
|
'@types/github-slugger': ^1.3.0
|
||||||
|
'@types/mdast': ^3.0.10
|
||||||
'@types/mocha': ^9.1.1
|
'@types/mocha': ^9.1.1
|
||||||
'@types/yargs-parser': ^21.0.0
|
'@types/yargs-parser': ^21.0.0
|
||||||
acorn: ^8.8.0
|
acorn: ^8.8.0
|
||||||
|
@ -2940,6 +2941,7 @@ importers:
|
||||||
'@types/chai': 4.3.4
|
'@types/chai': 4.3.4
|
||||||
'@types/estree': 1.0.0
|
'@types/estree': 1.0.0
|
||||||
'@types/github-slugger': 1.3.0
|
'@types/github-slugger': 1.3.0
|
||||||
|
'@types/mdast': 3.0.10
|
||||||
'@types/mocha': 9.1.1
|
'@types/mocha': 9.1.1
|
||||||
'@types/yargs-parser': 21.0.0
|
'@types/yargs-parser': 21.0.0
|
||||||
astro: link:../../astro
|
astro: link:../../astro
|
||||||
|
|
Loading…
Add table
Reference in a new issue