diff --git a/.changeset/khaki-crabs-develop.md b/.changeset/khaki-crabs-develop.md new file mode 100644 index 000000000000..b42001b6edd2 --- /dev/null +++ b/.changeset/khaki-crabs-develop.md @@ -0,0 +1,7 @@ +--- +'astro': patch +'@astrojs/mdx': patch +'@astrojs/markdown-remark': patch +--- + +Prevent relative image paths in `src/content/` diff --git a/packages/astro/src/content/index.ts b/packages/astro/src/content/index.ts index 7fc96be290e6..5c3e6defef15 100644 --- a/packages/astro/src/content/index.ts +++ b/packages/astro/src/content/index.ts @@ -4,3 +4,4 @@ export { } from './vite-plugin-content-assets.js'; export { astroContentServerPlugin } from './vite-plugin-content-server.js'; export { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.js'; +export { getContentPaths } from './utils.js'; diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index a20b33e18490..11dfb1788285 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -12,6 +12,7 @@ import type { RouteType, SSRLoadedRenderer, } from '../../@types/astro'; +import { getContentPaths } from '../../content/index.js'; import { BuildInternals, hasPrerenderedPages } from '../../core/build/internal.js'; import { prependForwardSlash, @@ -352,6 +353,8 @@ async function generatePath( markdown: { ...settings.config.markdown, isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown, + isExperimentalContentCollections: settings.config.experimental.contentCollections, + contentDir: getContentPaths(settings.config).contentDir, }, mode: opts.mode, renderers, diff --git a/packages/astro/src/core/build/vite-plugin-ssr.ts b/packages/astro/src/core/build/vite-plugin-ssr.ts index 71a3b513ae94..dbe5b1b4c503 100644 --- a/packages/astro/src/core/build/vite-plugin-ssr.ts +++ b/packages/astro/src/core/build/vite-plugin-ssr.ts @@ -15,6 +15,7 @@ import { serializeRouteData } from '../routing/index.js'; import { addRollupInput } from './add-rollup-input.js'; import { getOutFile, getOutFolder } from './common.js'; import { eachPrerenderedPageData, eachServerPageData, sortedCSS } from './internal.js'; +import { getContentPaths } from '../../content/index.js'; export const virtualModuleId = '@astrojs-ssr-virtual-entry'; const resolvedVirtualModuleId = '\0' + virtualModuleId; @@ -208,6 +209,8 @@ function buildManifest( markdown: { ...settings.config.markdown, isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown, + isExperimentalContentCollections: settings.config.experimental.contentCollections, + contentDir: getContentPaths(settings.config).contentDir, }, pageMap: null as any, renderers: [], diff --git a/packages/astro/src/core/render/dev/environment.ts b/packages/astro/src/core/render/dev/environment.ts index 5c4a8ed73983..1fabf486d139 100644 --- a/packages/astro/src/core/render/dev/environment.ts +++ b/packages/astro/src/core/render/dev/environment.ts @@ -1,4 +1,5 @@ import type { AstroSettings, RuntimeMode } from '../../../@types/astro'; +import { getContentPaths } from '../../../content/index.js'; import type { LogOptions } from '../../logger/core.js'; import type { ModuleLoader } from '../../module-loader/index'; import type { Environment } from '../index'; @@ -24,6 +25,8 @@ export function createDevelopmentEnvironment( markdown: { ...settings.config.markdown, isAstroFlavoredMd: settings.config.legacy.astroFlavoredMarkdown, + isExperimentalContentCollections: settings.config.experimental.contentCollections, + contentDir: getContentPaths(settings.config).contentDir, }, mode, // This will be overridden in the dev server diff --git a/packages/astro/src/core/render/environment.ts b/packages/astro/src/core/render/environment.ts index 8746df5f1720..edf3cc0e6df3 100644 --- a/packages/astro/src/core/render/environment.ts +++ b/packages/astro/src/core/render/environment.ts @@ -37,7 +37,11 @@ export function createBasicEnvironment(options: CreateBasicEnvironmentArgs): Env const mode = options.mode ?? 'development'; return createEnvironment({ ...options, - markdown: options.markdown ?? {}, + markdown: { + ...(options.markdown ?? {}), + // Stub out, not important for basic rendering + contentDir: new URL('file:///src/content/'), + }, mode, renderers: options.renderers ?? [], resolve: options.resolve ?? ((s: string) => Promise.resolve(s)), diff --git a/packages/astro/src/vite-plugin-markdown-legacy/index.ts b/packages/astro/src/vite-plugin-markdown-legacy/index.ts index b72418bdb08d..5203a2b3f179 100644 --- a/packages/astro/src/vite-plugin-markdown-legacy/index.ts +++ b/packages/astro/src/vite-plugin-markdown-legacy/index.ts @@ -4,6 +4,7 @@ import matter from 'gray-matter'; import { fileURLToPath } from 'url'; import { Plugin, ResolvedConfig, transformWithEsbuild } from 'vite'; import type { AstroSettings } from '../@types/astro'; +import { getContentPaths } from '../content/index.js'; import { pagesVirtualModuleId } from '../core/app/index.js'; import { cachedCompilation, CompileProps } from '../core/compile/index.js'; import { AstroErrorData, MarkdownError } from '../core/errors/index.js'; @@ -162,6 +163,8 @@ export default function markdown({ settings }: AstroPluginOptions): Plugin { ...renderOpts, fileURL: fileUrl, isAstroFlavoredMd: true, + isExperimentalContentCollections: settings.config.experimental.contentCollections, + contentDir: getContentPaths(settings.config).contentDir, } as any); let { code: astroResult, metadata } = renderResult; const { layout = '', components = '', setup = '', ...content } = frontmatter; diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index 66955dca5c37..67ad4c1e09d9 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import type { Plugin } from 'vite'; import { normalizePath } from 'vite'; import type { AstroSettings } from '../@types/astro'; +import { getContentPaths } from '../content/index.js'; import { AstroErrorData, MarkdownError } from '../core/errors/index.js'; import type { LogOptions } 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, fileURL: new URL(`file://${fileId}`), isAstroFlavoredMd: false, + isExperimentalContentCollections: settings.config.experimental.contentCollections, + contentDir: getContentPaths(settings.config).contentDir, } as any); const html = renderResult.code; diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 08a681097437..e2ae2521f097 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -52,6 +52,7 @@ "@types/chai": "^4.3.1", "@types/estree": "^1.0.0", "@types/github-slugger": "^1.3.0", + "@types/mdast": "^3.0.10", "@types/mocha": "^9.1.1", "@types/yargs-parser": "^21.0.0", "astro": "workspace:*", diff --git a/packages/integrations/mdx/src/plugins.ts b/packages/integrations/mdx/src/plugins.ts index d46ab6cd418f..a93db383b767 100644 --- a/packages/integrations/mdx/src/plugins.ts +++ b/packages/integrations/mdx/src/plugins.ts @@ -1,9 +1,11 @@ import { rehypeHeadingIds } from '@astrojs/markdown-remark'; +import type { Image } from 'mdast'; import { nodeTypes } from '@mdx-js/mdx'; import type { PluggableList } from '@mdx-js/mdx/lib/core.js'; import type { Options as MdxRollupPluginOptions } from '@mdx-js/rollup'; import type { AstroConfig, MarkdownAstroData } from 'astro'; import type { Literal, MemberExpression } from 'estree'; +import { visit } from 'unist-util-visit'; import { visit as estreeVisit } from 'estree-util-visit'; import { bold, yellow } from 'kleur/colors'; import rehypeRaw from 'rehype-raw'; @@ -15,7 +17,8 @@ import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js'; import rehypeMetaString from './rehype-meta-string.js'; import remarkPrism from './remark-prism.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({ importMetaEnv, @@ -113,6 +116,34 @@ export function rehypeApplyFrontmatterExport(pageFrontmatter: Record { + const isContentFile = pathToFileURL(vfile.path).href.startsWith(contentDir.href); + if (!isContentFile) return; + + const relImagePaths = new Set(); + 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_REHYPE_PLUGINS: PluggableList = []; @@ -146,6 +177,11 @@ export async function getRemarkPlugins( } remarkPlugins = [...remarkPlugins, ...(mdxOptions.remarkPlugins ?? [])]; + + // Apply last in case user plugins resolve relative image paths + if (config.experimental.contentCollections) { + remarkPlugins.push(toRemarkContentRelImageError(config)); + } return remarkPlugins; } diff --git a/packages/integrations/mdx/src/utils.ts b/packages/integrations/mdx/src/utils.ts index 60a50c85a4a0..e803dc300b02 100644 --- a/packages/integrations/mdx/src/utils.ts +++ b/packages/integrations/mdx/src/utils.ts @@ -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 === '/'; +} diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index 6d69bcd20a20..07df39ee8c6a 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -14,6 +14,7 @@ import remarkPrism from './remark-prism.js'; import scopedStyles from './remark-scoped-styles.js'; import remarkShiki from './remark-shiki.js'; import remarkUnwrap from './remark-unwrap.js'; +import toRemarkContentRelImageError from './remark-content-rel-image-error.js'; import rehypeRaw from 'rehype-raw'; import rehypeStringify from 'rehype-stringify'; @@ -42,6 +43,8 @@ export async function renderMarkdown( remarkRehype = {}, extendDefaultPlugins = false, isAstroFlavoredMd = false, + isExperimentalContentCollections = false, + contentDir, } = opts; const input = new VFile({ value: content, path: fileURL }); const scopedClassName = opts.$?.scopedClassName; @@ -73,6 +76,11 @@ export async function renderMarkdown( parser.use([remarkPrism(scopedClassName)]); } + // Apply later in case user plugins resolve relative image paths + if (isExperimentalContentCollections) { + parser.use([toRemarkContentRelImageError({ contentDir })]); + } + parser.use([ [ markdownToHtml as any, diff --git a/packages/markdown/remark/src/remark-content-rel-image-error.ts b/packages/markdown/remark/src/remark-content-rel-image-error.ts new file mode 100644 index 000000000000..0704ebdd16bc --- /dev/null +++ b/packages/markdown/remark/src/remark-content-rel-image-error.ts @@ -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(); + 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 === '/'; +} diff --git a/packages/markdown/remark/src/types.ts b/packages/markdown/remark/src/types.ts index 76dfe9b73d98..15465d95008f 100644 --- a/packages/markdown/remark/src/types.ts +++ b/packages/markdown/remark/src/types.ts @@ -54,6 +54,10 @@ export interface MarkdownRenderingOptions extends AstroMarkdownOptions { scopedClassName: string | null; }; 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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9abcaf0a39f2..8fd5d9273d51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2889,6 +2889,7 @@ importers: '@types/chai': ^4.3.1 '@types/estree': ^1.0.0 '@types/github-slugger': ^1.3.0 + '@types/mdast': ^3.0.10 '@types/mocha': ^9.1.1 '@types/yargs-parser': ^21.0.0 acorn: ^8.8.0 @@ -2940,6 +2941,7 @@ importers: '@types/chai': 4.3.4 '@types/estree': 1.0.0 '@types/github-slugger': 1.3.0 + '@types/mdast': 3.0.10 '@types/mocha': 9.1.1 '@types/yargs-parser': 21.0.0 astro: link:../../astro